diff --git a/AGENTS.md b/AGENTS.md index ce72dd50d4..7a75ee6afa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -782,7 +782,7 @@ Read `docs/ai/porting-hyperf.md` only when porting a Hyperf package or update. If the Hypervel version of the package doesn't exist yet, create the skeleton using an existing package as a template: - **Porting a Laravel first-party package:** Use the `cache` package as reference -- **Porting a Hyperf package:** Use the `pool` package as reference +- **Porting a Hyperf package:** Use the `websocket-server` package as reference - **Porting a Laravel-ecosystem third-party package:** Use the `permission` package as a reference Read the reference package's `composer.json`, `LICENSE.md`, and `README.md` and create equivalents for the new package. Every package must be wired in both places: its own `src/{package}/composer.json` for the subtree split, and the root `composer.json` for monorepo development. Update autoloading, `replace`, and Hypervel provider / alias discovery metadata as needed, and add root dependencies with `composer require` — see Providers and Listeners for where providers should be registered. Create the README using the Package READMEs format under Development Conventions. diff --git a/composer.json b/composer.json index 717d8d1ee8..67c3c69b26 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "Hypervel\\Bus\\": "src/bus/src/", "Hypervel\\Cache\\": "src/cache/src/", "Hypervel\\Config\\": "src/config/src/", + "Hypervel\\ConnectionPool\\": "src/connection-pool/src/", "Hypervel\\Console\\": "src/console/src/", "Hypervel\\Container\\": "src/container/src/", "Hypervel\\Context\\": "src/context/src/", @@ -66,7 +67,6 @@ "Hypervel\\Pagination\\": "src/pagination/src/", "Hypervel\\Passkeys\\": "src/passkeys/src/", "Hypervel\\Pipeline\\": "src/pipeline/src/", - "Hypervel\\Pool\\": "src/pool/src/", "Hypervel\\Process\\": "src/process/src/", "Hypervel\\Prompts\\": "src/prompts/src/", "Hypervel\\Queue\\": "src/queue/src/", @@ -236,6 +236,7 @@ "hypervel/concurrency": "self.version", "hypervel/conditionable": "self.version", "hypervel/config": "self.version", + "hypervel/connection-pool": "self.version", "hypervel/console": "self.version", "hypervel/container": "self.version", "hypervel/context": "self.version", @@ -273,7 +274,6 @@ "hypervel/pagination": "self.version", "hypervel/passkeys": "self.version", "hypervel/pipeline": "self.version", - "hypervel/pool": "self.version", "hypervel/process": "self.version", "hypervel/prompts": "self.version", "hypervel/queue": "self.version", diff --git a/docs/ai/porting-hyperf.md b/docs/ai/porting-hyperf.md index 90eecf0769..c92e8145fa 100644 --- a/docs/ai/porting-hyperf.md +++ b/docs/ai/porting-hyperf.md @@ -77,7 +77,7 @@ Before migrating a ConfigProvider, read: - `src/docs/packages.md#class-map-overrides` if the package uses class map replacement - `Hypervel\Support\ServiceProvider` -Use existing Hypervel packages as pattern references. For low-level Swoole / Hyperf-style infrastructure, useful references include `pool`, `object-pool`, `engine`, `server`, `signal`, and `sentry`. The `database` package is a good reference for translating Hyperf provider patterns into Hypervel provider code. +Use `websocket-server` as the package skeleton reference for Composer metadata, licensing, and provider discovery. For provider patterns, see `engine` for contract bindings and `signal` for worker-lifecycle listeners. The `database` package is a useful reference for more involved provider registration. Adapt the relevant pattern rather than copying a package's runtime-specific setup. ### Categorize the ConfigProvider entries diff --git a/docs/plans/2026-09-08-0816-pool-api-and-lifecycle-redesign.md b/docs/plans/2026-09-08-0816-pool-api-and-lifecycle-redesign.md new file mode 100644 index 0000000000..bd2eb2ef3f --- /dev/null +++ b/docs/plans/2026-09-08-0816-pool-api-and-lifecycle-redesign.md @@ -0,0 +1,416 @@ +# Hypervel pool API and lifecycle redesign + +## Status + +Implementation, integration with `0.4`, and verification of the combined code are complete. + +## Outcome and boundaries + +Make the two packages immediately distinguishable: reusable objects belong to `hypervel/object-pool`; protocol connections belong to `hypervel/connection-pool`. Use consistent names for equivalent operations while preserving their different lifecycles. Combine the naming migration with the verified correctness fixes and removal of repeated checkout work. + +Hypervel 0.4 is greenfield: remove obsolete Hypervel names instead of providing aliases. Preserve supported Laravel APIs, including named parameters and protected extension points. Pool-specific Hypervel APIs may change as specified here. Laravel conventions take precedence; ordinary pooling vocabulary fills gaps where Laravel has no matching API. + +Keep useful capabilities even without a present first-party consumer. No shared generic pool superclass, strategy registry, class-string configuration, replacement options interface, generic maintenance framework, clock service, or extra maintained occupancy counter. Benchmark only when workload behavior, regression uncertainty, or added complexity could change the design decision. + +## Final names and contracts + +Paths below are relative to the components repository. Old identifiers in the mapping describe migration work; they are not compatibility APIs. + +| Existing | Final | +|---|---| +| `hypervel/pool`, `src/pool`, `Hypervel\Pool`, `tests/Pool` | `hypervel/connection-pool`, `src/connection-pool`, `Hypervel\ConnectionPool`, `tests/ConnectionPool` | +| Abstract `Pool` | `ConnectionPool` | +| `PoolOption` | `PoolOptions` | +| `SimpleObjectPool` | `CallbackObjectPool` | +| `Database\Pool\DbPool` | `Database\Pool\DatabasePool` | +| Database and Redis `Pool\PoolFactory` | Each subsystem's `Pool\PoolManager` | +| `Sentry\Transport\Pool` | `Sentry\Transport\HttpTransportPool` | +| `ObjectPool\Traits\HasPoolProxy` | `ObjectPool\Concerns\HasPoolProxy` | +| `Pool\Events\ReleaseConnection` | `ConnectionPool\Events\ConnectionReleasing` | +| `Frequency` | `BorrowRateTracker` | +| `ConstantFrequency` | Explicitly owned `IdleConnectionMonitor` | + +Keep `Connection`, `KeepaliveConnection`, `ObjectPool`, `PoolDefinition`, `PoolFingerprint`, `PoolProxy`, and contextual `PoolOptions` names. Keep `HttpPoolTransport`, which implements Sentry's asynchronous transport behavior, distinct from `HttpTransportPool`, which creates and pools SDK transports. + +Move the contracts with `mv`, retaining strict native signatures and necessary generics: + +- `Hypervel\Contracts\ConnectionPool\ConnectionPool`, `Connection`, and `UsageTracker`. +- `Hypervel\Contracts\ObjectPool\ObjectPool`, `Factory`, `Recycler`, and `InvalidatesPool`. + +Remove `PoolOptionInterface`, `FrequencyInterface`, `LowFrequencyInterface`, and `ClearableFrequencyInterface`. `UsageTracker` replaces the recording/decision contract split; periodic maintenance no longer pretends to be a frequency strategy. Options and the monitor are concrete collaborators. Central contract signatures may reference optional package types lazily; do not introduce reverse Composer dependencies or cycles solely for those type names. `Factory` remains the object-pool manager contract, consistent with Laravel's factory/manager convention. + +### Pool and manager APIs + +| Operation | Final API and meaning | +|---|---| +| Acquire ownership | `borrow()` instead of pool `get()` | +| Return or dispose | `release()` and `discard()`; preserve ownership validation | +| Terminal teardown | `close()` and `isClosed()` | +| Options | `getOptions()` and protected `$options` | +| Instantiated resources owned by pool | `getManagedCount()` | +| Application checkouts | `getBorrowedCount()` | +| Available resources | `getIdleCount()` | +| Coroutines waiting for capacity | `getWaitingCount()` | +| Snapshot | `getStats(): array{managed: int, borrowed: int, idle: int, waiting: int, closed: bool}` | +| Connection excess-idle trimming | `trimExcessIdle()` replaces connection `flush()` | +| Object age maintenance | Keep `trimIdle()` and `sweepExpired()` | +| Whole object-pool inactivity | `isIdleExpired()` replaces `isIdle()` | +| Manager resolve/create | `pool()` replaces connection managers' `getPool()` | +| Registry inspection | `getPools()` replaces `pools()`; object `getDefinition()` replaces `definition()` | +| Detach and close one | `purge()` replaces object `remove()` and connection `flushPool()` | +| Detach and close all | `purgeAll()` replaces manager `flush()`/`flushAll()` | +| All physical variants of a DB connection | `purgeForConnection()` replaces `flushPoolsForConnection()` | +| Object proxy pool name | Keep `getPoolName()`, returning the fully qualified registry name consistently | + +Both pool contracts expose the count and snapshot APIs. Managed counts exclude in-flight creation reservations but include owned resources undergoing maintenance or destruction. Therefore managed need not equal borrowed plus idle during a yield. Capacity enforcement still includes creation reservations. Counts remain O(1) reads of existing state. + +`trimExcessIdle()` destroys only idle connections while the managed count exceeds `minRetainedConnections`, independent of idle age. The retention floor is not a target idle count, eager creation policy or guarantee after failures. Object `trimIdle()` additionally requires the individual idle-age threshold; `sweepExpired()` applies absolute lifetime regardless of that floor. Do not collapse these distinct operations just to match names. + +Use “Close excess idle connections without checking their age.” as the connection method's contract/concrete docblock title, with the retained-minimum condition beneath it. Keep this documented user operation on the contract. `checkIdleConnection()` stays concrete: it is the supplied monitor's maintenance primitive, not a required operation for every contract-only pool. + +Keep `Lease::get()` as access to an already-held object. Keep connection-level `getConnection()` and keepalive `call()`; these expose different underlying-resource models. Keep pool and configured connection `getName()` accessors and database physical-name resolution; do not rename every occurrence of `getPoolName` blindly. + +Preserve object `getOrCreate($definition, $createCallback)`, required existing-only `get($identity)`, and `has($identity)`. A missing required lookup keeps its existing framework/native exception; do not add a nullable synonym or `PoolNotFoundException`. Document that `has()` does not reserve registry membership across a yield. Rename database pool manager `hasPool()` to `has()`, retaining its physical-name resolution; do not add new presence/lookup methods to other managers merely for symmetry. + +Keep optional expected-instance comparison and existing boolean returns on targeted purge. Detach registry entries and definitions before yielding cleanup. Do not reset outstanding reservations during manager purge while borrowed resources still own them. + +Rename creation callback parameters/properties to `$createCallback` in `CallbackObjectPool`, object manager, proxy and concern where they create a resource. Preserve distinct `$releaseCallback` and `$destroyCallback`. Rename numeric `$creating`/`$acquiring` to `$creatingCount`/`$acquiringCount`. Rename concrete pool-manager dependencies/accessors to `$poolManager`/`poolManager()`; retain object `Factory` contract terminology where it actually identifies that contract. + +Across mail, queue, filesystem and broadcasting, use `$poolableDrivers`, `addPoolableDriver()`, `removePoolableDriver()`, `getPoolableDrivers()`, and `setPoolableDrivers()`. Update facade annotations and preserve boot-only mutation warnings and existing driver-selection behavior. + +Document that `HasPoolProxy` requires the host's protected array `$poolableDrivers`. Keep differing defaults on the managers; do not add a conflicting trait property or accessor machinery solely to declare that requirement. + +## Immutable options and duration semantics + +Both packages use `final readonly PoolOptions` with public camelCase properties, a private normalized constructor, and `fromArray()`. Callers configure values before constructing the pool and read `$pool->getOptions()->maxConnections` or `->maxObjects`. Remove redundant per-field getters/setters; do not add a live-resizing mechanism. Retain `equals()` and `toArray()` only on object options, where definitions and mismatch diagnostics use them. + +| Option | Connection default | Object default | +|---|---:|---:| +| `min_retained_connections` / `min_retained_objects` | `1` | `1` | +| `max_connections` / `max_objects` | `10` | `10` | +| `connect_timeout` | `10.0` | — | +| `wait_timeout` | `3.0` | `3.0` | +| `heartbeat_interval` | `null` | — | +| `heartbeat_timeout` | `1.0` | — | +| `idle_check_interval` | `null` | — | +| `max_idle_time` | `60.0` | `null` | +| `max_lifetime` | `null` | `60.0` | +| `pool_idle_timeout` | — | `300.0` | +| `events` | `[]` | — | + +Rename `min_connections`, `heartbeat`, and object `idle_ttl` to their corresponding keys above. Rename `DEFAULT_IDLE_TTL` to `DEFAULT_POOL_IDLE_TIMEOUT`. Optional duration values are null to disable or finite positive integers/floats; reject zero, negative sentinels, strings, booleans, and non-finite numbers. `connect_timeout`, `wait_timeout`, and `heartbeat_timeout` remain finite positive non-null durations. Counts are integers, minimum at least zero, maximum at least one, minimum no greater than maximum. + +Retain distinct defaults deliberately. Generic objects have no standard health/reconnect protocol, so lifetime rotation supplies age control. Protocol connections have health/reconnect behavior and can enable absolute lifetime rotation when needed. Matching defaults mechanically would change useful behavior without a corresponding benefit. Neither pool force-kills an application-owned resource simply because it has aged. + +Normalize once with explicit presence checks: omission selects the documented default; explicit null disables a nullable option instead of falling through `??` to an enabled default. Reject unknown keys and invalid list shapes. Cast environment-backed numbers/booleans at config boundaries, preserving null before casting. Retain object definition equivalence/fingerprint rules and strict option comparisons. + +Align Hypervel-owned environment names with the renamed keys: `*_MIN_CONNECTIONS` becomes `*_MIN_RETAINED_CONNECTIONS`, and interval settings `*_HEARTBEAT` become `*_HEARTBEAT_INTERVAL`. Preserve the owning DB/DB_POOLED/REDIS/REDIS_CACHE/REDIS_SESSION/REDIS_QUEUE/REDIS_REVERB prefixes and existing inheritance. Update config examples and any matching local environment keys without printing their values. Do not rename `*_HEARTBEAT_TIMEOUT` or unrelated protocol settings. Keep `idle_check_interval` discoverable in config/docs without inventing a new family of unnecessary environment switches. + +Use this null-preserving shape for an inherited duration in a returned config array, evaluating its environment expression once: + +```php +'heartbeat_interval' => ($duration = env( + 'REDIS_CACHE_HEARTBEAT_INTERVAL', + env('REDIS_HEARTBEAT_INTERVAL', null), +)) === null ? null : (float) $duration, +``` + +Apply the same shape to all 18 heartbeat/lifetime negative-sentinel sites in foundation database config, retaining every nested fallback, and to its nine `max_idle_time` entries, whose enabled default remains 60. In `src/foundation/config/filesystems.php` and `src/foundation/config/queue.php`, change all four literal `'max_idle_time' => 0.0` entries to null and rename their `idle_ttl` keys to `pool_idle_timeout`. Reuse the local `$duration` name in nullable environment expressions; each value is consumed within its array element. Explicit environment `null` (or `(null)`) disables the setting even when its inherited value is enabled; an absent variable selects the fallback. `Env::getOption()` preserves this distinction through `Option::fromValue()->map()`. Do not cast null to zero, discard inheritance, or add a general duration-conversion service. Test omission, inherited numeric values, explicit outer null, inherited null, and positive overrides against loaded config. + +Connection `events` is a list of event class names. Validate strings and `class_exists()` once in `fromArray()`, allowing third-party classes without an allowlist, reflection layer or marker interface. Keep `hasListeners()` before constructing `ConnectionReleasing`; dispatch remains before the connection is returned, with existing exception/cancellation cleanup. + +Concrete pools preprocess raw config before base normalization. Database removes `testing_enabled`; for in-memory SQLite, clamp valid raw retained/max values to a maximum of one while retaining current invalid-input rejection. Preserve DB driver connect-timeout precedence and Redis's native timeout fallback semantics. + +In `src/sentry/src/SentryServiceProvider.php`, update `sentryPoolOptions()` explicitly: its final normalization must force `'max_idle_time' => null` and `'pool_idle_timeout' => null`, replacing zero and the old key. Retain its supported input allowlist (`max_objects`, `wait_timeout`, `max_lifetime`); none of those names change. Exercise the provider normalization through its existing config tests, not only the options value object. + +### Jitter and disabled deadlines + +On connection `PoolOptions`, change the jitter helper to an instance method: + +```php +public function jitteredLifetimeDeadline(float $createdAt): ?float +{ + if ($this->maxLifetime === null) { + return null; + } + + $factor = random_int(self::MIN_LIFETIME_JITTER_BASIS, self::LIFETIME_JITTER_SCALE) + / self::LIFETIME_JITTER_SCALE; + + return $createdAt + $this->maxLifetime * $factor; +} +``` + +Keep the existing public 9000/10000 jitter constants. Reading validated instance state removes repeated lifetime validation without allowing arbitrary unchecked durations. Keep this method on options: database `PooledConnection` implements the contract directly, whereas Redis extends the base connection, so moving it to that base would strand the database caller. + +Database/Redis `$lifetimeExpiresAt` becomes `?float = null`. Apply null-disable behavior to base connection checks, keepalive idle timeout, database/Redis expiry and heartbeat scheduling. Keep actual activity/initialization timestamps numeric: zero means not recorded, not a disabled option. In particular retain the last-release initialization checks and never refresh user activity during maintenance. + +## Maintenance without redundant machinery + +### Usage-triggered shrinking + +`Hypervel\Contracts\ConnectionPool\UsageTracker` contains: + +```php +public function recordBorrow(): void; +public function shouldTrimExcessIdle(): bool; +``` + +`BorrowRateTracker` implements it and exposes concrete `getBorrowRate(): float`. The protected `ConnectionPool::createUsageTracker(): ?UsageTracker` returns null by default; database and Redis override it to create a fresh tracker. Custom connection pools acquire no new default tracker. A custom non-rate policy can implement the contract without inheriting bucket machinery. Preserve customization through protected factory/subclass construction, not global singleton resolution or class-string config. + +Initialize lazily on the first successful acquisition in `borrow()`, immediately before recording, inside the existing maintenance exception/cancellation boundary. Never call the overridable factory from the base constructor. Cache a successful result, including null, using an initialization flag beside the nullable tracker so opted-out pools do not invoke the factory on every borrow. Set the flag only after the factory returns. The factory constructs a lightweight per-pool collaborator synchronously; it does not borrow from the pool or perform yielding I/O. No locking or general initialization framework is needed. Test a subclass whose factory reads state assigned after `parent::__construct()`, one-time null initialization, and ordinary/cancellation factory failure cleanup. + +The protected factory lets subclasses select or disable a policy using normalized configuration and completed subclass state without replacing the database/Redis constructor. Null means disabled, not “construct the default tracker.” + +The built-in tracker's sample window and initial cooldown begin at first acquisition; time before first use is not usage history. + +Replace repeated `Frequency::flush()` and `array_sum()` on checkout with once-per-second pruning/backfilling and a running count. Each borrow increments the current bucket and running count; expiration subtracts removed buckets. Rate is the running count divided by the current number of samples, returning zero before any samples exist. Preserve the 10-second default window, threshold 5 and 60-second cooldown, including the existing strict cooldown boundary. Keep window, threshold and cooldown customizable using clearly named protected settings. Bound pruning/backfill work and retained state by the configured window, including after long idle periods. + +Use `intdiv(hrtime(true), 1_000_000_000)` in protected `currentTime()` for monotonic sampling seconds. Wall-clock corrections must not retain future buckets or distort cooldowns. Keep the existing deterministic clock override and sampling tests; verify the default clock against monotonic bounds captured immediately before and after its call. No clock service or wall-clock repair logic is needed. + +Do not memoize the rate for a second: multiple borrows in that second must immediately change it, including after cooldown eligibility when high traffic prevents trimming. The extra running count is justified because otherwise that path repeatedly sums buckets. Use one captured second per operation so a clock rollover cannot split pruning and recording across different seconds. Preserve warmup and sample-boundary behavior, not just steady-state averages. + +When migrating the seeded `FrequencyStub`, synchronize its aggregate and invalidate its prune memo whenever tests replace buckets or beginning time. Do not add a production state-rebuilding API. Prefer deterministic public-behavior tests; a small protected time method is sufficient if required to replace real-time races and long sleeps. + +`borrow()` records successful ownership acquisition and asks the tracker whether to call `trimExcessIdle()`. Preserve its existing cancellation cleanup: a cancellation during maintenance disposes the just-acquired resource and rethrows the original; ordinary maintenance failures remain reported. Keep nullable direct dispatch instead of the old union property and `instanceof` branches. + +### Optional periodic idle checks + +`IdleConnectionMonitor` replaces the useful timer capability of `ConstantFrequency`; it is not merely a rename of an unused class. It is an owned concrete collaborator, with idempotent `start()`/`stop()`, enabled through `idle_check_interval`. Construct it per pool with an owned `Timer` and allow constructor timer injection for testing/custom construction. Do not resolve an unbound stateful timer/monitor as a worker-wide auto-singleton. + +Concrete `ConnectionPool::start()` prepares the configured idle monitor after accepted construction. Before activation, borrowing does not create or start it. An empty activated pool starts its prepared monitor on the first successful borrow; an already-warmed pool starts it during activation. Keep the nullable monitor as this feature's activation state, without another flag. Its existing closed-state guard handles closure during yielding usage trimming. No generic idle timer exists for a pool that never acquires a connection. + +Set an in-progress/started guard before calling `Timer::tick()`. `Coroutine::afterCreated()` hooks run synchronously before the timer ID is returned and can reenter or close the owner without suspending. Reset startup state if creation fails. If close occurs during creation, clear the returned timer instead of publishing it. Keep this a direct lifecycle guard; do not add locks, generations, a registry, or a second pending interval field. Tests must not use suspending after-created hooks, which violate that API's contract. + +Pool close marks terminal state, stops the monitor, then closes/drains its channel. A check already in flight destroys its connection rather than requeueing after closure. The timer callback respects worker exit. Restart/start calls must not produce duplicate timers or revive a closed pool. + +Check one FIFO idle connection per tick through `checkIdleConnection()`, with no extra maintenance queue. For N continuously idle connections, a pass takes roughly N intervals plus check time and scheduling; this is not a strict reclamation deadline. Custom `check()` can perform protocol I/O. It must not refresh activity timestamps. The monitor checks health/idle validity even below the retention floor; it is distinct from trimming healthy excess capacity. + +Keep database/Redis protocol heartbeat sweeps and `KeepaliveConnection` socket heartbeat. They have protocol-specific timeouts, lifetime/idle policy and cleanup. Both generic monitoring and a driver's heartbeat may be enabled; document overlapping checks instead of silently giving one precedence. Custom drivers whose health checks require an active checkout must reject a non-null `idle_check_interval`. + +### Object recycler + +The `Recycler` contract contains only `start()` and `stop()`. Its start title is “Start periodic pool maintenance.” Concrete `PoolRecycler` keeps constructor interval configuration, finite-positive validation and `getInterval()`, with optional `?Timer` injection and a fresh owned default. Remove public timer/id access and mutable timer/interval setters. Document constructor/binding customization, retaining contract/concrete service identity and worker-start/pre-fork wiring. + +Retain whole-pool idle eviction, lifetime sweeping, idle trimming, exact-instance purge, protection for acquiring/borrowed resources, and ordinary per-pool failure isolation. Do not introduce a maintenance service framework or replace these operations with the connection monitor. + +The current recycler catches `Throwable` both around an individual pool and around the scheduled maintenance call. A custom contract pool or yielding destruction can propagate cancellation, which these catches convert into an ordinary report and further maintenance. Add typed cancellation rethrows at both boundaries so the existing Timer loop can stop; keep ordinary failure isolation and do not drain other pools after a canceled maintenance operation. Verify original exception identity at the direct maintenance call and the captured timer callback. Keep the existing simple timer-ID start/stop idempotence: recycler startup is a worker-lifecycle operation, without the monitor's demonstrated borrow-path reentry. Do not add a publication guard for a hook that would have to call recycler lifecycle methods deliberately. + +## Shared channel and preserved ownership invariants + +Move the matching pool channel implementation into `Hypervel\Coroutine\PoolChannel`, with `@template T of object`, `SplQueue`, `push(object $data): bool`, and `pop(): object|false` carrying the generic return annotation. Each pool retains its typed object/connection boundaries. Move one channel with `mv` and reconcile the second before deleting its duplicate; do the same for channel tests, merging all distinct coverage into `tests/Coroutine/PoolChannelTest.php`. + +Keep the queue independent of execution mode and the native channel solely for wake signals. Preserve signal coalescing, nonblocking queue ownership, canceled-wait classification, waiter decrement in finally, close retaining queued resources for draining, non-coroutine/coroutine transitions, and the final state pass after a deadline or lost wake signal. The existing `Coroutine\Channel\Pool` caches native channels and remains a different feature. + +Both pools retain: reservation before yielding creation; maximum including creation slots; a single capacity-wait deadline; fresh-instance checks; strict foreign/double-release/discard rejection; destruction when creation finishes after close; release-after-close cleanup; no maintenance activity refresh; and exactly-once deferred leases. Keep bounded inspection of the idle population present at a sweep's start. Do not unify connection reconnection and object lifetime policy behind a shared pool abstraction. + +## Correctness repairs at their owning boundaries + +### Database and Redis pool publication + +Both managers construct named pools through contextual container resolution. Custom initialization and resolving callbacks can yield before the candidate is registered, allowing competing candidates and orphaned heartbeat timers. Heartbeat child startup alone does not suspend the constructing caller. + +Use a local lookup loop in each `pool()` method: + +1. Return an open cached pool; remove a closed entry without closing it again. Database applies this to its exact-name fast path and then its resolved physical key, preserving base/read/write mapping. +2. Construct the candidate outside the registry, call its concrete `start()` after container resolution returns, then recheck its physical key. +3. If no open entry exists, publish and return the candidate. If the registered open entry is the candidate itself, return it directly. +4. Close a distinct losing candidate, then restart lookup. Cleanup can yield while the winner closes or is replaced, so do not return a saved winner afterward. Propagate cleanup errors and cancellation unchanged without altering the registered winner. + +The loop repeats only after competing publication; it does not retry construction failures. Keep `has()` as registry membership and `getPools()` as existing-only inspection. No shared registry abstraction, lock, generation counter or new manager API is needed. Outstanding borrowed resources remain owned by the closed old pool until their release. + +For each manager, test direct-close replacement/reuse, concurrent resolution with exactly-once loser closure and no timer left after purge, a closed entry appearing during construction, candidate/winner identity, and original cleanup failure/cancellation with the winner preserved. Exercise yielding loser cleanup while the winner closes or is replaced to verify lookup returns the current open entry. Database also covers physical read/write aliases. Use boot-registered resolving callbacks and bounded coroutine coordination, never suspending `afterCreated` hooks. Close every candidate in `finally` and reuse existing test setup where practical. + +### Accepted construction and background activation + +Constructors must not start DB/Redis heartbeats. A later resolving callback can fail before the manager receives the candidate; an automatically started timer would retain that rejected pool. Warming through borrow/release can create the same root through the generic idle monitor, so both background tasks use one explicit activation boundary. + +Add concrete `ConnectionPool::start()` without changing the pool contract. Return when closed or generic monitoring is disabled; otherwise initialize the owned monitor with `??=` and start it only when managed resources exist. In `borrow()`, replace monitor creation with `$this->idleMonitor?->start()` inside the existing maintenance failure boundary. This preserves deferred scheduling for empty accepted pools and supports warming before activation without another state flag. + +DB/Redis constructors configure their owned Timer but do not schedule it. Their public `start()` calls `parent::start()` and protected `startHeartbeat()`. Heartbeat startup returns when closed, disabled or already starting/started, retaining the database shared-SQLite exemption. Set a boolean startup guard before `tick()`, reset it on failure, and hold the returned ID locally. If stopped or closed during synchronous coroutine-start hooks, clear the returned timer instead of publishing it. `clearHeartbeat()` resets the guard and stored ID before clearing the captured ID. Explicit repeated starts are idempotent and a failed timer creation permits an explicit retry. Keep the guards local; no new heartbeat service, weak-reference ownership or destructor scheme. + +Successful container resolution must provide an open pool; custom initialization throws if it fails. Managers own activation failures after `make()` returns: close that exact candidate without changing another registered winner, then propagate failure. Activation cancellation stays primary; cleanup cancellation replaces an ordinary activation failure; otherwise preserve the original activation failure. Do not retry failed activation or add exception aggregation/reporting machinery. + +Directly constructed pools call `start()` to enable background maintenance. Update direct-construction consumers, fixtures and canonical documentation; do not keep constructor auto-start through a flag. Initializers that explicitly call `start()`, spawn children or retain resources own cleanup for that explicit work. Merely borrowing/releasing before accepted construction must not root a rejected pool through framework timers. + +Test failed resolving callbacks before/after warming with heartbeat and generic monitoring enabled: no registered pool, timer or live weak reference after collection. Cover empty/warmed activation, first-borrow scheduling, repeated start, close/no-restart, timer creation failure/retry and close during timer publication. Test manager activation failures with exact exception identity/precedence and candidate cleanup. Concurrent publication expects no timer on the candidate blocked in resolution, then one accepted winner timer and none after complete cleanup. + +### Connection acquisition and release + +Base `Connection::getConnection()` delegates once to `getActiveConnection()` and preserves its result or original failure. Do not retry arbitrary throwables: programming errors, invalid configuration, authentication failures and completed deadlines are not a generic pool recovery policy. Redis retains its existing check/reconnect paths and native retry settings. Database's independent wrapper is unchanged. Replace the blanket-retry test with success and one-attempt/original-exception coverage for ordinary errors, `TypeError` and cancellation; remove the failure-once fixture behavior. + +Simplify `Connection::release()` into event handling followed by one pool release. A private `dispatchReleasingEvent()` owns timestamp/event dispatch and ordinary listener-error logging, with cancellation passed through. Capture any escaping throwable, attempt pool release once, then apply these rules: + +- Ordinary listener failure is logged and swallowed when logging succeeds. +- Listener or logger cancellation stays primary over cleanup failure; ordinary secondary cleanup errors are reported without replacing that cancellation. +- An ordinary logger failure propagates when cleanup succeeds; cleanup failure takes precedence otherwise. Do not retry an already-failing logger to report its own failure. +- Cleanup failure propagates when no earlier failure exists. + +Preserve `hasListeners()` and event-before-return ordering. Test ordinary logger failure with successful and failing cleanup alongside existing cancellation and exactly-once return coverage. Do not introduce a general exception or cleanup service. + +### Shared SQLite resource closure + +In `DatabasePool::close()`, clear `$sharedInMemorySqlitePdo` in a `finally` around `parent::close()`, keeping heartbeat shutdown first. Parent closure can propagate cancellation after marking the pool closed; a retained closed pool must not keep exposing its shared PDO. Preserve the original exception. Add a focused test using a real shared SQLite pool and a controlled cancellation from its owned connection's close, asserting cancellation identity and the cleared reference. Base drain/count/idempotence matrices need not be duplicated. + +### Complete detached-manager drains + +Object `PoolManager::flush()` detaches everything and then uses a bare close loop. A thrown close abandons later detached pools. Make every relevant manager's `purgeAll()` attempt the complete finite set, keeping the first ordinary failure and first cancellation separately and preferring cancellation afterward. Database already supplies the direct `closePools()` pattern; Redis has equivalent behavior that can use the clearer typed catches. Do not introduce a shared helper service. + +```php +$firstException = null; +$firstCancellation = null; + +foreach ($pools as $pool) { + try { + $pool->close(); + } catch (CanceledException $exception) { + $firstCancellation ??= $exception; + } catch (Throwable $exception) { + $firstException ??= $exception; + } +} + +if ($firstCancellation !== null) { + throw $firstCancellation; +} + +if ($firstException !== null) { + throw $firstException; +} +``` + +Detach the entire selected set before entering this loop. A concurrent replacement remains registered and open. Retain object definition cleanup and database read/write variant selection. + +### Custom connection cleanup + +`destroyConnection()` unsets ownership and signals capacity in finally, then propagates cancellation. Expose protected `ConnectionPool::ensureManaged(ConnectionContract $connection): int`, returning the validated object ID, so driver overrides can establish ownership before cleanup. Preserve the existing error and distinct channel/borrowed-ownership diagnostics. Overrides must not release reservations for rejected foreign or duplicate destruction; preserve primary cancellation if secondary cleanup fails. + +### Generic checks and database/Redis heartbeat disposal + +`ConnectionPool::checkIdleConnection()` currently catches cancellation as an ordinary failed check. Database and Redis `heartbeatConnection()` also put destruction inside the same catch region as health evaluation: cancellation from destruction is caught and causes a second destruction of an already-unmanaged connection. + +Guard only expiry/health evaluation. On evaluation cancellation, dispose the currently owned idle connection exactly once and rethrow the original cancellation over secondary cleanup failures. Ordinary evaluation failures are reported and select disposal. Perform normal requeue/destruction outside those catches; cancellation during that disposal propagates directly and cannot trigger another disposal. Preserve database's open-transaction diagnostic and each driver's timeout/late-completion cleanup. + +```php +try { + $healthy = $connection->check(); +} catch (CanceledException $cancellation) { + try { + $this->destroyConnection($connection); + } catch (CanceledException) { + } catch (Throwable $exception) { + $this->report($exception); + } + + throw $cancellation; +} catch (Throwable $exception) { + $this->report($exception); + $healthy = false; +} + +if ($healthy && ! $this->closed) { + $this->requeueConnection($connection); +} else { + $this->destroyConnection($connection); +} +``` + +Adapt the evaluation to each driver's lifetime, retained-floor and health rules; use its existing disposal routine for protocol diagnostics. A small named decision method is acceptable where it makes those branches clearer. Do not add a heartbeat strategy or an ownership guard masking double disposal. A canceled sweep stops after its current resource is cleaned up; it does not drain every other idle connection. `Timer` already contains callback cancellation at its loop boundary. + +### Keepalive socket ownership + +Each reconnect publishes a new native channel. Use that channel object as the socket identity; no generation counter, state wrapper or lock. In `call()`, capture the channel being popped and inspect canceled status on that instance immediately after a false return. Cancellation becomes `CanceledException`; timeout or closed-channel failure remains `SocketPopException`. A failed ordinary waiter owns no socket and must not clear another caller's connection. + +Only refresh activity after successful callback execution and requeue in finally when the captured channel is still current and connected. Otherwise drop the socket without protocol work that could replace a primary exception. Document that subclasses must return resources whose release/destructor closes the underlying socket. + +`isTimeout()` returns false when disconnected, including before the first connection and after close, before reading its channel. Retain the non-nullable channel and the nullable previous-channel reads in close/reconnect; do not allocate an eager channel or spread nullable fallbacks through connected paths. Test the public predicate's initial, connected, closed and disabled-expiry states. + +`close()` captures its channel. Retain the inner finally clearing state before requeue, and add an outer finally for acquisition failure; each clears only if the captured channel is still current. Keep the existing connected check before `sendClose()` on the socket actually held. Remove the duplicate clear from `closeAfterFailure()`; it suppresses secondary cleanup failure only. Preserve public/protected signatures. + +The heartbeat callback captures its channel before protocol work. Guard failure cleanup against a replacement, not ordinary logging. Catch cancellation before `Throwable`, clean up only its own connection, then rethrow the original without ordinary error logging. `Timer::tick()` contains cancellation. Publish a locally returned heartbeat timer ID only if its channel remains current and connected; otherwise clear it. This handles non-suspending `afterCreated` hooks that close the ready connection before timer creation returns. + +Concurrent reconnects can create multiple sockets and overwrite a live timer ID. After `getActiveConnection()` returns, check `isConnected()`: if another attempt supplied a live connection, protocol-close the unused socket without touching shared state or starting a timer. Report ordinary close failure through the existing logger/error-log behavior; propagate cancellation. If the other connection has already closed, publish the newly created socket normally. + +Capture the previous channel immediately before publishing its replacement. Establish replacement connection/heartbeat state before closing the previous channel, including on heartbeat-startup failure, so awakened waiters see settled state and do not wait on an abandoned channel until timeout. The startup failure catch cleans up only its own candidate channel. Do not retain a pre-creation snapshot as the loser predicate or close target: another connection can be published and closed during creation. + +Keepalive tests use creation/heartbeat/close callbacks on the existing fixture for bounded interleavings while retaining explicitly supplied protocol objects. Cover stale holder requeue/activity, late close/heartbeat failure, concurrent reconnect winner/loser cleanup, replacement after a winner closes, prompt old-channel waiter failure, and close during timer publication. Include heartbeat enabled/disabled explicit-close cases and primary cancellation identity. + +### Precise exhaustion handling in Sentry + +Add `PoolExhaustedException` and `PoolClosedException` directly under each pool package's `Exceptions`, extending `RuntimeException`. Throw them only at the capacity-wait exhaustion and closed-borrow boundaries, including closure during creation. Keep ownership/factory errors distinct, with their current native/framework failures. + +`Sentry\Transport\HttpPoolTransport::send()` currently catches any `RuntimeException` from pool acquisition and labels it skipped. Catch only the object-pool exhausted/closed exceptions. Preserve asynchronous send, lease/release/discard, rate-limit state and shutdown behavior. Unexpected factory/ownership errors must reach the SDK's existing error handling; do not add another wrapper hierarchy. + +## Observability + +Use Hypervel's Laravel-derived Sentry integration and its own OpenTelemetry implementation as the implementation references. + +In Sentry `Features/RedisFeature.php`, replace the misleading `db.redis.pool.using` mapping of total managed resources with explicit `managed`, `borrowed`, `idle`, and `waiting` attributes. Keep max/idle-time observations and retain `db.redis.pool.max_idle_time` with a null value when disabled; the Sentry SDK accepts null span data. Update the impossible idle 5/managed 2 fixture to realistic managed 7/idle 5/borrowed 2. Preserve sampling and feature guards before observation work. + +Make Redis span observation existing-only: use the renamed manager's `getPools()[$event->connectionName] ?? null`, never its create-capable `pool()` method. The current `getPool()` call can create/cache a replacement and start a heartbeat timer after a concurrent purge or an event from a separately constructed connection. Record the command span regardless of registry presence; add pool name/options/count fields only when a currently registered pool exists. Do not introduce a new manager lookup API for this single observer. Cover absent and detached pools, asserting the command remains traced and no pool or timer is created. + +For both connection and object OpenTelemetry counts, `used = managed - idle`; maintenance/disposal can occupy resources without an application borrower. Keep the existing OpenTelemetry instrument/attribute names and idle/used states. The object observer currently emits borrowed as used and loses this occupied capacity during yielding destruction; update it and its test. Explicit pool `getBorrowedCount()` remains the application ownership count. Do not introduce another maintained state counter. + +Observe live manager registries in O(number of pools), with O(1) per-pool count reads. Keep disabled-instrument guards and avoid allocating stats when only a maximum is requested. Do not create pools, run health checks or perform network work solely to collect metrics. Do not add a timer per observed pool that captures obsolete instances. Retain metric identity/cardinality guidance in `src/docs/opentelemetry.md`. + +## Source and research anchors + +These references explain decisions; they do not make upstream structure a porting target. + +| Evidence | Consequence | +|---|---| +| Laravel `src/Illuminate/Contracts`, database `DatabaseManager::purge()`/`getConnections()`, Redis manager and existing subsystem managers | Central contracts, clear manager responsibilities and lifecycle names; preserve actual Laravel APIs instead of globally renaming similarly spelled methods. | +| Hyperf `docs/en/pool.md`, `src/pool/src/{Frequency,ConstantFrequency}.php`; db-connection/db/redis/json-rpc pool construction; `CHANGELOG-3.0.md` PR 6099 | Usage shrinking and periodic checking are distinct useful features; preserve both and customizable policies. The [Hyperf pool guide](https://github.com/hyperf/hyperf/blob/master/docs/en/pool.md) documents replacement policies. | +| Hypervel `src/pool/src/Pool.php`, `Connection.php`, and `tests/Pool/ConnectionTest.php` | Preserve ownership bookkeeping and the existing regression that health checks do not refresh activity. | +| `src/coordinator/src/Timer.php`, `src/coroutine/src/Coroutine.php`, `src/queue/src/CoroutineQueue.php` | Existing owned timer injection/cancellation support; synchronous non-suspending startup hooks require a small publication guard. | +| Database `Pool/PoolFactory::closePools()` and `Pool/PooledConnection::close()` | Existing direct drain/error-precedence and finally-cleanup patterns. | +| [FriendsOfHyperf pool watcher](https://github.com/friendsofhyperf/sentry/blob/main/src/Metrics/Listener/PoolWatcher.php), adjacent DB/Redis watchers, [tracing listener](https://github.com/friendsofhyperf/sentry/blob/main/src/Tracing/Listener/EventHandleListener.php), and `Transport/CoHttpTransport.php` | Counts/options suffice for external instrumentation. Its managed-as-in-use and idle-as-waiting labels must not be copied. Its transport architecture is not Hypervel's target. | +| `src/opentelemetry/src/Instrumentation/PoolInstrumentation.php`, installed sem-conv DB state constants, [OpenTelemetry database metrics](https://opentelemetry.io/docs/specs/semconv/db/database-metrics/) | Idle/used metric states remain distinct from application borrowed ownership; derive occupied capacity from existing state. | + +Hypervel's connection pool becomes independently maintained. Update its minimal README/package identity and remove the upstream-tracking line while retaining historical credit in canonical `src/docs/pools.md`. + +## Package integration + +Update root/split metadata, autoloading, facade annotations, CI configuration and active documentation. Object-pool no longer directly requires Engine; connection-pool still does. `bin/split.sh` derives repository names from source directories, so publishing requires a `hypervel/connection-pool` destination. + +### Coordinated application-skeleton migration + +The separate `hypervel/framework` wrapper requires `hypervel/pool`. Replace that requirement with `hypervel/connection-pool` and validate its dependency graph with the updated split packages. + +The `hypervel/hypervel` application skeleton requires a companion configuration migration: otherwise generated applications supply removed keys/sentinels and fail as soon as the corresponding pool is constructed. + +Update its `config/database.php` with the same connection option/environment names, nullable heartbeat/lifetime/idle values, retained defaults and inherited fallbacks specified above. In `config/filesystems.php` and `config/queue.php`, replace `idle_ttl` with `pool_idle_timeout` and disabled `max_idle_time: 0.0` with null; keep the existing retained-object and positive lifetime defaults. Check its config comments, examples and environment templates for matching active references. The committed components testbench application skeleton has no matching pool keys to migrate; its separate testing fixtures still need the inventory already specified. + +Validate loaded default configs through the new option factories, null/inheritance cases and application bootstrap with the paired framework dependency. Coordinate release sequencing so the framework/package rename ships with a compatible application skeleton. + +Preserve the application skeleton's Laravel-style `//` placeholders and set `no_empty_comment` to false in its formatter rules. Keep normalized migration imports and same-line anonymous-class braces, matching Hypervel migrations. Do not change the components formatter policy. Verify the configuration loads and a second skeleton formatter run makes no changes. + +## Testing and acceptance + +Mechanical renames migrate existing tests; behavioral changes receive focused regression coverage. Preserve existing assertions about supported behavior and replace tests of removed mutable APIs with tests of their approved immutable construction behavior. Do not weaken ownership checks or introduce production-only test hooks. New tests extend the Hypervel bases, use realistic resources/fixtures and bounded deterministic synchronization, and clean up owned children/timers in finally. + +| Area / current test anchors | Required verification | +|---|---| +| `tests/Pool/{PoolTest,PoolNonCoroutineTest,ConnectionTest,PoolOptionTest}.php` → `tests/ConnectionPool` with corresponding class names | Defaults/unknown keys/counts/types; every optional null-disable case and required-positive rejection; event list/class validation including third-party class; jitter bounds/null; capacity reservations versus managed count; borrowed count/stats; closed/exhausted exception distinctions; one deadline, cancellation, strict ownership, release/creation after close and no timestamp refresh. | +| Both current `ChannelTest.php` files → `tests/Coroutine/PoolChannelTest.php` | Merge distinct cases: FIFO, queue survives signal close for drain, wait cancellation and waiter cleanup, coalescing/cross-mode signals, non-coroutine use and final-state retry after deadline. Keep both pool-level non-coroutine suites. | +| `tests/Pool/FrequencyTest.php` → `BorrowRateTrackerTest.php`; monitor cases → `IdleConnectionMonitorTest.php` | Rate initially zero, warmup/sample divisor, expired boundary, immediate same-second increments, cooldown boundary, high-traffic eligible cooldown, long idle, custom settings and running-count invariant. Pool-level tracker tests cover post-construction subclass state, first-borrow timing, cached null opt-out and factory failure cleanup. Monitor opt-in/default off, no constructor start, first successful borrow, fresh per-pool ownership, FIFO health checks, no activity refresh, start reentry/failure, close during startup/check, shutdown and no retained timer. | +| `tests/ObjectPool/{ObjectPoolTest,ObjectPoolNonCoroutineTest,PoolOptionsTest,PoolManagerTest,PoolRecyclerTest,PoolProxyTest,HasPoolProxyTest,LeaseTest,SimpleObjectPoolTest,PoolDefinitionTest,PoolFingerprintTest,ObjectPoolServiceProviderTest}.php` | Updated names/options and immutable equivalence; new exception types; all-pool close despite ordinary error/cancellation; cancellation priority; expected-instance replacement; acquiring/borrowed idle-eviction prevention; recycler constructor injection, service alias identity, cancellation through both catches and ordinary start/stop idempotence; complete lifecycle/lease/reset/deferred coverage. Rename callback-pool test and migrate rather than lose timer/setter capability assertions. | +| `tests/ObjectPool/PoolErrorReporterTest.php` | Retain its existing behavior coverage unchanged: the reporter's API/semantics are not being redesigned. Include it in the affected object-pool suite; the recycler fixes must propagate cancellation before calling the reporter, rather than changing its deliberately non-throwing reporting contract. | +| `tests/Pool/HeartbeatConnectionTest.php` → `tests/ConnectionPool/KeepaliveConnectionTest.php` | Canceled false pop versus timeout; typed heartbeat cancellation/no ordinary log; original exception survives close failure; ordinary canceled waiter preserves active owner's socket; explicit close timeout/cancellation clears state and timer with heartbeat enabled/disabled; active holder cannot requeue afterward; normal successful close/send failure behavior. | +| `tests/Database/PoolFactoryTest.php`, `tests/Redis/PoolFactoryTest.php`, corresponding lifecycle tests | Manager naming and physical lookup; detach before yielding close; complete drain and exception precedence; registry replacement survives old closure. Rename test files/classes to PoolManager. | +| `tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php`, `tests/Redis/RedisPoolHeartbeatTest.php`, teardown lifecycle tests | Typed/native cancellation through actual health path, one destruction, original cancellation survives secondary failure, disposal cancellation never causes second destruction, canceled sweep leaves later resources untouched, closed-during-check disposal, timeout/late completion, retention/lifetime, transaction diagnostic, disabled heartbeat, no extra application instrumentation. Rename DatabasePool test paths/classes coherently. | +| DB `PooledConnectionTest`, SQLite shared-PDO/pool tests, Redis connection/cancellation/event/proxy suites | Null idle/lifetime semantics, reconnect generation jitter, no borrowed-expiry interruption, SQLite one-owner/raw-invalid-input behavior, native timeout settings and existing coroutine pinning/reset/error semantics. | +| `tests/Sentry/{PoolTest,HttpPoolTransportTest,Features/RedisIntegrationTest,Features/StorageIntegrationTest,ConfigTest}.php`, OTel `Instrumentation/PoolInstrumentationTest.php` | New transport-pool name; expected exhausted/closed is skipped, arbitrary factory error escapes; SDK rate-limit state preserved; real readonly options and explicit null idle timeout in span data; realistic counts; absent/detached Redis registry still records the command without creating pools/timers; used includes yielding object cleanup and connection maintenance; explicit borrowed distinct; disabled/max-only observations avoid unnecessary work; replaced pools disappear from collected registries. | +| Filesystem/mail/queue/broadcast manager/proxy suites and service integrations | Poolable driver methods/facades; identity access; purge versus forget; resource equivalence; deferred streams/job leases; SDK/client reuse and supported Laravel signatures unchanged. Include test-support consumers in Foundation/Testbench and observability storage wrappers. | + +For tests requiring new support types, use existing `Fixtures` directories or test-local helpers. Prefer regression cases through the existing concrete hooks instead of new monitoring registries, clocks or synthetic invalid coroutine lifecycle hooks. Cover real yields with controlled children/barriers, not unbounded polling or long timing sleeps. + +Run framework tests from the repository root: + +```sh +./vendor/bin/phpunit --no-progress tests/ConnectionPool/PoolOptionsTest.php +``` + +Run `composer lint:fix`, `composer analyse`, then the targeted suites. Complete the full framework suite with `composer test:parallel`, the Testbench package-mode suite and dogfood checks. Choose an explicit worker count that fits available memory and Redis database allocation. Use existing service traits and CI configuration; configured-but-unreachable services are failures. + +Finish with Composer metadata/autoload checks, `git diff --check`, and searches for renamed symbols/keys, old getters/setters and inaccurate metric labels. Inspect matches: generic `get`, `flush`, `heartbeat`, real connection names, Laravel methods, attribution and historical plans can be legitimate. No stale active API examples, duplicate channels, obsolete frequency contracts or unused fixtures should remain. diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index 17b6d67154..68ebf6eee4 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -35,12 +35,12 @@ "symfony/http-kernel": "^8.1", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", + "hypervel/connection-pool": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/object-pool": "^0.4", - "hypervel/pool": "^0.4", "hypervel/queue": "^0.4", "hypervel/routing": "^0.4", "hypervel/support": "^0.4" diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 5c215dc714..4453b17a5c 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -23,12 +23,12 @@ use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Foundation\CachesRoutes; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\Factory as Queue; use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Request; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; -use Hypervel\ObjectPool\Traits\HasPoolProxy; +use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Queue\Attributes\Connection as ConnectionAttribute; use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\Attributes\ReadsQueueAttributes; @@ -70,7 +70,7 @@ class BroadcastManager implements BroadcastingFactoryContract /** * The array of drivers which will be wrapped as pool proxies. */ - protected array $poolables = []; + protected array $poolableDrivers = []; /** * Create a new manager instance. @@ -310,7 +310,7 @@ protected function resolve(string $name): Broadcaster $constructionConfig = Arr::except($config, ['pool']); - return in_array($config['driver'], $this->poolables, true) + return in_array($config['driver'], $this->poolableDrivers, true) ? $this->createPoolProxy( $config['driver'], fn () => $this->doResolve(null, $constructionConfig), @@ -530,7 +530,7 @@ public function purge(UnitEnum|string|null $name = null): void $config = $this->getConfig($name); - if (is_null($config) || ! in_array($config['driver'], $this->poolables, true)) { + if (is_null($config) || ! in_array($config['driver'], $this->poolableDrivers, true)) { return; } @@ -541,7 +541,7 @@ public function purge(UnitEnum|string|null $name = null): void $constructionConfig, ); - $this->poolFactory()->remove($definition->identity); + $this->poolFactory()->purge($definition->identity); } /** diff --git a/src/broadcasting/src/Broadcasters/RedisBroadcaster.php b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php index 466e7bca61..4335463cbb 100644 --- a/src/broadcasting/src/Broadcasters/RedisBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php @@ -5,10 +5,10 @@ namespace Hypervel\Broadcasting\Broadcasters; use Hypervel\Broadcasting\BroadcastException; +use Hypervel\ConnectionPool\Exceptions\ConnectionException; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Http\Request; -use Hypervel\Pool\Exceptions\ConnectionException; use Hypervel\Support\Arr; use RedisClusterException; use RedisException; diff --git a/src/pool/LICENSE.md b/src/connection-pool/LICENSE.md similarity index 100% rename from src/pool/LICENSE.md rename to src/connection-pool/LICENSE.md diff --git a/src/pool/README.md b/src/connection-pool/README.md similarity index 55% rename from src/pool/README.md rename to src/connection-pool/README.md index 0ed4f93e45..602db73d02 100644 --- a/src/pool/README.md +++ b/src/connection-pool/README.md @@ -1,8 +1,6 @@ -Pool for Hypervel +Connection Pool for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/pool) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/connection-pool) Documentation: https://hypervel.org/docs/pools#connection-pools - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/pool diff --git a/src/pool/composer.json b/src/connection-pool/composer.json similarity index 92% rename from src/pool/composer.json rename to src/connection-pool/composer.json index 78f6d8590c..fe43955829 100644 --- a/src/pool/composer.json +++ b/src/connection-pool/composer.json @@ -1,5 +1,5 @@ { - "name": "hypervel/pool", + "name": "hypervel/connection-pool", "type": "library", "description": "Connection pooling for Hypervel packages.", "license": "MIT", @@ -26,7 +26,7 @@ }, "autoload": { "psr-4": { - "Hypervel\\Pool\\": "src/" + "Hypervel\\ConnectionPool\\": "src/" } }, "require": { diff --git a/src/connection-pool/src/BorrowRateTracker.php b/src/connection-pool/src/BorrowRateTracker.php new file mode 100644 index 0000000000..c2ff7abbb8 --- /dev/null +++ b/src/connection-pool/src/BorrowRateTracker.php @@ -0,0 +1,124 @@ + + */ + protected array $borrows = []; + + protected int $window = 10; + + protected int $threshold = 5; + + protected int $cooldown = 60; + + protected ?int $startedAt = null; + + protected ?int $lastTrimAt = null; + + protected ?int $lastPrunedAt = null; + + protected int $borrowCount = 0; + + /** + * Record a successful connection borrow. + */ + public function recordBorrow(): void + { + $now = $this->currentTime(); + + if ($this->startedAt === null) { + $this->startedAt = $now; + $this->lastTrimAt = $now; + } + + $this->prune($now); + $this->borrows[$now] = ($this->borrows[$now] ?? 0) + 1; + ++$this->borrowCount; + } + + /** + * Return the average number of borrows per sampled second. + */ + public function getBorrowRate(): float + { + if ($this->startedAt === null) { + return 0.0; + } + + $this->prune($this->currentTime()); + $sampleCount = count($this->borrows); + + return $sampleCount === 0 ? 0.0 : $this->borrowCount / $sampleCount; + } + + /** + * Determine whether low usage and the cooldown permit trimming. + */ + public function shouldTrimExcessIdle(): bool + { + if ($this->lastTrimAt === null) { + return false; + } + + $now = $this->currentTime(); + + if ($this->lastTrimAt + $this->cooldown >= $now) { + return false; + } + + $this->prune($now); + $sampleCount = count($this->borrows); + + if (($sampleCount === 0 ? 0.0 : $this->borrowCount / $sampleCount) < $this->threshold) { + $this->lastTrimAt = $now; + + return true; + } + + return false; + } + + /** + * Remove expired samples and fill completed seconds without borrows. + */ + protected function prune(int $now): void + { + if ($this->lastPrunedAt === $now) { + return; + } + + $latest = $now - $this->window + 1; + + foreach ($this->borrows as $second => $count) { + if ($second < $latest) { + $this->borrowCount -= $count; + unset($this->borrows[$second]); + } + } + + for ($second = max($this->startedAt, $latest); $second < $now; ++$second) { + $this->borrows[$second] ??= 0; + } + + $this->lastPrunedAt = $now; + } + + /** + * Return the current sampling second. + */ + protected function currentTime(): int + { + return intdiv(hrtime(true), 1_000_000_000); + } +} diff --git a/src/pool/src/Connection.php b/src/connection-pool/src/Connection.php similarity index 54% rename from src/pool/src/Connection.php rename to src/connection-pool/src/Connection.php index 1cb45b1a16..19793fb199 100644 --- a/src/pool/src/Connection.php +++ b/src/connection-pool/src/Connection.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Hypervel\Pool; +namespace Hypervel\ConnectionPool; +use Hypervel\ConnectionPool\Events\ConnectionReleasing; +use Hypervel\Contracts\ConnectionPool\Connection as ConnectionContract; +use Hypervel\Contracts\ConnectionPool\ConnectionPool as ConnectionPoolContract; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\ConnectionInterface; -use Hypervel\Contracts\Pool\PoolInterface; -use Hypervel\Pool\Events\ReleaseConnection; use Swoole\Coroutine\CanceledException; use Throwable; -abstract class Connection implements ConnectionInterface +abstract class Connection implements ConnectionContract { protected float $lastUseTime = 0.0; @@ -27,7 +27,7 @@ abstract class Connection implements ConnectionInterface public function __construct( protected Container $container, - protected PoolInterface $pool + protected ConnectionPoolContract $pool ) { if ($this->container->bound('events')) { $this->dispatcher = $this->container->make('events'); @@ -43,45 +43,53 @@ public function __construct( */ public function release(): void { - $cancellation = null; + $failure = null; try { - try { - $this->lastReleaseTime = hrtime(true) / 1e9; - $events = $this->pool->getOption()->getEvents(); - - if (in_array(ReleaseConnection::class, $events, true) - && $this->dispatcher?->hasListeners(ReleaseConnection::class) - ) { - $this->dispatcher->dispatch(new ReleaseConnection($this)); - } - } catch (CanceledException $exception) { - $cancellation = $exception; - } catch (Throwable $exception) { - $this->logger?->error((string) $exception); + $this->dispatchReleasingEvent(); + } catch (Throwable $exception) { + $failure = $exception; + } + + try { + $this->pool->release($this); + } catch (Throwable $exception) { + if (! $failure instanceof CanceledException) { + throw $exception; } - } catch (CanceledException $exception) { - // Logging an ordinary listener failure may itself be canceled. - $cancellation = $exception; - } finally { - if ($cancellation === null) { - $this->pool->release($this); - } else { + + // Preserve the listener or logger cancellation over secondary cleanup failures. + if (! $exception instanceof CanceledException) { try { - $this->pool->release($this); - } catch (CanceledException) { - // The listener or logger cancellation remains primary. - } catch (Throwable $exception) { - try { - $this->logger?->error((string) $exception); - } catch (Throwable) { - } + $this->logger?->error((string) $exception); + } catch (Throwable) { } } } - if ($cancellation !== null) { - throw $cancellation; + if ($failure !== null) { + throw $failure; + } + } + + /** + * Dispatch the release event and report ordinary listener failures. + */ + private function dispatchReleasingEvent(): void + { + try { + $this->lastReleaseTime = hrtime(true) / 1e9; + $events = $this->pool->getOptions()->events; + + if (in_array(ConnectionReleasing::class, $events, true) + && $this->dispatcher?->hasListeners(ConnectionReleasing::class) + ) { + $this->dispatcher->dispatch(new ConnectionReleasing($this)); + } + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $this->logger?->error((string) $exception); } } @@ -94,19 +102,11 @@ public function discard(): void } /** - * Get the underlying connection, with retry on failure. + * Get the underlying connection. */ public function getConnection(): mixed { - try { - return $this->getActiveConnection(); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable $exception) { - $this->logger?->warning('Get connection failed, try again. ' . $exception); - - return $this->getActiveConnection(); - } + return $this->getActiveConnection(); } /** @@ -118,10 +118,10 @@ public function check(): bool return false; } - $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); + $maxIdleTime = $this->pool->getOptions()->maxIdleTime; $now = hrtime(true) / 1e9; - if ($now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { + if ($maxIdleTime !== null && $now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { return false; } diff --git a/src/pool/src/Pool.php b/src/connection-pool/src/ConnectionPool.php similarity index 67% rename from src/pool/src/Pool.php rename to src/connection-pool/src/ConnectionPool.php index b6b7c532a7..0e043a3cb9 100644 --- a/src/pool/src/Pool.php +++ b/src/connection-pool/src/ConnectionPool.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace Hypervel\Pool; +namespace Hypervel\ConnectionPool; +use Hypervel\ConnectionPool\Exceptions\PoolClosedException; +use Hypervel\ConnectionPool\Exceptions\PoolExhaustedException; +use Hypervel\Contracts\ConnectionPool\Connection as ConnectionContract; +use Hypervel\Contracts\ConnectionPool\ConnectionPool as ConnectionPoolContract; +use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\ConnectionInterface; -use Hypervel\Contracts\Pool\FrequencyInterface; -use Hypervel\Contracts\Pool\PoolInterface; -use Hypervel\Contracts\Pool\PoolOptionInterface; -use InvalidArgumentException; +use Hypervel\Coroutine\PoolChannel; use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -18,11 +19,12 @@ /** * Manage reusable connections with explicit ownership and terminal teardown. */ -abstract class Pool implements PoolInterface +abstract class ConnectionPool implements ConnectionPoolContract { - protected Channel $channel; + /** @var PoolChannel */ + protected PoolChannel $channel; - protected PoolOptionInterface $option; + protected PoolOptions $options; /** @var array */ protected array $managedConnections = []; @@ -30,11 +32,15 @@ abstract class Pool implements PoolInterface /** @var array */ protected array $borrowedConnections = []; - protected int $creating = 0; + protected int $creatingCount = 0; protected bool $closed = false; - protected FrequencyInterface|LowFrequencyInterface|null $frequency = null; + protected ?UsageTracker $usageTracker = null; + + protected bool $usageTrackerInitialized = false; + + protected ?IdleConnectionMonitor $idleMonitor = null; /** * Create a connection pool. @@ -44,9 +50,8 @@ public function __construct( protected string $name, array $config = [] ) { - $this->initOption($config); - - $this->channel = new Channel($this->option->getMaxConnections()); + $this->options = PoolOptions::fromArray($config); + $this->channel = new PoolChannel($this->options->maxConnections); } /** @@ -58,28 +63,47 @@ public function getName(): string } /** - * Get a connection from the pool. + * Enable background maintenance after pool initialization succeeds. */ - public function get(): ConnectionInterface + public function start(): void + { + if ($this->closed || $this->options->idleCheckInterval === null) { + return; + } + + $this->idleMonitor ??= new IdleConnectionMonitor($this, $this->options->idleCheckInterval); + + if ($this->getManagedCount() > 0) { + $this->idleMonitor->start(); + } + } + + /** + * Borrow a connection from the pool. + */ + public function borrow(): ConnectionContract { if ($this->closed) { - throw new RuntimeException('Cannot borrow from a closed connection pool.'); + throw new PoolClosedException('Cannot borrow from a closed connection pool.'); } - $deadline = $this->deadline($this->option->getWaitTimeout()); + $deadline = $this->deadline($this->options->waitTimeout); $connection = $this->getConnection($deadline); $this->borrowedConnections[spl_object_id($connection)] = true; try { - if ($this->frequency instanceof FrequencyInterface) { - $this->frequency->hit(); + if (! $this->usageTrackerInitialized) { + $this->usageTracker = $this->createUsageTracker(); + $this->usageTrackerInitialized = true; } - if ($this->frequency instanceof LowFrequencyInterface - && $this->frequency->isLowFrequency() - ) { - $this->flush(); + $this->usageTracker?->recordBorrow(); + + if ($this->usageTracker?->shouldTrimExcessIdle()) { + $this->trimExcessIdle(); } + + $this->idleMonitor?->start(); } catch (CanceledException $cancellation) { try { $this->discard($connection); @@ -99,7 +123,7 @@ public function get(): ConnectionInterface /** * Release a connection back to the pool. */ - public function release(ConnectionInterface $connection): void + public function release(ConnectionContract $connection): void { $connectionId = $this->ensureBorrowed($connection, 'release'); unset($this->borrowedConnections[$connectionId]); @@ -116,21 +140,23 @@ public function release(ConnectionInterface $connection): void /** * Discard a borrowed connection from the pool. */ - public function discard(ConnectionInterface $connection): void + public function discard(ConnectionContract $connection): void { $this->ensureBorrowed($connection, 'discard'); $this->destroyConnection($connection); } /** - * Close idle connections while the total managed count exceeds the configured minimum. + * Close excess idle connections without checking their age. + * + * Stop when the managed count reaches the retained minimum or no idle connections remain. */ - public function flush(): void + public function trimExcessIdle(): void { - $connectionsToInspect = $this->getConnectionsInChannel(); + $connectionsToInspect = $this->getIdleCount(); while ($connectionsToInspect-- > 0 - && count($this->managedConnections) > $this->option->getMinConnections() + && count($this->managedConnections) > $this->options->minRetainedConnections && $connection = $this->popIdleConnection() ) { $this->destroyConnection($connection); @@ -150,6 +176,15 @@ public function checkIdleConnection(): void try { $healthy = $connection->check(); + } catch (CanceledException $cancellation) { + try { + $this->destroyConnection($connection); + } catch (CanceledException) { + } catch (Throwable $exception) { + $this->report($exception); + } + + throw $cancellation; } catch (Throwable $exception) { $this->report($exception); $healthy = false; @@ -177,17 +212,17 @@ public function close(): void } $this->closed = true; + $cancellation = null; - if ($this->frequency instanceof ClearableFrequencyInterface) { - try { - $this->frequency->clear(); - } catch (Throwable $exception) { - $this->report($exception); - } + try { + $this->idleMonitor?->stop(); + } catch (CanceledException $exception) { + $cancellation = $exception; + } catch (Throwable $exception) { + $this->report($exception); } $this->channel->close(); - $cancellation = null; while ($connection = $this->popIdleConnection()) { try { @@ -213,23 +248,31 @@ public function isClosed(): bool /** * Get the current number of connections managed by the pool. */ - public function getCurrentConnections(): int + public function getManagedCount(): int { return count($this->managedConnections); } + /** + * Return the number of connections borrowed by callers. + */ + public function getBorrowedCount(): int + { + return count($this->borrowedConnections); + } + /** * Get the pool configuration options. */ - public function getOption(): PoolOptionInterface + public function getOptions(): PoolOptions { - return $this->option; + return $this->options; } /** * Get the number of connections currently available in the pool. */ - public function getConnectionsInChannel(): int + public function getIdleCount(): int { return $this->channel->length(); } @@ -237,47 +280,33 @@ public function getConnectionsInChannel(): int /** * Get the number of coroutines waiting for a connection. */ - public function getWaiters(): int + public function getWaitingCount(): int { return $this->channel->waiters(); } /** - * Initialize pool options from configuration. + * Return the pool's current resource counts and closed state. + * + * @return array{managed: int, borrowed: int, idle: int, waiting: int, closed: bool} */ - protected function initOption(array $options = []): void + public function getStats(): array { - $knownOptions = [ - 'min_connections', - 'max_connections', - 'connect_timeout', - 'wait_timeout', - 'heartbeat', - 'heartbeat_timeout', - 'max_idle_time', - 'max_lifetime', - 'events', + return [ + 'managed' => $this->getManagedCount(), + 'borrowed' => $this->getBorrowedCount(), + 'idle' => $this->getIdleCount(), + 'waiting' => $this->getWaitingCount(), + 'closed' => $this->isClosed(), ]; - $unknownOptions = array_diff(array_keys($options), $knownOptions); - - if ($unknownOptions !== []) { - throw new InvalidArgumentException( - 'Unknown connection pool option(s) [' . implode(', ', $unknownOptions) . ']. Known options are [' - . implode(', ', $knownOptions) . '].', - ); - } + } - $this->option = new PoolOption( - minConnections: $options['min_connections'] ?? 1, - maxConnections: $options['max_connections'] ?? 10, - connectTimeout: $options['connect_timeout'] ?? 10.0, - waitTimeout: $options['wait_timeout'] ?? 3.0, - heartbeat: $options['heartbeat'] ?? -1.0, - heartbeatTimeout: $options['heartbeat_timeout'] ?? 1.0, - maxIdleTime: $options['max_idle_time'] ?? 60.0, - maxLifetime: $options['max_lifetime'] ?? -1.0, - events: $options['events'] ?? [], - ); + /** + * Create this pool's usage policy after subclass initialization has completed. + */ + protected function createUsageTracker(): ?UsageTracker + { + return null; } /** @@ -286,12 +315,12 @@ protected function initOption(array $options = []): void * @phpstan-impure Connection factories may yield, allowing another * coroutine to change this pool's lifecycle state. */ - abstract protected function createConnection(): ConnectionInterface; + abstract protected function createConnection(): ConnectionContract; /** * Pop and validate one idle connection. */ - protected function popIdleConnection(): ConnectionInterface|false + protected function popIdleConnection(): ConnectionContract|false { $connection = $this->channel->pop(); @@ -315,7 +344,7 @@ protected function popIdleConnection(): ConnectionInterface|false /** * Return an idle connection without changing its activity timestamps. */ - protected function requeueConnection(ConnectionInterface $connection): void + protected function requeueConnection(ConnectionContract $connection): void { $connectionId = spl_object_id($connection); @@ -333,13 +362,9 @@ protected function requeueConnection(ConnectionInterface $connection): void /** * Destroy a managed connection and release its capacity. */ - protected function destroyConnection(ConnectionInterface $connection): void + protected function destroyConnection(ConnectionContract $connection): void { - $connectionId = spl_object_id($connection); - - if (! isset($this->managedConnections[$connectionId])) { - throw new RuntimeException('Cannot destroy a connection this pool does not manage.'); - } + $connectionId = $this->ensureManaged($connection); try { $connection->close(); @@ -382,32 +407,32 @@ protected function getLogger(): ?StdoutLoggerInterface /** * Get or create a connection before the checkout deadline. */ - private function getConnection(int $deadline): ConnectionInterface + private function getConnection(int $deadline): ConnectionContract { $timedOut = false; while (true) { if ($this->closed) { - throw new RuntimeException('Cannot borrow from a closed connection pool.'); + throw new PoolClosedException('Cannot borrow from a closed connection pool.'); } if ($connection = $this->popIdleConnection()) { return $connection; } - if (count($this->managedConnections) + $this->creating < $this->option->getMaxConnections()) { - ++$this->creating; + if (count($this->managedConnections) + $this->creatingCount < $this->options->maxConnections) { + ++$this->creatingCount; try { $connection = $this->createConnection(); } catch (Throwable $exception) { - --$this->creating; + --$this->creatingCount; $this->channel->signal(); throw $exception; } - --$this->creating; + --$this->creatingCount; $connectionId = spl_object_id($connection); if (isset($this->managedConnections[$connectionId])) { @@ -424,14 +449,14 @@ private function getConnection(int $deadline): ConnectionInterface if ($this->closed) { $this->destroyConnection($connection); - throw new RuntimeException('Cannot borrow from a closed connection pool.'); + throw new PoolClosedException('Cannot borrow from a closed connection pool.'); } return $connection; } if ($timedOut) { - throw new RuntimeException( + throw new PoolExhaustedException( 'Connection pool exhausted. Cannot establish new connection before wait_timeout.' ); } @@ -477,10 +502,24 @@ protected function deadline(float $seconds): int : $now + $duration; } + /** + * Ensure that a connection belongs to this pool before destroying it. + */ + protected function ensureManaged(ConnectionContract $connection): int + { + $connectionId = spl_object_id($connection); + + if (! isset($this->managedConnections[$connectionId])) { + throw new RuntimeException('Cannot destroy a connection this pool does not manage.'); + } + + return $connectionId; + } + /** * Ensure that a connection is currently borrowed from this pool. */ - private function ensureBorrowed(ConnectionInterface $connection, string $operation): int + private function ensureBorrowed(ConnectionContract $connection, string $operation): int { $connectionId = spl_object_id($connection); diff --git a/src/connection-pool/src/Events/ConnectionReleasing.php b/src/connection-pool/src/Events/ConnectionReleasing.php new file mode 100644 index 0000000000..0b5ea2c596 --- /dev/null +++ b/src/connection-pool/src/Events/ConnectionReleasing.php @@ -0,0 +1,18 @@ +timer = $timer ?? new Timer; + } + + /** + * Start checking idle connections until stopped or the worker exits. + */ + public function start(): void + { + if ($this->started || $this->pool->isClosed()) { + return; + } + + // Timer creation invokes synchronous coroutine hooks before returning its ID. + $this->started = true; + + try { + $timerId = $this->timer->tick($this->interval, function (bool $isClosing): void { + if ($isClosing || $this->pool->isClosed()) { + $this->stop(); + + return; + } + + $this->pool->checkIdleConnection(); + }); + } catch (Throwable $exception) { + $this->started = false; + + throw $exception; + } + + if (! $this->started || $this->pool->isClosed()) { + $this->started = false; + $this->timer->clear($timerId); + + return; + } + + $this->timerId = $timerId; + } + + /** + * Stop checking idle connections. + */ + public function stop(): void + { + $timerId = $this->timerId; + $this->timerId = null; + $this->started = false; + + if ($timerId !== null) { + $this->timer->clear($timerId); + } + } +} diff --git a/src/pool/src/KeepaliveConnection.php b/src/connection-pool/src/KeepaliveConnection.php similarity index 60% rename from src/pool/src/KeepaliveConnection.php rename to src/connection-pool/src/KeepaliveConnection.php index 846af5234c..bdd670b52d 100644 --- a/src/pool/src/KeepaliveConnection.php +++ b/src/connection-pool/src/KeepaliveConnection.php @@ -2,18 +2,19 @@ declare(strict_types=1); -namespace Hypervel\Pool; +namespace Hypervel\ConnectionPool; use Closure; +use Hypervel\ConnectionPool\Exceptions\InvalidArgumentException; +use Hypervel\ConnectionPool\Exceptions\SocketPopException; +use Hypervel\Contracts\ConnectionPool\Connection as ConnectionContract; +use Hypervel\Contracts\ConnectionPool\ConnectionPool as ConnectionPoolContract; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\ConnectionInterface; -use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Coordinator\Timer; use Hypervel\Engine\Channel; -use Hypervel\Pool\Exceptions\InvalidArgumentException; -use Hypervel\Pool\Exceptions\SocketPopException; use Psr\Log\LoggerInterface; +use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -21,8 +22,12 @@ * * Uses a timer to periodically check connection health and * automatically closes idle connections. + * + * Subclasses must return resources that close their underlying socket when + * the final reference is released. Failed or canceled cleanup may release + * a socket without sending the close protocol message. */ -abstract class KeepaliveConnection implements ConnectionInterface +abstract class KeepaliveConnection implements ConnectionContract { protected Timer $timer; @@ -38,7 +43,7 @@ abstract class KeepaliveConnection implements ConnectionInterface public function __construct( protected Container $container, - protected PoolInterface $pool + protected ConnectionPoolContract $pool ) { $this->timer = new Timer; } @@ -84,17 +89,41 @@ public function reconnect(): bool $connection = $this->getActiveConnection(); + if ($this->isConnected()) { + try { + $this->sendClose($connection); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $message = sprintf('Socket of %s close failed, %s', $this->name, $exception); + + if ($logger = $this->getLogger()) { + $logger->error($message); + } else { + error_log($message); + } + } + + return $this->isConnected(); + } + $channel = new Channel(1); $channel->push($connection); + $previousChannel = $this->channel ?? null; $this->channel = $channel; $this->lastUseTime = hrtime(true) / 1e9; try { $this->addHeartbeat(); } catch (Throwable $exception) { - $this->closeAfterFailure(); + if ($this->channel === $channel) { + $this->closeAfterFailure(); + } throw $exception; + } finally { + // Wake old waiters only after the replacement state is settled. + $previousChannel?->close(); } return true; @@ -111,21 +140,25 @@ public function call(Closure $closure, bool $refresh = true): mixed $this->reconnect(); } - $connection = $this->channel->pop($this->pool->getOption()->getWaitTimeout()); + $channel = $this->channel; + $connection = $channel->pop($this->pool->getOptions()->waitTimeout); if ($connection === false) { + if ($channel->isCanceled()) { + throw new CanceledException('The keepalive connection wait was canceled.'); + } + throw new SocketPopException(sprintf('Socket of %s is exhausted. Cannot establish socket before timeout.', $this->name)); } try { $result = $closure($connection); - if ($refresh) { + if ($refresh && $this->channel === $channel && $this->isConnected()) { $this->lastUseTime = hrtime(true) / 1e9; } } finally { - if ($this->isConnected()) { - $this->channel->push($connection, 0.001); + if ($this->channel === $channel && $this->isConnected()) { + $channel->push($connection, 0.001); } else { - // Unset and drop the connection. unset($connection); } } @@ -146,16 +179,26 @@ public function isConnected(): bool */ public function close(): bool { - if ($this->isConnected()) { - $this->call(function ($connection) { - try { - if ($this->isConnected()) { - $this->sendClose($connection); + $channel = $this->channel ?? null; + + try { + if ($this->isConnected()) { + $this->call(function ($connection) use ($channel): void { + try { + if ($this->isConnected()) { + $this->sendClose($connection); + } + } finally { + if ($this->channel === $channel) { + $this->clear(); + } } - } finally { - $this->clear(); - } - }, false); + }, false); + } + } finally { + if (($this->channel ?? null) === $channel) { + $this->clear(); + } } return true; @@ -166,7 +209,14 @@ public function close(): bool */ public function isTimeout(): bool { - return $this->lastUseTime < hrtime(true) / 1e9 - $this->pool->getOption()->getMaxIdleTime() + if (! $this->isConnected()) { + return false; + } + + $maxIdleTime = $this->pool->getOptions()->maxIdleTime; + + return $maxIdleTime !== null + && $this->lastUseTime < hrtime(true) / 1e9 - $maxIdleTime && $this->channel->getLength() > 0; } @@ -180,13 +230,14 @@ protected function addHeartbeat(): void { $this->connected = true; - $heartbeat = $this->pool->getOption()->getHeartbeat(); + $heartbeatInterval = $this->pool->getOptions()->heartbeatInterval; - if ($heartbeat <= 0) { + if ($heartbeatInterval === null) { return; } - $this->timerId = $this->timer->tick($heartbeat, function () { + $channel = $this->channel; + $timerId = $this->timer->tick($heartbeatInterval, function () use ($channel): void { try { if (! $this->isConnected()) { return; @@ -200,8 +251,16 @@ protected function addHeartbeat(): void } $this->heartbeat(); + } catch (CanceledException $exception) { + if ($this->channel === $channel) { + $this->closeAfterFailure(); + } + + throw $exception; } catch (Throwable $throwable) { - $this->closeAfterFailure(); + if ($this->channel === $channel) { + $this->closeAfterFailure(); + } $message = sprintf('Socket of %s heartbeat failed, %s', $this->name, $throwable); if ($logger = $this->getLogger()) { @@ -211,6 +270,14 @@ protected function addHeartbeat(): void } } }); + + if ($this->channel !== $channel || ! $this->isConnected()) { + $this->timer->clear($timerId); + + return; + } + + $this->timerId = $timerId; } /** @@ -220,7 +287,7 @@ protected function clear(): void { $this->connected = false; - if ($this->timerId) { + if ($this->timerId !== null) { $this->timer->clear($this->timerId); $this->timerId = null; } @@ -234,9 +301,6 @@ private function closeAfterFailure(): void try { $this->close(); } catch (Throwable) { - // A concurrent caller may still own the connection. Clearing makes - // its existing finally block drop the resource instead of requeueing it. - $this->clear(); } } diff --git a/src/connection-pool/src/PoolOptions.php b/src/connection-pool/src/PoolOptions.php new file mode 100644 index 0000000000..c9422d73d2 --- /dev/null +++ b/src/connection-pool/src/PoolOptions.php @@ -0,0 +1,187 @@ + $events + */ + private function __construct( + public int $minRetainedConnections, + public int $maxConnections, + public float $connectTimeout, + public float $waitTimeout, + public ?float $heartbeatInterval, + public float $heartbeatTimeout, + public ?float $idleCheckInterval, + public ?float $maxIdleTime, + public ?float $maxLifetime, + public array $events, + ) { + } + + /** + * Create normalized pool options from configuration. + */ + public static function fromArray(array $options): self + { + $knownOptions = [ + 'min_retained_connections', + 'max_connections', + 'connect_timeout', + 'wait_timeout', + 'heartbeat_interval', + 'heartbeat_timeout', + 'idle_check_interval', + 'max_idle_time', + 'max_lifetime', + 'events', + ]; + $unknownOptions = array_diff(array_keys($options), $knownOptions); + + if ($unknownOptions !== []) { + throw new InvalidArgumentException( + 'Unknown connection pool option(s) [' . implode(', ', $unknownOptions) . ']. Known options are [' + . implode(', ', $knownOptions) . '].', + ); + } + + $minimum = self::integerOption($options, 'min_retained_connections', self::DEFAULT_MIN_RETAINED_CONNECTIONS); + $maximum = self::integerOption($options, 'max_connections', self::DEFAULT_MAX_CONNECTIONS); + self::validateConnectionCounts($minimum, $maximum); + + $events = array_key_exists('events', $options) ? $options['events'] : []; + + if (! is_array($events) || ! array_is_list($events)) { + throw new InvalidArgumentException('Pool option [events] must be a list of event class names.'); + } + + foreach ($events as $event) { + if (! is_string($event) || ! class_exists($event)) { + throw new InvalidArgumentException('Pool option [events] must contain existing event class names.'); + } + } + + return new self( + $minimum, + $maximum, + self::durationOption($options, 'connect_timeout', self::DEFAULT_CONNECT_TIMEOUT), + self::durationOption($options, 'wait_timeout', self::DEFAULT_WAIT_TIMEOUT), + self::nullableDurationOption($options, 'heartbeat_interval', null), + self::durationOption($options, 'heartbeat_timeout', self::DEFAULT_HEARTBEAT_TIMEOUT), + self::nullableDurationOption($options, 'idle_check_interval', null), + self::nullableDurationOption($options, 'max_idle_time', self::DEFAULT_MAX_IDLE_TIME), + self::nullableDurationOption($options, 'max_lifetime', null), + $events, + ); + } + + /** + * Return a jittered lifetime deadline for a connection generation. + */ + public function jitteredLifetimeDeadline(float $createdAt): ?float + { + if ($this->maxLifetime === null) { + return null; + } + + $factor = random_int(self::MIN_LIFETIME_JITTER_BASIS, self::LIFETIME_JITTER_SCALE) / self::LIFETIME_JITTER_SCALE; + + return $createdAt + ($this->maxLifetime * $factor); + } + + /** + * Validate the connection-count relationship. + */ + private static function validateConnectionCounts(int $minRetainedConnections, int $maxConnections): void + { + if ($minRetainedConnections < 0) { + throw new InvalidArgumentException('Pool option [min_retained_connections] must be at least 0.'); + } + + if ($maxConnections < 1) { + throw new InvalidArgumentException('Pool option [max_connections] must be at least 1.'); + } + + if ($minRetainedConnections > $maxConnections) { + throw new InvalidArgumentException( + 'Pool option [min_retained_connections] must not exceed [max_connections].', + ); + } + } + + /** + * Read an integer option without accepting numeric strings or booleans. + */ + private static function integerOption(array $options, string $name, int $default): int + { + $value = array_key_exists($name, $options) ? $options[$name] : $default; + + if (! is_int($value)) { + throw new InvalidArgumentException("Pool option [{$name}] must be an integer."); + } + + return $value; + } + + /** + * Read and normalize a finite positive duration option. + */ + private static function durationOption(array $options, string $name, ?float $default): float + { + $value = array_key_exists($name, $options) ? $options[$name] : $default; + + if (! is_int($value) && ! is_float($value)) { + throw new InvalidArgumentException("Pool option [{$name}] must be an integer or float."); + } + + $value = (float) $value; + + if (! is_finite($value) || $value <= 0.0) { + throw new InvalidArgumentException("Pool option [{$name}] must be a finite number greater than 0."); + } + + return $value; + } + + /** + * Read and normalize a nullable finite positive duration option. + */ + private static function nullableDurationOption(array $options, string $name, ?float $default): ?float + { + if ((array_key_exists($name, $options) ? $options[$name] : $default) === null) { + return null; + } + + return self::durationOption($options, $name, $default); + } +} diff --git a/src/contracts/src/Pool/ConnectionInterface.php b/src/contracts/src/ConnectionPool/Connection.php similarity index 70% rename from src/contracts/src/Pool/ConnectionInterface.php rename to src/contracts/src/ConnectionPool/Connection.php index 7cd8e495b0..ccc40cc7c2 100644 --- a/src/contracts/src/Pool/ConnectionInterface.php +++ b/src/contracts/src/ConnectionPool/Connection.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Contracts\Pool; +namespace Hypervel\Contracts\ConnectionPool; -interface ConnectionInterface +interface Connection { /** - * Get the real connection from pool. + * Return the underlying connection. */ public function getConnection(): mixed; @@ -17,7 +17,7 @@ public function getConnection(): mixed; public function reconnect(): bool; /** - * Check the connection is valid. + * Determine if the connection is valid. */ public function check(): bool; @@ -27,7 +27,7 @@ public function check(): bool; public function close(): bool; /** - * Release the connection to pool. + * Release the connection to its pool. */ public function release(): void; diff --git a/src/contracts/src/ConnectionPool/ConnectionPool.php b/src/contracts/src/ConnectionPool/ConnectionPool.php new file mode 100644 index 0000000000..ed689da1cf --- /dev/null +++ b/src/contracts/src/ConnectionPool/ConnectionPool.php @@ -0,0 +1,79 @@ + */ - public function pools(): array; + public function getPools(): array; /** * Get the definition currently registered for an identity. */ - public function definition(string $identity): ?PoolDefinition; + public function getDefinition(string $identity): ?PoolDefinition; /** * Remove and close a pool when it still matches an optional expected instance. */ - public function remove(string $identity, ?ObjectPool $expected = null): bool; + public function purge(string $identity, ?ObjectPool $expected = null): bool; /** * Remove and close every registered pool. @@ -62,5 +64,5 @@ public function remove(string $identity, ?ObjectPool $expected = null): bool; * Boot or tests only. This clears worker-lifetime pools shared by every * coroutine; use targeted removal for runtime resource recovery. */ - public function flush(): void; + public function purgeAll(): void; } diff --git a/src/object-pool/src/Contracts/InvalidatesPool.php b/src/contracts/src/ObjectPool/InvalidatesPool.php similarity index 80% rename from src/object-pool/src/Contracts/InvalidatesPool.php rename to src/contracts/src/ObjectPool/InvalidatesPool.php index 06ed93e518..d1b59ef44a 100644 --- a/src/object-pool/src/Contracts/InvalidatesPool.php +++ b/src/contracts/src/ObjectPool/InvalidatesPool.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\ObjectPool\Contracts; +namespace Hypervel\Contracts\ObjectPool; interface InvalidatesPool { diff --git a/src/object-pool/src/Contracts/ObjectPool.php b/src/contracts/src/ObjectPool/ObjectPool.php similarity index 73% rename from src/object-pool/src/Contracts/ObjectPool.php rename to src/contracts/src/ObjectPool/ObjectPool.php index c6ae2429a4..71d6ec40b0 100644 --- a/src/object-pool/src/Contracts/ObjectPool.php +++ b/src/contracts/src/ObjectPool/ObjectPool.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Hypervel\ObjectPool\Contracts; +namespace Hypervel\Contracts\ObjectPool; use Hypervel\ObjectPool\PoolOptions; interface ObjectPool { /** - * Get an object from the object pool. + * Borrow an object from the pool. */ - public function get(): object; + public function borrow(): object; /** * Release an object back to the object pool. @@ -44,29 +44,29 @@ public function close(): void; public function isClosed(): bool; /** - * Determine if the entire pool has exceeded its idle TTL. + * Determine if the entire pool has exceeded its idle timeout. */ - public function isIdle(): bool; + public function isIdleExpired(): bool; /** * Return the number of objects currently checked out. */ - public function getBorrowedObjectNumber(): int; + public function getBorrowedCount(): int; /** * Return the current number of objects managed by the pool. */ - public function getCurrentObjectNumber(): int; + public function getManagedCount(): int; /** * Return the number of objects currently available in the pool. */ - public function getObjectNumberInPool(): int; + public function getIdleCount(): int; /** * Return the number of coroutines waiting for an object. */ - public function getWaiters(): int; + public function getWaitingCount(): int; /** * Get the normalized pool options. @@ -76,7 +76,7 @@ public function getOptions(): PoolOptions; /** * Return statistics about the pool's current state. * - * @return array{total: int, idle: int, borrowed: int, waiters: int, closed: bool} + * @return array{managed: int, borrowed: int, idle: int, waiting: int, closed: bool} */ public function getStats(): array; } diff --git a/src/contracts/src/ObjectPool/Recycler.php b/src/contracts/src/ObjectPool/Recycler.php new file mode 100644 index 0000000000..0d3aad897c --- /dev/null +++ b/src/contracts/src/ObjectPool/Recycler.php @@ -0,0 +1,24 @@ + */ + /** @var SplQueue */ protected SplQueue $queue; /** @var EngineChannel */ @@ -38,6 +37,8 @@ public function __construct(int $size) /** * Retrieve an idle object without waiting. + * + * @return false|T */ public function pop(): false|object { @@ -46,6 +47,8 @@ public function pop(): false|object /** * Push an idle object and wake one waiter. + * + * @param T $data */ public function push(object $data): bool { @@ -94,7 +97,7 @@ public function wait(float $timeout): bool $result = $this->signal->pop($timeout); if ($result === false && $this->signal->isCanceled()) { - throw new CanceledException('The object pool wait was canceled.'); + throw new CanceledException('The pool wait was canceled.'); } return $result !== false || ! $this->signal->isTimeout(); diff --git a/src/database/composer.json b/src/database/composer.json index 0fee82bbdd..d34fbeba16 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -44,6 +44,7 @@ "hypervel/broadcasting": "^0.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", + "hypervel/connection-pool": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", @@ -57,7 +58,6 @@ "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", "hypervel/pagination": "^0.4", - "hypervel/pool": "^0.4", "hypervel/prompts": "^0.4", "hypervel/queue": "^0.4", "hypervel/support": "^0.4" diff --git a/src/database/src/Capsule/Manager.php b/src/database/src/Capsule/Manager.php index 6b9eab9b01..32de215630 100644 --- a/src/database/src/Capsule/Manager.php +++ b/src/database/src/Capsule/Manager.php @@ -19,13 +19,8 @@ /** * Standalone database manager for non-production use outside full application bootstrap. * - * Capsule provides quick Eloquent/Query Builder access for unit tests and basic scripts - * without requiring the full Hypervel infrastructure. It uses SimpleConnectionResolver - * (non-pooled) because: - * - No high-concurrency requirements in these contexts - * - Avoids dependency on PoolFactory/Config infrastructure - * - Ensures same connection is reused (important for in-memory SQLite tests) - * - Enables direct porting of Laravel tests that use Capsule + * Uses SimpleConnectionResolver to retain non-pooled connections, including + * in-memory SQLite databases, without requiring full application bootstrap. * * For production Swoole applications with connection pooling, use the full * Hypervel application with ConnectionResolver instead. diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index 961a0ec2f1..8660d2e872 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -9,7 +9,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Pool\PooledConnection; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -40,7 +40,7 @@ class ConnectionResolver implements ConnectionResolverInterface */ protected readonly ?string $default; - protected PoolFactory $factory; + protected PoolManager $poolManager; /** * Pooled wrappers retained by non-coroutine task execution. @@ -55,7 +55,7 @@ class ConnectionResolver implements ConnectionResolverInterface public function __construct( protected Container $container ) { - $this->factory = $container->make(PoolFactory::class); + $this->poolManager = $container->make(PoolManager::class); $this->default = $container->make('config')->string('database.default'); } @@ -88,8 +88,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa } } - // Get a pooled connection wrapper from the pool - $pool = $this->factory->getPool($connectionName->requested); + $pool = $this->poolManager->pool($connectionName->requested); // Role aliases of one shared in-memory PDO must share its sole wrapper owner. if ($pool->getSharedInMemorySqlitePdo() !== null) { @@ -110,7 +109,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa } /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $connection = $pooledConnection->getConnection(); diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index ba398d89fd..aa409de9ba 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -16,7 +16,7 @@ use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\Events\ConnectionEstablished; use Hypervel\Database\Events\QueryExecuted; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Support\Arr; use Hypervel\Support\Fluent; use Hypervel\Support\InteractsWithTime; @@ -283,9 +283,9 @@ public function purge(UnitEnum|string|null $name = null): void } } - // Flush the pool to honor config changes - if ($this->app->has(PoolFactory::class)) { - $this->app->make(PoolFactory::class)->flushPoolsForConnection($connectionName->base); + // Purge the pools to honor config changes. + if ($this->app->has(PoolManager::class)) { + $this->app->make(PoolManager::class)->purgeForConnection($connectionName->base); } } diff --git a/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php b/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php index afb606ca9b..f60112d170 100644 --- a/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php +++ b/src/database/src/Listeners/DatabaseConnectionLifecycleListener.php @@ -6,12 +6,15 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Database\ConnectionResolver; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Swoole\Coroutine\CanceledException; use Throwable; class DatabaseConnectionLifecycleListener { + /** + * Create a connection lifecycle listener. + */ public function __construct( protected ContainerContract $container, ) { @@ -52,9 +55,9 @@ public function discardProcessConnections(): void } } - if ($this->container->resolved(PoolFactory::class)) { + if ($this->container->resolved(PoolManager::class)) { try { - $this->container->make(PoolFactory::class)->flushAll(); + $this->container->make(PoolManager::class)->purgeAll(); } catch (Throwable $throwable) { if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException) diff --git a/src/database/src/Pool/DbPool.php b/src/database/src/Pool/DatabasePool.php similarity index 66% rename from src/database/src/Pool/DbPool.php rename to src/database/src/Pool/DatabasePool.php index 4ed4935877..c57902923f 100644 --- a/src/database/src/Pool/DbPool.php +++ b/src/database/src/Pool/DatabasePool.php @@ -4,30 +4,29 @@ namespace Hypervel\Database\Pool; +use Hypervel\ConnectionPool\BorrowRateTracker; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; +use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Pool\ConnectionInterface; use Hypervel\Coordinator\Timer; use Hypervel\Database\ConnectionName; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\SQLiteDatabase; -use Hypervel\Pool\Frequency; -use Hypervel\Pool\Pool; use Hypervel\Support\Arr; use InvalidArgumentException; use PDO; +use Swoole\Coroutine\CanceledException; use Throwable; /** * Database connection pool. * - * Extends the base Pool to create PooledConnection instances that wrap - * our Laravel-ported Connection class. - * * For in-memory SQLite, manages a shared PDO behind a single pooled owner. * Non-pooled paths (Capsule, SimpleConnectionResolver) bypass this entirely * and get isolated connections as expected. */ -class DbPool extends Pool +class DatabasePool extends ConnectionPool { protected array $config; @@ -35,11 +34,16 @@ class DbPool extends Pool protected ?int $heartbeatTimerId = null; + protected bool $heartbeatStarted = false; + /** * Shared PDO for in-memory SQLite. */ protected ?PDO $sharedInMemorySqlitePdo = null; + /** + * Create a database connection pool. + */ public function __construct(Container $container, string $name) { $connectionName = ConnectionName::parse($name); @@ -64,14 +68,13 @@ public function __construct(Container $container, string $name) $this->config = $config; - // Extract pool options $poolOptions = Arr::except( Arr::get($this->config, 'pool', []), ['testing_enabled'], ); - $minimum = $poolOptions['min_connections'] ?? 1; - $maximum = $poolOptions['max_connections'] ?? 10; + $minimum = array_key_exists('min_retained_connections', $poolOptions) ? $poolOptions['min_retained_connections'] : 1; + $maximum = array_key_exists('max_connections', $poolOptions) ? $poolOptions['max_connections'] : 10; if ($this->isInMemorySqlite() && is_int($minimum) @@ -80,12 +83,10 @@ public function __construct(Container $container, string $name) && $maximum >= 1 && $minimum <= $maximum ) { - $poolOptions['min_connections'] = min($minimum, 1); + $poolOptions['min_retained_connections'] = min($minimum, 1); $poolOptions['max_connections'] = 1; } - $this->frequency = new Frequency; - parent::__construct($container, $name, $poolOptions); $this->configureConnectTimeout(); @@ -95,7 +96,14 @@ public function __construct(Container $container, string $name) if ($this->isInMemorySqlite()) { $this->sharedInMemorySqlitePdo = $this->createSharedInMemorySqlitePdo(); } + } + /** + * Enable background maintenance after pool initialization succeeds. + */ + public function start(): void + { + parent::start(); $this->startHeartbeat(); } @@ -110,7 +118,7 @@ public function getSharedInMemorySqlitePdo(): ?PDO /** * Create a new pooled connection. */ - protected function createConnection(): ConnectionInterface + protected function createConnection(): PoolConnection { return new PooledConnection($this->container, $this, $this->config); } @@ -120,7 +128,7 @@ protected function createConnection(): ConnectionInterface */ private function configureConnectTimeout(): void { - $this->config['connect_timeout'] ??= $this->option->getConnectTimeout(); + $this->config['connect_timeout'] ??= $this->options->connectTimeout; } /** @@ -137,6 +145,14 @@ protected function createSharedInMemorySqlitePdo(): PDO return $connection->getPdo(); } + /** + * Create a usage policy for this database pool. + */ + protected function createUsageTracker(): ?UsageTracker + { + return new BorrowRateTracker; + } + /** * Check if this pool is for an in-memory SQLite database. */ @@ -180,8 +196,11 @@ public function close(): void $this->clearHeartbeat(); - parent::close(); - $this->sharedInMemorySqlitePdo = null; + try { + parent::close(); + } finally { + $this->sharedInMemorySqlitePdo = null; + } } /** @@ -189,22 +208,42 @@ public function close(): void */ protected function startHeartbeat(): void { - if ($this->heartbeatTimer === null || $this->option->getHeartbeat() <= 0 || $this->sharedInMemorySqlitePdo !== null) { + if ($this->heartbeatStarted || $this->isClosed() || $this->heartbeatTimer === null + || $this->options->heartbeatInterval === null || $this->sharedInMemorySqlitePdo !== null + ) { return; } - $this->heartbeatTimerId = $this->heartbeatTimer->tick( - $this->option->getHeartbeat(), - function (bool $isClosing): ?string { - if ($isClosing || $this->isClosed()) { - return Timer::STOP; + // Timer creation can reenter pool lifecycle methods through startup hooks. + $this->heartbeatStarted = true; + + try { + $timerId = $this->heartbeatTimer->tick( + $this->options->heartbeatInterval, + function (bool $isClosing): ?string { + if ($isClosing || $this->isClosed()) { + return Timer::STOP; + } + + $this->heartbeat(); + + return null; } + ); + } catch (Throwable $exception) { + $this->heartbeatStarted = false; - $this->heartbeat(); + throw $exception; + } - return null; - } - ); + if (! $this->heartbeatStarted || $this->isClosed()) { + $this->heartbeatStarted = false; + $this->heartbeatTimer->clear($timerId); + + return; + } + + $this->heartbeatTimerId = $timerId; } /** @@ -212,12 +251,13 @@ function (bool $isClosing): ?string { */ protected function clearHeartbeat(): void { - if ($this->heartbeatTimer === null || $this->heartbeatTimerId === null) { - return; - } - - $this->heartbeatTimer->clear($this->heartbeatTimerId); + $timerId = $this->heartbeatTimerId; $this->heartbeatTimerId = null; + $this->heartbeatStarted = false; + + if ($timerId !== null) { + $this->heartbeatTimer?->clear($timerId); + } } /** @@ -225,7 +265,7 @@ protected function clearHeartbeat(): void */ protected function heartbeat(): void { - $connectionsToInspect = $this->getConnectionsInChannel(); + $connectionsToInspect = $this->getIdleCount(); for ($index = 0; $index < $connectionsToInspect; ++$index) { /** @var false|PooledConnection $connection */ @@ -247,35 +287,27 @@ protected function heartbeatConnection(PooledConnection $connection): void try { $now = hrtime(true) / 1e9; - if ($connection->isLifetimeExpired($now)) { - $this->discardHeartbeatConnection($connection); - - return; - } - - if ($connection->isIdleExpired($now) - && $this->getCurrentConnections() > $this->option->getMinConnections() - ) { + $expired = $connection->isLifetimeExpired($now) + || ($connection->isIdleExpired($now) + && $this->getManagedCount() > $this->options->minRetainedConnections); + $healthy = ! $expired && $connection->ping($this->options->heartbeatTimeout); + } catch (CanceledException $cancellation) { + try { $this->discardHeartbeatConnection($connection); - - return; - } - - if ($connection->ping($this->option->getHeartbeatTimeout())) { - if ($this->isClosed()) { - $this->discardHeartbeatConnection($connection); - - return; - } - - $this->requeueConnection($connection); - - return; + } catch (CanceledException) { + } catch (Throwable $exception) { + $this->report($exception); } - $this->discardHeartbeatConnection($connection); + throw $cancellation; } catch (Throwable $exception) { $this->report('Database heartbeat failed: ' . $exception); + $healthy = false; + } + + if ($healthy && ! $this->isClosed()) { + $this->requeueConnection($connection); + } else { $this->discardHeartbeatConnection($connection); } } diff --git a/src/database/src/Pool/PoolFactory.php b/src/database/src/Pool/PoolManager.php similarity index 64% rename from src/database/src/Pool/PoolFactory.php rename to src/database/src/Pool/PoolManager.php index 94701059b3..abdd233434 100644 --- a/src/database/src/Pool/PoolFactory.php +++ b/src/database/src/Pool/PoolManager.php @@ -10,18 +10,18 @@ use Swoole\Coroutine\CanceledException; use Throwable; -/** - * Factory for creating and caching database connection pools. - */ -class PoolFactory +class PoolManager { /** * The cached pool instances. * - * @var array + * @var array */ protected array $pools = []; + /** + * Create a pool manager. + */ public function __construct( protected Container $container ) { @@ -30,29 +30,66 @@ public function __construct( /** * Get or create a pool for the given connection name. */ - public function getPool(string $name): DbPool + public function pool(string $name): DatabasePool { - if (isset($this->pools[$name])) { - return $this->pools[$name]; - } + while (true) { + if (($pool = $this->pools[$name] ?? null) !== null) { + if (! $pool->isClosed()) { + return $pool; + } - $poolName = $this->getPoolName($name); + unset($this->pools[$name]); + } - if (isset($this->pools[$poolName])) { - return $this->pools[$poolName]; - } + $poolName = $this->getPoolName($name); - $pool = $this->container->make(DbPool::class, ['name' => $poolName]); + if (($pool = $this->pools[$poolName] ?? null) !== null) { + if (! $pool->isClosed()) { + return $pool; + } - return $this->pools[$poolName] = $pool; + unset($this->pools[$poolName]); + } + + $pool = $this->container->make(DatabasePool::class, ['name' => $poolName]); + + try { + $pool->start(); + } catch (Throwable $failure) { + try { + $pool->close(); + } catch (CanceledException $cancellation) { + if (! $failure instanceof CanceledException) { + throw $cancellation; + } + } catch (Throwable) { + // Preserve the activation failure over an ordinary cleanup failure. + } + + throw $failure; + } + + $existing = $this->pools[$poolName] ?? null; + + if ($existing === null || $existing->isClosed()) { + return $this->pools[$poolName] = $pool; + } + + if ($existing === $pool) { + return $pool; + } + + // Cleanup can yield while the registered pool closes or is replaced. + $pool->close(); + } } /** * Get the existing pools keyed by their physical connection names. * - * @return array + * @return array */ - public function pools(): array + public function getPools(): array { return $this->pools; } @@ -89,18 +126,18 @@ protected function getPoolName(string $name): string /** * Check if a pool exists for the given connection name. */ - public function hasPool(string $name): bool + public function has(string $name): bool { return isset($this->pools[$this->getExistingPoolName($name)]); } /** - * Flush a specific pool, closing all connections. + * Remove a pool and close its connections. * * Boot or tests only. Closes a worker-shared pool; connections already * checked out by concurrent coroutines are destroyed on release. */ - public function flushPool(string $name): void + public function purge(string $name): void { $poolName = $this->getExistingPoolName($name); $pool = $this->pools[$poolName] ?? null; @@ -122,12 +159,12 @@ protected function getExistingPoolName(string $name): string } /** - * Flush all pool variants for a configured connection. + * Remove every pool for a configured connection. * * Boot or tests only. This closes shared worker pools and affects every * coroutine that later resolves the same configured connection. */ - public function flushPoolsForConnection(string $name): void + public function purgeForConnection(string $name): void { $base = ConnectionName::parse($name)->base; $pools = []; @@ -143,12 +180,12 @@ public function flushPoolsForConnection(string $name): void } /** - * Flush all pools, closing all connections. + * Remove all pools and close their connections. * * Boot or tests only. Closes every worker-shared pool; connections already * checked out by concurrent coroutines are destroyed on release. */ - public function flushAll(): void + public function purgeAll(): void { $pools = $this->pools; $this->pools = []; @@ -159,7 +196,7 @@ public function flushAll(): void /** * Close a finite set of detached pools. * - * @param array $pools + * @param array $pools */ private function closePools(array $pools): void { diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index f874de8401..68c73f3f6e 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -5,10 +5,11 @@ namespace Hypervel\Database\Pool; use Closure; +use Hypervel\ConnectionPool\Events\ConnectionReleasing; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\ConnectionInterface as PoolConnectionInterface; use Hypervel\Coroutine\Coroutine as FrameworkCoroutine; use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; @@ -16,8 +17,6 @@ use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; use Hypervel\Engine\Exceptions\CoroutineCreateException; -use Hypervel\Pool\Events\ReleaseConnection; -use Hypervel\Pool\PoolOption; use Psr\Log\LoggerInterface; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -26,10 +25,10 @@ /** * Wraps a database Connection for use with Hypervel's connection pool. * - * This adapter implements Hypervel's pool ConnectionInterface, allowing our + * This adapter implements Hypervel's pool connection contract, allowing our * Laravel-ported Connection to work with Hypervel's pooling infrastructure. */ -class PooledConnection implements PoolConnectionInterface +class PooledConnection implements PoolConnection { /** * Maximum allowed errors before marking connection as stale. @@ -48,7 +47,7 @@ class PooledConnection implements PoolConnectionInterface protected float $createdAt = 0.0; - protected float $lifetimeExpiresAt = 0.0; + protected ?float $lifetimeExpiresAt = null; protected bool $availableForReuse = false; @@ -61,7 +60,7 @@ class PooledConnection implements PoolConnectionInterface */ public function __construct( protected Container $container, - protected DbPool $pool, + protected DatabasePool $pool, protected array $config ) { $this->factory = $container->make('db.factory'); @@ -191,9 +190,9 @@ public function check(): bool return false; } - $maxIdleTime = $this->pool->getOption()->getMaxIdleTime(); + $maxIdleTime = $this->pool->getOptions()->maxIdleTime; - if ($now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { + if ($maxIdleTime !== null && $now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { return false; } } @@ -210,7 +209,9 @@ public function isIdleExpired(?float $now = null): bool return false; } - return ($now ?? hrtime(true) / 1e9) > $this->pool->getOption()->getMaxIdleTime() + $this->lastReleaseTime; + $maxIdleTime = $this->pool->getOptions()->maxIdleTime; + + return $maxIdleTime !== null && ($now ?? hrtime(true) / 1e9) > $maxIdleTime + $this->lastReleaseTime; } /** @@ -336,11 +337,11 @@ public function release(): void $this->lastReleaseTime = hrtime(true) / 1e9; // Dispatch release event if configured - $events = $this->pool->getOption()->getEvents(); - if (in_array(ReleaseConnection::class, $events, true) - && $this->dispatcher?->hasListeners(ReleaseConnection::class) + $events = $this->pool->getOptions()->events; + if (in_array(ConnectionReleasing::class, $events, true) + && $this->dispatcher?->hasListeners(ConnectionReleasing::class) ) { - $this->dispatcher->dispatch(new ReleaseConnection($this)); + $this->dispatcher->dispatch(new ConnectionReleasing($this)); } } catch (CanceledException $cancellation) { $cancellationFailure = $cancellation; @@ -427,7 +428,7 @@ public function getCreatedAt(): float */ public function isLifetimeExpired(?float $now = null): bool { - if ($this->lifetimeExpiresAt <= 0) { + if ($this->lifetimeExpiresAt === null) { return false; } @@ -465,10 +466,7 @@ protected function markValid(): void private function stampGeneration(float $now): void { $this->createdAt = $now; - $this->lifetimeExpiresAt = PoolOption::jitteredLifetimeDeadline( - $now, - $this->pool->getOption()->getMaxLifetime() - ); + $this->lifetimeExpiresAt = $this->pool->getOptions()->jitteredLifetimeDeadline($now); } /** diff --git a/src/docs/database.md b/src/docs/database.md index 2259c4c550..53e4866572 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -135,14 +135,15 @@ To see how read / write connections should be configured, let's look at this exa \Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], 'pool' => [ - 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('DB_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('DB_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('DB_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('DB_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('DB_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], ``` @@ -179,25 +180,30 @@ Each connection may define its own `pool` configuration: // ... 'pool' => [ - 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('DB_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('DB_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('DB_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('DB_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('DB_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], ``` -The `min_connections` option controls how far Hypervel may trim excess idle connections. It does not prewarm or automatically replenish the pool, and the pool may have no idle connections while it is under load. The coroutine that first needs each new connection therefore pays the cost of opening it. Expired, unhealthy, or discarded connections may reduce the managed connection count below this value. A failed connection attempt may do the same. +The `min_retained_connections` option controls how many connections Hypervel keeps when trimming excess idle connections. Connections are opened only when needed, so the first operation that uses a new connection waits for it to open. This setting does not create connections in advance or replace connections that fail, expire, or are discarded. The pool may have no idle connections while they are all in use. The `max_connections` option determines the maximum number of connections that may be opened for the worker. The `connect_timeout` option controls how long Hypervel will wait while opening a new database connection, while `wait_timeout` controls how long a coroutine may wait for an available connection when the pool is exhausted. -The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool. Set this value to `-1` to disable heartbeats. When heartbeats are enabled, Hypervel asks the database driver to check each retained idle connection without firing query events, query logs, or query duration handlers. Hypervel's PDO drivers use a raw `SELECT 1` query, while native and HTTP drivers may use their own protocol. The `heartbeat_timeout` option controls how long a heartbeat check may run before the connection is discarded. +You may enable background health checks by setting `heartbeat_interval` to a positive number of seconds. By default, it is null and heartbeats are disabled. These checks do not fire query events, write to query logs, or invoke query duration handlers. PDO drivers use a raw `SELECT 1` query, while other drivers use their own health checks. The `heartbeat_timeout` option limits how long a check may run before the connection is discarded. -The `max_idle_time` option controls how long an idle connection may remain in the pool while the managed connection count is above `min_connections`. The `max_lifetime` option controls how long a pooled connection may live. Hypervel recycles an expired connection only while it is idle or before it is reused. To avoid synchronized reconnects, Hypervel varies each connection's effective lifetime between 90 and 100 percent of this value. Set `max_lifetime` to `-1` to disable lifetime recycling. +The `max_idle_time` option controls how long a connection may remain unused before it expires. Background heartbeats remove idle connections above `min_retained_connections`; a connection that has expired is also refreshed before its next use. Set `max_idle_time` to null to disable idle expiry. + +You may use `max_lifetime` to replace connections periodically, even when they are used regularly. Hypervel replaces an expired connection only while it is idle or before it is reused. To avoid reconnecting every connection at once, each connection receives a lifetime between 90 and 100 percent of the configured value. By default, `max_lifetime` is null and lifetime expiry is disabled. + +For the full option reference and custom maintenance behavior, see the [pool documentation](/docs/{{version}}/pools#connection-pool-options). For a connection with separate read and write hosts, each base pool slot may lazily open one write PDO and one read PDO. It does not open one PDO per configured host. If `max_connections` is `10`, a worker may therefore hold up to roughly 20 server-side database connections for that configured connection once both sides have been used. Size your database server, PgBouncer, PgDog, or other pooler capacity with that in mind. Increase `max_connections` for more concurrent database work per worker, not simply because you configured more read hosts. diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 16eab6b612..781cbe6c14 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -217,8 +217,8 @@ If you need to configure a Google Cloud Storage filesystem manually, you may use 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], ``` @@ -241,13 +241,13 @@ You may configure a pool using the disk's `pool` option: 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], ``` -`min_retained_objects` is an idle-trimming floor; it does not eagerly create clients. `max_lifetime` expires clients by absolute age, while `max_idle_time` trims individual idle clients. `idle_ttl` removes an entirely unused pool after 300 seconds by default; set it explicitly to `null` to disable whole-pool eviction. If all clients are in use and no capacity becomes available before `wait_timeout`, a `RuntimeException` is thrown. +`min_retained_objects` is an idle-trimming floor; it does not eagerly create clients. `max_lifetime` expires clients by absolute age, while `max_idle_time` trims individual idle clients. `pool_idle_timeout` removes an entirely unused pool after 300 seconds by default. Set any of these three optional durations to `null` to disable it. If all clients are in use and no capacity becomes available before `wait_timeout`, a `RuntimeException` is thrown. An explicit pool name may be useful when multiple configurations intentionally identify the same operational resource: diff --git a/src/docs/mail.md b/src/docs/mail.md index 415bd9e94d..a77e225e44 100644 --- a/src/docs/mail.md +++ b/src/docs/mail.md @@ -335,13 +335,13 @@ The default pool settings are suitable for most applications. If your applicatio 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], ``` -`min_retained_objects` is an idle-trimming floor and does not eagerly create transports. `max_objects` limits concurrent pool capacity. `wait_timeout` determines how long a coroutine waits for capacity before a `RuntimeException` is thrown. `max_lifetime` expires transports by absolute age, `max_idle_time` trims individual idle transports, and `idle_ttl` removes an entirely unused pool after 300 seconds by default. Set `idle_ttl` explicitly to `null` to disable whole-pool eviction. +`min_retained_objects` is an idle-trimming floor and does not eagerly create transports. `max_objects` limits concurrent pool capacity. `wait_timeout` determines how long a coroutine waits for capacity before a `RuntimeException` is thrown. `max_lifetime` expires transports by absolute age, `max_idle_time` trims individual idle transports, and `pool_idle_timeout` removes an entirely unused pool after 300 seconds by default. Set any of these three optional durations to `null` to disable it. Use `pool.name` to select a readable explicit identity and `pool.fingerprint` to declare construction equivalence when a custom transport config contains an object, closure, or resource that cannot be fingerprinted automatically. Reusing an explicit name with a different transport type, fingerprint, or normalized options fails immediately. @@ -358,7 +358,7 @@ $mailer = Mail::build([ 'password' => $account->smtp_password, 'pool' => [ 'max_objects' => 20, - 'idle_ttl' => 300, + 'pool_idle_timeout' => 300, ], ]); ``` @@ -369,7 +369,7 @@ Equivalent jobs reuse the same bounded transport pool, while different credentia $mailer->getSymfonyTransport()->invalidatePool(); ``` -Otherwise, the pool is reclaimed automatically after `idle_ttl` once it has no active borrow. Custom transports require both declarations: register the transport with `poolable: true`, then set `pool` to `true` or an option array for on-demand builds. This ensures the transport author declares reuse safe and the caller deliberately requests retention. +Otherwise, the pool is reclaimed automatically after `pool_idle_timeout` once it has no active borrow. Custom transports require both declarations: register the transport with `poolable: true`, then set `pool` to `true` or an option array for on-demand builds. This ensures the transport author declares reuse safe and the caller deliberately requests retention. ## Generating Mailables diff --git a/src/docs/opentelemetry.md b/src/docs/opentelemetry.md index 6237167580..d724dcd8e0 100644 --- a/src/docs/opentelemetry.md +++ b/src/docs/opentelemetry.md @@ -369,6 +369,8 @@ Runtime metrics use one snapshot per source during collection. The complete defa - `hypervel.server.connections`, `hypervel.server.requests`, `hypervel.server.tasks.active`, and `hypervel.server.task_queue.size`; - `hypervel.worker.requests` and `hypervel.worker.coroutines`. +Pool metrics report idle resources and used capacity. Used capacity is the managed count minus the idle count, so it includes resources undergoing maintenance or cleanup as well as application borrows. Collection reads existing pools without creating resources or running health checks. + Object-pool metrics use the exact pool registry name. Framework-generated automatic names contain a construction fingerprint and may change when construction input changes. Use an explicit stable `pool.name` when dashboard continuity matters. Deliberately dynamic application pool names and recycler eviction can create historical backend series even though the live worker contains only a bounded set. Disable those metrics or use a metric view when that is not acceptable. diff --git a/src/docs/pools.md b/src/docs/pools.md index 6a4eacc81d..4bf785d7c9 100644 --- a/src/docs/pools.md +++ b/src/docs/pools.md @@ -10,12 +10,14 @@ - [Borrowing Objects](#borrowing-objects) - [Leases](#leases) - [Object Pool Lifecycle](#object-pool-lifecycle) - - [Consumer Integration](#consumer-integration) + - [Consumer Integration Examples](#consumer-integration-examples) - [Connection Pools](#connection-pools) - [Defining a Connection Pool](#defining-a-connection-pool) - [Borrowing Connections](#borrowing-connections) - [Connection Pool Options](#connection-pool-options) - [Connection Pool Lifecycle](#connection-pool-lifecycle) + - [Usage-Based Trimming](#usage-based-trimming) + - [Periodic Idle Checks](#periodic-idle-checks) - [Credits](#credits) @@ -42,10 +44,10 @@ The `Hypervel\ObjectPool` component provides managed pools for application and f ### Managed Pools -For most application pools, resolve `Hypervel\ObjectPool\Contracts\Factory` from the container and call `pool()` with a name, a callback, and any pool options: +To create an object pool, resolve `Hypervel\Contracts\ObjectPool\Factory` from the container and invoke its `pool` method. This method accepts a pool name, a closure that creates an object, and any options you wish to customize: ```php -use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\Contracts\ObjectPool\Factory; $pool = app(Factory::class)->pool( 'app:reports', @@ -70,12 +72,12 @@ If the callback depends on credentials or other values that may change, use a po ### Pool Definitions -Every managed pool is registered from an immutable `PoolDefinition` containing four values: +If your objects depend on credentials or other configuration that may change, you may register a `PoolDefinition`. A definition describes which objects can safely share a pool: - `identity` is the unique registry key for the pool. -- `resourceType` prevents an explicit identity from joining pools of different object kinds. -- `fingerprint` identifies the exact construction input for the pooled object. -- `options` is a normalized `PoolOptions` value. +- `resourceType` identifies the kind of object, such as a service client. +- `fingerprint` identifies the configuration used to create the object. +- `options` contains the pool's limits and timeouts. ```php use Hypervel\ObjectPool\PoolDefinition; @@ -101,10 +103,10 @@ $definition = new PoolDefinition( `PoolFingerprint::fromConfig()` creates a stable fingerprint from nulls, booleans, integers, floats, strings, enums, lists, and associative arrays. The order of associative-array keys does not affect the result, but list order does. Objects, closures, and resources are rejected because they cannot describe how an object should be created. Framework features that use object pools provide a `pool.fingerprint` setting when you need to declare this value yourself. -Resolve `Hypervel\ObjectPool\Contracts\Factory` from the container and call `getOrCreate()`: +Resolve `Hypervel\Contracts\ObjectPool\Factory` from the container and call `getOrCreate()`: ```php -use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\Contracts\ObjectPool\Factory; $pool = app(Factory::class)->getOrCreate( $definition, @@ -112,21 +114,21 @@ $pool = app(Factory::class)->getOrCreate( ); ``` -If the identity already exists, the resource type, fingerprint, and normalized options must all match. Matching definitions return the existing pool and ignore the new callback. A mismatch throws an exception instead of sharing objects created with different credentials or settings. Therefore, every value captured by the callback should come from the configuration used to build the fingerprint. +If a pool with the same identity already exists, Hypervel returns it when the resource type, fingerprint, and options match. Otherwise, an exception is thrown. Include every value that affects the object in its fingerprint so objects created with different credentials or settings cannot accidentally share a pool. Managed pools do not accept a destruction callback because they may be removed and recreated without the original caller. If an object requires custom cleanup, use a standalone pool whose owner controls its complete lifecycle. ### Standalone Pools -Sometimes you may want one service to own a pool directly instead of registering it with the pool manager. You may create a `SimpleObjectPool` using an object factory, normalized pool options, and an optional callback that destroys an object: +Sometimes you may want one service to own a pool directly instead of registering it with the pool manager. You may create a `CallbackObjectPool` with a closure that creates objects and an optional closure that closes them: ```php use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; +use Hypervel\ObjectPool\CallbackObjectPool; -$pool = new SimpleObjectPool( - callback: fn () => new ReportsClient, +$pool = new CallbackObjectPool( + createCallback: fn () => new ReportsClient, options: PoolOptions::fromArray([ 'max_objects' => 20, ]), @@ -144,21 +146,23 @@ When the owning service is stopped, call the pool's `close()` method to destroy | Option | Default | Purpose | |---|---:|---| | `min_retained_objects` | `1` | Idle-trimming floor. Objects are not created eagerly or replenished to this value. | -| `max_objects` | `10` | Maximum number of managed objects, including checked-out objects and creation slots. | +| `max_objects` | `10` | Maximum pool capacity, including borrowed objects and reserved creation slots. | | `wait_timeout` | `3.0` | Maximum seconds a coroutine waits for an object or newly freed creation capacity. | -| `max_lifetime` | `60.0` | Absolute object lifetime in seconds; `0` disables it. Expiry ignores the retention floor. | -| `max_idle_time` | `0.0` | Individual idle-object lifetime in seconds; `0` disables it. | -| `idle_ttl` | `300.0` | Managed-pool idle lifetime in seconds; explicit `null` disables pool eviction. | +| `max_lifetime` | `60.0` | Absolute object lifetime in seconds; `null` disables it. Expiry ignores the retention floor. | +| `max_idle_time` | `null` | Individual idle-object lifetime in seconds; `null` disables it. | +| `pool_idle_timeout` | `300.0` | Whole-pool idle timeout in seconds; `null` disables pool eviction. | -Counts must be integers and durations must be finite integers or floats. Unknown option keys are rejected so misspellings cannot silently select defaults. Standalone pools are not registered with the recycler, so their `idle_ttl` is only used when their owner calls `isIdle()`. +You may customize these options when creating the pool. Counts must be integers, and timeouts must be finite positive numbers of seconds. Use null to disable an optional timeout. Options cannot be changed after the pool is created, and unknown option names throw an exception. + +Standalone pools are not registered with the recycler. Their owner may use `isIdleExpired()` to check whether `pool_idle_timeout` has elapsed. ### Borrowing Objects -For synchronous work, borrow with `get()` and make sure the object is either released or discarded: +For synchronous work, call `borrow()` and make sure the object is either released or discarded: ```php -$client = $pool->get(); +$client = $pool->borrow(); try { $result = $client->execute($command); @@ -181,7 +185,7 @@ Use a `Lease` when a stream, job, response callback, or other deferred result mu ```php use Hypervel\ObjectPool\Lease; -$lease = new Lease($pool, $pool->get()); +$lease = new Lease($pool, $pool->borrow()); $client = $lease->get(); try { @@ -198,52 +202,110 @@ try { Leases finalize exactly once. An optional release callback may reset an object before it is returned; if that callback throws, the lease discards the object and propagates the reset failure. -Use `getCurrentObjectNumber()`, `getBorrowedObjectNumber()`, `getObjectNumberInPool()`, and `getWaiters()` to inspect a pool without changing it. The `getStats()` method returns the same current total, borrowed, idle, and waiting counts together with the closed state. +Use `getManagedCount()`, `getBorrowedCount()`, `getIdleCount()`, and `getWaitingCount()` to inspect a pool without changing it. The `getStats()` method returns these values under the keys `managed`, `borrowed`, `idle`, and `waiting`, together with `closed`. + +Managed objects include those undergoing cleanup, but exclude objects whose creation has not finished. Therefore, the managed count can temporarily exceed borrowed plus idle while cleanup is in progress. Capacity limits also account for reserved creation slots. ### Object Pool Lifecycle -`ObjectPool::close()` permanently closes the pool. It rejects future checkouts, wakes waiting borrowers, destroys every idle object, and destroys borrowed objects when they are eventually returned. `PoolManager::remove($identity)` removes one registry entry before closing its pool, so another operation may create a fresh pool without waiting for old borrows to finish. `PoolManager::flush()` removes and closes every pool and should only be called during worker boot or tests. +To close a pool, invoke its `close` method. Idle objects are destroyed immediately, and borrowed objects are destroyed when they are returned. Waiting borrowers are woken, and further attempts to borrow an object throw an exception. A closed pool cannot be reopened. + +You may use the manager's `purge` method to remove and close a registered pool. Hypervel removes the pool before closing it, allowing another operation to create a replacement while existing borrowers finish. If you wish to purge only a particular instance, pass that pool as the second argument: + +```php +$manager->purge($identity, $pool); +``` + +The `purgeAll` method removes and closes every registered pool. Call it only during worker boot or tests. + +Use `getPools()` to inspect registered pools and `getDefinition($identity)` to inspect a registered definition. `get($identity)` requires an existing pool and throws if none is registered. `has($identity)` checks current membership; another coroutine may purge the pool if your code yields before retrieving it. After a worker starts, `PoolRecycler` regularly removes expired idle objects and pools. A pool is not removed while an object is borrowed or another coroutine is acquiring one. Pool maintenance does not make an inactive pool appear active. -Close pools while the worker runtime is active. Application shutdown and garbage collection are not substitutes for `remove()`, `flush()`, or a framework manager's `purge()` method. +To change the maintenance interval, bind the recycler during service provider registration: - -### Consumer Integration +```php +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\ObjectPool\PoolRecycler; + +$this->app->singleton(PoolRecycler::class, fn ($app) => new PoolRecycler( + $app->make(Factory::class), + interval: 5.0, +)); +``` + +The interval must be a finite positive number of seconds. The recycler creates its own timer; you may supply a `Hypervel\Coordinator\Timer` through its `timer` constructor argument. Custom recyclers implement `Hypervel\Contracts\ObjectPool\Recycler`, which requires `start()` and `stop()`. + +Close pools while the worker runtime is active. Application shutdown and garbage collection are not substitutes for `purge()`, `purgeAll()`, or a framework manager's `purge()` method. + + +### Consumer Integration Examples + +These examples show how framework consumers use the shared pooling APIs and how custom integrations can follow the same patterns. Hypervel does not provide a generic magic proxy for object pools. A proxy cannot know whether a result is complete or is a lazy stream, iterator, promise, or another object that still needs the borrowed resource. Consumer proxies should list their synchronous methods and use the protected `PoolProxy::invoke()` method. Deferred methods should keep a `Lease` until their work is finished. Framework managers for filesystems, mail, and queues build definitions from the actual construction input, expose normalized `pool` configuration, and distinguish cache-only forgetting from pool-invalidating purge operations. Broadcasting does the same only for drivers explicitly marked as poolable. Prefer those manager APIs when using a framework resource instead of creating definitions directly. +For example, a custom broadcasting driver can opt into pooling during service provider boot, before it is resolved: + +```php +use Hypervel\Broadcasting\BroadcastManager; + +public function boot(BroadcastManager $broadcasts): void +{ + $broadcasts->addPoolableDriver('custom'); +} +``` + +The filesystem, mail, queue, and broadcasting managers also expose `getPoolableDrivers()`, `removePoolableDriver($driver)`, and `setPoolableDrivers($drivers)`. Configure the list during worker boot; changing it does not replace drivers that have already been resolved. + Filesystem client pools and whole-driver pools use different construction input. S3 and Google Cloud Storage pools contain only the SDK client, so the logical disk name does not affect their fingerprint. Whole-driver pools contain the complete disk, so their fingerprints include the complete normalized disk configuration, the nullable logical name, and any serving-route owner or prefix that changes the constructed adapter. If two custom whole-driver disks may safely share a pool despite having different names, configure the same `pool.fingerprint` for both disks. You may also configure the same `pool.name` when you want to choose the shared identity, but the fingerprint must still match. Never declare matching fingerprints unless every construction detail is equivalent, including serving-route behavior. +On a pooled proxy, `getPoolName()` returns the fully qualified registry name. The manager adds its namespace to a configured `pool.name`, or generates a name from the resource type and fingerprint when no name is configured. Use the returned name when looking up that pool in the registry. + ## Connection Pools -The `Hypervel\Pool` component provides the lower-level foundation used by Hypervel's database and Redis connection pools. It is also available to package authors who need to manage another connection type. +The `Hypervel\ConnectionPool` component provides the lower-level foundation used by Hypervel's database and Redis connection pools. It is also available to package authors who need to manage another connection type. + +Use `getManagedCount()`, `getBorrowedCount()`, `getIdleCount()`, and `getWaitingCount()` to inspect a pool without borrowing a connection. The `getStats()` method returns these counts under the `managed`, `borrowed`, `idle`, and `waiting` keys, together with a `closed` flag. + +The managed count includes connections being checked or destroyed, but excludes connections still being created. During cleanup, it may therefore exceed the borrowed and idle counts combined. + +Database and Redis each provide a `Pool\PoolManager`. Use `pool($name)` to resolve a named pool or `getPools()` to inspect existing pools without creating one: + +```php +use Hypervel\Database\Pool\PoolManager; + +$manager = app(PoolManager::class); +$pool = $manager->pool('mysql'); + +$stats = $pool->getStats(); +``` -Use `getCurrentConnections()`, `getConnectionsInChannel()`, and `getWaiters()` to inspect the current managed, idle, and waiting counts without borrowing a connection. +During worker boot or tests, `purge($name)` removes and closes one pool, while `purgeAll()` removes all pools. The database manager also offers `purgeForConnection($name)` to remove a connection's read and write pools together. Existing borrowers may finish; their connections are destroyed when returned. Hypervel's database pool owns borrowing, deadlines, heartbeat cancellation, and idle connection recycling. Each database connection owns its protocol-specific health check, reconnection, cleanup, and reuse rules. Therefore, PDO, native, and HTTP database drivers can use the same pool without exposing their underlying client to the pool component. ### Defining a Connection Pool -To define a connection pool, extend the `Pool` class and implement its `createConnection` method. Each connection must implement `ConnectionInterface`. You may extend the base `Connection` class when its release handling and idle-time checks fit your protocol: +To define a connection pool, extend the `ConnectionPool` class and implement its `createConnection` method. Each connection must implement the `Hypervel\Contracts\ConnectionPool\Connection` contract. You may extend the base `Connection` class when its release handling and idle-time checks fit your protocol: ```php +use Hypervel\ConnectionPool\Connection; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; +use Hypervel\Contracts\ConnectionPool\ConnectionPool as ConnectionPoolContract; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Pool\ConnectionInterface; -use Hypervel\Contracts\Pool\PoolInterface; -use Hypervel\Pool\Connection; -use Hypervel\Pool\Pool; -class ServicePool extends Pool +class ServicePool extends ConnectionPool { - protected function createConnection(): ConnectionInterface + protected function createConnection(): PoolConnection { return $this->container->make(ServiceConnection::class, [ 'pool' => $this, @@ -257,7 +319,7 @@ class ServiceConnection extends Connection public function __construct( Container $container, - PoolInterface $pool, + ConnectionPoolContract $pool, protected ServiceClientFactory $clientFactory, ) { parent::__construct($container, $pool); @@ -279,7 +341,7 @@ class ServiceConnection extends Connection $this->close(); $this->connection = $this->clientFactory->connect( - timeout: $this->pool->getOption()->getConnectTimeout(), + timeout: $this->pool->getOptions()->connectTimeout, ); $this->lastUseTime = hrtime(true) / 1e9; $this->markValid(); @@ -312,15 +374,17 @@ The connection class is responsible for translating connection options into the If a protocol needs to keep one socket alive with a periodic heartbeat, you may extend `KeepaliveConnection`. This connection type exposes a `call()` method for working with its socket and does not allow direct `getConnection()` access. Your subclass should create the socket through `getActiveConnection()` and may override `heartbeat()` and `sendClose()` for the protocol. +If a connection is closed or replaced while a call is running, the call's socket is dropped when it finishes instead of being returned for reuse. This cleanup does not send a protocol close message. Return a socket resource or client whose release or destructor closes the underlying connection. + Connection pools are worker-lifetime services. A package should keep them in a manager that returns the current pool for each operation instead of retaining a borrowed connection or a pool that has been removed. ### Borrowing Connections -The `get` method borrows one connection from the pool. Always release a healthy connection after the operation completes. If a network or protocol failure may have left the connection in an unknown state, discard it instead: +The `borrow` method borrows one connection from the pool. Always release a healthy connection after the operation completes. If a network or protocol failure may have left the connection in an unknown state, discard it instead: ```php -$connection = $pool->get(); +$connection = $pool->borrow(); try { $response = $connection->getConnection()->send($request); @@ -335,6 +399,8 @@ $connection->release(); The pool rejects foreign connections, repeated releases or discards, and connection factories that return the same connection object more than once. +If capacity remains unavailable for `wait_timeout` seconds, borrowing throws `Hypervel\ConnectionPool\Exceptions\PoolExhaustedException`. Borrowing from a closed pool throws `Hypervel\ConnectionPool\Exceptions\PoolClosedException`. + ### Connection Pool Options @@ -344,46 +410,107 @@ Connection pool options are passed to the pool constructor as an array: $pool = app()->make(ServicePool::class, [ 'name' => 'reports', 'config' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 10, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1.0, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, + 'idle_check_interval' => null, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, 'events' => [], ], ]); + +$pool->start(); ``` | Option | Default | Purpose | |---|---:|---| -| `min_connections` | `1` | Managed-connection floor used when `flush()` trims excess idle connections. Connections are not created eagerly or automatically replenished to this value. | -| `max_connections` | `10` | Maximum number of managed connections, including borrowed connections and connections being created. | +| `min_retained_connections` | `1` | Managed-connection floor used when trimming excess idle connections. Connections are not created eagerly or automatically replenished to this value. | +| `max_connections` | `10` | Maximum pool capacity, including managed connections and reserved creation slots. | | `connect_timeout` | `10.0` | Maximum seconds allowed to establish a connection. The connection implementation must apply this value to its client. | | `wait_timeout` | `3.0` | Maximum seconds a borrower waits for an idle connection or newly freed creation capacity. | -| `heartbeat` | `-1.0` | Heartbeat interval in seconds; `-1` disables it. The connection or pool implementation must schedule the heartbeat. | +| `heartbeat_interval` | `null` | Heartbeat interval in seconds; null disables it. The connection or pool implementation must schedule the heartbeat. | | `heartbeat_timeout` | `1.0` | Maximum seconds allowed for a heartbeat check. The heartbeat implementation must apply this value. | -| `max_idle_time` | `60.0` | Maximum idle time in seconds. The base `Connection` class applies this value in its `check()` method. | -| `max_lifetime` | `-1.0` | Maximum connection lifetime in seconds; `-1` disables it. The connection implementation must enforce this limit. | -| `events` | `[]` | Connection lifecycle event class names. The base `Connection` class dispatches `ReleaseConnection` when it is included. | +| `idle_check_interval` | `null` | Interval between generic idle-connection checks; null disables them. | +| `max_idle_time` | `60.0` | Maximum idle time in seconds; null disables expiry. The base `Connection` class applies this value in its `check()` method. | +| `max_lifetime` | `null` | Maximum connection lifetime in seconds; null disables it. The connection implementation must enforce this limit. | +| `events` | `[]` | Connection lifecycle event class names. Include `ConnectionReleasing` to dispatch it before a connection returns to the pool. | + +You may inspect a pool's options using the `getOptions` method: + +```php +$options = $pool->getOptions(); + +$options->maxConnections; +$options->waitTimeout; +``` -The base pool enforces `max_connections` and `wait_timeout`, and uses `min_connections` when `flush()` trims idle connections. The remaining connection-specific options are provided to connection and pool implementations; they do not add protocol behavior by themselves. Unknown option keys are rejected. +Options cannot be changed after the pool is created. Omitted options use the defaults above. Timeouts must be finite positive numbers of seconds; use null to disable an optional timeout. Invalid counts, unknown option names, and event classes that do not exist throw an exception. + +The base pool enforces `max_connections` and `wait_timeout`, and uses `min_retained_connections` when trimming idle connections. Protocol-specific options are provided to connection and pool implementations; they do not add protocol behavior by themselves. ### Connection Pool Lifecycle -Connection pools create connections lazily as callers need them, up to `max_connections`, so the first caller that needs a new connection pays the cost of opening it. The `min_connections` option controls how far `flush()` may reduce the total managed connection count while trimming idle connections. It does not prewarm the pool or guarantee a minimum number of idle or managed connections. A pool may have no idle connections while they are borrowed, and unhealthy, expired, discarded, or failed connections may leave the managed count below this value. +Connections are created when first needed, up to `max_connections`. The retained minimum controls trimming; it does not prewarm the pool or replenish discarded connections. + +Database and Redis managers call `start()` after pool initialization succeeds. When constructing a pool yourself, call `start()` to enable its configured background maintenance. You may borrow and release connections before starting it. Repeated calls do not create duplicate timers. + +When customizing pool resolution, ensure the container returns an open pool; throw an exception if initialization fails. The `close()` method is terminal and may be called more than once. It destroys idle connections immediately, rejects new borrows, and destroys connections that were already borrowed when their owners return them. -Packages that cache connection pools must close them before the server forks and before each worker starts so a child process never inherits an open connection. Remove the cached pool from its manager before calling `close()`. Closing may yield while resources are released, and another coroutine must be able to resolve a fresh pool instead of receiving the pool being closed. +To shrink an open pool, call `trimExcessIdle()`. This closes idle connections while the managed count exceeds `min_retained_connections`, regardless of their age. Borrowed connections remain available to their owners. + +Close cached pools before forking or starting a worker to avoid inheriting open connections. Remove each pool from its manager before closing it, allowing concurrent callers to resolve a replacement while cleanup finishes. > [!WARNING] > Do not call native channel methods from a destructor. Close connection pools explicitly while the worker runtime is active. + +### Usage-Based Trimming + +Database and Redis pools trim excess idle connections when usage drops below five borrows per sampled second. Their `BorrowRateTracker` samples 10 seconds, with a cooldown of more than 60 seconds. Both periods begin with the first successful borrow. + +Custom pools may enable this behavior by overriding `createUsageTracker`: + +```php +use Hypervel\ConnectionPool\BorrowRateTracker; +use Hypervel\Contracts\ConnectionPool\UsageTracker; + +protected function createUsageTracker(): ?UsageTracker +{ + return new BorrowRateTracker; +} +``` + +The factory runs on the first successful borrow, after pool construction. Return a fresh tracker or null to disable trimming. Its result is cached. The factory must construct the policy without borrowing connections or performing I/O. + +To customize the policy, extend `BorrowRateTracker` and set its protected `$window`, `$threshold`, and `$cooldown` properties. The window and cooldown use seconds. Read the rate with `getBorrowRate()`, or implement `UsageTracker` with your own `recordBorrow()` and `shouldTrimExcessIdle()` methods. + + +### Periodic Idle Checks + +Set `idle_check_interval` to check idle connections even when no new connections are being borrowed: + +```php +'pool' => [ + 'idle_check_interval' => 5.0, +], +``` + +After `start()`, an empty pool schedules its `IdleConnectionMonitor` on the first successful borrow. A pool that already holds connections schedules it immediately. The monitor stops on pool closure or worker exit; null disables it. Each tick checks one idle connection in FIFO order. Unhealthy or expired connections are discarded, even below the retained minimum; checks do not refresh activity. + +A full pass takes roughly one interval per idle connection, plus check time. The interval is not an expiry deadline. + +The monitor calls `check()`, which must work without a request or operation context. Database and Redis heartbeats are separate; enabling both can cause overlapping checks. + +For standalone use, construct `IdleConnectionMonitor($pool, $interval)` and call `start()` and `stop()`. It owns a fresh timer unless you supply a `Hypervel\Coordinator\Timer` as the third argument. + ## Credits -Hypervel Pool began as a port of [Hyperf Pool](https://github.com/hyperf/hyperf/tree/master/src/pool) and has been adapted for Hypervel's framework architecture and coroutine runtime. +Hypervel's connection pool began as a port of [Hyperf Pool](https://github.com/hyperf/hyperf/tree/master/src/pool). It is maintained independently for Hypervel's APIs and coroutine runtime. diff --git a/src/docs/queues.md b/src/docs/queues.md index 6e7d991bc5..d48e079021 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -140,8 +140,8 @@ Configure a connection pool inside its queue connection definition: 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], ``` diff --git a/src/docs/redis.md b/src/docs/redis.md index 4deb4b70d1..48eb3a8bc7 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -65,14 +65,15 @@ You may configure your application's Redis settings via the `config/database.php 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), 'pool' => [ - 'min_connections' => (int) env('REDIS_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('REDIS_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('REDIS_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('REDIS_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('REDIS_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], ], @@ -188,14 +189,15 @@ If your application is utilizing Redis Cluster, you should define a `cluster` ar 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), 'pool' => [ - 'min_connections' => (int) env('REDIS_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('REDIS_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('REDIS_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('REDIS_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('REDIS_MAX_LIFETIME')) === null ? null : (float) $duration, ], 'cluster' => [ 'enabled' => true, @@ -271,24 +273,35 @@ Hypervel pools Redis connections so commands can reuse established sockets acros // ... 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 10, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, + 'idle_check_interval' => null, 'max_idle_time' => 60.0, - 'max_lifetime' => -1, + 'max_lifetime' => null, ], ], ``` When the `pool` array is omitted, Hypervel uses a managed-connection floor of one and allows up to 10 connections, with 10-second connection, three-second wait, and 60-second idle timeouts. Heartbeats and maximum-lifetime recycling are disabled, and the heartbeat timeout is one second. The environment variables shown above only apply to connection records that declare a `pool` array. -The `min_connections` option controls how far trimming excess idle connections may reduce the total managed connection count. It is not an idle-count invariant or a guaranteed total minimum, and it does not prewarm or automatically replenish the pool. The caller that first needs each new connection pays its connection-establishment cost, and the pool may have zero idle connections under load. Lifecycle-expired or unhealthy connections and explicit discards can reduce the managed count below `min_connections`; failed connection creation can leave it below that value. None is automatically replenished. The `max_connections` option caps the number of connections the worker may open. The `connect_timeout` option controls how long Hypervel will wait while opening a new Redis connection. The `wait_timeout` option controls how long a coroutine may wait for a pooled connection to become available. The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool; set this value to `-1` to disable background heartbeats. The `heartbeat_timeout` option controls how long a heartbeat ping may run before the connection is discarded. The `max_idle_time` option controls how long an idle connection may remain reusable while the total managed count is above `min_connections`, and the `max_lifetime` option controls the upper bound for how long a pooled connection generation may live before it is recycled while idle or before it is reused; Hypervel assigns each generation an effective lifetime between 90-100% of this value to avoid synchronized reconnects. Set `max_lifetime` to `-1` to disable lifetime recycling. +The `min_retained_connections` option controls how many connections Hypervel keeps when trimming excess idle connections. Connections are opened only when needed, so the first operation that uses a new connection waits for it to open. This setting does not create connections in advance or replace connections that fail, expire, or are discarded. The pool may have no idle connections while they are all in use. + +The `max_connections` option limits how many connections a worker may open. You may use `connect_timeout` to limit how long Hypervel waits while opening a Redis connection, and `wait_timeout` to limit how long a coroutine waits for an available pool connection. + +To check idle connections in the background, set `heartbeat_interval` to a positive number of seconds. Set it to null to disable these checks. The `heartbeat_timeout` option limits how long a ping may run before the connection is discarded. + +The `max_idle_time` option controls how long a connection may remain unused before it expires. Background heartbeats remove idle connections above `min_retained_connections`; a connection that has expired is also refreshed before its next use. Set `max_idle_time` to null to disable idle expiry. + +You may use `max_lifetime` to replace connections periodically, even when they are used regularly. Connections are replaced only while idle or before their next use. Each connection receives a lifetime between 90 and 100 percent of this value so they do not all reconnect at once. Set `max_lifetime` to null to disable lifetime expiry. Idle and lifetime recycling are checked when a connection is borrowed from the pool. When heartbeat is enabled, Hypervel also runs a background sweep over idle pooled Redis connections so stale sockets are found before a request needs them. Heartbeat and max lifetime recycling apply to Hypervel's worker pool whether the connection points directly at Redis, a managed Redis service, or a proxy. +For the full option reference and custom maintenance behavior, see the [pool documentation](/docs/{{version}}/pools#connection-pool-options). + ## Interacting With Redis diff --git a/src/docs/sentry.md b/src/docs/sentry.md index 6248bd1178..8fa9a4a31b 100644 --- a/src/docs/sentry.md +++ b/src/docs/sentry.md @@ -318,7 +318,9 @@ Spotlight may be used without configuring a Sentry DSN. ## Delivery and Shutdown -Buffered logs and metrics are flushed when their execution finishes. Sentry envelopes are then sent from detached coroutines using a bounded pool of reusable HTTP transports. Requests, queued jobs, scheduled tasks, and WebSocket callbacks do not wait for event delivery, and commands use the same non-blocking delivery by default. Sends started outside a coroutine complete before returning so short-lived CLI processes cannot exit while an accepted send is still running. If the pool is exhausted during an exception storm, new telemetry is dropped instead of delaying application work. +Buffered logs and metrics are flushed when their execution finishes. Sentry envelopes are then sent from detached coroutines using a bounded pool of reusable HTTP transports. Requests, queued jobs, scheduled tasks, and WebSocket callbacks do not wait for event delivery, and commands use the same non-blocking delivery by default. Sends started outside a coroutine complete before returning so short-lived CLI processes cannot exit while an accepted send is still running. + +If no transport becomes available within `sentry.pool.wait_timeout`, or the pool has closed, the event is skipped. Unexpected transport creation errors reach the Sentry SDK's error handling instead of being treated as pool exhaustion. Graceful worker shutdown performs a bounded drain after the worker-exit coordinator is released, then closes the transport pool. Delivery during a worker exit is best effort because Swoole may terminate outstanding reactor work after its shutdown deadline. diff --git a/src/filesystem/src/ClientPooledFilesystem.php b/src/filesystem/src/ClientPooledFilesystem.php index 350c8ef6c7..d24ac330ee 100644 --- a/src/filesystem/src/ClientPooledFilesystem.php +++ b/src/filesystem/src/ClientPooledFilesystem.php @@ -6,9 +6,9 @@ use Closure; use Hypervel\Contracts\Filesystem\Cloud; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; use Hypervel\Filesystem\Concerns\InteractsWithPooledFilesystem; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolDefinition; use RuntimeException; @@ -44,7 +44,7 @@ public function getDefinition(): PoolDefinition } /** - * Get the pooled client's identity. + * Return the pool's fully qualified registry name. */ public function getPoolName(): string { @@ -56,7 +56,7 @@ public function getPoolName(): string */ public function invalidatePool(): bool { - return $this->pools->remove($this->definition->identity); + return $this->pools->purge($this->definition->identity); } /** @@ -109,7 +109,7 @@ protected function withBorrowed(Closure $operation): mixed protected function leaseStack(): array { $pool = $this->pools->getOrCreate($this->definition, $this->clientFactory); - $lease = new Lease($pool, $pool->get(), $this->releaseCallback); + $lease = new Lease($pool, $pool->borrow(), $this->releaseCallback); try { return [$lease, $this->buildStack($lease->get())]; diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index db1467d7b5..7455e44528 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -11,10 +11,10 @@ use Hypervel\Contracts\Filesystem\Cloud; use Hypervel\Contracts\Filesystem\Factory as FactoryContract; use Hypervel\Contracts\Filesystem\Filesystem; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; +use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\ObjectPool\PoolDefinition; -use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\Support\Arr; use Hypervel\Support\RebindsCallbacksToSelf; use Hypervel\Support\Str; @@ -98,7 +98,7 @@ class FilesystemManager implements FactoryContract /** * The array of drivers which will be wrapped as pool proxies. */ - protected array $poolables = ['s3', 'gcs']; + protected array $poolableDrivers = ['s3', 'gcs']; /** * Create a new filesystem manager instance. @@ -214,7 +214,7 @@ private function resolveConstructionDescriptor( } $driver = $config['driver']; - $hasPool = in_array($driver, $this->poolables, true); + $hasPool = in_array($driver, $this->poolableDrivers, true); $constructionConfig = Arr::except($config, ['pool']); $resolver = fn (Filesystem $filesystem): Filesystem => $this->configureServingRoute( $filesystem, @@ -285,11 +285,11 @@ protected function createDriverPooledDisk( ?string $name, ?string $servingRouteDisk, string $servingRoutePrefix, - Closure $resolver, + Closure $createCallback, ): FilesystemPoolProxy { return new FilesystemPoolProxy( $this->diskPoolDefinition($driver, $config, $name, $servingRouteDisk, $servingRoutePrefix), - $resolver, + $createCallback, $this->poolFactory(), Arr::except($config, ['pool']), $this->getReleaseCallback($driver), @@ -910,7 +910,7 @@ public function purge(?string $name = null): void public function extend(string $driver, Closure $callback, bool $poolable = false): static { if ($poolable) { - $this->addPoolable($driver); + $this->addPoolableDriver($driver); } try { diff --git a/src/filesystem/src/FilesystemPoolProxy.php b/src/filesystem/src/FilesystemPoolProxy.php index 96458c63da..ac0e4af125 100644 --- a/src/filesystem/src/FilesystemPoolProxy.php +++ b/src/filesystem/src/FilesystemPoolProxy.php @@ -7,8 +7,8 @@ use Closure; use Hypervel\Contracts\Filesystem\Cloud; use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; +use Hypervel\Contracts\ObjectPool\Factory; use Hypervel\Filesystem\Concerns\InteractsWithPooledFilesystem; -use Hypervel\ObjectPool\Contracts\Factory; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolProxy; use RuntimeException; @@ -23,12 +23,12 @@ class FilesystemPoolProxy extends PoolProxy implements Cloud */ public function __construct( PoolDefinition $definition, - Closure $resolver, + Closure $createCallback, Factory $pools, protected array $config, ?Closure $releaseCallback = null, ) { - parent::__construct($definition, $resolver, $pools, $releaseCallback); + parent::__construct($definition, $createCallback, $pools, $releaseCallback); } /** @@ -38,7 +38,7 @@ protected function configureBorrowed(object $object): void { if (! $object instanceof FilesystemContract) { throw new RuntimeException( - 'Pooled filesystem resolvers must return an instance of ' . FilesystemContract::class . '.', + 'Pooled filesystem creation callbacks must return an instance of ' . FilesystemContract::class . '.', ); } diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php index eb6a04c6d2..3acbb36bce 100644 --- a/src/foundation/config/database.php +++ b/src/foundation/config/database.php @@ -78,14 +78,15 @@ Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], 'pool' => [ - 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('DB_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('DB_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('DB_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('DB_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('DB_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], @@ -108,14 +109,15 @@ Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], 'pool' => [ - 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('DB_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('DB_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('DB_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('DB_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('DB_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], @@ -136,14 +138,15 @@ PDO::ATTR_EMULATE_PREPARES => true, ], 'pool' => [ - 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('DB_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('DB_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('DB_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('DB_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('DB_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('DB_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('DB_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('DB_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], @@ -165,14 +168,15 @@ ], 'migrations_connection' => 'pgsql', 'pool' => [ - 'min_connections' => (int) env('DB_POOLED_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('DB_POOLED_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('DB_POOLED_MAX_CONNECTIONS', 20), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('DB_POOLED_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('DB_POOLED_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('DB_POOLED_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('DB_POOLED_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('DB_POOLED_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('DB_POOLED_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('DB_POOLED_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], ], @@ -226,14 +230,15 @@ 'backoff_base' => (int) env('REDIS_BACKOFF_BASE', 100), 'backoff_cap' => (int) env('REDIS_BACKOFF_CAP', 1000), 'pool' => [ - 'min_connections' => (int) env('REDIS_MIN_CONNECTIONS', 1), + 'min_retained_connections' => (int) env('REDIS_MIN_RETAINED_CONNECTIONS', 1), 'max_connections' => (int) env('REDIS_MAX_CONNECTIONS', 10), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_HEARTBEAT', -1), + 'heartbeat_interval' => ($duration = env('REDIS_HEARTBEAT_INTERVAL')) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('REDIS_HEARTBEAT_TIMEOUT', 1.0), - 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), - 'max_lifetime' => (float) env('REDIS_MAX_LIFETIME', -1), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('REDIS_MAX_IDLE_TIME', 60)) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('REDIS_MAX_LIFETIME')) === null ? null : (float) $duration, ], ], @@ -249,14 +254,15 @@ 'backoff_base' => (int) env('REDIS_CACHE_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), 'backoff_cap' => (int) env('REDIS_CACHE_BACKOFF_CAP', env('REDIS_BACKOFF_CAP', 1000)), 'pool' => [ - 'min_connections' => (int) env('REDIS_CACHE_MIN_CONNECTIONS', env('REDIS_MIN_CONNECTIONS', 1)), + 'min_retained_connections' => (int) env('REDIS_CACHE_MIN_RETAINED_CONNECTIONS', env('REDIS_MIN_RETAINED_CONNECTIONS', 1)), 'max_connections' => (int) env('REDIS_CACHE_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_CACHE_HEARTBEAT', env('REDIS_HEARTBEAT', -1)), + 'heartbeat_interval' => ($duration = env('REDIS_CACHE_HEARTBEAT_INTERVAL', env('REDIS_HEARTBEAT_INTERVAL'))) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('REDIS_CACHE_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)), - 'max_idle_time' => (float) env('REDIS_CACHE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)), - 'max_lifetime' => (float) env('REDIS_CACHE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('REDIS_CACHE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60))) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('REDIS_CACHE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME'))) === null ? null : (float) $duration, ], ], @@ -272,14 +278,15 @@ 'backoff_base' => (int) env('REDIS_SESSION_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), 'backoff_cap' => (int) env('REDIS_SESSION_BACKOFF_CAP', env('REDIS_BACKOFF_CAP', 1000)), 'pool' => [ - 'min_connections' => (int) env('REDIS_SESSION_MIN_CONNECTIONS', env('REDIS_MIN_CONNECTIONS', 1)), + 'min_retained_connections' => (int) env('REDIS_SESSION_MIN_RETAINED_CONNECTIONS', env('REDIS_MIN_RETAINED_CONNECTIONS', 1)), 'max_connections' => (int) env('REDIS_SESSION_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_SESSION_HEARTBEAT', env('REDIS_HEARTBEAT', -1)), + 'heartbeat_interval' => ($duration = env('REDIS_SESSION_HEARTBEAT_INTERVAL', env('REDIS_HEARTBEAT_INTERVAL'))) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('REDIS_SESSION_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)), - 'max_idle_time' => (float) env('REDIS_SESSION_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)), - 'max_lifetime' => (float) env('REDIS_SESSION_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('REDIS_SESSION_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60))) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('REDIS_SESSION_MAX_LIFETIME', env('REDIS_MAX_LIFETIME'))) === null ? null : (float) $duration, ], ], @@ -295,14 +302,15 @@ 'backoff_base' => (int) env('REDIS_QUEUE_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), 'backoff_cap' => (int) env('REDIS_QUEUE_BACKOFF_CAP', env('REDIS_BACKOFF_CAP', 1000)), 'pool' => [ - 'min_connections' => (int) env('REDIS_QUEUE_MIN_CONNECTIONS', env('REDIS_MIN_CONNECTIONS', 1)), + 'min_retained_connections' => (int) env('REDIS_QUEUE_MIN_RETAINED_CONNECTIONS', env('REDIS_MIN_RETAINED_CONNECTIONS', 1)), 'max_connections' => (int) env('REDIS_QUEUE_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_QUEUE_HEARTBEAT', env('REDIS_HEARTBEAT', -1)), + 'heartbeat_interval' => ($duration = env('REDIS_QUEUE_HEARTBEAT_INTERVAL', env('REDIS_HEARTBEAT_INTERVAL'))) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('REDIS_QUEUE_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)), - 'max_idle_time' => (float) env('REDIS_QUEUE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)), - 'max_lifetime' => (float) env('REDIS_QUEUE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('REDIS_QUEUE_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60))) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('REDIS_QUEUE_MAX_LIFETIME', env('REDIS_MAX_LIFETIME'))) === null ? null : (float) $duration, ], ], @@ -318,14 +326,15 @@ 'backoff_base' => (int) env('REDIS_REVERB_BACKOFF_BASE', env('REDIS_BACKOFF_BASE', 100)), 'backoff_cap' => (int) env('REDIS_REVERB_BACKOFF_CAP', env('REDIS_BACKOFF_CAP', 1000)), 'pool' => [ - 'min_connections' => (int) env('REDIS_REVERB_MIN_CONNECTIONS', env('REDIS_MIN_CONNECTIONS', 1)), + 'min_retained_connections' => (int) env('REDIS_REVERB_MIN_RETAINED_CONNECTIONS', env('REDIS_MIN_RETAINED_CONNECTIONS', 1)), 'max_connections' => (int) env('REDIS_REVERB_MAX_CONNECTIONS', env('REDIS_MAX_CONNECTIONS', 10)), 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => (float) env('REDIS_REVERB_HEARTBEAT', env('REDIS_HEARTBEAT', -1)), + 'heartbeat_interval' => ($duration = env('REDIS_REVERB_HEARTBEAT_INTERVAL', env('REDIS_HEARTBEAT_INTERVAL'))) === null ? null : (float) $duration, 'heartbeat_timeout' => (float) env('REDIS_REVERB_HEARTBEAT_TIMEOUT', env('REDIS_HEARTBEAT_TIMEOUT', 1.0)), - 'max_idle_time' => (float) env('REDIS_REVERB_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60)), - 'max_lifetime' => (float) env('REDIS_REVERB_MAX_LIFETIME', env('REDIS_MAX_LIFETIME', -1)), + 'idle_check_interval' => null, + 'max_idle_time' => ($duration = env('REDIS_REVERB_MAX_IDLE_TIME', env('REDIS_MAX_IDLE_TIME', 60))) === null ? null : (float) $duration, + 'max_lifetime' => ($duration = env('REDIS_REVERB_MAX_LIFETIME', env('REDIS_MAX_LIFETIME'))) === null ? null : (float) $duration, ], ], ], diff --git a/src/foundation/config/filesystems.php b/src/foundation/config/filesystems.php index 37fc242360..563f5e503e 100644 --- a/src/foundation/config/filesystems.php +++ b/src/foundation/config/filesystems.php @@ -72,8 +72,8 @@ 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], @@ -97,8 +97,8 @@ 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], ], diff --git a/src/foundation/config/queue.php b/src/foundation/config/queue.php index e0be4daed4..35f485dd71 100644 --- a/src/foundation/config/queue.php +++ b/src/foundation/config/queue.php @@ -92,8 +92,8 @@ 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], @@ -119,8 +119,8 @@ 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, + 'max_idle_time' => null, + 'pool_idle_timeout' => 300.0, ], ], diff --git a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php index 48c18fc2ca..624691c2b7 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php @@ -6,7 +6,7 @@ use Hypervel\Container\Container; use Hypervel\Foundation\Testing\RedisTestConfiguration; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisProxy; use Hypervel\Support\Facades\Redis; use Hypervel\Testing\ParallelTesting; @@ -78,13 +78,10 @@ protected function tearDownInteractsWithRedis(): void // Ignore cleanup errors } - // Flush the Redis connection pool so phpredis sockets are closed - // before $this->app->flush() drops the pool factory. Without this, - // the Pool/Connection reference cycle keeps sockets open until PHP's - // cycle collector eventually fires, which trips the FD limit under - // long ParaTest runs. - if ($this->app->resolved(PoolFactory::class)) { - $this->app->make(PoolFactory::class)->flushAll(); + // Close sockets before dropping the manager: pool reference cycles can + // otherwise retain enough sockets to exhaust file descriptors in long runs. + if ($this->app->resolved(PoolManager::class)) { + $this->app->make(PoolManager::class)->purgeAll(); } } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 42d7675bb1..de2349f602 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -7,7 +7,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Database\DatabaseTransactionsManager; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Foundation\Bootstrap\HandleExceptions; use Hypervel\Foundation\Testing\Attributes\SetUp; use Hypervel\Foundation\Testing\Attributes\TearDown; @@ -123,17 +123,11 @@ protected function tearDownTheTestEnvironment(): void } try { - // Flush the DB connection pool in a separate coroutine so the - // pooled connections checked out during the destroyed callbacks - // (e.g. migrate:rollback) are first released by their Coroutine::defer - // when the previous coroutine ends. This lets close() drain them - // immediately; any genuinely late release is still destroyed by - // the closed pool rather than returned to circulation. - // The resolved() gate skips the work for tests that never touched - // the DB pool factory. - if ($app->resolved(PoolFactory::class)) { + // Use a separate coroutine so destroyed callbacks release their + // borrowed connections through defer before the pools are closed. + if ($app->resolved(PoolManager::class)) { $this->runInCoroutine( - fn () => $app->make(PoolFactory::class)->flushAll() + fn () => $app->make(PoolManager::class)->purgeAll() ); } } catch (Throwable $throwable) { diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index f789dc8417..ade3e0f6b8 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -6,15 +6,15 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Contracts\Pool\ConnectionInterface as PoolConnectionInterface; use Hypervel\Database\CachedConnectionResolver; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionName; use Hypervel\Database\ConnectionResolver; -use Hypervel\Database\Pool\DbPool; +use Hypervel\Database\Pool\DatabasePool; use LogicException; use Throwable; use UnitEnum; @@ -40,7 +40,7 @@ class DatabaseConnectionResolver extends ConnectionResolver implements CachedCon /** * Borrowed pooled wrappers that own the cached bare connections. * - * @var array + * @var array */ protected static array $pooledConnections = []; @@ -158,14 +158,14 @@ public function getResolvedConnection(string $name): ?ConnectionInterface /** * Resolve the cache key that owns the pooled wrapper. */ - protected function connectionCacheKey(string $name, ?DbPool $pool = null): string + protected function connectionCacheKey(string $name, ?DatabasePool $pool = null): string { if ($pool === null) { - if (! $this->factory->hasPool($name)) { + if (! $this->poolManager->has($name)) { return $name; } - $pool = $this->factory->getPool($name); + $pool = $this->poolManager->pool($name); } return $pool->getSharedInMemorySqlitePdo() !== null @@ -202,7 +202,7 @@ protected static function discardCachedConnection(string $cacheKey): void /** * Get a database connection instance. * - * Creates connections through the pool factory and retains their owning + * Borrows connections through the pool manager and retains their owning * wrappers until terminal test teardown. */ public function connection(UnitEnum|string|null $name = null): ConnectionInterface @@ -234,7 +234,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa return $connection; } - $pool = $this->factory->getPool($connectionName->requested); + $pool = $this->poolManager->pool($connectionName->requested); $cacheKey = $this->connectionCacheKey($connectionName->requested, $pool); if ($cacheKey !== $connectionName->requested @@ -247,7 +247,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa return $connection; } - $pooled = $pool->get(); + $pooled = $pool->borrow(); try { $connection = $pooled->getConnection(); diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index f8155b2ba0..a0de95d156 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Mail\Factory as FactoryContract; use Hypervel\Contracts\Mail\Mailer as MailerContract; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Contracts\View\Factory as ViewFactory; use Hypervel\Log\LogManager; @@ -19,8 +20,7 @@ use Hypervel\Mail\Transport\LogTransport; use Hypervel\Mail\Transport\ResendTransport; use Hypervel\Mail\Transport\SesV2Transport; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; -use Hypervel\ObjectPool\Traits\HasPoolProxy; +use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Support\Arr; use Hypervel\Support\ConfigurationUrlParser; use Hypervel\Support\Str; @@ -78,7 +78,7 @@ class MailManager implements FactoryContract */ // These transports retain persistent connections, mutable clients, interactive // processes, or composite state that must not be shared by concurrent sends. - protected array $poolables = [ + protected array $poolableDrivers = [ 'smtp', 'sendmail', 'mail', 'mailgun', 'ses-v2', 'postmark', 'resend', 'cloudflare', 'failover', 'roundrobin', ]; @@ -234,7 +234,7 @@ protected function createMailerTransport( protected function transportPoolConfig(string $transport, array $config, bool $poolByDefault): ?array { if (! array_key_exists('pool', $config)) { - return $poolByDefault && in_array($transport, $this->poolables, true) ? [] : null; + return $poolByDefault && in_array($transport, $this->poolableDrivers, true) ? [] : null; } $pool = $config['pool']; @@ -251,7 +251,7 @@ protected function transportPoolConfig(string $transport, array $config, bool $p ); } - if (! in_array($transport, $this->poolables, true)) { + if (! in_array($transport, $this->poolableDrivers, true)) { throw new InvalidArgumentException("Mail transport [{$transport}] is not registered as poolable."); } @@ -750,7 +750,7 @@ public function purge(UnitEnum|string|null $name = null): void $constructionConfig, ); - $this->poolFactory()->remove($definition->identity); + $this->poolFactory()->purge($definition->identity); } } @@ -766,7 +766,7 @@ public function purge(UnitEnum|string|null $name = null): void public function extend(string $driver, Closure $callback, bool $poolable = false): static { if ($poolable) { - $this->addPoolable($driver); + $this->addPoolableDriver($driver); } $this->customCreators[$driver] = $callback; diff --git a/src/object-pool/composer.json b/src/object-pool/composer.json index 4d1982c029..9ec0ac3614 100644 --- a/src/object-pool/composer.json +++ b/src/object-pool/composer.json @@ -29,7 +29,6 @@ "hypervel/contracts": "^0.4", "hypervel/coordinator": "^0.4", "hypervel/coroutine": "^0.4", - "hypervel/engine": "^0.4", "hypervel/core": "^0.4", "hypervel/support": "^0.4" }, diff --git a/src/object-pool/src/SimpleObjectPool.php b/src/object-pool/src/CallbackObjectPool.php similarity index 58% rename from src/object-pool/src/SimpleObjectPool.php rename to src/object-pool/src/CallbackObjectPool.php index 49886f2422..48aded477c 100644 --- a/src/object-pool/src/SimpleObjectPool.php +++ b/src/object-pool/src/CallbackObjectPool.php @@ -6,19 +6,19 @@ use Closure; -class SimpleObjectPool extends ObjectPool +class CallbackObjectPool extends ObjectPool { - protected Closure $callback; + protected Closure $createCallback; /** - * Create a simple callback-backed object pool. + * Create an object pool using a construction callback. */ public function __construct( - callable $callback, + callable $createCallback, PoolOptions $options, ?Closure $destroyCallback = null, ) { - $this->callback = Closure::fromCallable($callback); + $this->createCallback = Closure::fromCallable($createCallback); parent::__construct($options, $destroyCallback); } @@ -28,6 +28,6 @@ public function __construct( */ protected function createObject(): object { - return ($this->callback)(); + return ($this->createCallback)(); } } diff --git a/src/object-pool/src/Traits/HasPoolProxy.php b/src/object-pool/src/Concerns/HasPoolProxy.php similarity index 82% rename from src/object-pool/src/Traits/HasPoolProxy.php rename to src/object-pool/src/Concerns/HasPoolProxy.php index f0626b353e..1803882052 100644 --- a/src/object-pool/src/Traits/HasPoolProxy.php +++ b/src/object-pool/src/Concerns/HasPoolProxy.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Hypervel\ObjectPool\Traits; +namespace Hypervel\ObjectPool\Concerns; use Closure; -use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\Contracts\ObjectPool\Factory; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolFingerprint; use Hypervel\ObjectPool\PoolOptions; @@ -13,6 +13,9 @@ use Hypervel\Support\Arr; use InvalidArgumentException; +/** + * Hosts must declare a protected array $poolableDrivers containing their default poolable drivers. + */ trait HasPoolProxy { /** @var array */ @@ -23,7 +26,7 @@ trait HasPoolProxy */ protected function createPoolProxy( string $driver, - Closure $resolver, + Closure $createCallback, PoolDefinition $definition, string $proxyClass, ): mixed { @@ -33,7 +36,7 @@ protected function createPoolProxy( return new $proxyClass( $definition, - $resolver, + $createCallback, $this->poolFactory(), $this->getReleaseCallback($driver), ); @@ -90,10 +93,10 @@ public function getReleaseCallback(string $driver): ?Closure * is consulted on subsequent driver creation. Per-request use races across * coroutines and does not affect already-cached drivers. */ - public function addPoolable(string $driver): static + public function addPoolableDriver(string $driver): static { - if (! in_array($driver, $this->poolables, true)) { - $this->poolables[] = $driver; + if (! in_array($driver, $this->poolableDrivers, true)) { + $this->poolableDrivers[] = $driver; } return $this; @@ -106,16 +109,16 @@ public function addPoolable(string $driver): static * is consulted on subsequent driver creation. Per-request use races across * coroutines and does not affect already-cached drivers. */ - public function removePoolable(string $driver): static + public function removePoolableDriver(string $driver): static { - $index = array_search($driver, $this->poolables, true); + $index = array_search($driver, $this->poolableDrivers, true); if ($index === false) { return $this; } - unset($this->poolables[$index]); - $this->poolables = array_values($this->poolables); + unset($this->poolableDrivers[$index]); + $this->poolableDrivers = array_values($this->poolableDrivers); return $this; } @@ -123,9 +126,9 @@ public function removePoolable(string $driver): static /** * Get the poolable-driver list. */ - public function getPoolables(): array + public function getPoolableDrivers(): array { - return $this->poolables; + return $this->poolableDrivers; } /** @@ -135,9 +138,9 @@ public function getPoolables(): array * is consulted on subsequent driver creation. Per-request use races across * coroutines and does not affect already-cached drivers. */ - public function setPoolables(array $poolables): static + public function setPoolableDrivers(array $poolableDrivers): static { - $this->poolables = array_values($poolables); + $this->poolableDrivers = array_values($poolableDrivers); return $this; } diff --git a/src/object-pool/src/Contracts/Recycler.php b/src/object-pool/src/Contracts/Recycler.php deleted file mode 100644 index 40fa9d79fd..0000000000 --- a/src/object-pool/src/Contracts/Recycler.php +++ /dev/null @@ -1,60 +0,0 @@ - */ + protected PoolChannel $channel; /** @var array */ protected array $managed = []; @@ -33,9 +37,9 @@ abstract class ObjectPool implements ObjectPoolContract protected bool $closed = false; - protected int $acquiring = 0; + protected int $acquiringCount = 0; - protected int $creating = 0; + protected int $creatingCount = 0; protected ?Closure $destroyCallback; @@ -47,24 +51,24 @@ public function __construct( ?Closure $destroyCallback = null, ) { $this->destroyCallback = $destroyCallback; - $this->channel = new Channel($options->maxObjects); + $this->channel = new PoolChannel($options->maxObjects); $this->lastUsedAt = hrtime(true); } /** - * Retrieve an object from the pool. + * Borrow an object from the pool. * * @return T */ - public function get(): object + public function borrow(): object { if ($this->closed) { - throw new RuntimeException('Cannot borrow from a closed pool.'); + throw new PoolClosedException('Cannot borrow from a closed pool.'); } $this->lastUsedAt = hrtime(true); $deadline = $this->deadline($this->options->waitTimeout); - ++$this->acquiring; + ++$this->acquiringCount; try { $object = $this->getObject($deadline); @@ -73,12 +77,14 @@ public function get(): object return $object; } finally { - --$this->acquiring; + --$this->acquiringCount; } } /** * Release an object back to the pool. + * + * @param T $object */ public function release(object $object): void { @@ -114,7 +120,7 @@ public function discard(object $object): void */ public function sweepExpired(): void { - if ($this->options->maxLifetime <= 0.0) { + if ($this->options->maxLifetime === null) { return; } @@ -134,7 +140,7 @@ public function sweepExpired(): void */ public function trimIdle(): void { - if ($this->options->maxIdleTime <= 0.0) { + if ($this->options->maxIdleTime === null) { return; } @@ -192,20 +198,20 @@ public function isClosed(): bool } /** - * Determine if the entire pool has exceeded its idle TTL. + * Determine if the entire pool has exceeded its idle timeout. */ - public function isIdle(): bool + public function isIdleExpired(): bool { - return $this->options->idleTtl !== null - && $this->acquiring === 0 - && $this->getBorrowedObjectNumber() === 0 - && (hrtime(true) - $this->lastUsedAt) > $this->nanoseconds($this->options->idleTtl); + return $this->options->poolIdleTimeout !== null + && $this->acquiringCount === 0 + && $this->getBorrowedCount() === 0 + && (hrtime(true) - $this->lastUsedAt) > $this->nanoseconds($this->options->poolIdleTimeout); } /** * Return the number of objects currently checked out. */ - public function getBorrowedObjectNumber(): int + public function getBorrowedCount(): int { return count($this->borrowed); } @@ -213,7 +219,7 @@ public function getBorrowedObjectNumber(): int /** * Return the current number of objects managed by the pool. */ - public function getCurrentObjectNumber(): int + public function getManagedCount(): int { return count($this->managed); } @@ -221,7 +227,7 @@ public function getCurrentObjectNumber(): int /** * Return the number of objects currently available in the pool. */ - public function getObjectNumberInPool(): int + public function getIdleCount(): int { return $this->channel->length(); } @@ -229,7 +235,7 @@ public function getObjectNumberInPool(): int /** * Return the number of coroutines waiting for an object. */ - public function getWaiters(): int + public function getWaitingCount(): int { return $this->channel->waiters(); } @@ -245,15 +251,15 @@ public function getOptions(): PoolOptions /** * Return statistics about the pool's current state. * - * @return array{total: int, idle: int, borrowed: int, waiters: int, closed: bool} + * @return array{managed: int, borrowed: int, idle: int, waiting: int, closed: bool} */ public function getStats(): array { return [ - 'total' => count($this->managed), - 'idle' => $this->getObjectNumberInPool(), + 'managed' => count($this->managed), 'borrowed' => count($this->borrowed), - 'waiters' => $this->getWaiters(), + 'idle' => $this->getIdleCount(), + 'waiting' => $this->getWaitingCount(), 'closed' => $this->closed, ]; } @@ -288,6 +294,8 @@ protected function ensureBorrowed(object $object): int /** * Return an object to the idle channel without recording user activity. + * + * @param T $object */ protected function requeue(object $object): void { @@ -329,7 +337,7 @@ protected function destroyObject(object $object): void */ protected function exceedsMaxLifetime(object $object): bool { - if ($this->options->maxLifetime <= 0.0) { + if ($this->options->maxLifetime === null) { return false; } @@ -349,7 +357,7 @@ private function getObject(int $deadline): object while (true) { if ($this->closed) { - throw new RuntimeException('Cannot borrow from a closed pool.'); + throw new PoolClosedException('Cannot borrow from a closed pool.'); } if (($object = $this->channel->pop()) !== false) { @@ -362,19 +370,19 @@ private function getObject(int $deadline): object return $object; } - if (count($this->managed) + $this->creating < $this->options->maxObjects) { - ++$this->creating; + if (count($this->managed) + $this->creatingCount < $this->options->maxObjects) { + ++$this->creatingCount; try { $object = $this->createObject(); } catch (Throwable $exception) { - --$this->creating; + --$this->creatingCount; $this->channel->signal(); throw $exception; } - --$this->creating; + --$this->creatingCount; $id = spl_object_id($object); if (isset($this->managed[$id])) { @@ -392,14 +400,14 @@ private function getObject(int $deadline): object if ($this->closed) { $this->destroyObject($object); - throw new RuntimeException('Cannot borrow from a closed pool.'); + throw new PoolClosedException('Cannot borrow from a closed pool.'); } return $object; } if ($timedOut) { - throw new RuntimeException('Object pool exhausted. Cannot create new object before wait_timeout.'); + throw new PoolExhaustedException('Object pool exhausted. Cannot create new object before wait_timeout.'); } $timedOut = ! $this->waitForStateChange($deadline); diff --git a/src/object-pool/src/ObjectPoolServiceProvider.php b/src/object-pool/src/ObjectPoolServiceProvider.php index 98c5a79585..bb60a02dd6 100644 --- a/src/object-pool/src/ObjectPoolServiceProvider.php +++ b/src/object-pool/src/ObjectPoolServiceProvider.php @@ -4,10 +4,10 @@ namespace Hypervel\ObjectPool; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\Recycler; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\BeforeServerFork; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\Recycler; use Hypervel\ObjectPool\Listeners\StartRecycler; use Hypervel\Support\ServiceProvider; @@ -32,7 +32,7 @@ public function boot(): void $events->listen(BeforeServerFork::class, function (): void { if ($this->app->resolved(PoolManager::class)) { - $this->app->make(PoolManager::class)->flush(); + $this->app->make(PoolManager::class)->purgeAll(); } }); diff --git a/src/object-pool/src/PoolManager.php b/src/object-pool/src/PoolManager.php index 7e2cec0c90..835698f84f 100644 --- a/src/object-pool/src/PoolManager.php +++ b/src/object-pool/src/PoolManager.php @@ -4,10 +4,12 @@ namespace Hypervel\ObjectPool; -use Hypervel\ObjectPool\Contracts\Factory as FactoryContract; -use Hypervel\ObjectPool\Contracts\ObjectPool; +use Hypervel\Contracts\ObjectPool\Factory as FactoryContract; +use Hypervel\Contracts\ObjectPool\ObjectPool; use JsonException; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class PoolManager implements FactoryContract { @@ -26,7 +28,7 @@ class PoolManager implements FactoryContract */ public function pool( string $name, - callable $callback, + callable $createCallback, array $options = [], ): ObjectPool { return $this->getOrCreate( @@ -36,7 +38,7 @@ public function pool( fingerprint: PoolFingerprint::fromExplicit($name), options: PoolOptions::fromArray($options), ), - $callback, + $createCallback, ); } @@ -45,7 +47,7 @@ public function pool( */ public function getOrCreate( PoolDefinition $definition, - callable $callback, + callable $createCallback, ): ObjectPool { $identity = $definition->identity; @@ -83,7 +85,7 @@ public function getOrCreate( } } - $pool = new SimpleObjectPool($callback, $definition->options); + $pool = new CallbackObjectPool($createCallback, $definition->options); $this->definitions[$identity] = $definition; @@ -104,6 +106,8 @@ public function get(string $identity): ObjectPool /** * Determine if a pool is currently registered for an identity. + * + * This does not reserve registry membership across a coroutine yield. */ public function has(string $identity): bool { @@ -115,7 +119,7 @@ public function has(string $identity): bool * * @return array */ - public function pools(): array + public function getPools(): array { return $this->pools; } @@ -123,7 +127,7 @@ public function pools(): array /** * Get the definition currently registered for an identity. */ - public function definition(string $identity): ?PoolDefinition + public function getDefinition(string $identity): ?PoolDefinition { return $this->definitions[$identity] ?? null; } @@ -131,7 +135,7 @@ public function definition(string $identity): ?PoolDefinition /** * Remove and close a pool when it still matches an optional expected instance. */ - public function remove(string $identity, ?ObjectPool $expected = null): bool + public function purge(string $identity, ?ObjectPool $expected = null): bool { $pool = $this->pools[$identity] ?? null; @@ -151,14 +155,30 @@ public function remove(string $identity, ?ObjectPool $expected = null): bool * Boot or tests only. This clears worker-lifetime pools shared by every * coroutine; use targeted removal for runtime resource recovery. */ - public function flush(): void + public function purgeAll(): void { $pools = $this->pools; $this->pools = []; $this->definitions = []; + $firstException = null; + $firstCancellation = null; foreach ($pools as $pool) { - $pool->close(); + try { + $pool->close(); + } catch (CanceledException $exception) { + $firstCancellation ??= $exception; + } catch (Throwable $exception) { + $firstException ??= $exception; + } + } + + if ($firstCancellation !== null) { + throw $firstCancellation; + } + + if ($firstException !== null) { + throw $firstException; } } diff --git a/src/object-pool/src/PoolOptions.php b/src/object-pool/src/PoolOptions.php index 431fe0905e..1d1f7c498e 100644 --- a/src/object-pool/src/PoolOptions.php +++ b/src/object-pool/src/PoolOptions.php @@ -8,7 +8,7 @@ final readonly class PoolOptions { - public const float DEFAULT_IDLE_TTL = 300.0; + public const float DEFAULT_POOL_IDLE_TIMEOUT = 300.0; private const int DEFAULT_MIN_RETAINED_OBJECTS = 1; @@ -18,8 +18,6 @@ private const float DEFAULT_MAX_LIFETIME = 60.0; - private const float DEFAULT_MAX_IDLE_TIME = 0.0; - /** * Create normalized pool options. */ @@ -27,9 +25,9 @@ private function __construct( public int $minRetainedObjects, public int $maxObjects, public float $waitTimeout, - public float $maxLifetime, - public float $maxIdleTime, - public ?float $idleTtl, + public ?float $maxLifetime, + public ?float $maxIdleTime, + public ?float $poolIdleTimeout, ) { } @@ -44,7 +42,7 @@ public static function fromArray(array $options): self 'wait_timeout', 'max_lifetime', 'max_idle_time', - 'idle_ttl', + 'pool_idle_timeout', ]; $unknownOptions = array_diff(array_keys($options), $knownOptions); @@ -62,9 +60,9 @@ public static function fromArray(array $options): self ); $maxObjects = self::integerOption($options, 'max_objects', self::DEFAULT_MAX_OBJECTS); $waitTimeout = self::durationOption($options, 'wait_timeout', self::DEFAULT_WAIT_TIMEOUT); - $maxLifetime = self::durationOption($options, 'max_lifetime', self::DEFAULT_MAX_LIFETIME); - $maxIdleTime = self::durationOption($options, 'max_idle_time', self::DEFAULT_MAX_IDLE_TIME); - $idleTtl = self::nullableDurationOption($options, 'idle_ttl', self::DEFAULT_IDLE_TTL); + $maxLifetime = self::nullableDurationOption($options, 'max_lifetime', self::DEFAULT_MAX_LIFETIME); + $maxIdleTime = self::nullableDurationOption($options, 'max_idle_time', null); + $poolIdleTimeout = self::nullableDurationOption($options, 'pool_idle_timeout', self::DEFAULT_POOL_IDLE_TIMEOUT); if ($minRetainedObjects < 0) { throw new InvalidArgumentException('Pool option [min_retained_objects] must be at least 0.'); @@ -84,16 +82,16 @@ public static function fromArray(array $options): self throw new InvalidArgumentException('Pool option [wait_timeout] must be greater than 0.'); } - if ($maxLifetime < 0.0) { - throw new InvalidArgumentException('Pool option [max_lifetime] must be at least 0.'); + if ($maxLifetime !== null && $maxLifetime <= 0.0) { + throw new InvalidArgumentException('Pool option [max_lifetime] must be null or greater than 0.'); } - if ($maxIdleTime < 0.0) { - throw new InvalidArgumentException('Pool option [max_idle_time] must be at least 0.'); + if ($maxIdleTime !== null && $maxIdleTime <= 0.0) { + throw new InvalidArgumentException('Pool option [max_idle_time] must be null or greater than 0.'); } - if ($idleTtl !== null && $idleTtl <= 0.0) { - throw new InvalidArgumentException('Pool option [idle_ttl] must be null or greater than 0.'); + if ($poolIdleTimeout !== null && $poolIdleTimeout <= 0.0) { + throw new InvalidArgumentException('Pool option [pool_idle_timeout] must be null or greater than 0.'); } return new self( @@ -102,7 +100,7 @@ public static function fromArray(array $options): self $waitTimeout, $maxLifetime, $maxIdleTime, - $idleTtl, + $poolIdleTimeout, ); } @@ -116,7 +114,7 @@ public function equals(self $other): bool && $this->waitTimeout === $other->waitTimeout && $this->maxLifetime === $other->maxLifetime && $this->maxIdleTime === $other->maxIdleTime - && $this->idleTtl === $other->idleTtl; + && $this->poolIdleTimeout === $other->poolIdleTimeout; } /** @@ -126,9 +124,9 @@ public function equals(self $other): bool * min_retained_objects: int, * max_objects: int, * wait_timeout: float, - * max_lifetime: float, - * max_idle_time: float, - * idle_ttl: ?float + * max_lifetime: ?float, + * max_idle_time: ?float, + * pool_idle_timeout: ?float * } */ public function toArray(): array @@ -139,7 +137,7 @@ public function toArray(): array 'wait_timeout' => $this->waitTimeout, 'max_lifetime' => $this->maxLifetime, 'max_idle_time' => $this->maxIdleTime, - 'idle_ttl' => $this->idleTtl, + 'pool_idle_timeout' => $this->poolIdleTimeout, ]; } @@ -160,7 +158,7 @@ private static function integerOption(array $options, string $name, int $default /** * Read and normalize a finite duration option. */ - private static function durationOption(array $options, string $name, float $default): float + private static function durationOption(array $options, string $name, ?float $default): float { $value = array_key_exists($name, $options) ? $options[$name] : $default; @@ -180,9 +178,9 @@ private static function durationOption(array $options, string $name, float $defa /** * Read and normalize a nullable finite duration option. */ - private static function nullableDurationOption(array $options, string $name, float $default): ?float + private static function nullableDurationOption(array $options, string $name, ?float $default): ?float { - if (array_key_exists($name, $options) && $options[$name] === null) { + if ((array_key_exists($name, $options) ? $options[$name] : $default) === null) { return null; } diff --git a/src/object-pool/src/PoolProxy.php b/src/object-pool/src/PoolProxy.php index 06389962c4..e1b1f6a745 100644 --- a/src/object-pool/src/PoolProxy.php +++ b/src/object-pool/src/PoolProxy.php @@ -5,9 +5,9 @@ namespace Hypervel\ObjectPool; use Closure; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; -use Hypervel\ObjectPool\Contracts\ObjectPool; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; +use Hypervel\Contracts\ObjectPool\ObjectPool; use Throwable; class PoolProxy implements InvalidatesPool @@ -17,7 +17,7 @@ class PoolProxy implements InvalidatesPool */ public function __construct( protected PoolDefinition $definition, - protected Closure $resolver, + protected Closure $createCallback, protected Factory $pools, protected ?Closure $releaseCallback = null, ) { @@ -28,7 +28,7 @@ public function __construct( */ protected function pool(): ObjectPool { - return $this->pools->getOrCreate($this->definition, $this->resolver); + return $this->pools->getOrCreate($this->definition, $this->createCallback); } /** @@ -37,7 +37,7 @@ protected function pool(): ObjectPool protected function lease(): Lease { $pool = $this->pool(); - $object = $pool->get(); + $object = $pool->borrow(); $lease = new Lease($pool, $object, $this->releaseCallback); try { @@ -83,7 +83,7 @@ public function getDefinition(): PoolDefinition } /** - * Get this proxy's pool identity. + * Return the pool's fully qualified registry name. */ public function getPoolName(): string { @@ -95,6 +95,6 @@ public function getPoolName(): string */ public function invalidatePool(): bool { - return $this->pools->remove($this->definition->identity); + return $this->pools->purge($this->definition->identity); } } diff --git a/src/object-pool/src/PoolRecycler.php b/src/object-pool/src/PoolRecycler.php index 4b756be0bf..eaa3e328f3 100644 --- a/src/object-pool/src/PoolRecycler.php +++ b/src/object-pool/src/PoolRecycler.php @@ -4,79 +4,41 @@ namespace Hypervel\ObjectPool; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\Recycler; use Hypervel\Coordinator\Timer; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\Recycler; use InvalidArgumentException; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Throwable; class PoolRecycler implements Recycler { - protected ?Timer $timer = null; + protected Timer $timer; protected ?int $timerId = null; - protected float $interval; - /** * Create a pool recycler. */ public function __construct( protected Factory $manager, - float $interval = 10.0, + protected float $interval = 10.0, + ?Timer $timer = null, ) { - $this->setInterval($interval); - } - - /** - * Get the maintenance interval in seconds. - */ - public function getInterval(): float - { - return $this->interval; - } - - /** - * Set the maintenance interval in seconds. - * - * Boot-only. The interval persists on the singleton recycler for the worker - * lifetime and controls every subsequently scheduled maintenance loop. - */ - public function setInterval(float $interval): void - { if (! is_finite($interval) || $interval <= 0.0) { throw new InvalidArgumentException('The recycler interval must be a finite number greater than 0.'); } - $this->interval = $interval; + $this->timer = $timer ?? new Timer; } /** - * Get the timer used to schedule maintenance. - */ - public function getTimer(): Timer - { - return $this->timer ??= new Timer; - } - - /** - * Set the timer used to schedule maintenance. - * - * Boot or tests only. Replacing the timer after start would diverge from - * the already-scheduled loop retained by the previous timer. - */ - public function setTimer(Timer $timer): void - { - $this->timer = $timer; - } - - /** - * Get the active maintenance timer ID. + * Get the maintenance interval in seconds. */ - public function getTimerId(): ?int + public function getInterval(): float { - return $this->timerId; + return $this->interval; } /** @@ -91,11 +53,13 @@ public function start(): void return; } - $this->timerId = $this->getTimer()->tick( + $this->timerId = $this->timer->tick( $this->interval, function (): void { try { $this->maintainPools(); + } catch (CanceledException $exception) { + throw $exception; } catch (Throwable $exception) { PoolErrorReporter::report($exception); } @@ -112,7 +76,7 @@ function (): void { public function stop(): void { if ($this->timerId !== null) { - $this->getTimer()->clear($this->timerId); + $this->timer->clear($this->timerId); } $this->timerId = null; @@ -123,17 +87,19 @@ public function stop(): void */ protected function maintainPools(): void { - foreach ($this->manager->pools() as $identity => $pool) { + foreach ($this->manager->getPools() as $identity => $pool) { // A throwing public-contract pool must not starve unrelated pools of maintenance. try { - if ($pool->isIdle()) { - $this->manager->remove($identity, $pool); + if ($pool->isIdleExpired()) { + $this->manager->purge($identity, $pool); continue; } $pool->sweepExpired(); $pool->trimIdle(); + } catch (CanceledException $exception) { + throw $exception; } catch (Throwable $exception) { PoolErrorReporter::report(new RuntimeException( "Pool maintenance failed for [{$identity}].", diff --git a/src/opentelemetry/composer.json b/src/opentelemetry/composer.json index 1f452e44a1..90555c1cdf 100644 --- a/src/opentelemetry/composer.json +++ b/src/opentelemetry/composer.json @@ -39,6 +39,7 @@ "guzzlehttp/promises": "^2.5.2", "hypervel/cache": "^0.4", "hypervel/config": "^0.4", + "hypervel/connection-pool": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", @@ -53,7 +54,6 @@ "hypervel/http-server": "^0.4", "hypervel/log": "^0.4", "hypervel/object-pool": "^0.4", - "hypervel/pool": "^0.4", "hypervel/queue": "^0.4", "hypervel/redis": "^0.4", "hypervel/routing": "^0.4", diff --git a/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php b/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php index 04460b08e9..de1273562f 100644 --- a/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php @@ -4,10 +4,10 @@ namespace Hypervel\OpenTelemetry\Instrumentation; -use Hypervel\Database\Pool\PoolFactory as DatabasePoolFactory; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\Database\Pool\PoolManager as DatabasePoolManager; use Hypervel\ObjectPool\PoolManager as ObjectPoolManager; -use Hypervel\Pool\Pool; -use Hypervel\Redis\Pool\PoolFactory as RedisPoolFactory; +use Hypervel\Redis\Pool\PoolManager as RedisPoolManager; use OpenTelemetry\API\Metrics\MeterInterface; use OpenTelemetry\API\Metrics\MeterProviderInterface; use OpenTelemetry\API\Metrics\ObservableUpDownCounterInterface; @@ -31,8 +31,8 @@ class PoolInstrumentation extends AbstractInstrumentation * Create pool instrumentation. */ public function __construct( - protected DatabasePoolFactory $databasePools, - protected RedisPoolFactory $redisPools, + protected DatabasePoolManager $databasePools, + protected RedisPoolManager $redisPools, protected ObjectPoolManager $objectPools, protected MeterProviderInterface $meterProvider, ) { @@ -135,11 +135,11 @@ protected function createInstrument(MeterInterface $meter, string $name): Observ */ protected function observeConnectionPools(array $observers): void { - foreach ($this->databasePools->pools() as $name => $pool) { + foreach ($this->databasePools->getPools() as $name => $pool) { $this->observeConnectionPool($observers, $pool, 'database:' . $name); } - foreach ($this->redisPools->pools() as $name => $pool) { + foreach ($this->redisPools->getPools() as $name => $pool) { $this->observeConnectionPool($observers, $pool, 'redis:' . $name); } } @@ -149,13 +149,13 @@ protected function observeConnectionPools(array $observers): void * * @param array $observers */ - protected function observeConnectionPool(array $observers, Pool $pool, string $name): void + protected function observeConnectionPool(array $observers, ConnectionPool $pool, string $name): void { $attributes = [DbIncubatingAttributes::DB_CLIENT_CONNECTION_POOL_NAME => $name]; if (isset($observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT])) { - $idle = $pool->getConnectionsInChannel(); - $used = $pool->getCurrentConnections() - $idle; + $idle = $pool->getIdleCount(); + $used = $pool->getManagedCount() - $idle; $observer = $observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT]; $observer->observe($idle, $attributes + [ DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE => DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE_VALUE_IDLE, @@ -167,14 +167,14 @@ protected function observeConnectionPool(array $observers, Pool $pool, string $n if (isset($observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX])) { $observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX]->observe( - $pool->getOption()->getMaxConnections(), + $pool->getOptions()->maxConnections, $attributes, ); } if (isset($observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS])) { $observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS]->observe( - $pool->getWaiters(), + $pool->getWaitingCount(), $attributes, ); } @@ -187,7 +187,7 @@ protected function observeConnectionPool(array $observers, Pool $pool, string $n */ protected function observeObjectPools(array $observers): void { - foreach ($this->objectPools->pools() as $identity => $pool) { + foreach ($this->objectPools->getPools() as $identity => $pool) { $attributes = [self::OBJECT_POOL_NAME_ATTRIBUTE => $identity]; $stats = null; @@ -197,7 +197,7 @@ protected function observeObjectPools(array $observers): void $observer->observe($stats['idle'], $attributes + [ self::OBJECT_POOL_STATE_ATTRIBUTE => DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE_VALUE_IDLE, ]); - $observer->observe($stats['borrowed'], $attributes + [ + $observer->observe($stats['managed'] - $stats['idle'], $attributes + [ self::OBJECT_POOL_STATE_ATTRIBUTE => DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE_VALUE_USED, ]); } @@ -212,7 +212,7 @@ protected function observeObjectPools(array $observers): void if (isset($observers[self::OBJECTS_PENDING_REQUESTS_METRIC])) { $stats ??= $pool->getStats(); $observers[self::OBJECTS_PENDING_REQUESTS_METRIC]->observe( - $stats['waiters'], + $stats['waiting'], $attributes, ); } diff --git a/src/pool/src/Channel.php b/src/pool/src/Channel.php deleted file mode 100644 index 08c89bac27..0000000000 --- a/src/pool/src/Channel.php +++ /dev/null @@ -1,151 +0,0 @@ - */ - protected SplQueue $queue; - - /** @var EngineChannel */ - protected EngineChannel $signal; - - protected int $waiters = 0; - - protected bool $closed = false; - - /** - * Create a pool channel. - */ - public function __construct(int $size) - { - $this->queue = new SplQueue; - $this->signal = new EngineChannel($size); - } - - /** - * Pop an idle connection without waiting. - */ - public function pop(): ConnectionInterface|false - { - return $this->queue->isEmpty() ? false : $this->queue->dequeue(); - } - - /** - * Push an idle connection and wake one waiter. - */ - public function push(ConnectionInterface $data): bool - { - if ($this->closed) { - return false; - } - - $this->queue->enqueue($data); - $this->signal(); - - return true; - } - - /** - * Get the number of connections in the channel. - */ - public function length(): int - { - return $this->queue->count(); - } - - /** - * Get the number of coroutines waiting for pool state to change. - */ - public function waiters(): int - { - return $this->waiters; - } - - /** - * Wait for pool state to change. - */ - public function wait(float $timeout): bool - { - if ($this->closed) { - return true; - } - - if (! Coroutine::inCoroutine() || $timeout <= 0.0) { - return false; - } - - ++$this->waiters; - - try { - $result = $this->signal->pop($timeout); - - if ($result === false && $this->signal->isCanceled()) { - throw new CanceledException('The connection pool wait was canceled.'); - } - - return $result !== false || ! $this->signal->isTimeout(); - } finally { - --$this->waiters; - } - } - - /** - * Wake one waiter after a capacity-relevant state change. - */ - public function signal(): void - { - if ($this->closed || $this->waiters === 0) { - return; - } - - if (Coroutine::inCoroutine()) { - $this->pushSignal(); - - return; - } - - try { - Coroutine::create($this->pushSignal(...)); - } catch (CoroutineCreateException) { - // The state change is already committed; checkout performs a final state pass. - } - } - - /** - * Push a coalesced wake signal without blocking on a full channel. - */ - protected function pushSignal(): void - { - if (! $this->closed && ! $this->signal->isFull()) { - $this->signal->push(true); - } - } - - /** - * Close the signal channel and wake every waiter. - */ - public function close(): void - { - if ($this->closed) { - return; - } - - $this->closed = true; - $this->signal->close(); - } -} diff --git a/src/pool/src/ClearableFrequencyInterface.php b/src/pool/src/ClearableFrequencyInterface.php deleted file mode 100644 index 85b1143836..0000000000 --- a/src/pool/src/ClearableFrequencyInterface.php +++ /dev/null @@ -1,16 +0,0 @@ -timer = new Timer; - - if ($pool) { - $this->timerId = $this->timer->tick( - $this->interval / 1000, - fn () => $this->pool->checkIdleConnection() - ); - } - } - - /** - * Clear the timer. - */ - public function clear(): void - { - if ($this->timerId) { - $this->timer->clear($this->timerId); - } - - $this->timerId = null; - } - - /** - * Always returns false since flushing is handled by the timer. - */ - public function isLowFrequency(): bool - { - return false; - } -} diff --git a/src/pool/src/Events/ReleaseConnection.php b/src/pool/src/Events/ReleaseConnection.php deleted file mode 100644 index dc0c180cb2..0000000000 --- a/src/pool/src/Events/ReleaseConnection.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ - protected array $hits = []; - - /** - * Time window in seconds for frequency calculation. - */ - protected int $time = 10; - - /** - * Threshold below which frequency is considered "low". - */ - protected int $lowFrequency = 5; - - /** - * Time when frequency tracking began. - */ - protected int $beginTime; - - /** - * Last time low frequency was triggered. - */ - protected int $lowFrequencyTime; - - /** - * Minimum interval between low frequency triggers. - */ - protected int $lowFrequencyInterval = 60; - - public function __construct() - { - $this->beginTime = time(); - $this->lowFrequencyTime = time(); - } - - /** - * Record a hit. - */ - public function hit(int $number = 1): bool - { - $this->flush(); - - $now = time(); - $hit = $this->hits[$now] ?? 0; - $this->hits[$now] = $number + $hit; - - return true; - } - - /** - * Calculate the average frequency over the time window. - */ - public function frequency(): float - { - $this->flush(); - - $sampleCount = count($this->hits); - - if ($sampleCount === 0) { - return 0.0; - } - - return array_sum($this->hits) / $sampleCount; - } - - /** - * Check if currently in low frequency mode. - */ - public function isLowFrequency(): bool - { - $now = time(); - - if ($this->lowFrequencyTime + $this->lowFrequencyInterval < $now && $this->frequency() < $this->lowFrequency) { - $this->lowFrequencyTime = $now; - - return true; - } - - return false; - } - - /** - * Flush old hits outside the time window. - */ - protected function flush(): void - { - $now = time(); - $latest = $now - $this->time + 1; - - foreach ($this->hits as $time => $hit) { - if ($time < $latest) { - unset($this->hits[$time]); - } - } - - if (count($this->hits) < $this->time) { - $beginTime = max($this->beginTime, $latest); - for ($i = $beginTime; $i < $now; ++$i) { - $this->hits[$i] = $this->hits[$i] ?? 0; - } - } - } -} diff --git a/src/pool/src/LowFrequencyInterface.php b/src/pool/src/LowFrequencyInterface.php deleted file mode 100644 index 5c72cd8cd0..0000000000 --- a/src/pool/src/LowFrequencyInterface.php +++ /dev/null @@ -1,16 +0,0 @@ - $events Events to trigger on connection lifecycle - */ - public function __construct( - private int $minConnections = 1, - private int $maxConnections = 10, - private float $connectTimeout = 10.0, - private float $waitTimeout = 3.0, - private float $heartbeat = -1.0, - private float $heartbeatTimeout = 1.0, - private float $maxIdleTime = 60.0, - private float $maxLifetime = -1.0, - private array $events = [], - ) { - self::validateConnectionCounts($this->minConnections, $this->maxConnections); - self::validatePositiveDuration($this->connectTimeout, 'connect_timeout'); - self::validatePositiveDuration($this->waitTimeout, 'wait_timeout'); - self::validateDisabledOrPositiveDuration($this->heartbeat, 'heartbeat'); - self::validatePositiveDuration($this->heartbeatTimeout, 'heartbeat_timeout'); - self::validatePositiveDuration($this->maxIdleTime, 'max_idle_time'); - self::validateDisabledOrPositiveDuration($this->maxLifetime, 'max_lifetime'); - self::validateEvents($this->events); - } - - public function getMaxConnections(): int - { - return $this->maxConnections; - } - - /** - * Set the maximum number of connections in the pool. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setMaxConnections(int $maxConnections): static - { - self::validateConnectionCounts($this->minConnections, $maxConnections); - $this->maxConnections = $maxConnections; - - return $this; - } - - public function getMinConnections(): int - { - return $this->minConnections; - } - - /** - * Set the managed-connection floor for excess-idle trimming. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setMinConnections(int $minConnections): static - { - self::validateConnectionCounts($minConnections, $this->maxConnections); - $this->minConnections = $minConnections; - - return $this; - } - - public function getConnectTimeout(): float - { - return $this->connectTimeout; - } - - /** - * Set the timeout for establishing a connection. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setConnectTimeout(float $connectTimeout): static - { - self::validatePositiveDuration($connectTimeout, 'connect_timeout'); - $this->connectTimeout = $connectTimeout; - - return $this; - } - - public function getHeartbeat(): float - { - return $this->heartbeat; - } - - public function getHeartbeatTimeout(): float - { - return $this->heartbeatTimeout; - } - - /** - * Set the heartbeat interval in seconds. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setHeartbeat(float $heartbeat): static - { - self::validateDisabledOrPositiveDuration($heartbeat, 'heartbeat'); - $this->heartbeat = $heartbeat; - - return $this; - } - - /** - * Set the heartbeat timeout in seconds. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setHeartbeatTimeout(float $heartbeatTimeout): static - { - self::validatePositiveDuration($heartbeatTimeout, 'heartbeat_timeout'); - $this->heartbeatTimeout = $heartbeatTimeout; - - return $this; - } - - public function getWaitTimeout(): float - { - return $this->waitTimeout; - } - - /** - * Set the timeout for waiting to get a connection from the pool. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setWaitTimeout(float $waitTimeout): static - { - self::validatePositiveDuration($waitTimeout, 'wait_timeout'); - $this->waitTimeout = $waitTimeout; - - return $this; - } - - public function getMaxIdleTime(): float - { - return $this->maxIdleTime; - } - - /** - * Set the maximum idle time before a connection is closed. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setMaxIdleTime(float $maxIdleTime): static - { - self::validatePositiveDuration($maxIdleTime, 'max_idle_time'); - $this->maxIdleTime = $maxIdleTime; - - return $this; - } - - /** - * Get the maximum lifetime in seconds before a connection is recycled. - */ - public function getMaxLifetime(): float - { - return $this->maxLifetime; - } - - /** - * Return a jittered lifetime deadline for a connection generation. - */ - public static function jitteredLifetimeDeadline(float $createdAt, float $maxLifetime): float - { - self::validateDisabledOrPositiveDuration($maxLifetime, 'max_lifetime'); - - if ($maxLifetime === -1.0) { - return 0.0; - } - - $factor = random_int(self::MIN_LIFETIME_JITTER_BASIS, self::LIFETIME_JITTER_SCALE) / self::LIFETIME_JITTER_SCALE; - - return $createdAt + ($maxLifetime * $factor); - } - - /** - * Set the maximum lifetime in seconds before a connection is recycled. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setMaxLifetime(float $maxLifetime): static - { - self::validateDisabledOrPositiveDuration($maxLifetime, 'max_lifetime'); - $this->maxLifetime = $maxLifetime; - - return $this; - } - - public function getEvents(): array - { - return $this->events; - } - - /** - * Set the events to trigger on connection lifecycle. - * - * Boot-only. The value persists on the worker-lifetime pool option and is - * read by every subsequent pool operation. Per-request use races across - * coroutines. - */ - public function setEvents(array $events): static - { - self::validateEvents($events); - $this->events = $events; - - return $this; - } - - /** - * Validate the connection-count relationship. - */ - private static function validateConnectionCounts(int $minConnections, int $maxConnections): void - { - if ($minConnections < 0) { - throw new InvalidArgumentException('Pool option [min_connections] must be at least 0.'); - } - - if ($maxConnections < 1) { - throw new InvalidArgumentException('Pool option [max_connections] must be at least 1.'); - } - - if ($minConnections > $maxConnections) { - throw new InvalidArgumentException( - 'Pool option [min_connections] must not exceed [max_connections].', - ); - } - } - - /** - * Validate a finite, positive duration. - */ - private static function validatePositiveDuration(float $duration, string $name): void - { - if (! is_finite($duration) || $duration <= 0.0) { - throw new InvalidArgumentException("Pool option [{$name}] must be a finite number greater than 0."); - } - } - - /** - * Validate a duration that uses -1 as its disabled sentinel. - */ - private static function validateDisabledOrPositiveDuration(float $duration, string $name): void - { - if ($duration !== -1.0 && (! is_finite($duration) || $duration <= 0.0)) { - throw new InvalidArgumentException( - "Pool option [{$name}] must be -1 to disable it or a finite number greater than 0.", - ); - } - } - - /** - * Validate lifecycle event names. - */ - private static function validateEvents(array $events): void - { - if (! array_is_list($events)) { - throw new InvalidArgumentException('Pool option [events] must be a list of non-empty strings.'); - } - - foreach ($events as $event) { - if (! is_string($event) || trim($event) === '') { - throw new InvalidArgumentException('Pool option [events] must be a list of non-empty strings.'); - } - } - } -} diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index d3658efad2..2c0d8eb51d 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -9,11 +9,11 @@ use DateTimeInterface; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\Factory as FactoryContract; use Hypervel\Contracts\Queue\Monitor as MonitorContract; use Hypervel\Contracts\Queue\Queue; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; -use Hypervel\ObjectPool\Traits\HasPoolProxy; +use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Queue\Connectors\ConnectorInterface; use Hypervel\Queue\Events\QueuePaused; use Hypervel\Queue\Events\QueueResumed; @@ -47,7 +47,7 @@ class QueueManager implements FactoryContract, MonitorContract /** * The array of drivers which will be wrapped as pool proxies. */ - protected array $poolables = ['beanstalkd', 'sqs']; + protected array $poolableDrivers = ['beanstalkd', 'sqs']; /** * The pool proxy classes for drivers with supplemental queue capabilities. @@ -363,16 +363,16 @@ protected function resolve(string $name): Queue } $constructionConfig = Arr::except($config, ['pool']); - $resolver = fn () => $this->getConnector($config['driver']) + $createCallback = fn () => $this->getConnector($config['driver']) ->connect($constructionConfig) ->setContainer($this->app) // @phpstan-ignore method.notFound (setContainer is on concrete Queue, not contract) ->setConfig($constructionConfig); - if (in_array($config['driver'], $this->poolables, true)) { + if (in_array($config['driver'], $this->poolableDrivers, true)) { /** @var QueuePoolProxy $proxy */ $proxy = $this->createPoolProxy( $config['driver'], - $resolver, + $createCallback, $this->poolDefinition($config['driver'], $config['pool'] ?? [], $constructionConfig), $this->poolProxyClasses[$config['driver']] ?? QueuePoolProxy::class, ); @@ -380,7 +380,7 @@ protected function resolve(string $name): Queue return $proxy->setConnectionName($name); } - return $resolver()->setConnectionName($name); + return $createCallback()->setConnectionName($name); } /** @@ -495,7 +495,7 @@ public function purge(?string $name = null): void $config = $this->getConfig($name); - if (is_null($config) || ! in_array($config['driver'], $this->poolables, true)) { + if (is_null($config) || ! in_array($config['driver'], $this->poolableDrivers, true)) { return; } @@ -506,7 +506,7 @@ public function purge(?string $name = null): void $constructionConfig, ); - $this->poolFactory()->remove($definition->identity); + $this->poolFactory()->purge($definition->identity); } /** diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php index 14c5a391d9..1896eedaed 100644 --- a/src/queue/src/QueuePoolProxy.php +++ b/src/queue/src/QueuePoolProxy.php @@ -7,10 +7,10 @@ use Closure; use DateInterval; use DateTimeInterface; +use Hypervel\Contracts\ObjectPool\Factory; use Hypervel\Contracts\Queue\IndexAwareQueue; use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Queue\Queue as QueueContract; -use Hypervel\ObjectPool\Contracts\Factory; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolErrorReporter; use Hypervel\ObjectPool\PoolProxy; @@ -35,7 +35,7 @@ class QueuePoolProxy extends PoolProxy implements QueueContract, IndexAwareQueue */ public function __construct( PoolDefinition $definition, - Closure $resolver, + Closure $createCallback, Factory $pools, ?Closure $releaseCallback = null, ) { @@ -43,7 +43,7 @@ public function __construct( parent::__construct( $definition, - $resolver, + $createCallback, $pools, static function (Queue $queue) use ($releaseCallback): void { $queue->setAfterCommitDispatcher(null); diff --git a/src/redis/composer.json b/src/redis/composer.json index 9ad475021a..47c0164f4b 100644 --- a/src/redis/composer.json +++ b/src/redis/composer.json @@ -38,6 +38,7 @@ "psr/log": "^3.0", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", + "hypervel/connection-pool": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", @@ -46,7 +47,6 @@ "hypervel/coroutine": "^0.4", "hypervel/engine": "^0.4", "hypervel/macroable": "^0.4", - "hypervel/pool": "^0.4", "hypervel/support": "^0.4" }, "config": { diff --git a/src/redis/src/Listeners/RedisConnectionLifecycleListener.php b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php index 27e9318342..5f423bd76a 100644 --- a/src/redis/src/Listeners/RedisConnectionLifecycleListener.php +++ b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php @@ -6,13 +6,16 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Redis\Factory as RedisFactory; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisManager; use Swoole\Coroutine\CanceledException; use Throwable; class RedisConnectionLifecycleListener { + /** + * Create a connection lifecycle listener. + */ public function __construct( protected ContainerContract $container, ) { @@ -55,9 +58,9 @@ public function discardProcessConnections(): void } } - if ($this->container->resolved(PoolFactory::class)) { + if ($this->container->resolved(PoolManager::class)) { try { - $this->container->make(PoolFactory::class)->flushAll(); + $this->container->make(PoolManager::class)->purgeAll(); } catch (Throwable $throwable) { if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { $exception = $throwable; diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php index 6ed0bd6142..4b403e25ae 100644 --- a/src/redis/src/PhpRedisClusterConnection.php +++ b/src/redis/src/PhpRedisClusterConnection.php @@ -4,7 +4,7 @@ namespace Hypervel\Redis; -use Hypervel\Pool\Exceptions\ConnectionException; +use Hypervel\ConnectionPool\Exceptions\ConnectionException; use InvalidArgumentException; use Redis; use RedisCluster; diff --git a/src/redis/src/PhpRedisConnection.php b/src/redis/src/PhpRedisConnection.php index 705e3d719c..daf5969b2b 100644 --- a/src/redis/src/PhpRedisConnection.php +++ b/src/redis/src/PhpRedisConnection.php @@ -4,9 +4,9 @@ namespace Hypervel\Redis; +use Hypervel\ConnectionPool\Exceptions\ConnectionException; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Pool\PoolInterface; -use Hypervel\Pool\Exceptions\ConnectionException; use Hypervel\Support\Str; use InvalidArgumentException; use Redis; @@ -23,7 +23,7 @@ class PhpRedisConnection extends RedisConnection * * @param array $config */ - public function __construct(Container $container, PoolInterface $pool, array $config) + public function __construct(Container $container, ConnectionPool $pool, array $config) { parent::__construct($container, $pool, $config); diff --git a/src/redis/src/Pool/PoolFactory.php b/src/redis/src/Pool/PoolFactory.php deleted file mode 100644 index d782678b9d..0000000000 --- a/src/redis/src/Pool/PoolFactory.php +++ /dev/null @@ -1,87 +0,0 @@ -pools; - $this->pools = []; - $exception = null; - - foreach ($pools as $pool) { - try { - $pool->close(); - } catch (Throwable $throwable) { - if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } - } - } - - if ($exception !== null) { - throw $exception; - } - } - - /** - * Flush a specific pool, closing all connections. - * - * Boot or tests only. Closes a worker-shared pool; connections already - * checked out by concurrent coroutines are destroyed on release. - */ - public function flushPool(string $name): void - { - $pool = $this->pools[$name] ?? null; - - if ($pool !== null) { - unset($this->pools[$name]); - $pool->close(); - } - } - - /** - * Get or create a pool for the given connection name. - */ - public function getPool(string $name): RedisPool - { - if (isset($this->pools[$name])) { - return $this->pools[$name]; - } - - return $this->pools[$name] = $this->container->make(RedisPool::class, ['name' => $name]); - } - - /** - * Get the existing pools keyed by connection name. - * - * @return array - */ - public function pools(): array - { - return $this->pools; - } -} diff --git a/src/redis/src/Pool/PoolManager.php b/src/redis/src/Pool/PoolManager.php new file mode 100644 index 0000000000..ce582e9708 --- /dev/null +++ b/src/redis/src/Pool/PoolManager.php @@ -0,0 +1,130 @@ + + */ + protected array $pools = []; + + /** + * Create a pool manager. + */ + public function __construct( + protected ContainerContract $container + ) { + } + + /** + * Remove all pools and close their connections. + * + * Boot or tests only. Closes worker-shared pools; connections already + * checked out by concurrent coroutines are destroyed on release. + */ + public function purgeAll(): void + { + $pools = $this->pools; + $this->pools = []; + $firstException = null; + $firstCancellation = null; + + foreach ($pools as $pool) { + try { + $pool->close(); + } catch (CanceledException $exception) { + $firstCancellation ??= $exception; + } catch (Throwable $exception) { + $firstException ??= $exception; + } + } + + if ($firstCancellation !== null) { + throw $firstCancellation; + } + + if ($firstException !== null) { + throw $firstException; + } + } + + /** + * Remove a pool and close its connections. + * + * Boot or tests only. Closes a worker-shared pool; connections already + * checked out by concurrent coroutines are destroyed on release. + */ + public function purge(string $name): void + { + $pool = $this->pools[$name] ?? null; + + if ($pool !== null) { + unset($this->pools[$name]); + $pool->close(); + } + } + + /** + * Get or create a pool for the given connection name. + */ + public function pool(string $name): RedisPool + { + while (true) { + if (($pool = $this->pools[$name] ?? null) !== null) { + if (! $pool->isClosed()) { + return $pool; + } + + unset($this->pools[$name]); + } + + $pool = $this->container->make(RedisPool::class, ['name' => $name]); + + try { + $pool->start(); + } catch (Throwable $failure) { + try { + $pool->close(); + } catch (CanceledException $cancellation) { + if (! $failure instanceof CanceledException) { + throw $cancellation; + } + } catch (Throwable) { + // Preserve the activation failure over an ordinary cleanup failure. + } + + throw $failure; + } + + $existing = $this->pools[$name] ?? null; + + if ($existing === null || $existing->isClosed()) { + return $this->pools[$name] = $pool; + } + + if ($existing === $pool) { + return $pool; + } + + // Cleanup can yield while the registered pool closes or is replaced. + $pool->close(); + } + } + + /** + * Get the existing pools keyed by connection name. + * + * @return array + */ + public function getPools(): array + { + return $this->pools; + } +} diff --git a/src/redis/src/Pool/RedisPool.php b/src/redis/src/Pool/RedisPool.php index 458bed3e76..71a89eb0e0 100644 --- a/src/redis/src/Pool/RedisPool.php +++ b/src/redis/src/Pool/RedisPool.php @@ -4,18 +4,20 @@ namespace Hypervel\Redis\Pool; +use Hypervel\ConnectionPool\BorrowRateTracker; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; +use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Pool\ConnectionInterface; use Hypervel\Coordinator\Timer; -use Hypervel\Pool\Frequency; -use Hypervel\Pool\Pool; use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisConnection; +use Swoole\Coroutine\CanceledException; use Throwable; -class RedisPool extends Pool +class RedisPool extends ConnectionPool { protected array $config; @@ -23,6 +25,8 @@ class RedisPool extends Pool protected ?int $heartbeatTimerId = null; + protected bool $heartbeatStarted = false; + /** * Create a new Redis pool instance. */ @@ -32,15 +36,21 @@ public function __construct(Container $container, string $name) $this->config = $configService->connectionConfig($name); $poolOptions = $this->config['pool']; - $this->frequency = new Frequency; - parent::__construct($container, $name, $poolOptions); if ($this->config['timeout'] === null) { - $this->config['timeout'] = $this->option->getConnectTimeout(); + $this->config['timeout'] = $this->options->connectTimeout; } $this->heartbeatTimer = new Timer($this->getLogger()); + } + + /** + * Enable background maintenance after pool initialization succeeds. + */ + public function start(): void + { + parent::start(); $this->startHeartbeat(); } @@ -52,10 +62,18 @@ public function getConfig(): array return $this->config; } + /** + * Create a usage policy for this Redis pool. + */ + protected function createUsageTracker(): ?UsageTracker + { + return new BorrowRateTracker; + } + /** * Create a new pooled Redis connection. */ - protected function createConnection(): ConnectionInterface + protected function createConnection(): PoolConnection { if ($this->config['cluster']['enabled'] ?? false) { return new PhpRedisClusterConnection($this->container, $this, $this->config); @@ -83,22 +101,42 @@ public function close(): void */ protected function startHeartbeat(): void { - if ($this->heartbeatTimer === null || $this->option->getHeartbeat() <= 0) { + if ($this->heartbeatStarted || $this->isClosed() || $this->heartbeatTimer === null + || $this->options->heartbeatInterval === null + ) { return; } - $this->heartbeatTimerId = $this->heartbeatTimer->tick( - $this->option->getHeartbeat(), - function (bool $isClosing): ?string { - if ($isClosing || $this->isClosed()) { - return Timer::STOP; + // Timer creation can reenter pool lifecycle methods through startup hooks. + $this->heartbeatStarted = true; + + try { + $timerId = $this->heartbeatTimer->tick( + $this->options->heartbeatInterval, + function (bool $isClosing): ?string { + if ($isClosing || $this->isClosed()) { + return Timer::STOP; + } + + $this->heartbeat(); + + return null; } + ); + } catch (Throwable $exception) { + $this->heartbeatStarted = false; - $this->heartbeat(); + throw $exception; + } - return null; - } - ); + if (! $this->heartbeatStarted || $this->isClosed()) { + $this->heartbeatStarted = false; + $this->heartbeatTimer->clear($timerId); + + return; + } + + $this->heartbeatTimerId = $timerId; } /** @@ -106,12 +144,13 @@ function (bool $isClosing): ?string { */ protected function clearHeartbeat(): void { - if ($this->heartbeatTimer === null || $this->heartbeatTimerId === null) { - return; - } - - $this->heartbeatTimer->clear($this->heartbeatTimerId); + $timerId = $this->heartbeatTimerId; $this->heartbeatTimerId = null; + $this->heartbeatStarted = false; + + if ($timerId !== null) { + $this->heartbeatTimer?->clear($timerId); + } } /** @@ -119,7 +158,7 @@ protected function clearHeartbeat(): void */ protected function heartbeat(): void { - $connectionsToInspect = $this->getConnectionsInChannel(); + $connectionsToInspect = $this->getIdleCount(); for ($index = 0; $index < $connectionsToInspect; ++$index) { /** @var false|RedisConnection $connection */ @@ -141,35 +180,27 @@ protected function heartbeatConnection(RedisConnection $connection): void try { $now = hrtime(true) / 1e9; - if ($connection->isLifetimeExpired($now)) { + $expired = $connection->isLifetimeExpired($now) + || ($connection->isIdleExpired($now) + && $this->getManagedCount() > $this->options->minRetainedConnections); + $healthy = ! $expired && $connection->heartbeatCheck($this->options->heartbeatTimeout); + } catch (CanceledException $cancellation) { + try { $this->discardHeartbeatConnection($connection); - - return; + } catch (CanceledException) { + } catch (Throwable $exception) { + $this->report($exception); } - if ($connection->isIdleExpired($now) - && $this->getCurrentConnections() > $this->option->getMinConnections() - ) { - $this->discardHeartbeatConnection($connection); - - return; - } - - if ($connection->heartbeatCheck($this->option->getHeartbeatTimeout())) { - if ($this->isClosed()) { - $this->discardHeartbeatConnection($connection); - - return; - } - - $this->requeueConnection($connection); - - return; - } - - $this->discardHeartbeatConnection($connection); + throw $cancellation; } catch (Throwable $exception) { $this->report('Redis heartbeat failed: ' . $exception); + $healthy = false; + } + + if ($healthy && ! $this->isClosed()) { + $this->requeueConnection($connection); + } else { $this->discardHeartbeatConnection($connection); } } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 7c75af06c6..4d47d2b7e8 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -7,18 +7,17 @@ use BadMethodCallException; use Closure; use Generator; +use Hypervel\ConnectionPool\Connection as BaseConnection; +use Hypervel\ConnectionPool\Exceptions\ConnectionException; use Hypervel\Context\NonCopyableContext; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Coroutine\Coroutine as FrameworkCoroutine; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; use Hypervel\Engine\Exceptions\CoroutineCreateException; -use Hypervel\Pool\Connection as BaseConnection; -use Hypervel\Pool\Exceptions\ConnectionException; -use Hypervel\Pool\PoolOption; use Hypervel\Redis\Exceptions\InvalidRedisOptionException; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Redis\Operations\FlushByPattern; @@ -347,7 +346,7 @@ abstract class RedisConnection extends BaseConnection implements NonCopyableCont protected float $createdAt = 0.0; - protected float $lifetimeExpiresAt = 0.0; + protected ?float $lifetimeExpiresAt = null; protected bool $availableForReuse = false; @@ -375,7 +374,7 @@ abstract class RedisConnection extends BaseConnection implements NonCopyableCont * * @param array $config */ - public function __construct(Container $container, PoolInterface $pool, array $config) + public function __construct(Container $container, ConnectionPool $pool, array $config) { parent::__construct($container, $pool); $this->config = $config; @@ -507,7 +506,9 @@ public function check(): bool return false; } - if ($now > $this->pool->getOption()->getMaxIdleTime() + max($this->lastReleaseTime, $this->lastUseTime)) { + $maxIdleTime = $this->pool->getOptions()->maxIdleTime; + + if ($maxIdleTime !== null && $now > $maxIdleTime + max($this->lastReleaseTime, $this->lastUseTime)) { return false; } } @@ -554,10 +555,7 @@ protected function markReconnected(): void $now = hrtime(true) / 1e9; $this->lastUseTime = $now; $this->createdAt = $now; - $this->lifetimeExpiresAt = PoolOption::jitteredLifetimeDeadline( - $now, - $this->pool->getOption()->getMaxLifetime() - ); + $this->lifetimeExpiresAt = $this->pool->getOptions()->jitteredLifetimeDeadline($now); $this->availableForReuse = false; $this->watching = false; $this->markValid(); @@ -943,7 +941,9 @@ public function isIdleExpired(?float $now = null): bool } // Heartbeat pings must not keep request-idle connections alive forever. - return ($now ?? hrtime(true) / 1e9) > $this->pool->getOption()->getMaxIdleTime() + $this->lastReleaseTime; + $maxIdleTime = $this->pool->getOptions()->maxIdleTime; + + return $maxIdleTime !== null && ($now ?? hrtime(true) / 1e9) > $maxIdleTime + $this->lastReleaseTime; } /** @@ -959,7 +959,7 @@ public function getCreatedAt(): float */ public function isLifetimeExpired(?float $now = null): bool { - if ($this->lifetimeExpiresAt <= 0) { + if ($this->lifetimeExpiresAt === null) { return false; } diff --git a/src/redis/src/RedisManager.php b/src/redis/src/RedisManager.php index 50217f5e9d..673d401c08 100644 --- a/src/redis/src/RedisManager.php +++ b/src/redis/src/RedisManager.php @@ -12,7 +12,7 @@ use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\Limiters\ConcurrencyLimiterBuilder; use Hypervel\Redis\Limiters\DurationLimiterBuilder; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use InvalidArgumentException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -37,7 +37,7 @@ class RedisManager implements FactoryContract, ConnectionContract */ public function __construct( protected ContainerContract $app, - protected PoolFactory $factory, + protected PoolManager $poolManager, protected RedisConfig $config, protected RedisSentinelFactory $sentinelFactory, ) { @@ -63,7 +63,7 @@ public function connection(UnitEnum|string|null $name = null): RedisProxy $this->config->connectionConfig($name); return $this->connections[$name] = new RedisProxy( - $this->factory, + $this->poolManager, $name, $this->sentinelFactory, ); @@ -102,7 +102,7 @@ public function purge(UnitEnum|string|null $name = null): void } try { - $this->factory->flushPool($poolName); + $this->poolManager->purge($poolName); } catch (Throwable $throwable) { if ($exception === null || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { $exception = $throwable; @@ -153,7 +153,7 @@ public function disableEvents(): void */ protected function refreshEventPools(bool $eventsEnabled): void { - foreach ($this->factory->pools() as $name => $pool) { + foreach ($this->poolManager->getPools() as $name => $pool) { if ($pool->getConfig()['events'] === $eventsEnabled) { continue; } diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 95ed08ee98..754f0984ec 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -16,7 +16,7 @@ use Hypervel\Redis\Exceptions\InvalidRedisConnectionException; use Hypervel\Redis\Limiters\ConcurrencyLimiterBuilder; use Hypervel\Redis\Limiters\DurationLimiterBuilder; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Subscriber\Subscriber; use Hypervel\Redis\Traits\MultiExec; use Hypervel\Support\Arr; @@ -87,7 +87,7 @@ class RedisProxy implements ConnectionContract * Create a new Redis proxy instance. */ public function __construct( - protected PoolFactory $factory, + protected PoolManager $poolManager, protected string $poolName, protected RedisSentinelFactory $sentinelFactory, ) { @@ -106,7 +106,7 @@ public function getName(): string */ public function isCluster(): bool { - $config = $this->factory->getPool($this->poolName)->getConfig(); + $config = $this->poolManager->pool($this->poolName)->getConfig(); return $config['cluster']['enabled'] ?? false; } @@ -475,7 +475,7 @@ protected function getConnection(bool $hasContextConnection, bool $transform = t : null; $connection = $connection - ?: $this->factory->getPool($this->poolName)->get(); + ?: $this->poolManager->pool($this->poolName)->borrow(); if (! $connection instanceof RedisConnection) { throw new InvalidRedisConnectionException('The connection is not a valid RedisConnection.'); @@ -625,7 +625,7 @@ public function withoutSerializationOrCompression(callable $callback): mixed */ public function subscriber(): Subscriber { - $pool = $this->factory->getPool($this->poolName); + $pool = $this->poolManager->pool($this->poolName); $config = $pool->getConfig(); if ($config['sentinel']['enabled'] ?? false) { @@ -650,7 +650,7 @@ public function subscriber(): Subscriber ); } - $connection = $pool->get(); + $connection = $pool->borrow(); $discoveryException = null; $releaseException = null; $masters = []; diff --git a/src/redis/src/RedisServiceProvider.php b/src/redis/src/RedisServiceProvider.php index 09d0c87d23..2715bc9881 100644 --- a/src/redis/src/RedisServiceProvider.php +++ b/src/redis/src/RedisServiceProvider.php @@ -9,7 +9,7 @@ use Hypervel\Core\Events\BeforeWorkerStart; use Hypervel\Core\Events\TaskTerminated; use Hypervel\Redis\Listeners\RedisConnectionLifecycleListener; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Support\ServiceProvider; use Swoole\Constant; @@ -22,7 +22,7 @@ public function register(): void { $this->app->singleton('redis', fn ($app) => new RedisManager( $app, - $app->make(PoolFactory::class), + $app->make(PoolManager::class), $app->make(RedisConfig::class), $app->make(RedisSentinelFactory::class), )); diff --git a/src/sentry/README.md b/src/sentry/README.md index 91251bf748..bc420ef31e 100644 --- a/src/sentry/README.md +++ b/src/sentry/README.md @@ -4,6 +4,6 @@ Documentation: https://hypervel.org/docs/sentry ## Differences From Laravel -- Events are sent asynchronously through a bounded, reusable transport pool. Pool exhaustion drops telemetry instead of blocking application work, and worker-exit delivery is best effort. +- Events are sent asynchronously through a bounded, reusable transport pool. Telemetry is dropped when pool acquisition times out, and worker-exit delivery is best effort. Ported from: https://github.com/getsentry/sentry-laravel diff --git a/src/sentry/src/Features/RedisFeature.php b/src/sentry/src/Features/RedisFeature.php index 051d2e35a0..f4f4ea6648 100644 --- a/src/sentry/src/Features/RedisFeature.php +++ b/src/sentry/src/Features/RedisFeature.php @@ -7,7 +7,7 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisManager; use Hypervel\Sentry\Features\Concerns\ResolvesEventOrigin; @@ -69,7 +69,7 @@ private function recordCommand(CommandExecuted|CommandFailed $event): void return; } - $pool = $this->container->make(PoolFactory::class)->getPool($event->connectionName); + $pool = $this->container->make(PoolManager::class)->getPools()[$event->connectionName] ?? null; $redisConfig = $this->container->make(RedisConfig::class); $config = $redisConfig->connectionConfig($event->connectionName); @@ -93,13 +93,21 @@ private function recordCommand(CommandExecuted|CommandFailed $event): void 'db.statement' => $redisStatement, 'db.redis.connection' => $event->connectionName, 'db.redis.database_index' => $config['database'] ?? 0, - 'db.redis.pool.name' => $event->connectionName, - 'db.redis.pool.max' => $pool->getOption()->getMaxConnections(), - 'db.redis.pool.max_idle_time' => $pool->getOption()->getMaxIdleTime(), - 'db.redis.pool.idle' => $pool->getConnectionsInChannel(), - 'db.redis.pool.using' => $pool->getCurrentConnections(), ]; + if ($pool !== null) { + $options = $pool->getOptions(); + $data += [ + 'db.redis.pool.name' => $event->connectionName, + 'db.redis.pool.max' => $options->maxConnections, + 'db.redis.pool.max_idle_time' => $options->maxIdleTime, + 'db.redis.pool.managed' => $pool->getManagedCount(), + 'db.redis.pool.borrowed' => $pool->getBorrowedCount(), + 'db.redis.pool.idle' => $pool->getIdleCount(), + 'db.redis.pool.waiting' => $pool->getWaitingCount(), + ]; + } + if ($event instanceof CommandFailed) { $data['db.redis.error'] = $event->exception->getMessage(); } diff --git a/src/sentry/src/Features/Storage/FilesystemDecorator.php b/src/sentry/src/Features/Storage/FilesystemDecorator.php index ed9e683d67..7b1937f0ed 100644 --- a/src/sentry/src/Features/Storage/FilesystemDecorator.php +++ b/src/sentry/src/Features/Storage/FilesystemDecorator.php @@ -5,9 +5,9 @@ namespace Hypervel\Sentry\Features\Storage; use Hypervel\Contracts\Filesystem\Filesystem; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; use Hypervel\Http\File; use Hypervel\Http\UploadedFile; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\Sentry\Integration; use Hypervel\Sentry\Util\Filesize; use Psr\Http\Message\StreamInterface; diff --git a/src/sentry/src/Features/Storage/SentryCloudFilesystem.php b/src/sentry/src/Features/Storage/SentryCloudFilesystem.php index 839bcd97ee..69d633da6c 100644 --- a/src/sentry/src/Features/Storage/SentryCloudFilesystem.php +++ b/src/sentry/src/Features/Storage/SentryCloudFilesystem.php @@ -5,7 +5,7 @@ namespace Hypervel\Sentry\Features\Storage; use Hypervel\Contracts\Filesystem\Cloud; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; class SentryCloudFilesystem implements Cloud, DecoratedFilesystem, InvalidatesPool { diff --git a/src/sentry/src/Features/Storage/SentryFilesystem.php b/src/sentry/src/Features/Storage/SentryFilesystem.php index 386205ae5e..dc4be3e9ad 100644 --- a/src/sentry/src/Features/Storage/SentryFilesystem.php +++ b/src/sentry/src/Features/Storage/SentryFilesystem.php @@ -5,7 +5,7 @@ namespace Hypervel\Sentry\Features\Storage; use Hypervel\Contracts\Filesystem\Filesystem; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; class SentryFilesystem implements DecoratedFilesystem, Filesystem, InvalidatesPool { diff --git a/src/sentry/src/Features/Storage/SentryFilesystemAdapter.php b/src/sentry/src/Features/Storage/SentryFilesystemAdapter.php index c37aeb409a..3c6fdb874e 100644 --- a/src/sentry/src/Features/Storage/SentryFilesystemAdapter.php +++ b/src/sentry/src/Features/Storage/SentryFilesystemAdapter.php @@ -4,8 +4,8 @@ namespace Hypervel\Sentry\Features\Storage; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; use Hypervel\Filesystem\FilesystemAdapter; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; class SentryFilesystemAdapter extends FilesystemAdapter implements DecoratedFilesystem, InvalidatesPool { diff --git a/src/sentry/src/Features/Storage/SentryS3V3Adapter.php b/src/sentry/src/Features/Storage/SentryS3V3Adapter.php index 78aa961f4e..0f0697e284 100644 --- a/src/sentry/src/Features/Storage/SentryS3V3Adapter.php +++ b/src/sentry/src/Features/Storage/SentryS3V3Adapter.php @@ -4,8 +4,8 @@ namespace Hypervel\Sentry\Features\Storage; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; use Hypervel\Filesystem\AwsS3V3Adapter; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; class SentryS3V3Adapter extends AwsS3V3Adapter implements DecoratedFilesystem, InvalidatesPool { diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 61a0130d0a..534bad2bf0 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -39,7 +39,7 @@ use Hypervel\Sentry\Tracing\Routing\TracingControllerDispatcherTracing; use Hypervel\Sentry\Tracing\ViewEngineDecorator; use Hypervel\Sentry\Transport\HttpPoolTransport; -use Hypervel\Sentry\Transport\Pool; +use Hypervel\Sentry\Transport\HttpTransportPool; use Hypervel\Support\ServiceProvider; use Hypervel\View\Engines\EngineResolver; use Hypervel\View\Factory as ViewFactory; @@ -218,7 +218,7 @@ protected function configureAndRegisterClient(): void // Set the pooled transport for async sending via Swoole coroutines $poolConfig = $this->app->make('config')->array("{$configRoot}.pool"); $transport = new HttpPoolTransport( - new Pool( + new HttpTransportPool( $clientBuilder->getOptions(), $this->sentryPoolOptions($poolConfig), ) @@ -347,8 +347,8 @@ protected function sentryPoolOptions(array $config): PoolOptions return PoolOptions::fromArray([ ...$config, - 'max_idle_time' => 0, - 'idle_ttl' => null, + 'max_idle_time' => null, + 'pool_idle_timeout' => null, ]); } diff --git a/src/sentry/src/Transport/HttpPoolTransport.php b/src/sentry/src/Transport/HttpPoolTransport.php index 703c58e80c..330bd2db66 100644 --- a/src/sentry/src/Transport/HttpPoolTransport.php +++ b/src/sentry/src/Transport/HttpPoolTransport.php @@ -8,7 +8,8 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\WaitGroup; -use RuntimeException; +use Hypervel\ObjectPool\Exceptions\PoolClosedException; +use Hypervel\ObjectPool\Exceptions\PoolExhaustedException; use Sentry\Event; use Sentry\Transport\HttpTransport; use Sentry\Transport\Result; @@ -26,7 +27,10 @@ class HttpPoolTransport implements TransportInterface protected WaitGroup $group; - public function __construct(protected Pool $pool) + /** + * Create a transport that sends events through an owned pool. + */ + public function __construct(protected HttpTransportPool $pool) { $this->group = new WaitGroup; } @@ -34,16 +38,14 @@ public function __construct(protected Pool $pool) /** * Send an event to Sentry via a pooled transport. * - * Checks out a transport from the pool. If the pool is exhausted, the event - * is silently dropped (backpressure) to avoid blocking the request coroutine. + * Skip the event when pool acquisition times out or the pool is closed. */ public function send(Event $event): Result { try { /** @var HttpTransport $transport */ - $transport = $this->pool->get(); - } catch (RuntimeException) { - // Pool exhausted — drop event to avoid blocking the request coroutine + $transport = $this->pool->borrow(); + } catch (PoolExhaustedException|PoolClosedException) { return new Result(ResultStatus::skipped()); } diff --git a/src/sentry/src/Transport/Pool.php b/src/sentry/src/Transport/HttpTransportPool.php similarity index 80% rename from src/sentry/src/Transport/Pool.php rename to src/sentry/src/Transport/HttpTransportPool.php index ec5989b471..8634d933b0 100644 --- a/src/sentry/src/Transport/Pool.php +++ b/src/sentry/src/Transport/HttpTransportPool.php @@ -16,8 +16,11 @@ /** * @extends ObjectPool */ -class Pool extends ObjectPool +class HttpTransportPool extends ObjectPool { + /** + * Create a pool of reusable Sentry HTTP transports. + */ public function __construct( protected Options $sentryOptions, PoolOptions $poolOptions, @@ -25,6 +28,9 @@ public function __construct( parent::__construct($poolOptions); } + /** + * Create an HTTP transport with its own rate-limit state. + */ protected function createObject(): HttpTransport { return new HttpTransport( @@ -35,6 +41,9 @@ protected function createObject(): HttpTransport ); } + /** + * Create the SDK HTTP client. + */ protected function getHttpClient(): HttpClientInterface { return new HttpClient(Version::getSdkIdentifier(), Version::getSdkVersion()); diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php index e53e466f35..084a445e4c 100644 --- a/src/support/src/Facades/Broadcast.php +++ b/src/support/src/Facades/Broadcast.php @@ -8,7 +8,7 @@ /** * @method static \Ably\AblyRest ably(array $config) - * @method static \Hypervel\Broadcasting\BroadcastManager addPoolable(string $driver) + * @method static \Hypervel\Broadcasting\BroadcastManager addPoolableDriver(string $driver) * @method static void channelRoutes(array|null $attributes = null) * @method static \Hypervel\Contracts\Broadcasting\Broadcaster connection(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Contracts\Broadcasting\Broadcaster driver(\UnitEnum|string|null $name = null) @@ -17,7 +17,7 @@ * @method static \Hypervel\Broadcasting\BroadcastManager forgetDrivers() * @method static \Hypervel\Contracts\Container\Container getApplication() * @method static string getDefaultDriver() - * @method static array getPoolables() + * @method static array getPoolableDrivers() * @method static \Closure|null getReleaseCallback(string $driver) * @method static \Hypervel\Broadcasting\AnonymousEvent on(\Hypervel\Broadcasting\Channel|array|string $channels) * @method static \Hypervel\Broadcasting\AnonymousEvent presence(string $channel) @@ -25,13 +25,13 @@ * @method static void purge(\UnitEnum|string|null $name = null) * @method static \Pusher\Pusher pusher(array $config) * @method static void queue(mixed $event) - * @method static \Hypervel\Broadcasting\BroadcastManager removePoolable(string $driver) + * @method static \Hypervel\Broadcasting\BroadcastManager removePoolableDriver(string $driver) * @method static string|null resolveConnectionFromQueueRoute(object $queueable) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void routes(array|null $attributes = null) * @method static \Hypervel\Broadcasting\BroadcastManager setApplication(\Hypervel\Contracts\Container\Container $app) * @method static void setDefaultDriver(\UnitEnum|string $name) - * @method static \Hypervel\Broadcasting\BroadcastManager setPoolables(array $poolables) + * @method static \Hypervel\Broadcasting\BroadcastManager setPoolableDrivers(array $poolableDrivers) * @method static \Hypervel\Broadcasting\BroadcastManager setReleaseCallback(string $driver, \Closure $callback) * @method static string|null socket(\Hypervel\Http\Request|null $request = null) * @method static void userRoutes(array|null $attributes = null) diff --git a/src/support/src/Facades/Mail.php b/src/support/src/Facades/Mail.php index 8bacbe4d96..f17bae42db 100644 --- a/src/support/src/Facades/Mail.php +++ b/src/support/src/Facades/Mail.php @@ -7,7 +7,7 @@ use Hypervel\Support\Testing\Fakes\MailFake; /** - * @method static \Hypervel\Mail\MailManager addPoolable(string $driver) + * @method static \Hypervel\Mail\MailManager addPoolableDriver(string $driver) * @method static \Hypervel\Mail\Mailer build(array $config) * @method static \Symfony\Component\Mailer\Transport\TransportInterface createSymfonyTransport(array $config) * @method static \Hypervel\Contracts\Mail\Mailer driver(\UnitEnum|string|null $driver = null) @@ -15,14 +15,14 @@ * @method static \Hypervel\Mail\MailManager forgetMailers() * @method static \Hypervel\Contracts\Container\Container getApplication() * @method static string getDefaultDriver() - * @method static array getPoolables() + * @method static array getPoolableDrivers() * @method static \Closure|null getReleaseCallback(string $driver) * @method static \Hypervel\Contracts\Mail\Mailer mailer(\UnitEnum|string|null $name = null) * @method static void purge(\UnitEnum|string|null $name = null) - * @method static \Hypervel\Mail\MailManager removePoolable(string $driver) + * @method static \Hypervel\Mail\MailManager removePoolableDriver(string $driver) * @method static \Hypervel\Mail\MailManager setApplication(\Hypervel\Contracts\Container\Container $app) * @method static void setDefaultDriver(\UnitEnum|string $name) - * @method static \Hypervel\Mail\MailManager setPoolables(array $poolables) + * @method static \Hypervel\Mail\MailManager setPoolableDrivers(array $poolableDrivers) * @method static \Hypervel\Mail\MailManager setReleaseCallback(string $driver, \Closure $callback) * @method static void alwaysFrom(string $address, string|null $name = null) * @method static void alwaysReplyTo(string $address, string|null $name = null) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index bbb81991ad..1528e78d32 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -10,7 +10,7 @@ /** * @method static void addConnector(string $driver, \Closure $resolver) - * @method static \Hypervel\Queue\QueueManager addPoolable(string $driver) + * @method static \Hypervel\Queue\QueueManager addPoolableDriver(string $driver) * @method static void after(mixed $callback) * @method static void before(mixed $callback) * @method static bool connected(\UnitEnum|string|null $name = null) @@ -22,7 +22,7 @@ * @method static string getDefaultDriver() * @method static string getName(string|null $connection = null) * @method static array getPausedQueues(string $connection, array $queues) - * @method static array getPoolables() + * @method static array getPoolableDrivers() * @method static \Closure|null getReleaseCallback(string $driver) * @method static bool isPaused(string $connection, string $queue) * @method static void looping(mixed $callback) @@ -30,7 +30,7 @@ * @method static void pauseAll() * @method static void pauseFor(string $connection, string $queue, \DateInterval|\DateTimeInterface|int $ttl) * @method static void purge(string|null $name = null) - * @method static \Hypervel\Queue\QueueManager removePoolable(string $driver) + * @method static \Hypervel\Queue\QueueManager removePoolableDriver(string $driver) * @method static string|null resolveConnectionFromQueueRoute(object $queueable) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void resume(string $connection, string $queue) @@ -38,7 +38,7 @@ * @method static void route(array|string $class, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null) * @method static \Hypervel\Queue\QueueManager setApplication(\Hypervel\Contracts\Container\Container $app) * @method static void setDefaultDriver(\UnitEnum|string $name) - * @method static \Hypervel\Queue\QueueManager setPoolables(array $poolables) + * @method static \Hypervel\Queue\QueueManager setPoolableDrivers(array $poolableDrivers) * @method static \Hypervel\Queue\QueueManager setReleaseCallback(string $driver, \Closure $callback) * @method static void starting(mixed $callback) * @method static void stopping(mixed $callback) diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index dcdfbdd941..cc6762ba61 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -12,7 +12,7 @@ use function Hypervel\Support\enum_value; /** - * @method static \Hypervel\Filesystem\FilesystemManager addPoolable(string $driver) + * @method static \Hypervel\Filesystem\FilesystemManager addPoolableDriver(string $driver) * @method static \Hypervel\Contracts\Filesystem\Filesystem build(array|string $config, string|null $name = null) * @method static \Hypervel\Contracts\Filesystem\Filesystem createFtpDriver(array $config) * @method static \Hypervel\Contracts\Filesystem\Cloud createGcsDriver(array $config) @@ -26,13 +26,13 @@ * @method static void flushState() * @method static \Hypervel\Filesystem\FilesystemManager forgetDisk(array|string $disk) * @method static string getDefaultDriver() - * @method static array getPoolables() + * @method static array getPoolableDrivers() * @method static \Closure|null getReleaseCallback(string $driver) * @method static void purge(string|null $name = null) - * @method static \Hypervel\Filesystem\FilesystemManager removePoolable(string $driver) + * @method static \Hypervel\Filesystem\FilesystemManager removePoolableDriver(string $driver) * @method static \Hypervel\Filesystem\FilesystemManager set(string $name, mixed $disk) * @method static \Hypervel\Filesystem\FilesystemManager setApplication(\Hypervel\Contracts\Container\Container $app) - * @method static \Hypervel\Filesystem\FilesystemManager setPoolables(array $poolables) + * @method static \Hypervel\Filesystem\FilesystemManager setPoolableDrivers(array $poolableDrivers) * @method static \Hypervel\Filesystem\FilesystemManager setReleaseCallback(string $driver, \Closure $callback) * @method static mixed unless(mixed $value = null, null|callable $callback = null, null|callable $default = null) * @method static mixed when(mixed $value = null, null|callable $callback = null, null|callable $default = null) diff --git a/tests/Broadcasting/BroadcastPoolProxyTest.php b/tests/Broadcasting/BroadcastPoolProxyTest.php index 2ce2adbbbd..0f9892c43f 100644 --- a/tests/Broadcasting/BroadcastPoolProxyTest.php +++ b/tests/Broadcasting/BroadcastPoolProxyTest.php @@ -26,7 +26,7 @@ class BroadcastPoolProxyTest extends TestCase protected function tearDownInCoroutine(): void { foreach ($this->poolManagers as $poolManager) { - $poolManager->flush(); + $poolManager->purgeAll(); } } diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index 0cf244b3dc..7ccf062d39 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -28,7 +28,7 @@ use Hypervel\Events\Dispatcher as Event; use Hypervel\Filesystem\Filesystem; use Hypervel\Redis\PhpRedisConnection; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Tests\Cache\Fixtures\ArrayFilesystem; use Hypervel\Tests\TestCase; @@ -966,17 +966,16 @@ protected function getAppWithRedis(array $userConfig): Container // Mock RedisPool $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->andReturn($connection); + $pool->shouldReceive('borrow')->andReturn($connection); - // Mock PoolFactory - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); // Mock RedisFactory $redisFactory = m::mock(RedisFactory::class); $app->instance('redis', $redisFactory); - $app->instance(PoolFactory::class, $poolFactory); + $app->instance(PoolManager::class, $poolManager); Container::setInstance($app); diff --git a/tests/ConnectionPool/BorrowRateTrackerTest.php b/tests/ConnectionPool/BorrowRateTrackerTest.php new file mode 100644 index 0000000000..801ac3b63f --- /dev/null +++ b/tests/ConnectionPool/BorrowRateTrackerTest.php @@ -0,0 +1,175 @@ +currentTime(); + $after = intdiv(hrtime(true), 1_000_000_000); + + $this->assertGreaterThanOrEqual($before, $sample); + $this->assertLessThanOrEqual($after, $sample); + } + + public function testRateAndTrimmingRemainInactiveBeforeTheFirstBorrow(): void + { + $tracker = new BorrowRateTrackerStub; + $tracker->now += 1000; + + $this->assertSame(0.0, $tracker->getBorrowRate()); + $this->assertFalse($tracker->shouldTrimExcessIdle()); + + $tracker->recordBorrow(); + + $this->assertSame(1.0, $tracker->getBorrowRate()); + $this->assertFalse($tracker->shouldTrimExcessIdle()); + } + + public function testEachBorrowImmediatelyUpdatesTheRate(): void + { + $tracker = new BorrowRateTrackerStub; + $tracker->seed(96, [100 => 1, 99 => 10, 98 => 10, 97 => 10, 96 => 10]); + + $this->assertSame(41 / 5, $tracker->getBorrowRate()); + + $tracker->recordBorrow(); + + $this->assertSame(42 / 5, $tracker->getBorrowRate()); + $this->assertSame(array_sum($tracker->getSamples()), $tracker->getBorrowCount()); + } + + public function testMissingCompletedSecondsContributeToTheSampleDivisor(): void + { + $tracker = new BorrowRateTrackerStub; + $tracker->seed(96, [100 => 1, 99 => 10, 98 => 10, 96 => 10]); + + $this->assertSame(31 / 5, $tracker->getBorrowRate()); + $tracker->recordBorrow(); + $this->assertSame(32 / 5, $tracker->getBorrowRate()); + + $tracker->seed(96, [100 => 1, 99 => 10, 98 => 10, 97 => 10]); + + $this->assertSame(31 / 5, $tracker->getBorrowRate()); + $tracker->recordBorrow(); + $this->assertSame(32 / 5, $tracker->getBorrowRate()); + } + + public function testExpiredEleventhBucketIsRemovedFromTheRunningCount(): void + { + $tracker = new BorrowRateTrackerStub; + $tracker->seed(90, array_fill_keys(range(91, 100), 0) + [90 => 100]); + + $this->assertSame(0.0, $tracker->getBorrowRate()); + $this->assertCount(10, $tracker->getSamples()); + $this->assertSame(0, $tracker->getBorrowCount()); + } + + public function testWarmupCountsOnlyCompletedSecondsAndSecondsWithBorrows(): void + { + $tracker = new BorrowRateTrackerStub; + $tracker->recordBorrow(); + $tracker->recordBorrow(); + $tracker->now = 101; + $this->assertSame(2.0, $tracker->getBorrowRate()); + + $tracker->now = 102; + $this->assertSame(1.0, $tracker->getBorrowRate()); + + $tracker->recordBorrow(); + $this->assertSame(1.0, $tracker->getBorrowRate()); + $this->assertSame([100 => 2, 101 => 0, 102 => 1], $tracker->getSamples()); + } + + public function testCooldownStartsAtFirstBorrowAndKeepsItsStrictBoundary(): void + { + $tracker = new BorrowRateTrackerStub; + $tracker->now = 1000; + $tracker->recordBorrow(); + $tracker->now = 1060; + $this->assertFalse($tracker->shouldTrimExcessIdle()); + + $tracker->now = 1061; + $this->assertTrue($tracker->shouldTrimExcessIdle()); + $this->assertFalse($tracker->shouldTrimExcessIdle()); + + $tracker->now = 1121; + $this->assertFalse($tracker->shouldTrimExcessIdle()); + $tracker->now = 1122; + $this->assertTrue($tracker->shouldTrimExcessIdle()); + } + + public function testSameSecondBorrowsRemainVisibleAfterCooldownEligibility(): void + { + $tracker = new BorrowRateTrackerStub; + $tracker->seed(1, array_fill_keys(range(91, 100), 5)); + + $this->assertFalse($tracker->shouldTrimExcessIdle()); + $this->assertSame(5.0, $tracker->getBorrowRate()); + + $tracker->recordBorrow(); + $tracker->recordBorrow(); + + $this->assertSame(5.2, $tracker->getBorrowRate()); + $this->assertFalse($tracker->shouldTrimExcessIdle()); + + $tracker->now = 101; + $this->assertSame(47 / 9, $tracker->getBorrowRate()); + $this->assertFalse($tracker->shouldTrimExcessIdle()); + + $tracker->now = 102; + $this->assertTrue($tracker->shouldTrimExcessIdle()); + } + + #[DataProvider('sampleWindows')] + public function testLongIdlePeriodsKeepSamplesAndCountsBounded(int $window): void + { + $tracker = new BorrowRateTrackerStub(window: $window); + $tracker->recordBorrow(); + $tracker->now += 1_000_000; + + $this->assertSame(0.0, $tracker->getBorrowRate()); + $this->assertCount($window - 1, $tracker->getSamples()); + $this->assertTrue($tracker->shouldTrimExcessIdle()); + + $tracker->recordBorrow(); + + $this->assertSame(1.0 / $window, $tracker->getBorrowRate()); + $this->assertCount($window, $tracker->getSamples()); + $this->assertSame(1, $tracker->getBorrowCount()); + } + + public static function sampleWindows(): array + { + return [[1], [3], [10]]; + } + + public function testCustomThresholdAndCooldownAreRespected(): void + { + $tracker = new BorrowRateTrackerStub(window: 3, threshold: 2, cooldown: 2); + $tracker->recordBorrow(); + $tracker->now = 102; + $this->assertFalse($tracker->shouldTrimExcessIdle()); + $tracker->now = 103; + $tracker->recordBorrow(); + $this->assertTrue($tracker->shouldTrimExcessIdle()); + } +} diff --git a/tests/Pool/PoolNonCoroutineTest.php b/tests/ConnectionPool/ConnectionPoolNonCoroutineTest.php similarity index 79% rename from tests/Pool/PoolNonCoroutineTest.php rename to tests/ConnectionPool/ConnectionPoolNonCoroutineTest.php index 6334182420..f88575e35f 100644 --- a/tests/Pool/PoolNonCoroutineTest.php +++ b/tests/ConnectionPool/ConnectionPoolNonCoroutineTest.php @@ -2,23 +2,23 @@ declare(strict_types=1); -namespace Hypervel\Tests\Pool; +namespace Hypervel\Tests\ConnectionPool; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\ConnectionPool\Exceptions\PoolExhaustedException; use Hypervel\Container\Container; -use Hypervel\Contracts\Pool\ConnectionInterface; +use Hypervel\Contracts\ConnectionPool\Connection; use Hypervel\Engine\Exceptions\CoroutineCreateException; -use Hypervel\Pool\Pool; -use Hypervel\Tests\Pool\Fixtures\HeartbeatPoolStub; -use Hypervel\Tests\Pool\Fixtures\KeepaliveConnectionStub; +use Hypervel\Tests\ConnectionPool\Fixtures\HeartbeatPoolStub; +use Hypervel\Tests\ConnectionPool\Fixtures\KeepaliveConnectionStub; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\RunInSeparateProcess; -use RuntimeException; use stdClass; use Swoole\Coroutine as SwooleCoroutine; use Swoole\Event; use Throwable; -class PoolNonCoroutineTest extends TestCase +class ConnectionPoolNonCoroutineTest extends TestCase { protected bool $runTestsInCoroutine = false; @@ -27,25 +27,25 @@ public function testExhaustedPoolFailsWithoutTryingToBlock(): void $container = new Container; Container::setInstance($container); $pool = new NonCoroutinePool($container, 'test', ['max_connections' => 1]); - $pool->get(); + $pool->borrow(); - $this->expectException(RuntimeException::class); + $this->expectException(PoolExhaustedException::class); $this->expectExceptionMessage( 'Connection pool exhausted. Cannot establish new connection before wait_timeout.' ); - $pool->get(); + $pool->borrow(); } #[RunInSeparateProcess] public function testDeadlineReleaseRemainsCommittedWhenItsWakeCannotBeCreated(): void { $pool = $this->createPool(); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $replacement = null; SwooleCoroutine::set(['max_coroutine' => 1]); SwooleCoroutine::create(function () use ($pool, &$replacement): void { - $replacement = $pool->get(); + $replacement = $pool->borrow(); }); $pool->release($borrowed); @@ -59,17 +59,17 @@ public function testDeadlineReleaseRemainsCommittedWhenItsWakeCannotBeCreated(): public function testDeadlineDiscardRemainsCommittedWhenItsWakeCannotBeCreated(): void { $pool = $this->createPool(); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $replacement = null; SwooleCoroutine::set(['max_coroutine' => 1]); SwooleCoroutine::create(function () use ($pool, &$replacement): void { - $replacement = $pool->get(); + $replacement = $pool->borrow(); }); $pool->discard($borrowed); Event::wait(); - $this->assertInstanceOf(ConnectionInterface::class, $replacement); + $this->assertInstanceOf(Connection::class, $replacement); $this->assertNotSame($borrowed, $replacement); $pool->release($replacement); } @@ -79,7 +79,7 @@ public function testHeartbeatCreationFailureClosesAcquiredConnection(): void { $container = new Container; Container::setInstance($container); - $pool = new HeartbeatPoolStub($container, 'test', ['heartbeat' => 1]); + $pool = new HeartbeatPoolStub($container, 'test', ['heartbeat_interval' => 1]); $exception = null; $connected = null; $closeCount = null; @@ -87,7 +87,7 @@ public function testHeartbeatCreationFailureClosesAcquiredConnection(): void SwooleCoroutine::set(['max_coroutine' => 1]); SwooleCoroutine::create(function () use ($pool, &$exception, &$connected, &$closeCount): void { /** @var KeepaliveConnectionStub $connection */ - $connection = $pool->get(); + $connection = $pool->borrow(); $connection->setActiveConnection(new stdClass); try { @@ -120,15 +120,15 @@ private function createPool(): NonCoroutinePool } } -class NonCoroutinePool extends Pool +class NonCoroutinePool extends ConnectionPool { - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { return new NonCoroutinePoolConnection; } } -class NonCoroutinePoolConnection implements ConnectionInterface +class NonCoroutinePoolConnection implements Connection { public function getConnection(): mixed { diff --git a/tests/Pool/PoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php similarity index 54% rename from tests/Pool/PoolTest.php rename to tests/ConnectionPool/ConnectionPoolTest.php index e43e035405..639c845fcc 100644 --- a/tests/Pool/PoolTest.php +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -2,34 +2,37 @@ declare(strict_types=1); -namespace Hypervel\Tests\Pool; +namespace Hypervel\Tests\ConnectionPool; use Closure; +use Hypervel\ConnectionPool\Connection as PoolConnection; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\ConnectionPool\Exceptions\PoolClosedException; +use Hypervel\ConnectionPool\Exceptions\PoolExhaustedException; +use Hypervel\ConnectionPool\IdleConnectionMonitor; use Hypervel\Container\Container; +use Hypervel\Contracts\ConnectionPool\Connection; +use Hypervel\Contracts\ConnectionPool\ConnectionPool as ConnectionPoolContract; +use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\ConnectionInterface; -use Hypervel\Contracts\Pool\FrequencyInterface; -use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Coordinator\Timer; use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\PoolChannel; use Hypervel\Engine\Coroutine as EngineCoroutine; -use Hypervel\Pool\Channel as PoolChannel; -use Hypervel\Pool\ClearableFrequencyInterface; -use Hypervel\Pool\Connection as PoolConnection; -use Hypervel\Pool\LowFrequencyInterface; -use Hypervel\Pool\Pool; -use Hypervel\Tests\Pool\Fixtures\ConstantFrequencyStub; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use ReflectionProperty; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; -class PoolTest extends TestCase +class ConnectionPoolTest extends TestCase { public function testUnknownPoolOptionsAreRejected(): void { @@ -39,26 +42,26 @@ public function testUnknownPoolOptionsAreRejected(): void $this->createPool(['max_conections' => 10]); } - public function testFlushClosesIdleConnectionsUntilManagedCountReachesMinimum(): void + public function testTrimExcessIdleClosesConnectionsUntilManagedCountReachesMinimum(): void { $connections = []; $pool = $this->createPool( - ['min_connections' => 1, 'max_connections' => 3], - function () use (&$connections): ConnectionInterface { + ['min_retained_connections' => 1, 'max_connections' => 3], + function () use (&$connections): Connection { return $connections[] = new PoolConnectionStub; } ); - $borrowed = [$pool->get(), $pool->get(), $pool->get()]; + $borrowed = [$pool->borrow(), $pool->borrow(), $pool->borrow()]; foreach ($borrowed as $connection) { $pool->release($connection); } - $pool->flush(); + $pool->trimExcessIdle(); - $this->assertSame(1, $pool->getConnectionsInChannel()); - $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getIdleCount()); + $this->assertSame(1, $pool->getManagedCount()); $this->assertSame(2, array_sum(array_column($connections, 'closeCount'))); } @@ -67,13 +70,13 @@ public function testCloseDrainsIdleConnectionsAndIsIdempotent(): void $connections = []; $pool = $this->createPool( ['max_connections' => 2], - function () use (&$connections): ConnectionInterface { + function () use (&$connections): Connection { return $connections[] = new PoolConnectionStub; } ); - $first = $pool->get(); - $second = $pool->get(); + $first = $pool->borrow(); + $second = $pool->borrow(); $pool->release($first); $pool->release($second); @@ -81,11 +84,53 @@ function () use (&$connections): ConnectionInterface { $pool->close(); $this->assertTrue($pool->isClosed()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getIdleCount()); + $this->assertSame(0, $pool->getManagedCount()); $this->assertSame(2, array_sum(array_column($connections, 'closeCount'))); } + public function testSnapshotsDistinguishCreationBorrowingAndCleanup(): void + { + $duringCreation = null; + $duringClose = null; + $pool = null; + $pool = $this->createPool([], function () use (&$pool, &$duringCreation, &$duringClose): Connection { + $duringCreation = $pool->getStats(); + + return new PoolConnectionStub(closeCallback: function () use (&$pool, &$duringClose): bool { + $duringClose = $pool->getStats(); + + return true; + }); + }); + + try { + $connection = $pool->borrow(); + + $this->assertSame([ + 'managed' => 0, 'borrowed' => 0, 'idle' => 0, 'waiting' => 0, 'closed' => false, + ], $duringCreation); + $this->assertSame([ + 'managed' => 1, 'borrowed' => 1, 'idle' => 0, 'waiting' => 0, 'closed' => false, + ], $pool->getStats()); + + $pool->release($connection); + + $this->assertSame([ + 'managed' => 1, 'borrowed' => 0, 'idle' => 1, 'waiting' => 0, 'closed' => false, + ], $pool->getStats()); + } finally { + $pool->close(); + } + + $this->assertSame([ + 'managed' => 1, 'borrowed' => 0, 'idle' => 0, 'waiting' => 0, 'closed' => true, + ], $duringClose); + $this->assertSame([ + 'managed' => 0, 'borrowed' => 0, 'idle' => 0, 'waiting' => 0, 'closed' => true, + ], $pool->getStats()); + } + public function testCloseDrainsEveryIdleConnectionBeforeRethrowingCancellation(): void { $cancellation = new CanceledException('close canceled'); @@ -95,12 +140,12 @@ public function testCloseDrainsEveryIdleConnectionBeforeRethrowingCancellation() ]; $pool = $this->createPool( ['max_connections' => 2], - static function () use (&$connections): ConnectionInterface { + static function () use (&$connections): Connection { return array_shift($connections); }, ); - $first = $pool->get(); - $second = $pool->get(); + $first = $pool->borrow(); + $second = $pool->borrow(); $pool->release($first); $pool->release($second); @@ -113,16 +158,18 @@ static function () use (&$connections): ConnectionInterface { $this->assertSame(1, $first->closeCount); $this->assertSame(1, $second->closeCount); - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getIdleCount()); + $this->assertSame(0, $pool->getManagedCount()); } - public function testCloseClearsConstantFrequencyTimer(): void + public function testFirstBorrowStartsTheMonitorAndCloseClearsIt(): void { - $pool = new ConstantFrequencyPool($this->createContainer(), 'test'); - $pool->useFrequency(new ConstantFrequencyStub($pool)); + $pool = new MonitoredPool($this->createContainer(), 'test', ['idle_check_interval' => 0.001]); + $pool->start(); + $this->assertSame(0, Timer::stats()['num']); try { + $pool->release($pool->borrow()); Coroutine::sleep(0.005); $this->assertGreaterThan(0, $pool->idleConnectionChecks); @@ -137,27 +184,53 @@ public function testCloseClearsConstantFrequencyTimer(): void } } - public function testCloseContinuesAfterFrequencyCleanupFails(): void + public function testWarmPoolDoesNotScheduleMaintenanceUntilStarted(): void + { + $pool = $this->createPool(['idle_check_interval' => 60.0]); + $timerCount = Timer::stats()['num']; + + try { + $pool->release($pool->borrow()); + $this->assertSame($timerCount, Timer::stats()['num']); + + $pool->start(); + $pool->start(); + $this->assertSame($timerCount + 1, Timer::stats()['num']); + + $pool->close(); + $pool->start(); + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + $pool->close(); + } + } + + public function testCloseContinuesAfterMonitorCleanupFails(): void { $logger = m::mock(StdoutLoggerInterface::class); $logger->shouldReceive('error') ->once() - ->with(m::on(static fn (string $message): bool => str_contains($message, 'frequency cleanup failed'))); + ->with(m::on(static fn (string $message): bool => str_contains($message, 'monitor cleanup failed'))); $container = $this->createContainer(); $container->instance(StdoutLoggerInterface::class, $logger); $pool = new CallbackPool($container, 'test'); $channel = new InspectablePoolChannel(1); $pool->replaceChannel($channel); - $connection = $pool->get(); + $connection = $pool->borrow(); $pool->release($connection); - $pool->useFrequency(new ThrowingClearableFrequency); + $timer = m::mock(Timer::class); + $timer->expects('tick')->with(1.0, m::type('callable'))->andReturn(1); + $timer->expects('clear')->with(1)->andThrow(new RuntimeException('monitor cleanup failed')); + $monitor = new IdleConnectionMonitor($pool, 1.0, $timer); + $monitor->start(); + (new ReflectionProperty(ConnectionPool::class, 'idleMonitor'))->setValue($pool, $monitor); $pool->close(); $this->assertTrue($channel->isClosedForTest()); $this->assertSame(1, $connection->closeCount); - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getIdleCount()); + $this->assertSame(0, $pool->getManagedCount()); } public function testBorrowFromClosedPoolThrows(): void @@ -165,24 +238,24 @@ public function testBorrowFromClosedPoolThrows(): void $pool = $this->createPool(); $pool->close(); - $this->expectException(RuntimeException::class); + $this->expectException(PoolClosedException::class); $this->expectExceptionMessage('Cannot borrow from a closed connection pool.'); - $pool->get(); + $pool->borrow(); } public function testConnectionReleasedAfterCloseIsDestroyed(): void { $connection = new PoolConnectionStub; - $pool = $this->createPool([], static fn (): ConnectionInterface => $connection); - $borrowed = $pool->get(); + $pool = $this->createPool([], static fn (): Connection => $connection); + $borrowed = $pool->borrow(); $pool->close(); $pool->release($borrowed); $this->assertSame(1, $connection->closeCount); - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getIdleCount()); + $this->assertSame(0, $pool->getManagedCount()); } public function testConnectionReleaseAfterCloseRepairsCapacityBeforeRethrowingCancellation(): void @@ -193,7 +266,7 @@ public function testConnectionReleaseAfterCloseRepairsCapacityBeforeRethrowingCa $pool = new CallbackPool( $container, 'test', - connectionFactory: static function () use (&$pool, $cancellation, $container): ConnectionInterface { + connectionFactory: static function () use (&$pool, $cancellation, $container): Connection { return new ReleasingPoolConnectionStub( $container, $pool, @@ -201,7 +274,7 @@ public function testConnectionReleaseAfterCloseRepairsCapacityBeforeRethrowingCa ); }, ); - $connection = $pool->get(); + $connection = $pool->borrow(); $pool->close(); try { @@ -212,31 +285,32 @@ public function testConnectionReleaseAfterCloseRepairsCapacityBeforeRethrowingCa } $this->assertSame(1, $connection->closeCount); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getBorrowedConnectionsForTest()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testCloseWakesEveryParkedBorrower(): void { $pool = $this->createPool(['max_connections' => 1, 'wait_timeout' => 0.2]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $messages = []; foreach ([0, 1] as $index) { Coroutine::create(function () use ($pool, &$messages, $index): void { try { - $pool->get(); - } catch (RuntimeException $exception) { + $pool->borrow(); + } catch (PoolClosedException $exception) { $messages[$index] = $exception->getMessage(); } }); } usleep(5_000); - $this->assertSame(2, $pool->getWaiters()); + $this->assertSame(2, $pool->getWaitingCount()); + $this->assertSame(2, $pool->getStats()['waiting']); $pool->close(); usleep(5_000); - $this->assertSame(0, $pool->getWaiters()); + $this->assertSame(0, $pool->getWaitingCount()); $pool->release($borrowed); ksort($messages); @@ -249,7 +323,7 @@ public function testCloseWakesEveryParkedBorrower(): void public function testCloseDuringSuspendedFactoryDestroysOrphan(): void { $connection = new PoolConnectionStub; - $pool = $this->createPool([], function () use ($connection): ConnectionInterface { + $pool = $this->createPool([], function () use ($connection): Connection { usleep(10_000); return $connection; @@ -258,8 +332,8 @@ public function testCloseDuringSuspendedFactoryDestroysOrphan(): void Coroutine::create(function () use ($pool, &$message): void { try { - $pool->get(); - } catch (RuntimeException $exception) { + $pool->borrow(); + } catch (PoolClosedException $exception) { $message = $exception->getMessage(); } }); @@ -270,7 +344,7 @@ public function testCloseDuringSuspendedFactoryDestroysOrphan(): void $this->assertSame('Cannot borrow from a closed connection pool.', $message); $this->assertSame(1, $connection->closeCount); - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getManagedCount()); } public function testHugeFiniteWaitTimeoutSaturatesInsteadOfTimingOutImmediately(): void @@ -279,7 +353,7 @@ public function testHugeFiniteWaitTimeoutSaturatesInsteadOfTimingOutImmediately( 'max_connections' => 1, 'wait_timeout' => PHP_INT_MAX, ]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $result = null; $failure = null; @@ -288,7 +362,7 @@ public function testHugeFiniteWaitTimeoutSaturatesInsteadOfTimingOutImmediately( Coroutine::create(function () use ($pool, &$result, &$failure): void { try { - $result = $pool->get(); + $result = $pool->borrow(); } catch (RuntimeException $exception) { $failure = $exception; } @@ -299,7 +373,7 @@ public function testHugeFiniteWaitTimeoutSaturatesInsteadOfTimingOutImmediately( usleep(2_000); $this->assertNull($failure); - $this->assertInstanceOf(ConnectionInterface::class, $result); + $this->assertInstanceOf(Connection::class, $result); $pool->release($result); $pool->close(); } @@ -307,7 +381,7 @@ public function testHugeFiniteWaitTimeoutSaturatesInsteadOfTimingOutImmediately( public function testForeignAndDoubleReleasesAreRejected(): void { $pool = $this->createPool(); - $connection = $pool->get(); + $connection = $pool->borrow(); $pool->release($connection); try { @@ -328,19 +402,19 @@ public function testDiscardDestroysBorrowedConnectionAndRestoresCapacity(): void $connections = []; $pool = $this->createPool( ['max_connections' => 1], - function () use (&$connections): ConnectionInterface { + function () use (&$connections): Connection { return $connections[] = new PoolConnectionStub; }, ); - $connection = $pool->get(); + $connection = $pool->borrow(); $pool->discard($connection); $this->assertSame(1, $connection->closeCount); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertNotSame($connection, $replacement = $pool->get()); - $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + $this->assertNotSame($connection, $replacement = $pool->borrow()); + $this->assertSame(1, $pool->getManagedCount()); $pool->release($replacement); } @@ -353,11 +427,11 @@ public function testDiscardRepairsCapacityBeforeRethrowingCancellation(): void ]; $pool = $this->createPool( ['max_connections' => 1], - static function () use (&$connections): ConnectionInterface { + static function () use (&$connections): Connection { return array_shift($connections); }, ); - $connection = $pool->get(); + $connection = $pool->borrow(); try { $pool->discard($connection); @@ -366,9 +440,9 @@ static function () use (&$connections): ConnectionInterface { $this->assertSame($cancellation, $exception); } - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getBorrowedConnectionsForTest()); - $this->assertNotSame($connection, $replacement = $pool->get()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertNotSame($connection, $replacement = $pool->borrow()); $pool->release($replacement); } @@ -383,7 +457,7 @@ public function testForeignIdleAndAlreadyDiscardedConnectionsAreRejected(): void $this->assertStringContainsString('does not manage', $exception->getMessage()); } - $idle = $pool->get(); + $idle = $pool->borrow(); $pool->release($idle); try { @@ -393,7 +467,7 @@ public function testForeignIdleAndAlreadyDiscardedConnectionsAreRejected(): void $this->assertStringContainsString('not checked out', $exception->getMessage()); } - $discarded = $pool->get(); + $discarded = $pool->borrow(); $pool->discard($discarded); try { @@ -409,14 +483,14 @@ public function testDuplicateFactoryConnectionIsRejected(): void $connection = new PoolConnectionStub; $pool = $this->createPool( ['max_connections' => 2], - static fn (): ConnectionInterface => $connection + static fn (): Connection => $connection ); - $pool->get(); + $pool->borrow(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('already manages'); - $pool->get(); + $pool->borrow(); } public function testYieldingFactoriesNeverExceedCapacity(): void @@ -425,7 +499,7 @@ public function testYieldingFactoriesNeverExceedCapacity(): void $maximumFactoriesRunning = 0; $pool = $this->createPool( ['max_connections' => 2, 'wait_timeout' => 0.5], - function () use (&$factoriesRunning, &$maximumFactoriesRunning): ConnectionInterface { + function () use (&$factoriesRunning, &$maximumFactoriesRunning): Connection { ++$factoriesRunning; $maximumFactoriesRunning = max($maximumFactoriesRunning, $factoriesRunning); usleep(5_000); @@ -436,7 +510,7 @@ function () use (&$factoriesRunning, &$maximumFactoriesRunning): ConnectionInter ); $results = parallel(array_fill(0, 8, function () use ($pool): bool { - $connection = $pool->get(); + $connection = $pool->borrow(); usleep(2_000); $pool->release($connection); @@ -445,7 +519,7 @@ function () use (&$factoriesRunning, &$maximumFactoriesRunning): ConnectionInter $this->assertSame(array_fill(0, 8, true), $results); $this->assertSame(2, $maximumFactoriesRunning); - $this->assertSame(2, $pool->getCurrentConnections()); + $this->assertSame(2, $pool->getManagedCount()); } public function testCreationFailureWakesAnotherBorrower(): void @@ -453,7 +527,7 @@ public function testCreationFailureWakesAnotherBorrower(): void $factoryCalls = 0; $pool = $this->createPool( ['max_connections' => 1, 'wait_timeout' => 0.2], - function () use (&$factoryCalls): ConnectionInterface { + function () use (&$factoryCalls): Connection { ++$factoryCalls; if ($factoryCalls === 1) { @@ -468,7 +542,7 @@ function () use (&$factoryCalls): ConnectionInterface { $results = parallel([ function () use ($pool): string { try { - $pool->get(); + $pool->borrow(); } catch (RuntimeException $exception) { return $exception->getMessage(); } @@ -476,7 +550,7 @@ function () use ($pool): string { return 'unexpected'; }, function () use ($pool): string { - $connection = $pool->get(); + $connection = $pool->borrow(); $pool->release($connection); return 'borrowed'; @@ -489,14 +563,14 @@ function () use ($pool): string { public function testExhaustedPoolTimesOut(): void { $pool = $this->createPool(['max_connections' => 1, 'wait_timeout' => 0.001]); - $pool->get(); + $pool->borrow(); - $this->expectException(RuntimeException::class); + $this->expectException(PoolExhaustedException::class); $this->expectExceptionMessage( 'Connection pool exhausted. Cannot establish new connection before wait_timeout.' ); - $pool->get(); + $pool->borrow(); } #[DataProvider('checkoutCancellationModes')] @@ -505,7 +579,7 @@ public function testCanceledCheckoutDoesNotCreateAPhantomBorrow(bool $throwExcep $pool = $this->createPool(['max_connections' => 1, 'wait_timeout' => 1.0]); $channel = new InspectablePoolChannel(1); $pool->replaceChannel($channel); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $cancellation = null; $unexpectedConnection = null; @@ -515,7 +589,7 @@ public function testCanceledCheckoutDoesNotCreateAPhantomBorrow(bool $throwExcep &$unexpectedConnection, ): void { try { - $unexpectedConnection = $pool->get(); + $unexpectedConnection = $pool->borrow(); } catch (CanceledException $exception) { $cancellation = $exception; } @@ -526,18 +600,18 @@ public function testCanceledCheckoutDoesNotCreateAPhantomBorrow(bool $throwExcep $this->assertInstanceOf(CanceledException::class, $cancellation); if ($throwException) { - $this->assertNotSame('The connection pool wait was canceled.', $cancellation->getMessage()); + $this->assertNotSame('The pool wait was canceled.', $cancellation->getMessage()); } else { - $this->assertSame('The connection pool wait was canceled.', $cancellation->getMessage()); + $this->assertSame('The pool wait was canceled.', $cancellation->getMessage()); } $this->assertNull($unexpectedConnection); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(1, $pool->getBorrowedConnectionsForTest()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getBorrowedCount()); $this->assertSame(0, $channel->getWaitersForTest()); $pool->release($borrowed); - $replacement = $pool->get(); + $replacement = $pool->borrow(); $pool->release($replacement); } @@ -552,13 +626,13 @@ public static function checkoutCancellationModes(): array public function testCheckoutPerformsOneFinalPassAfterADeadlineRelease(): void { $pool = $this->createPool(['max_connections' => 1, 'wait_timeout' => 0.001]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $channel = new DeadlinePoolChannel(function () use ($borrowed, $pool): void { $pool->release($borrowed); }); $pool->replaceChannel($channel); - $this->assertSame($borrowed, $returned = $pool->get()); + $this->assertSame($borrowed, $returned = $pool->borrow()); $this->assertSame(1, $channel->waitCount); $pool->release($returned); @@ -567,13 +641,13 @@ public function testCheckoutPerformsOneFinalPassAfterADeadlineRelease(): void public function testCheckoutPerformsOneFinalPassAfterADeadlineDiscard(): void { $pool = $this->createPool(['max_connections' => 1, 'wait_timeout' => 0.001]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $channel = new DeadlinePoolChannel(function () use ($borrowed, $pool): void { $pool->discard($borrowed); }); $pool->replaceChannel($channel); - $this->assertNotSame($borrowed, $replacement = $pool->get()); + $this->assertNotSame($borrowed, $replacement = $pool->borrow()); $this->assertSame(1, $channel->waitCount); $pool->release($replacement); @@ -586,15 +660,15 @@ public function testUnhealthyIdleConnectionIsDestroyed(): void $connections = [$first, $second]; $pool = $this->createPool( ['max_connections' => 1], - static function () use (&$connections): ConnectionInterface { + static function () use (&$connections): Connection { return array_shift($connections); } ); - $connection = $pool->get(); + $connection = $pool->borrow(); $pool->release($connection); $pool->checkIdleConnection(); - $replacement = $pool->get(); + $replacement = $pool->borrow(); $this->assertSame(1, $first->closeCount); $this->assertSame($second, $replacement); @@ -618,54 +692,143 @@ public function testThrowingIdleCheckIsReportedDestroyedAndFreesCapacity(): void $container, 'test', ['max_connections' => 1], - static function () use (&$connections): ConnectionInterface { + static function () use (&$connections): Connection { return array_shift($connections); } ); - $connection = $pool->get(); + $connection = $pool->borrow(); $pool->release($connection); $pool->checkIdleConnection(); - $replacement = $pool->get(); + $replacement = $pool->borrow(); $this->assertSame(1, $first->closeCount); $this->assertSame($second, $replacement); - $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getManagedCount()); } public function testHealthyIdleCheckRequeuesConnection(): void { $connection = new PoolConnectionStub; - $pool = $this->createPool([], static fn (): ConnectionInterface => $connection); - $pool->release($pool->get()); + $pool = $this->createPool([], static fn (): Connection => $connection); + $pool->release($pool->borrow()); $pool->checkIdleConnection(); $this->assertSame(1, $connection->checkCount); $this->assertSame(0, $connection->closeCount); - $this->assertSame($connection, $pool->get()); + $this->assertSame($connection, $pool->borrow()); + } + + #[DataProvider('idleCheckCleanupFailures')] + public function testCanceledIdleCheckDisposesOnceAndPreservesCancellation(?string $cleanupFailure): void + { + $cancellation = new CanceledException('idle check canceled'); + $secondary = match ($cleanupFailure) { + 'canceled' => new CanceledException('close canceled'), + 'failed' => new RuntimeException('close failed'), + default => null, + }; + $container = $this->createContainer(); + $logger = m::mock(StdoutLoggerInterface::class); + + if ($cleanupFailure === 'failed') { + $logger->shouldReceive('error')->once()->with((string) $secondary); + } else { + $logger->shouldNotReceive('error'); + } + + $container->instance(StdoutLoggerInterface::class, $logger); + $connection = new PoolConnectionStub( + checkCallback: static fn (): bool => throw $cancellation, + closeCallback: static fn (): bool => $secondary === null ? true : throw $secondary, + ); + $pool = new CallbackPool($container, 'test', connectionFactory: static fn () => $connection); + $pool->release($pool->borrow()); + $caught = null; + + try { + $pool->checkIdleConnection(); + } catch (CanceledException $exception) { + $caught = $exception; + } finally { + $pool->close(); + } + + $this->assertSame($cancellation, $caught); + $this->assertSame(1, $connection->closeCount); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + } + + public static function idleCheckCleanupFailures(): array + { + return [[null], ['canceled'], ['failed']]; + } + + public function testIdleDisposalCancellationPropagatesWithoutSecondDestruction(): void + { + $cancellation = new CanceledException('idle disposal canceled'); + $connection = new PoolConnectionStub( + checkCallback: static fn (): bool => false, + closeCallback: static fn (): bool => throw $cancellation, + ); + $pool = $this->createPool(factory: static fn () => $connection); + $pool->release($pool->borrow()); + $caught = null; + + try { + $pool->checkIdleConnection(); + } catch (CanceledException $exception) { + $caught = $exception; + } finally { + $pool->close(); + } + + $this->assertSame($cancellation, $caught); + $this->assertSame(1, $connection->closeCount); + $this->assertSame(0, $pool->getManagedCount()); } - public function testFrequencyFailureIsReportedWithoutLosingBorrow(): void + public function testHealthyIdleConnectionIsDisposedWhenPoolClosesDuringCheck(): void + { + $pool = null; + $connection = new PoolConnectionStub(checkCallback: static function () use (&$pool): bool { + $pool->close(); + + return true; + }); + $pool = $this->createPool(factory: static fn () => $connection); + $pool->release($pool->borrow()); + + $pool->checkIdleConnection(); + + $this->assertTrue($pool->isClosed()); + $this->assertSame(1, $connection->closeCount); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + } + + public function testUsageTrackerFailureIsReportedWithoutLosingBorrow(): void { $logger = m::mock(StdoutLoggerInterface::class); $logger->shouldReceive('error') ->once() - ->with(m::on(static fn (string $message): bool => str_contains($message, 'hit failed'))); + ->with(m::on(static fn (string $message): bool => str_contains($message, 'recording failed'))); $container = $this->createContainer(); $container->instance(StdoutLoggerInterface::class, $logger); $pool = new CallbackPool($container, 'test'); - $frequency = m::mock(FrequencyInterface::class); - $frequency->shouldReceive('hit')->once()->andThrow(new RuntimeException('hit failed')); - $pool->useFrequency($frequency); + $tracker = m::mock(UsageTracker::class); + $tracker->shouldReceive('recordBorrow')->once()->andThrow(new RuntimeException('recording failed')); + $pool->useUsageTracker($tracker); - $connection = $pool->get(); + $connection = $pool->borrow(); - $this->assertInstanceOf(ConnectionInterface::class, $connection); + $this->assertInstanceOf(Connection::class, $connection); $pool->release($connection); } - public function testFrequencyMaintenanceCancellationDiscardsTheUnreturnedBorrow(): void + public function testUsageMaintenanceCancellationDiscardsTheUnreturnedBorrow(): void { $cancellation = new CanceledException('maintenance canceled'); $connections = [ @@ -674,48 +837,183 @@ public function testFrequencyMaintenanceCancellationDiscardsTheUnreturnedBorrow( new PoolConnectionStub, ]; $pool = $this->createPool( - ['min_connections' => 0, 'max_connections' => 2], - static function () use (&$connections): ConnectionInterface { + ['min_retained_connections' => 0, 'max_connections' => 2], + static function () use (&$connections): Connection { return array_shift($connections); }, ); - $first = $pool->get(); - $second = $pool->get(); + $first = $pool->borrow(); + $second = $pool->borrow(); $pool->release($first); $pool->release($second); - $pool->useFrequency(new class implements FrequencyInterface, LowFrequencyInterface { - public function hit(int $number = 1): bool - { - return true; - } - - public function frequency(): float + $pool->useUsageTracker(new class implements UsageTracker { + public function recordBorrow(): void { - return 0.0; } - public function isLowFrequency(): bool + public function shouldTrimExcessIdle(): bool { return true; } }); try { - $pool->get(); - $this->fail('Frequency maintenance was expected to be canceled.'); + $pool->borrow(); + $this->fail('Usage maintenance was expected to be canceled.'); } catch (CanceledException $exception) { $this->assertSame($cancellation, $exception); } $this->assertSame(1, $first->closeCount); $this->assertSame(1, $second->closeCount); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getBorrowedConnectionsForTest()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); - $replacement = $pool->get(); + $replacement = $pool->borrow(); $pool->release($replacement); } + public function testUsageTrackerFactoryRunsOnceAfterSubclassConstruction(): void + { + $tracker = m::mock(UsageTracker::class); + $tracker->expects('recordBorrow')->twice(); + $tracker->expects('shouldTrimExcessIdle')->twice()->andReturnFalse(); + $pool = new UsageTrackerPool($this->createContainer(), static fn () => $tracker); + + $this->assertSame(0, $pool->factoryCalls); + + try { + $pool->release($pool->borrow()); + $pool->release($pool->borrow()); + + $this->assertSame(1, $pool->factoryCalls); + } finally { + $pool->close(); + } + } + + public function testNullUsagePolicyIsInitializedOnlyOnce(): void + { + $pool = new UsageTrackerPool($this->createContainer(), static fn () => null); + + try { + $pool->release($pool->borrow()); + $pool->release($pool->borrow()); + + $this->assertSame(1, $pool->factoryCalls); + $this->assertSame(0, Timer::stats()['num']); + } finally { + $pool->close(); + } + } + + #[TestWith([false])] + #[TestWith([true])] + public function testUsageFactoryFailureRetainsOwnershipAndAllowsRetry(bool $cancel): void + { + $failure = $cancel + ? new CanceledException('factory canceled') + : new RuntimeException('factory failed'); + $attempts = 0; + $pool = new UsageTrackerPool( + $this->createContainer(), + static function () use (&$attempts, $failure): ?UsageTracker { + if (++$attempts === 1) { + throw $failure; + } + + return null; + }, + ); + $caught = null; + + try { + try { + $connection = $pool->borrow(); + $this->assertSame(1, $pool->getBorrowedCount()); + $pool->release($connection); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cancel ? $failure : null, $caught); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame($cancel ? 0 : 1, $pool->getManagedCount()); + + $pool->release($pool->borrow()); + $pool->release($pool->borrow()); + $this->assertSame(2, $pool->factoryCalls); + } finally { + $pool->close(); + } + } + + public function testFailedAcquisitionDoesNotInitializeMaintenance(): void + { + $failure = new RuntimeException('connection creation failed'); + $pool = new UsageTrackerPool( + $this->createContainer(), + static fn () => null, + ['idle_check_interval' => 1.0], + static fn () => throw $failure, + ); + $caught = null; + $pool->start(); + + try { + $pool->borrow(); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + $pool->close(); + } + + $this->assertSame($failure, $caught); + $this->assertSame(0, $pool->factoryCalls); + $this->assertSame(0, Timer::stats()['num']); + } + + public function testCloseDuringMonitorStartupDoesNotPublishATimer(): void + { + $pool = $this->createPool(['idle_check_interval' => 60.0]); + $pool->start(); + Coroutine::afterCreated(static fn () => $pool->close()); + + $connection = $pool->borrow(); + + try { + $this->assertTrue($pool->isClosed()); + $this->assertSame(0, Timer::stats()['num']); + } finally { + $pool->release($connection); + } + + $this->assertSame(1, $connection->closeCount); + $this->assertSame(0, $pool->getManagedCount()); + } + + public function testCloseDuringUsageTrimmingDoesNotStartTheMonitor(): void + { + $pool = new class($this->createContainer(), 'test', ['idle_check_interval' => 60.0]) extends CallbackPool { + public function trimExcessIdle(): void + { + $this->close(); + } + }; + $tracker = m::mock(UsageTracker::class); + $tracker->expects('recordBorrow'); + $tracker->expects('shouldTrimExcessIdle')->andReturnTrue(); + $pool->useUsageTracker($tracker); + $pool->start(); + + $connection = $pool->borrow(); + $pool->release($connection); + + $this->assertTrue($pool->isClosed()); + $this->assertSame(0, Timer::stats()['num']); + $this->assertSame(1, $connection->closeCount); + } + protected function createPool(array $config = [], ?Closure $factory = null): CallbackPool { return new CallbackPool($this->createContainer(), 'test', $config, $factory); @@ -730,7 +1028,7 @@ protected function createContainer(): Container } } -class CallbackPool extends Pool +class CallbackPool extends ConnectionPool { protected Closure $connectionFactory; @@ -741,14 +1039,15 @@ public function __construct( ?Closure $connectionFactory = null, ) { $this->connectionFactory = $connectionFactory - ?? static fn (): ConnectionInterface => new PoolConnectionStub; + ?? static fn (): Connection => new PoolConnectionStub; parent::__construct($container, $name, $config); } - public function useFrequency(FrequencyInterface|LowFrequencyInterface|null $frequency): void + public function useUsageTracker(?UsageTracker $tracker): void { - $this->frequency = $frequency; + $this->usageTracker = $tracker; + $this->usageTrackerInitialized = true; } public function nanosecondsForTest(float $seconds): int @@ -766,18 +1065,13 @@ public function replaceChannel(PoolChannel $channel): void $this->channel = $channel; } - public function getBorrowedConnectionsForTest(): int - { - return count($this->borrowedConnections); - } - - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { return ($this->connectionFactory)(); } } -class ConstantFrequencyPool extends CallbackPool +class MonitoredPool extends CallbackPool { public int $idleConnectionChecks = 0; @@ -800,16 +1094,27 @@ public function getWaitersForTest(): int } } -class ThrowingClearableFrequency implements ClearableFrequencyInterface, LowFrequencyInterface +class UsageTrackerPool extends CallbackPool { - public function clear(): void - { - throw new RuntimeException('frequency cleanup failed'); + public int $factoryCalls = 0; + + protected Closure $trackerFactory; + + public function __construct( + ContainerContract $container, + Closure $trackerFactory, + array $config = [], + ?Closure $connectionFactory = null, + ) { + parent::__construct($container, 'test', $config, $connectionFactory); + $this->trackerFactory = $trackerFactory; } - public function isLowFrequency(): bool + protected function createUsageTracker(): ?UsageTracker { - return false; + ++$this->factoryCalls; + + return ($this->trackerFactory)(); } } @@ -831,7 +1136,7 @@ public function wait(float $timeout): bool } } -class PoolConnectionStub implements ConnectionInterface +class PoolConnectionStub implements Connection { public int $checkCount = 0; @@ -882,7 +1187,7 @@ class ReleasingPoolConnectionStub extends PoolConnection public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPoolContract $pool, protected Closure $closeCallback, ) { parent::__construct($container, $pool); diff --git a/tests/Pool/ConnectionTest.php b/tests/ConnectionPool/ConnectionTest.php similarity index 55% rename from tests/Pool/ConnectionTest.php rename to tests/ConnectionPool/ConnectionTest.php index 1e4f73cd0a..e6d72a8b4e 100644 --- a/tests/Pool/ConnectionTest.php +++ b/tests/ConnectionPool/ConnectionTest.php @@ -2,38 +2,76 @@ declare(strict_types=1); -namespace Hypervel\Tests\Pool; +namespace Hypervel\Tests\ConnectionPool; use Closure; +use Hypervel\ConnectionPool\Connection; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\ConnectionPool\Events\ConnectionReleasing; +use Hypervel\ConnectionPool\PoolOptions; +use Hypervel\Contracts\ConnectionPool\ConnectionPool as ConnectionPoolContract; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\PoolInterface; -use Hypervel\Pool\Connection; -use Hypervel\Pool\Events\ReleaseConnection; -use Hypervel\Pool\Pool; -use Hypervel\Pool\PoolOption; -use Hypervel\Tests\Pool\Fixtures\ActiveConnectionStub; +use Hypervel\Tests\ConnectionPool\Fixtures\ActiveConnectionStub; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; +use TypeError; class ConnectionTest extends TestCase { - public function testGetActiveConnectionAgain(): void + public function testGetConnectionReturnsTheActiveConnection(): void { $container = m::mock(ContainerContract::class); $logger = m::mock(StdoutLoggerInterface::class); - $logger->shouldReceive('warning')->withAnyArgs()->once()->andReturnTrue(); + $logger->shouldNotReceive('warning'); $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnTrue(); $container->shouldReceive('make')->with(StdoutLoggerInterface::class)->once()->andReturn($logger); $container->shouldReceive('bound')->with('events')->andReturnFalse(); - $connection = new ActiveConnectionStub($container, m::mock(Pool::class)); + $connection = new ActiveConnectionStub($container, m::mock(ConnectionPool::class)); $this->assertSame($connection, $connection->getConnection()); } + #[DataProvider('acquisitionFailures')] + public function testGetConnectionDoesNotRetryFailure(string $exceptionClass): void + { + $failure = new $exceptionClass('acquisition failed'); + $container = m::mock(ContainerContract::class); + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldNotReceive('warning'); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnTrue(); + $container->shouldReceive('make')->with(StdoutLoggerInterface::class)->once()->andReturn($logger); + $container->shouldReceive('bound')->with('events')->andReturnFalse(); + $connection = new ConnectionCallbackStub( + $container, + m::mock(ConnectionPool::class), + static fn (): mixed => throw $failure, + ); + $caught = null; + + try { + $connection->getConnection(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertSame(1, $connection->getActiveConnectionCalls); + } + + public static function acquisitionFailures(): array + { + return [ + 'ordinary failure' => [RuntimeException::class], + 'programming error' => [TypeError::class], + ]; + } + public function testGetConnectionDoesNotRetryCancellation(): void { $cancellation = new CanceledException('connection canceled'); @@ -45,7 +83,7 @@ public function testGetConnectionDoesNotRetryCancellation(): void $container->shouldReceive('bound')->with('events')->andReturnFalse(); $connection = new ConnectionCallbackStub( $container, - m::mock(Pool::class), + m::mock(ConnectionPool::class), static fn (): mixed => throw $cancellation, ); @@ -59,21 +97,21 @@ public function testGetConnectionDoesNotRetryCancellation(): void $this->assertSame(1, $connection->getActiveConnectionCalls); } - public function testReleaseConnectionEvent(): void + public function testConnectionReleasingEvent(): void { $assert = 0; $container = m::mock(ContainerContract::class); $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); - $dispatcher->shouldReceive('hasListeners')->once()->with(ReleaseConnection::class)->andReturnTrue(); - $dispatcher->shouldReceive('dispatch')->once()->with(ReleaseConnection::class)->andReturnUsing(function (ReleaseConnection $event) use (&$assert) { + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch')->once()->with(ConnectionReleasing::class)->andReturnUsing(function (ConnectionReleasing $event) use (&$assert) { $assert = $event->connection->getLastReleaseTime(); }); - $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); $pool->shouldReceive('release')->withAnyArgs()->andReturnNull(); - $pool->shouldReceive('getOption')->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $before = hrtime(true) / 1e9; $connection->release(); @@ -90,10 +128,10 @@ public function testReleaseListenerCancellationStillReturnsTheConnectionOnce(): $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); - $dispatcher->shouldReceive('hasListeners')->once()->with(ReleaseConnection::class)->andReturnTrue(); + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnTrue(); $dispatcher->shouldReceive('dispatch')->once()->andThrow($cancellation); - $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); - $pool->shouldReceive('getOption')->once()->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $pool->shouldReceive('release')->once()->with($connection); try { @@ -112,10 +150,10 @@ public function testReleaseListenerCancellationRemainsPrimaryOverCleanupCancella $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); - $dispatcher->shouldReceive('hasListeners')->once()->with(ReleaseConnection::class)->andReturnTrue(); + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnTrue(); $dispatcher->shouldReceive('dispatch')->once()->andThrow($cancellation); - $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); - $pool->shouldReceive('getOption')->once()->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $pool->shouldReceive('release')->once()->with($connection)->andThrow($cleanupCancellation); try { @@ -136,10 +174,10 @@ public function testCancellationWhileLoggingAListenerFailureStillReturnsTheConne $container->shouldReceive('make')->with(StdoutLoggerInterface::class)->once()->andReturn($logger); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); - $dispatcher->shouldReceive('hasListeners')->once()->with(ReleaseConnection::class)->andReturnTrue(); + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnTrue(); $dispatcher->shouldReceive('dispatch')->once()->andThrow(new RuntimeException('listener failed')); - $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); - $pool->shouldReceive('getOption')->once()->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $pool->shouldReceive('release')->once()->with($connection); try { @@ -161,10 +199,10 @@ public function testOrdinaryReleaseListenerFailureIsLoggedAndTheConnectionIsRetu $container->shouldReceive('make')->with(StdoutLoggerInterface::class)->once()->andReturn($logger); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); - $dispatcher->shouldReceive('hasListeners')->once()->with(ReleaseConnection::class)->andReturnTrue(); + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnTrue(); $dispatcher->shouldReceive('dispatch')->once()->andThrow(new RuntimeException('listener failed')); - $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); - $pool->shouldReceive('getOption')->once()->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $pool->shouldReceive('release')->once()->with($connection); $connection->release(); @@ -172,16 +210,92 @@ public function testOrdinaryReleaseListenerFailureIsLoggedAndTheConnectionIsRetu $this->addToAssertionCount(1); } + #[DataProvider('cleanupFailures')] + public function testLoggerFailureStillReleasesTheConnectionAndPreservesFailurePrecedence(?string $cleanupExceptionClass): void + { + $loggingFailure = new RuntimeException('logger failed'); + $cleanupFailure = $cleanupExceptionClass === null ? null : new $cleanupExceptionClass('cleanup failed'); + $container = m::mock(ContainerContract::class); + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('error')->once()->andThrow($loggingFailure); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnTrue(); + $container->shouldReceive('make')->with(StdoutLoggerInterface::class)->once()->andReturn($logger); + $container->shouldReceive('bound')->with('events')->andReturnTrue(); + $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch')->once()->andThrow(new RuntimeException('listener failed')); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); + $pool->shouldReceive('release')->once()->with($connection)->andReturnUsing(static function () use ($cleanupFailure): void { + if ($cleanupFailure !== null) { + throw $cleanupFailure; + } + }); + $caught = null; + + try { + $connection->release(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cleanupFailure ?? $loggingFailure, $caught); + } + + public static function cleanupFailures(): array + { + return [ + 'successful cleanup' => [null], + 'ordinary cleanup failure' => [RuntimeException::class], + 'canceled cleanup' => [CanceledException::class], + ]; + } + + #[DataProvider('secondaryReportingFailures')] + public function testReleaseCancellationSurvivesCleanupAndReportingFailures(string $reportingExceptionClass): void + { + $cancellation = new CanceledException('listener canceled'); + $container = m::mock(ContainerContract::class); + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('error')->once()->andThrow(new $reportingExceptionClass('secondary reporting failed')); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnTrue(); + $container->shouldReceive('make')->with(StdoutLoggerInterface::class)->once()->andReturn($logger); + $container->shouldReceive('bound')->with('events')->andReturnTrue(); + $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch')->once()->andThrow($cancellation); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); + $pool->shouldReceive('release')->once()->with($connection)->andThrow(new RuntimeException('cleanup failed')); + $caught = null; + + try { + $connection->release(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cancellation, $caught); + } + + public static function secondaryReportingFailures(): array + { + return [ + 'ordinary reporting failure' => [RuntimeException::class], + 'canceled reporting' => [CanceledException::class], + ]; + } + public function testConfiguredReleaseEventIsNotDispatchedWithoutListeners(): void { $container = m::mock(ContainerContract::class); $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); - $dispatcher->shouldReceive('hasListeners')->once()->with(ReleaseConnection::class)->andReturnFalse(); + $dispatcher->shouldReceive('hasListeners')->once()->with(ConnectionReleasing::class)->andReturnFalse(); $dispatcher->shouldReceive('dispatch')->never(); - $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); - $pool->shouldReceive('getOption')->once()->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $pool->shouldReceive('release')->once()->with($connection); $connection->release(); @@ -195,11 +309,11 @@ public function testDontHaveEvents(): void $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher = m::mock(Dispatcher::class)); - $dispatcher->shouldReceive('dispatch')->never()->with(ReleaseConnection::class)->andReturnNull(); + $dispatcher->shouldReceive('dispatch')->never()->with(ConnectionReleasing::class)->andReturnNull(); - $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); + $connection = new ActiveConnectionStub($container, $pool = m::mock(ConnectionPool::class)); $pool->shouldReceive('release')->withAnyArgs()->andReturnNull(); - $pool->shouldReceive('getOption')->andReturn(new PoolOption(events: [])); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); $connection->release(); @@ -211,7 +325,7 @@ public function testDiscardDelegatesToOwningPool(): void $container = m::mock(ContainerContract::class); $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); $container->shouldReceive('bound')->with('events')->andReturnFalse(); - $pool = m::mock(Pool::class); + $pool = m::mock(ConnectionPool::class); $connection = new ActiveConnectionStub($container, $pool); $pool->shouldReceive('discard')->once()->with($connection); @@ -225,9 +339,9 @@ public function testCheckDoesNotResetActivityTimestamp(): void $container = m::mock(ContainerContract::class); $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); $container->shouldReceive('bound')->with('events')->andReturnFalse(); - $pool = m::mock(Pool::class); + $pool = m::mock(ConnectionPool::class); $pool->shouldReceive('release')->once(); - $pool->shouldReceive('getOption')->twice()->andReturn(new PoolOption(maxIdleTime: 60.0)); + $pool->shouldReceive('getOptions')->twice()->andReturn(PoolOptions::fromArray(['max_idle_time' => 60.0])); $connection = new ActiveConnectionStub($container, $pool); $connection->release(); @@ -236,6 +350,20 @@ public function testCheckDoesNotResetActivityTimestamp(): void $this->assertTrue($connection->check()); $this->assertSame($lastUseTime, $connection->getLastUseTime()); } + + public function testNullIdleTimeoutKeepsAnUnusedConnectionValid(): void + { + $container = m::mock(ContainerContract::class); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); + $container->shouldReceive('bound')->with('events')->andReturnFalse(); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['max_idle_time' => null])); + $connection = new ActiveConnectionStub($container, $pool); + + $this->assertTrue($connection->check()); + $this->assertSame(0.0, $connection->getLastUseTime()); + $this->assertSame(0.0, $connection->getLastReleaseTime()); + } } class ConnectionCallbackStub extends Connection @@ -244,7 +372,7 @@ class ConnectionCallbackStub extends Connection public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPoolContract $pool, protected Closure $getActiveConnectionCallback, ) { parent::__construct($container, $pool); diff --git a/tests/Pool/Fixtures/ActiveConnectionStub.php b/tests/ConnectionPool/Fixtures/ActiveConnectionStub.php similarity index 58% rename from tests/Pool/Fixtures/ActiveConnectionStub.php rename to tests/ConnectionPool/Fixtures/ActiveConnectionStub.php index 3485432d91..864f58fd8c 100644 --- a/tests/Pool/Fixtures/ActiveConnectionStub.php +++ b/tests/ConnectionPool/Fixtures/ActiveConnectionStub.php @@ -2,22 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Tests\Pool\Fixtures; +namespace Hypervel\Tests\ConnectionPool\Fixtures; -use Exception; -use Hypervel\Pool\Connection; +use Hypervel\ConnectionPool\Connection; class ActiveConnectionStub extends Connection { - public int $count = 0; - public function getActiveConnection(): mixed { - if ($this->count === 0) { - ++$this->count; - throw new Exception; - } - return $this; } diff --git a/tests/ConnectionPool/Fixtures/BorrowRateTrackerStub.php b/tests/ConnectionPool/Fixtures/BorrowRateTrackerStub.php new file mode 100644 index 0000000000..7e4869ae8d --- /dev/null +++ b/tests/ConnectionPool/Fixtures/BorrowRateTrackerStub.php @@ -0,0 +1,43 @@ +window = $window; + $this->threshold = $threshold; + $this->cooldown = $cooldown; + } + + public function seed(int $startedAt, array $borrows): void + { + $this->startedAt = $startedAt; + $this->lastTrimAt = $startedAt; + $this->borrows = $borrows; + $this->borrowCount = array_sum($borrows); + $this->lastPrunedAt = null; + } + + public function getSamples(): array + { + return $this->borrows; + } + + public function getBorrowCount(): int + { + return $this->borrowCount; + } + + protected function currentTime(): int + { + return $this->now; + } +} diff --git a/tests/ConnectionPool/Fixtures/FooPool.php b/tests/ConnectionPool/Fixtures/FooPool.php new file mode 100644 index 0000000000..8146224579 --- /dev/null +++ b/tests/ConnectionPool/Fixtures/FooPool.php @@ -0,0 +1,17 @@ +container, $this); + } +} diff --git a/tests/Pool/Fixtures/KeepaliveConnectionStub.php b/tests/ConnectionPool/Fixtures/KeepaliveConnectionStub.php similarity index 69% rename from tests/Pool/Fixtures/KeepaliveConnectionStub.php rename to tests/ConnectionPool/Fixtures/KeepaliveConnectionStub.php index acd5626d2d..3c5438713b 100644 --- a/tests/Pool/Fixtures/KeepaliveConnectionStub.php +++ b/tests/ConnectionPool/Fixtures/KeepaliveConnectionStub.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Tests\Pool\Fixtures; +namespace Hypervel\Tests\ConnectionPool\Fixtures; +use Closure; +use Hypervel\ConnectionPool\KeepaliveConnection; use Hypervel\Context\CoroutineContext; use Hypervel\Coordinator\Timer; -use Hypervel\Pool\KeepaliveConnection; -use RuntimeException; +use Throwable; class KeepaliveConnectionStub extends KeepaliveConnection { @@ -15,7 +16,13 @@ class KeepaliveConnectionStub extends KeepaliveConnection public int $closeCount = 0; - public ?RuntimeException $heartbeatFailure = null; + public ?Throwable $heartbeatFailure = null; + + public ?Closure $createCallback = null; + + public ?Closure $heartbeatCallback = null; + + public ?Closure $closeCallback = null; protected mixed $activeConnection = null; @@ -26,12 +33,17 @@ public function setActiveConnection(mixed $connection): void protected function getActiveConnection(): mixed { + if ($this->createCallback !== null) { + return ($this->createCallback)(); + } + return $this->activeConnection; } protected function sendClose(mixed $connection): void { ++$this->closeCount; + $this->closeCallback?->__invoke($connection); $data = CoroutineContext::get('test.pool.heartbeat_connection', []); $data['close'] = 'close protocol'; @@ -40,6 +52,8 @@ protected function sendClose(mixed $connection): void protected function heartbeat(): void { + $this->heartbeatCallback?->__invoke(); + if ($this->heartbeatFailure !== null) { throw $this->heartbeatFailure; } diff --git a/tests/ConnectionPool/IdleConnectionMonitorTest.php b/tests/ConnectionPool/IdleConnectionMonitorTest.php new file mode 100644 index 0000000000..91e553466f --- /dev/null +++ b/tests/ConnectionPool/IdleConnectionMonitorTest.php @@ -0,0 +1,227 @@ +allows('isClosed')->andReturnFalse(); + $pool->shouldReceive('checkIdleConnection')->atLeast()->once(); + $timerCount = Timer::stats()['num']; + $monitor = new IdleConnectionMonitor($pool, 0.001); + + $this->assertSame($timerCount, Timer::stats()['num']); + + try { + $monitor->start(); + Coroutine::sleep(0.005); + } finally { + $monitor->stop(); + } + + $this->assertSame($timerCount, Timer::stats()['num']); + } + + public function testStartAndStopAreIdempotentAndPermitRestart(): void + { + $pool = m::mock(ConnectionPool::class); + $pool->allows('isClosed')->andReturnFalse(); + $timer = m::mock(Timer::class); + $timer->expects('tick')->with(5.0, m::type('callable'))->twice()->andReturn(1, 2); + $timer->expects('clear')->with(1); + $timer->expects('clear')->with(2); + $monitor = new IdleConnectionMonitor($pool, 5.0, $timer); + + $monitor->start(); + $monitor->start(); + $monitor->stop(); + $monitor->stop(); + $monitor->start(); + $monitor->stop(); + } + + public function testClosedPoolCannotStartAMonitor(): void + { + $pool = m::mock(ConnectionPool::class); + $pool->allows('isClosed')->andReturnTrue(); + $timer = m::mock(Timer::class); + $timer->shouldNotReceive('tick'); + + (new IdleConnectionMonitor($pool, 1.0, $timer))->start(); + } + + public function testSynchronousStartupReentryDoesNotCreateAnotherTimer(): void + { + $pool = m::mock(ConnectionPool::class); + $pool->allows('isClosed')->andReturnFalse(); + $monitor = new IdleConnectionMonitor($pool, 60.0); + $timerCount = Timer::stats()['num']; + $startupCalls = 0; + Coroutine::afterCreated(static function () use ($monitor, &$startupCalls): void { + ++$startupCalls; + $monitor->start(); + }); + + try { + $monitor->start(); + + $this->assertSame(1, $startupCalls); + $this->assertSame($timerCount + 1, Timer::stats()['num']); + } finally { + $monitor->stop(); + } + + $this->assertSame($timerCount, Timer::stats()['num']); + } + + public function testCloseDuringStartupClearsTheUnpublishedTimer(): void + { + $pool = new FooPool(new Container, 'test'); + $monitor = new IdleConnectionMonitor($pool, 60.0); + $timerCount = Timer::stats()['num']; + Coroutine::afterCreated(static fn () => $pool->close()); + + try { + $monitor->start(); + $monitor->start(); + + $this->assertTrue($pool->isClosed()); + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + $monitor->stop(); + $pool->close(); + } + } + + public function testStartupFailureAllowsTheNextStart(): void + { + $pool = m::mock(ConnectionPool::class); + $pool->allows('isClosed')->andReturnFalse(); + $failure = new RuntimeException('timer creation failed'); + $timer = m::mock(Timer::class); + $timer->expects('tick')->with(1.0, m::type('callable'))->ordered()->andThrow($failure); + $timer->expects('tick')->with(1.0, m::type('callable'))->ordered()->andReturn(1); + $timer->expects('clear')->with(1); + $monitor = new IdleConnectionMonitor($pool, 1.0, $timer); + $caught = null; + + try { + $monitor->start(); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $monitor->start(); + $monitor->stop(); + } + + public function testWorkerExitStopsWithoutCheckingIdleConnections(): void + { + $pool = m::mock(ConnectionPool::class); + $pool->allows('isClosed')->andReturnFalse(); + $pool->shouldNotReceive('checkIdleConnection'); + $monitor = new IdleConnectionMonitor($pool, 60.0); + $timerCount = Timer::stats()['num']; + + try { + $monitor->start(); + CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + $monitor->stop(); + } + } + + public function testEachMonitorOwnsItsTimer(): void + { + $pool = m::mock(ConnectionPool::class); + $pool->allows('isClosed')->andReturnFalse(); + $first = new IdleConnectionMonitor($pool, 60.0); + $second = new IdleConnectionMonitor($pool, 60.0); + $timerCount = Timer::stats()['num']; + + try { + $first->start(); + $second->start(); + $this->assertSame($timerCount + 2, Timer::stats()['num']); + + $first->stop(); + $this->assertSame($timerCount + 1, Timer::stats()['num']); + } finally { + $first->stop(); + $second->stop(); + } + + $this->assertSame($timerCount, Timer::stats()['num']); + } + + public function testEachTickChecksOneIdleConnectionInFifoOrder(): void + { + $pool = new FooPool(new Container, 'test', ['min_retained_connections' => 2]); + $first = $pool->borrow(); + $second = $pool->borrow(); + $first->expects('check')->once()->globally()->ordered()->andReturnTrue(); + $second->expects('check')->once()->globally()->ordered()->andReturnFalse(); + $first->expects('close'); + $second->expects('close'); + $pool->release($first); + $pool->release($second); + $callback = null; + $timer = m::mock(Timer::class); + $timer->expects('tick')->with(1.0, m::type('callable'))->andReturnUsing( + static function (float $interval, callable $check) use (&$callback): int { + $callback = $check; + + return 1; + }, + ); + $timer->expects('clear')->with(1); + $monitor = new IdleConnectionMonitor($pool, 1.0, $timer); + + try { + $monitor->start(); + $callback(false); + $this->assertSame(2, $pool->getIdleCount()); + + $callback(false); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); + } finally { + $monitor->stop(); + $pool->close(); + } + } + + #[DataProvider('invalidIntervals')] + public function testInvalidIntervalsAreRejected(float $interval): void + { + $this->expectException(InvalidArgumentException::class); + + new IdleConnectionMonitor(m::mock(ConnectionPool::class), $interval); + } + + public static function invalidIntervals(): array + { + return [[0.0], [-1.0], [INF], [NAN]]; + } +} diff --git a/tests/ConnectionPool/KeepaliveConnectionTest.php b/tests/ConnectionPool/KeepaliveConnectionTest.php new file mode 100644 index 0000000000..c154502835 --- /dev/null +++ b/tests/ConnectionPool/KeepaliveConnectionTest.php @@ -0,0 +1,795 @@ +getContainer(); + $pool = $container->make(HeartbeatPoolStub::class); + $connection = $pool->borrow(); + + $this->assertInstanceOf(KeepaliveConnectionStub::class, $connection); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + + $connection = $pool->borrow(); + $this->assertSame(2, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + + $connection->release(); + $this->assertSame(1, $pool->getIdleCount()); + + $connection = $pool->borrow(); + $this->assertSame(0, $pool->getIdleCount()); + $this->assertSame(2, $pool->getManagedCount()); + } + + public function testConnectionAcceptsPoolContract(): void + { + $pool = m::mock(ConnectionPool::class); + $connection = new KeepaliveConnectionStub( + m::mock(ContainerContract::class), + $pool, + ); + + $pool->shouldReceive('release')->once()->with($connection); + + $connection->release(); + } + + public function testConnectionCall(): void + { + $container = $this->getContainer(); + $pool = $container->make(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->borrow(); + $connection->setActiveConnection(new class { + public function send(string $data): string + { + return str_repeat($data, 2); + } + }); + $str = uniqid(); + $result = $connection->call(function ($connection) use ($str) { + return $connection->send($str); + }); + + $this->assertSame($result, str_repeat($str, 2)); + } + + public function testDiscardDelegatesToOwningPool(): void + { + $container = $this->getContainer(); + $pool = $container->make(HeartbeatPoolStub::class); + $connection = $pool->borrow(); + + $connection->discard(); + + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + } + + public function testConnectionHeartbeat(): void + { + $container = $this->getContainer(['heartbeat_interval' => 0.001]); + $pool = $container->make(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->borrow(); + $connection->reconnect(); + $timer = $connection->timer; + $this->assertSame(1, count((new ClassInvoker($timer))->coroutines)); + $this->assertTrue($connection->check()); + $connection->close(); + $this->assertSame(0, count((new ClassInvoker($timer))->coroutines)); + $this->assertFalse($connection->check()); + $this->assertSame('close protocol', CoroutineContext::get('test.pool.heartbeat_connection')['close']); + } + + public function testDisabledHeartbeatDoesNotStartTimer(): void + { + $container = $this->getContainer([ + 'heartbeat_interval' => null, + 'max_idle_time' => 0.001, + ]); + $pool = $container->make(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->borrow(); + $connection->reconnect(); + $timer = $connection->timer; + + $this->assertTrue($connection->check()); + $this->assertSame(0, count((new ClassInvoker($timer))->coroutines)); + + Coroutine::sleep(0.01); + + $this->assertTrue($connection->check()); + $this->assertSame(0, $connection->closeCount); + + $connection->close(); + } + + public function testTimeoutRequiresAConnectedIdleSocket(): void + { + $pool = new HeartbeatPoolStub(new Container, 'test'); + $connection = $pool->borrow(); + $connection->setActiveConnection(new stdClass); + + try { + $this->assertFalse($connection->isTimeout()); + + $connection->reconnect(); + $this->assertFalse($connection->isTimeout()); + + (new ReflectionProperty($connection, 'lastUseTime'))->setValue( + $connection, + hrtime(true) / 1e9 - $pool->getOptions()->maxIdleTime - 1.0, + ); + $this->assertTrue($connection->isTimeout()); + + $connection->call(function () use ($connection): void { + $this->assertFalse($connection->isTimeout()); + }, false); + $this->assertTrue($connection->isTimeout()); + + $connection->close(); + $this->assertFalse($connection->isTimeout()); + } finally { + $pool->discard($connection); + $pool->close(); + } + } + + public function testNullIdleTimeoutKeepsAnOldSocketOpen(): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', ['max_idle_time' => null]); + $connection = $pool->borrow(); + $connection->reconnect(); + + try { + (new ReflectionProperty($connection, 'lastUseTime'))->setValue($connection, 0.0); + + $this->assertFalse($connection->isTimeout()); + $this->assertTrue($connection->isConnected()); + } finally { + $connection->close(); + $pool->release($connection); + $pool->close(); + } + } + + public function testEnabledHeartbeatClosesIdleConnection(): void + { + $container = $this->getContainer([ + 'heartbeat_interval' => 0.001, + 'max_idle_time' => 0.001, + ]); + $pool = $container->make(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->borrow(); + $connection->reconnect(); + + Coroutine::sleep(0.01); + + $this->assertFalse($connection->check()); + $this->assertSame(1, $connection->closeCount); + } + + public function testHeartbeatFailureFallsBackToThePhpErrorLogWithoutALogger(): void + { + $directory = ParallelTesting::tempDir('KeepaliveConnectionTest'); + (new Filesystem)->deleteDirectory($directory); + mkdir($directory, 0777, true); + $errorLog = $directory . '/php-error.log'; + $previousErrorLog = ini_set('error_log', $errorLog); + $previousLogErrors = ini_set('log_errors', '1'); + + try { + $container = $this->getContainer(['heartbeat_interval' => 0.001]); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); + $pool = $container->make(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->borrow(); + $connection->heartbeatFailure = new RuntimeException('heartbeat fallback failed'); + $connection->reconnect(); + + Coroutine::sleep(0.01); + + $this->assertFalse($connection->check()); + $this->assertSame(1, $connection->closeCount); + $contents = file_get_contents($errorLog); + $this->assertIsString($contents); + $this->assertStringContainsString('heartbeat fallback failed', $contents); + } finally { + if ($previousErrorLog !== false) { + ini_set('error_log', $previousErrorLog); + } + + if ($previousLogErrors !== false) { + ini_set('log_errors', $previousLogErrors); + } + + (new Filesystem)->deleteDirectory($directory); + } + } + + public function testConnectionCloseProtocolRunsOnPoolFlush(): void + { + $container = $this->getContainer(); + $pool = $container->make(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->borrow(); + $connection->reconnect(); + $connection->release(); + + $connection = $pool->borrow(); + $connection->reconnect(); + $connection->release(); + + $pool->trimExcessIdle(); + + $this->assertSame('close protocol', CoroutineContext::get('test.pool.heartbeat_connection')['close']); + } + + #[DataProvider('heartbeatModes')] + public function testCanceledWaiterPreservesTheActiveSocket(?float $heartbeatInterval): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', ['heartbeat_interval' => $heartbeatInterval]); + $connection = $pool->borrow(); + $socket = new stdClass; + $connection->setActiveConnection($socket); + $release = new Channel(1); + $completed = new Channel(1); + $failure = null; + + Coroutine::create(function () use ($connection, $release, $completed): void { + try { + $connection->call(fn () => $release->pop(1.0)); + } finally { + $completed->push(true); + } + }); + + try { + $waiter = EngineCoroutine::create(function () use ($connection, &$failure): void { + try { + $connection->call(static fn () => null); + } catch (Throwable $exception) { + $failure = $exception; + } + }); + + $this->assertTrue(EngineCoroutine::cancelById($waiter->getId())); + $this->assertInstanceOf(CanceledException::class, $failure); + $this->assertTrue($connection->isConnected()); + } finally { + $release->push(true); + $completed->pop(1.0); + $this->assertSame($socket, $connection->call(static fn ($socket) => $socket)); + $connection->discard(); + } + } + + #[DataProvider('heartbeatModes')] + public function testCloseTimeoutClearsStateAndStopsHeartbeat(?float $heartbeatInterval): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', [ + 'heartbeat_interval' => $heartbeatInterval, + 'wait_timeout' => 0.001, + ]); + $connection = $pool->borrow(); + $connection->setActiveConnection(new stdClass); + $release = new Channel(1); + $completed = new Channel(1); + + Coroutine::create(function () use ($connection, $release, $completed): void { + try { + $connection->call(fn () => $release->pop(1.0)); + } finally { + $completed->push(true); + } + }); + + try { + try { + $connection->close(); + $this->fail('Expected close to time out behind the active holder.'); + } catch (SocketPopException) { + $this->assertFalse($connection->isConnected()); + $this->assertSame([], (new ClassInvoker($connection->timer))->coroutines); + } + } finally { + $release->push(true); + $completed->pop(1.0); + $connection->discard(); + $connection->timer->clearAll(); + } + } + + public function testSupersededHolderCannotRequeueOrRefreshTheReplacement(): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', ['wait_timeout' => 0.001]); + $connection = $pool->borrow(); + $connection->createCallback = static fn () => new stdClass; + $oldRelease = new Channel(1); + $newRelease = new Channel(1); + $oldCompleted = new Channel(1); + $newCompleted = new Channel(1); + $replacementStarted = false; + + Coroutine::create(function () use ($connection, $oldRelease, $oldCompleted): void { + try { + $connection->call(fn () => $oldRelease->pop(1.0)); + } finally { + $oldCompleted->push(true); + } + }); + + try { + try { + $connection->close(); + $this->fail('Expected the held socket to prevent protocol close.'); + } catch (SocketPopException) { + } + + $replacementStarted = true; + Coroutine::create(function () use ($connection, $newRelease, $newCompleted): void { + try { + $connection->call(fn () => $newRelease->pop(1.0)); + } finally { + $newCompleted->push(true); + } + }); + + $state = new ClassInvoker($connection); + $lastUseTime = $state->lastUseTime; + $oldRelease->push(true); + $this->assertTrue($oldCompleted->pop(1.0)); + + $this->assertSame($lastUseTime, $state->lastUseTime); + $this->assertSame(0, $state->channel->getLength()); + } finally { + $oldRelease->close(); + $newRelease->close(); + + if ($replacementStarted) { + $newCompleted->pop(1.0); + } + + $connection->discard(); + } + } + + #[DataProvider('cleanupFailures')] + public function testConcurrentReconnectKeepsOneSocketAndOneTimer(?Throwable $closeFailure): void + { + $container = new Container; + $logger = m::mock(StdoutLoggerInterface::class); + $container->instance(StdoutLoggerInterface::class, $logger); + + if ($closeFailure !== null && ! $closeFailure instanceof CanceledException) { + $logger->shouldReceive('error')->once()->with(m::on( + static fn (string $message): bool => str_contains($message, $closeFailure->getMessage()), + )); + } else { + $logger->shouldNotReceive('error'); + } + + $pool = new HeartbeatPoolStub($container, 'test', ['heartbeat_interval' => 3600.0]); + $connection = $pool->borrow(); + $firstReady = new Channel(1); + $secondReady = new Channel(1); + $completed = new Channel(2); + $created = []; + $closed = []; + $connection->createCallback = function () use (&$created, $firstReady, $secondReady): object { + $socket = new stdClass; + $created[] = $socket; + (count($created) === 1 ? $firstReady : $secondReady)->pop(1.0); + + return $socket; + }; + $connection->closeCallback = function (object $socket) use (&$closed, &$created, $closeFailure): void { + $closed[] = $socket; + + if ($socket === $created[0] && $closeFailure !== null) { + throw $closeFailure; + } + }; + + try { + foreach ([0, 1] as $attempt) { + Coroutine::create(function () use ($connection, $completed): void { + try { + $completed->push($connection->call(static fn ($socket) => $socket)); + } catch (Throwable $exception) { + $completed->push($exception); + } + }); + } + + $secondReady->push(true); + $firstReady->push(true); + + $this->assertSame($created[1], $completed->pop(1.0)); + $this->assertSame( + $closeFailure instanceof CanceledException ? $closeFailure : $created[1], + $completed->pop(1.0), + ); + $this->assertSame([$created[0]], $closed); + $this->assertCount(1, (new ClassInvoker($connection->timer))->coroutines); + } finally { + $firstReady->close(); + $secondReady->close(); + $connection->discard(); + $connection->timer->clearAll(); + } + } + + public static function cleanupFailures(): array + { + return [ + 'successful cleanup' => [null], + 'ordinary cleanup failure' => [new RuntimeException('loser close failed')], + 'canceled cleanup' => [new CanceledException('loser close canceled')], + ]; + } + + #[DataProvider('heartbeatModes')] + public function testCanceledCloseClearsStateAndReconnectWakesOldWaiters(?float $heartbeatInterval): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', [ + 'heartbeat_interval' => $heartbeatInterval, + 'wait_timeout' => 1.0, + ]); + $connection = $pool->borrow(); + $connection->createCallback = static fn () => new stdClass; + $release = new Channel(1); + $completed = new Channel(1); + $closeFailure = null; + $waiterFailure = null; + + Coroutine::create(function () use ($connection, $release, $completed): void { + try { + $connection->call(fn () => $release->pop(1.0)); + } finally { + $completed->push(true); + } + }); + + $oldChannel = (new ClassInvoker($connection))->channel; + + try { + $closer = EngineCoroutine::create(function () use ($connection, &$closeFailure): void { + try { + $connection->close(); + } catch (Throwable $exception) { + $closeFailure = $exception; + } + }); + EngineCoroutine::create(function () use ($connection, &$waiterFailure): void { + try { + $connection->call(static fn () => null); + } catch (Throwable $exception) { + $waiterFailure = $exception; + } + }); + + $this->assertTrue(EngineCoroutine::cancelById($closer->getId())); + $this->assertInstanceOf(CanceledException::class, $closeFailure); + $this->assertFalse($connection->isConnected()); + $this->assertSame([], (new ClassInvoker($connection->timer))->coroutines); + $this->assertNull($waiterFailure); + + $connection->call(static fn () => null); + + $this->assertInstanceOf(SocketPopException::class, $waiterFailure); + $this->assertTrue($oldChannel->isClosing()); + $this->assertFalse($oldChannel->isCanceled()); + $this->assertTrue($connection->isConnected()); + } finally { + $oldChannel->close(); + $release->push(true); + $completed->pop(1.0); + $connection->discard(); + $connection->timer->clearAll(); + } + } + + public static function heartbeatModes(): array + { + return [ + 'disabled' => [null], + 'enabled' => [3600.0], + ]; + } + + public function testLateCloseDoesNotClearAReplacement(): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', [ + 'heartbeat_interval' => 3600.0, + 'wait_timeout' => 0.001, + ]); + $connection = $pool->borrow(); + $connection->createCallback = static fn () => new stdClass; + $original = $connection->call(static fn ($socket) => $socket); + $release = new Channel(1); + $completed = new Channel(1); + $connection->closeCallback = function (object $socket) use ($original, $release): void { + if ($socket === $original) { + $release->pop(1.0); + } + }; + + Coroutine::create(function () use ($connection, $completed): void { + try { + $completed->push($connection->close()); + } catch (Throwable $exception) { + $completed->push($exception); + } + }); + + try { + try { + $connection->close(); + $this->fail('Expected close to time out behind the first close.'); + } catch (SocketPopException) { + } + + $replacement = $connection->call(static fn ($socket) => $socket); + $timerId = (new ClassInvoker($connection))->timerId; + $release->push(true); + + $this->assertTrue($completed->pop(1.0)); + $this->assertTrue($connection->isConnected()); + $this->assertSame($timerId, (new ClassInvoker($connection))->timerId); + $this->assertCount(1, (new ClassInvoker($connection->timer))->coroutines); + $this->assertSame($replacement, $connection->call(static fn ($socket) => $socket)); + } finally { + $release->close(); + $connection->discard(); + $connection->timer->clearAll(); + } + } + + public function testCloseDuringTimerCreationDoesNotRetainTheTimer(): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', ['heartbeat_interval' => 3600.0]); + $connection = $pool->borrow(); + $connection->setActiveConnection(new stdClass); + $closed = false; + Coroutine::afterCreated(function () use ($connection, &$closed): void { + if (! $closed) { + $closed = true; + $connection->close(); + } + }); + + try { + $connection->reconnect(); + + $this->assertTrue($closed); + $this->assertFalse($connection->isConnected()); + $this->assertNull((new ClassInvoker($connection))->timerId); + $this->assertSame([], (new ClassInvoker($connection->timer))->coroutines); + } finally { + $connection->discard(); + $connection->timer->clearAll(); + } + } + + public function testReconnectPublishesIfAnEarlierWinnerHasAlreadyClosed(): void + { + $pool = new HeartbeatPoolStub(new Container, 'test', ['heartbeat_interval' => 3600.0]); + $connection = $pool->borrow(); + $ready = new Channel(1); + $completed = new Channel(1); + $created = []; + $connection->createCallback = function () use (&$created, $ready): object { + $socket = new stdClass; + $created[] = $socket; + + if (count($created) === 1) { + $ready->pop(1.0); + } + + return $socket; + }; + + Coroutine::create(function () use ($connection, $completed): void { + try { + $completed->push($connection->call(static fn ($socket) => $socket)); + } catch (Throwable $exception) { + $completed->push($exception); + } + }); + + try { + $winner = $connection->call(static fn ($socket) => $socket); + $previousChannel = (new ClassInvoker($connection))->channel; + $this->assertSame($created[1], $winner); + $connection->close(); + $ready->push(true); + + $this->assertSame($created[0], $completed->pop(1.0)); + $this->assertTrue($previousChannel->isClosing()); + $this->assertTrue($connection->isConnected()); + $this->assertCount(1, (new ClassInvoker($connection->timer))->coroutines); + } finally { + $ready->close(); + $connection->discard(); + $connection->timer->clearAll(); + } + } + + public function testHeartbeatCancellationRemainsPrimaryWhenProtocolCloseFails(): void + { + $container = new Container; + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldNotReceive('error'); + $container->instance(StdoutLoggerInterface::class, $logger); + $pool = new HeartbeatPoolStub($container, 'test', ['heartbeat_interval' => 3600.0]); + $connection = $pool->borrow(); + $connection->setActiveConnection(new stdClass); + $callback = null; + $timer = m::mock(Timer::class); + $timer->shouldReceive('tick')->once()->andReturnUsing( + function (float $interval, callable $heartbeat) use (&$callback): int { + $callback = $heartbeat; + + return 1; + }, + ); + $timer->shouldReceive('clear')->once()->with(1); + $connection->timer = $timer; + $cancellation = new CanceledException('heartbeat canceled'); + $connection->heartbeatFailure = $cancellation; + $connection->closeCallback = static fn () => throw new RuntimeException('protocol close failed'); + + try { + $connection->reconnect(); + + try { + $callback(); + $this->fail('Expected the heartbeat cancellation.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertFalse($connection->isConnected()); + $this->assertSame(1, $connection->closeCount); + $this->assertNull((new ClassInvoker($connection))->timerId); + } finally { + $connection->discard(); + } + } + + #[DataProvider('heartbeatFailures')] + public function testLateHeartbeatFailureDoesNotCloseTheReplacement(Throwable $failure): void + { + $container = new Container; + $logger = m::mock(StdoutLoggerInterface::class); + $container->instance(StdoutLoggerInterface::class, $logger); + + if ($failure instanceof CanceledException) { + $logger->shouldNotReceive('error'); + } else { + $logger->shouldReceive('error')->once()->with(m::on( + static fn (string $message): bool => str_contains($message, $failure->getMessage()), + )); + } + + $pool = new HeartbeatPoolStub($container, 'test', [ + 'heartbeat_interval' => 3600.0, + 'wait_timeout' => 0.001, + ]); + $connection = $pool->borrow(); + $connection->createCallback = static fn () => new stdClass; + $callbacks = []; + $cleared = []; + $timer = m::mock(Timer::class); + $timer->shouldReceive('tick')->twice()->andReturnUsing( + function (float $interval, callable $heartbeat) use (&$callbacks): int { + $callbacks[] = $heartbeat; + + return count($callbacks); + }, + ); + $timer->shouldReceive('clear')->andReturnUsing(function (int $id) use (&$cleared): void { + $cleared[] = $id; + }); + $connection->timer = $timer; + $release = new Channel(1); + $completed = new Channel(1); + $connection->heartbeatCallback = function () use ($connection, $release, $failure): void { + $connection->call(function () use ($release, $failure): void { + $release->pop(1.0); + + throw $failure; + }, false); + }; + $connection->reconnect(); + + Coroutine::create(function () use (&$callbacks, $completed): void { + try { + $callbacks[0](); + $completed->push(true); + } catch (Throwable $exception) { + $completed->push($exception); + } + }); + + try { + try { + $connection->close(); + $this->fail('Expected protocol work to hold the old socket.'); + } catch (SocketPopException) { + } + + $replacement = $connection->call(static fn ($socket) => $socket); + $release->push(true); + + $this->assertSame($failure instanceof CanceledException ? $failure : true, $completed->pop(1.0)); + $this->assertSame([1], $cleared); + $this->assertTrue($connection->isConnected()); + $this->assertSame(2, (new ClassInvoker($connection))->timerId); + $this->assertSame($replacement, $connection->call(static fn ($socket) => $socket)); + } finally { + $release->close(); + $connection->discard(); + } + } + + public static function heartbeatFailures(): array + { + return [ + 'ordinary failure' => [new RuntimeException('late heartbeat failed')], + 'cancellation' => [new CanceledException('late heartbeat canceled')], + ]; + } + + protected function getContainer(array $poolConfig = []): ContainerContract + { + $container = m::mock(Container::class); + Container::setInstance($container); + + $container->shouldReceive('make')->with(HeartbeatPoolStub::class)->andReturnUsing(function () use ($container, $poolConfig) { + return new HeartbeatPoolStub($container, 'test', $poolConfig); + }); + + return $container; + } +} diff --git a/tests/ConnectionPool/PoolOptionsTest.php b/tests/ConnectionPool/PoolOptionsTest.php new file mode 100644 index 0000000000..82cf40d874 --- /dev/null +++ b/tests/ConnectionPool/PoolOptionsTest.php @@ -0,0 +1,158 @@ +assertSame(1, $options->minRetainedConnections); + $this->assertSame(10, $options->maxConnections); + $this->assertSame(10.0, $options->connectTimeout); + $this->assertSame(3.0, $options->waitTimeout); + $this->assertSame(1.0, $options->heartbeatTimeout); + $this->assertSame(60.0, $options->maxIdleTime); + $this->assertNull($options->heartbeatInterval); + $this->assertNull($options->idleCheckInterval); + $this->assertNull($options->maxLifetime); + $this->assertSame([], $options->events); + } + + public function testMaxLifetimeCanBeConfigured(): void + { + $options = PoolOptions::fromArray(['max_lifetime' => 120.0]); + + $this->assertSame(120.0, $options->maxLifetime); + } + + public function testOptionsCannotBeChangedAfterConstruction(): void + { + $options = PoolOptions::fromArray([]); + + $this->expectException(Error::class); + $options->maxLifetime = 30.0; + } + + public function testJitteredLifetimeDeadlineDefaultsToDisabled(): void + { + $this->assertNull(PoolOptions::fromArray([])->jitteredLifetimeDeadline(100.0)); + } + + public function testJitteredLifetimeDeadlineKeepsConfiguredLifetimeAsUpperBound(): void + { + $createdAt = 100.0; + $maxLifetime = 60.0; + + $deadline = PoolOptions::fromArray(['max_lifetime' => $maxLifetime])->jitteredLifetimeDeadline($createdAt); + + $this->assertGreaterThanOrEqual( + $createdAt + ($maxLifetime * PoolOptions::MIN_LIFETIME_JITTER_BASIS / PoolOptions::LIFETIME_JITTER_SCALE), + $deadline + ); + $this->assertLessThanOrEqual($createdAt + $maxLifetime, $deadline); + } + + public function testConfiguredValuesAreNormalized(): void + { + $events = [ConnectionReleasing::class, CustomPoolEvent::class]; + $options = PoolOptions::fromArray([ + 'min_retained_connections' => 0, + 'max_connections' => 20, + 'connect_timeout' => 2, + 'wait_timeout' => 2.5, + 'heartbeat_interval' => 5, + 'heartbeat_timeout' => 0.5, + 'idle_check_interval' => 3, + 'max_idle_time' => 30, + 'max_lifetime' => 120, + 'events' => $events, + ]); + + $this->assertSame(0, $options->minRetainedConnections); + $this->assertSame(20, $options->maxConnections); + $this->assertSame(2.0, $options->connectTimeout); + $this->assertSame(2.5, $options->waitTimeout); + $this->assertSame(5.0, $options->heartbeatInterval); + $this->assertSame(0.5, $options->heartbeatTimeout); + $this->assertSame(3.0, $options->idleCheckInterval); + $this->assertSame(30.0, $options->maxIdleTime); + $this->assertSame(120.0, $options->maxLifetime); + $this->assertSame($events, $options->events); + } + + public function testNullDisablesOptionalDurations(): void + { + $options = PoolOptions::fromArray([ + 'heartbeat_interval' => null, + 'idle_check_interval' => null, + 'max_idle_time' => null, + 'max_lifetime' => null, + ]); + + $this->assertNull($options->heartbeatInterval); + $this->assertNull($options->idleCheckInterval); + $this->assertNull($options->maxIdleTime); + $this->assertNull($options->maxLifetime); + $this->assertNull($options->jitteredLifetimeDeadline(100.0)); + } + + #[DataProvider('invalidOptions')] + public function testInvalidOptionsAreRejected(array $options, string $field): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains("[{$field}]"); + + PoolOptions::fromArray($options); + } + + public static function invalidOptions(): iterable + { + yield 'negative minimum' => [['min_retained_connections' => -1], 'min_retained_connections']; + yield 'zero maximum' => [['max_connections' => 0], 'max_connections']; + yield 'minimum exceeds maximum' => [ + ['min_retained_connections' => 2, 'max_connections' => 1], 'min_retained_connections', + ]; + + foreach (['min_retained_connections', 'max_connections'] as $field) { + foreach ([null, true, '2', 2.5] as $index => $value) { + yield "{$field} type {$index}" => [[$field => $value], $field]; + } + } + + foreach (['connect_timeout', 'wait_timeout', 'heartbeat_timeout'] as $field) { + yield "{$field} null" => [[$field => null], $field]; + } + + foreach ([ + 'connect_timeout', 'wait_timeout', 'heartbeat_interval', 'heartbeat_timeout', + 'idle_check_interval', 'max_idle_time', 'max_lifetime', + ] as $field) { + foreach ([0, -1, -2.0, NAN, INF, -INF, true, '1', []] as $index => $value) { + yield "{$field} invalid {$index}" => [[$field => $value], $field]; + } + } + + foreach ([null, 'event', ['event' => CustomPoolEvent::class], [''], [1], ['MissingPoolEvent']] as $index => $events) { + yield "events {$index}" => [['events' => $events], 'events']; + } + + foreach (['min_connections', 'heartbeat', 'unknown'] as $field) { + yield "unknown {$field}" => [[$field => 1], $field]; + } + } +} + +class CustomPoolEvent +{ +} diff --git a/tests/ObjectPool/ChannelTest.php b/tests/Coroutine/PoolChannelTest.php similarity index 78% rename from tests/ObjectPool/ChannelTest.php rename to tests/Coroutine/PoolChannelTest.php index 7f43874870..757ed9e8f7 100644 --- a/tests/ObjectPool/ChannelTest.php +++ b/tests/Coroutine/PoolChannelTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Tests\ObjectPool; +namespace Hypervel\Tests\Coroutine; use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\PoolChannel; use Hypervel\Engine\Coroutine as EngineCoroutine; -use Hypervel\ObjectPool\Channel; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use stdClass; @@ -16,13 +16,13 @@ use function Hypervel\Coroutine\run; -class ChannelTest extends TestCase +class PoolChannelTest extends TestCase { protected bool $runTestsInCoroutine = false; public function testObjectsAreVisibleAcrossExecutionModes(): void { - $channel = new Channel(2); + $channel = new PoolChannel(2); $outsideObject = new stdClass; $insideObject = new stdClass; @@ -38,12 +38,12 @@ public function testObjectsAreVisibleAcrossExecutionModes(): void public function testEmptyPopOutsideCoroutineReturnsFalse(): void { - $this->assertFalse((new Channel(1))->pop()); + $this->assertFalse((new PoolChannel(1))->pop()); } public function testCoroutineWaiterIsWokenByPush(): void { - $channel = new Channel(1); + $channel = new PoolChannel(1); $object = new stdClass; run(function () use ($channel, $object): void { @@ -65,16 +65,17 @@ public function testCoroutineWaiterIsWokenByPush(): void public function testWaitTimesOut(): void { - $channel = new Channel(1); + $channel = new PoolChannel(1); run(function () use ($channel): void { $this->assertFalse($channel->wait(0.001)); + $this->assertSame(0, $channel->waiters()); }); } public function testWaitConvertsNonThrowingCancellation(): void { - $channel = new Channel(1); + $channel = new PoolChannel(1); run(function () use ($channel): void { $cancellation = null; @@ -88,13 +89,14 @@ public function testWaitConvertsNonThrowingCancellation(): void $this->assertTrue(EngineCoroutine::cancelById($coroutine->getId())); $this->assertInstanceOf(CanceledException::class, $cancellation); - $this->assertSame('The object pool wait was canceled.', $cancellation->getMessage()); + $this->assertSame('The pool wait was canceled.', $cancellation->getMessage()); + $this->assertSame(0, $channel->waiters()); }); } public function testSignalNeverBlocksWhenWakeIsAlreadyPending(): void { - $channel = new FullSignalObjectPoolChannel; + $channel = new FullSignalPoolChannel; run(function () use ($channel): void { $channel->fillSignal(); @@ -114,7 +116,7 @@ public function testSignalNeverBlocksWhenWakeIsAlreadyPending(): void public function testCloseWakesEveryWaiter(): void { - $channel = new Channel(2); + $channel = new PoolChannel(2); run(function () use ($channel): void { $results = []; @@ -131,12 +133,13 @@ public function testCloseWakesEveryWaiter(): void ksort($results); $this->assertSame([true, true], $results); + $this->assertSame(0, $channel->waiters()); }); } public function testCloseIsIdempotentAndLaterSignalOperationsUseLocalState(): void { - $channel = new Channel(1); + $channel = new PoolChannel(1); $channel->close(); $channel->close(); @@ -147,7 +150,7 @@ public function testCloseIsIdempotentAndLaterSignalOperationsUseLocalState(): vo public function testPushAfterCloseIsRejectedWithoutRetainingTheObject(): void { - $channel = new Channel(1); + $channel = new PoolChannel(1); $channel->close(); @@ -156,11 +159,28 @@ public function testPushAfterCloseIsRejectedWithoutRetainingTheObject(): void $this->assertFalse($channel->pop()); } + public function testCloseRetainsQueuedObjectsForFifoDraining(): void + { + $channel = new PoolChannel(2); + $first = new stdClass; + $second = new stdClass; + + $channel->push($first); + $channel->push($second); + $channel->close(); + + $this->assertSame(2, $channel->length()); + $this->assertSame($first, $channel->pop()); + $this->assertSame($second, $channel->pop()); + $this->assertFalse($channel->pop()); + $this->assertSame(0, $channel->length()); + } + #[RunInSeparateProcess] public function testOutsideCoroutinePushCommitsWhenAWakeCoroutineCannotBeCreated(): void { SwooleCoroutine::set(['max_coroutine' => 1]); - $channel = new Channel(1); + $channel = new PoolChannel(1); $waitResult = null; $object = new stdClass; @@ -178,7 +198,7 @@ public function testOutsideCoroutinePushCommitsWhenAWakeCoroutineCannotBeCreated } } -class FullSignalObjectPoolChannel extends Channel +class FullSignalPoolChannel extends PoolChannel { public function __construct() { diff --git a/tests/Database/ConnectionResolverTest.php b/tests/Database/ConnectionResolverTest.php index ae3464532a..e3897592df 100644 --- a/tests/Database/ConnectionResolverTest.php +++ b/tests/Database/ConnectionResolverTest.php @@ -9,9 +9,9 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolver; -use Hypervel\Database\Pool\DbPool; +use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PooledConnection; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Engine\Coroutine; use Hypervel\Tests\TestCase; use Mockery as m; @@ -127,22 +127,22 @@ public function testNestedOverrideRestoresExactPriorValue(): void public function testNonCoroutineConnectionIsRetainedUntilTerminalRelease(): void { - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $firstWrapper = m::mock(PooledConnection::class); $secondWrapper = m::mock(PooledConnection::class); $firstConnection = m::mock(Connection::class); $secondConnection = m::mock(Connection::class); - $factory->expects('getPool')->twice()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->twice()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->twice()->andReturn($firstWrapper, $secondWrapper); + $pool->expects('borrow')->twice()->andReturn($firstWrapper, $secondWrapper); $firstWrapper->expects('getConnection')->andReturn($firstConnection); $firstWrapper->expects('release'); $secondWrapper->expects('getConnection')->andReturn($secondConnection); $secondWrapper->expects('release'); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); $this->assertSame($firstConnection, $resolver->connection()); $this->assertSame($firstConnection, $resolver->connection()); @@ -156,17 +156,17 @@ public function testNonCoroutineConnectionIsRetainedUntilTerminalRelease(): void public function testTerminalReleaseOwnsEveryRequestedConnectionRole(): void { - $factory = m::mock(PoolFactory::class); - $resolver = $this->makeResolver('mysql', $factory); + $poolManager = m::mock(PoolManager::class); + $resolver = $this->makeResolver('mysql', $poolManager); foreach (['mysql', 'mysql::read', 'mysql::write'] as $name) { - $pool = m::mock(DbPool::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); $connection = m::mock(Connection::class); - $factory->expects('getPool')->once()->with($name)->andReturn($pool); + $poolManager->expects('pool')->once()->with($name)->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andReturn($connection); $wrapper->expects('release'); @@ -182,22 +182,22 @@ public function testTerminalReleaseOwnsEveryRequestedConnectionRole(): void public function testSharedInMemorySqliteAliasesReuseOneConnectionOwner(): void { - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); $connection = m::mock(Connection::class); - $factory->expects('getPool')->once()->with('sqlite')->andReturn($pool); - $factory->expects('getPool')->once()->with('sqlite::read')->andReturn($pool); - $factory->expects('getPool')->once()->with('sqlite::write')->andReturn($pool); + $poolManager->expects('pool')->once()->with('sqlite')->andReturn($pool); + $poolManager->expects('pool')->once()->with('sqlite::read')->andReturn($pool); + $poolManager->expects('pool')->once()->with('sqlite::write')->andReturn($pool); $pool->expects('getSharedInMemorySqlitePdo')->times(3)->andReturn(m::mock(PDO::class)); $pool->expects('getName')->times(3)->andReturn('sqlite'); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->once()->andReturn($connection); $connection->expects('useWriteConnectionWhenReading')->once(); $wrapper->expects('release')->once(); - $resolver = $this->makeResolver('sqlite', $factory); + $resolver = $this->makeResolver('sqlite', $poolManager); $this->assertSame($connection, $resolver->connection('sqlite')); $this->assertSame($connection, $resolver->connection('sqlite::read')); @@ -208,21 +208,21 @@ public function testSharedInMemorySqliteAliasesReuseOneConnectionOwner(): void public function testTerminalStateIsDetachedBeforeReleaseCanReenterTheResolver(): void { - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $firstWrapper = m::mock(PooledConnection::class); $secondWrapper = m::mock(PooledConnection::class); $firstConnection = m::mock(Connection::class); $secondConnection = m::mock(Connection::class); - $factory->expects('getPool')->twice()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->twice()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->twice()->andReturn($firstWrapper, $secondWrapper); + $pool->expects('borrow')->twice()->andReturn($firstWrapper, $secondWrapper); $firstWrapper->expects('getConnection')->andReturn($firstConnection); $secondWrapper->expects('getConnection')->andReturn($secondConnection); $secondWrapper->expects('release'); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); $resolver->setDefaultConnection('reporting'); $firstWrapper->expects('release')->andReturnUsing(function () use ($resolver, $secondConnection): void { @@ -240,19 +240,19 @@ public function testTerminalReleaseExhaustsConnectionsAndPreservesTheFirstFailur { $firstException = new RuntimeException('First release failed.'); $secondException = new RuntimeException('Second release failed.'); - $factory = m::mock(PoolFactory::class); - $resolver = $this->makeResolver('first', $factory); + $poolManager = m::mock(PoolManager::class); + $resolver = $this->makeResolver('first', $poolManager); foreach ([ ['first', $firstException], ['second', $secondException], ] as [$name, $exception]) { - $pool = m::mock(DbPool::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with($name)->andReturn($pool); + $poolManager->expects('pool')->once()->with($name)->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andReturn(m::mock(Connection::class)); $wrapper->expects('release')->andThrow($exception); @@ -274,20 +274,20 @@ public function testTerminalReleasePrioritizesTheFirstCancellationAndStillExhaus $ordinaryFailure = new RuntimeException('First release failed.'); $firstCancellation = new CanceledException('Second release was canceled.'); $secondCancellation = new CanceledException('Third release was canceled.'); - $factory = m::mock(PoolFactory::class); - $resolver = $this->makeResolver('first', $factory); + $poolManager = m::mock(PoolManager::class); + $resolver = $this->makeResolver('first', $poolManager); foreach ([ ['first', $ordinaryFailure], ['second', $firstCancellation], ['third', $secondCancellation], ] as [$name, $failure]) { - $pool = m::mock(DbPool::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with($name)->andReturn($pool); + $poolManager->expects('pool')->once()->with($name)->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andReturn(m::mock(Connection::class)); $wrapper->expects('release')->andThrow($failure); @@ -306,16 +306,16 @@ public function testTerminalReleasePrioritizesTheFirstCancellationAndStillExhaus public function testTerminalDiscardExhaustsExactConnections(): void { - $factory = m::mock(PoolFactory::class); - $resolver = $this->makeResolver('first', $factory); + $poolManager = m::mock(PoolManager::class); + $resolver = $this->makeResolver('first', $poolManager); foreach (['first', 'second'] as $name) { - $pool = m::mock(DbPool::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with($name)->andReturn($pool); + $poolManager->expects('pool')->once()->with($name)->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andReturn(m::mock(Connection::class)); $wrapper->expects('discard'); @@ -329,17 +329,17 @@ public function testTerminalDiscardExhaustsExactConnections(): void public function testConnectionRetrievalFailureDiscardsTheExactWrapper(): void { $exception = new RuntimeException('Connection retrieval failed.'); - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->once()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andThrow($exception); $wrapper->expects('discard'); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); try { $resolver->connection(); @@ -352,19 +352,19 @@ public function testConnectionRetrievalFailureDiscardsTheExactWrapper(): void public function testWriteRoleConfigurationFailureDiscardsTheExactWrapper(): void { $exception = new RuntimeException('Write role configuration failed.'); - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); $connection = m::mock(Connection::class); - $factory->expects('getPool')->once()->with('mysql::write')->andReturn($pool); + $poolManager->expects('pool')->once()->with('mysql::write')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andReturn($connection); $connection->expects('useWriteConnectionWhenReading')->andThrow($exception); $wrapper->expects('discard'); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); try { $resolver->connection('mysql::write'); @@ -378,17 +378,17 @@ public function testDiscardFailureDoesNotReplaceTheSetupFailure(): void { $setupException = new RuntimeException('Connection retrieval failed.'); $discardException = new RuntimeException('Discard failed.'); - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->once()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andThrow($setupException); $wrapper->expects('discard')->andThrow($discardException); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); try { $resolver->connection(); @@ -402,17 +402,17 @@ public function testDiscardCancellationReplacesAnOrdinarySetupFailure(): void { $setupException = new RuntimeException('Connection retrieval failed.'); $discardCancellation = new CanceledException('Discard was canceled.'); - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->once()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andThrow($setupException); $wrapper->expects('discard')->andThrow($discardCancellation); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); try { $resolver->connection(); @@ -426,17 +426,17 @@ public function testSetupCancellationRemainsPrimaryOverAnOrdinaryDiscardFailure( { $setupCancellation = new CanceledException('Connection setup was canceled.'); $discardException = new RuntimeException('Discard failed.'); - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->once()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andThrow($setupCancellation); $wrapper->expects('discard')->andThrow($discardException); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); try { $resolver->connection(); @@ -450,17 +450,17 @@ public function testSetupCancellationRemainsPrimaryOverDiscardCancellation(): vo { $setupCancellation = new CanceledException('Connection setup was canceled.'); $discardCancellation = new CanceledException('Discard was canceled.'); - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); - $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->once()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andThrow($setupCancellation); $wrapper->expects('discard')->andThrow($discardCancellation); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); try { $resolver->connection(); @@ -472,18 +472,18 @@ public function testSetupCancellationRemainsPrimaryOverDiscardCancellation(): vo public function testCoroutineConnectionRemainsDeferOwned(): void { - $factory = m::mock(PoolFactory::class); - $pool = m::mock(DbPool::class); + $poolManager = m::mock(PoolManager::class); + $pool = m::mock(DatabasePool::class); $wrapper = m::mock(PooledConnection::class); $connection = m::mock(Connection::class); - $factory->expects('getPool')->once()->with('mysql')->andReturn($pool); + $poolManager->expects('pool')->once()->with('mysql')->andReturn($pool); $pool->allows('getSharedInMemorySqlitePdo')->andReturnNull(); - $pool->expects('get')->once()->andReturn($wrapper); + $pool->expects('borrow')->once()->andReturn($wrapper); $wrapper->expects('getConnection')->andReturn($connection); $wrapper->expects('release'); - $resolver = $this->makeResolver('mysql', $factory); + $resolver = $this->makeResolver('mysql', $poolManager); run(function () use ($resolver, $connection): void { $this->assertSame($connection, $resolver->connection()); @@ -495,13 +495,13 @@ public function testCoroutineConnectionRemainsDeferOwned(): void protected function makeResolver( string $configuredDefault, - ?PoolFactory $factory = null, + ?PoolManager $poolManager = null, ): ConnectionResolver { $app = Container::getInstance(); $app->instance('config', new Repository([ 'database' => ['default' => $configuredDefault], ])); - $app->instance(PoolFactory::class, $factory ?? m::mock(PoolFactory::class)); + $app->instance(PoolManager::class, $poolManager ?? m::mock(PoolManager::class)); return new ConnectionResolver($app); } diff --git a/tests/Database/DatabaseConnectionLifecycleListenerTest.php b/tests/Database/DatabaseConnectionLifecycleListenerTest.php index cb4171fbf7..7825ac8a55 100644 --- a/tests/Database/DatabaseConnectionLifecycleListenerTest.php +++ b/tests/Database/DatabaseConnectionLifecycleListenerTest.php @@ -7,7 +7,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Database\ConnectionResolver; use Hypervel\Database\Listeners\DatabaseConnectionLifecycleListener; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Database\SimpleConnectionResolver; use Hypervel\Tests\TestCase; use Mockery as m; @@ -50,40 +50,40 @@ public function testProcessCleanupDoesNotResolveUnusedOwners(): void { $container = m::mock(Container::class); $container->expects('resolved')->with('db.resolver')->andReturnFalse(); - $container->expects('resolved')->with(PoolFactory::class)->andReturnFalse(); + $container->expects('resolved')->with(PoolManager::class)->andReturnFalse(); $container->shouldNotReceive('make'); (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); } - public function testProcessCleanupDiscardsResolverAndFlushesPoolFactory(): void + public function testProcessCleanupDiscardsResolverAndPurgesPools(): void { $resolver = m::mock(ConnectionResolver::class); $resolver->expects('discardConnections'); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll'); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll'); $container = m::mock(Container::class); $container->expects('resolved')->with('db.resolver')->andReturnTrue(); $container->expects('make')->with('db.resolver')->andReturn($resolver); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); } - public function testResolverFailureDoesNotSkipPoolFlushAndRemainsPrimary(): void + public function testResolverFailureDoesNotSkipPoolPurgeAndRemainsPrimary(): void { $resolverException = new RuntimeException('Resolver discard failed.'); - $factoryException = new RuntimeException('Pool flush failed.'); + $purgeException = new RuntimeException('Pool purge failed.'); $resolver = m::mock(ConnectionResolver::class); $resolver->expects('discardConnections')->andThrow($resolverException); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll')->andThrow($factoryException); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll')->andThrow($purgeException); $container = m::mock(Container::class); $container->expects('resolved')->with('db.resolver')->andReturnTrue(); $container->expects('make')->with('db.resolver')->andReturn($resolver); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); try { (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); @@ -93,62 +93,62 @@ public function testResolverFailureDoesNotSkipPoolFlushAndRemainsPrimary(): void } } - public function testPoolFactoryFailurePropagatesAfterResolverCleanup(): void + public function testPoolPurgeFailurePropagatesAfterResolverCleanup(): void { - $exception = new RuntimeException('Pool flush failed.'); + $exception = new RuntimeException('Pool purge failed.'); $resolver = m::mock(ConnectionResolver::class); $resolver->expects('discardConnections'); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll')->andThrow($exception); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll')->andThrow($exception); $container = m::mock(Container::class); $container->expects('resolved')->with('db.resolver')->andReturnTrue(); $container->expects('make')->with('db.resolver')->andReturn($resolver); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); try { (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); - $this->fail('Expected the pool factory failure to propagate.'); + $this->fail('Expected the pool purge failure to propagate.'); } catch (RuntimeException $throwable) { $this->assertSame($exception, $throwable); } } - public function testPoolFactoryCancellationSupersedesAnOrdinaryResolverFailure(): void + public function testPoolPurgeCancellationSupersedesAnOrdinaryResolverFailure(): void { $resolverException = new RuntimeException('Resolver discard failed.'); - $factoryCancellation = new CanceledException('Pool flush was canceled.'); + $purgeCancellation = new CanceledException('Pool purge was canceled.'); $resolver = m::mock(ConnectionResolver::class); $resolver->expects('discardConnections')->andThrow($resolverException); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll')->andThrow($factoryCancellation); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll')->andThrow($purgeCancellation); $container = m::mock(Container::class); $container->expects('resolved')->with('db.resolver')->andReturnTrue(); $container->expects('make')->with('db.resolver')->andReturn($resolver); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); try { (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); - $this->fail('Expected pool flush cancellation to propagate.'); + $this->fail('Expected pool purge cancellation to propagate.'); } catch (CanceledException $throwable) { - $this->assertSame($factoryCancellation, $throwable); + $this->assertSame($purgeCancellation, $throwable); } } - public function testResolverCancellationRemainsPrimaryOverAnOrdinaryPoolFactoryFailure(): void + public function testResolverCancellationRemainsPrimaryOverAnOrdinaryPoolPurgeFailure(): void { $resolverCancellation = new CanceledException('Resolver discard was canceled.'); - $factoryException = new RuntimeException('Pool flush failed.'); + $purgeException = new RuntimeException('Pool purge failed.'); $resolver = m::mock(ConnectionResolver::class); $resolver->expects('discardConnections')->andThrow($resolverCancellation); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll')->andThrow($factoryException); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll')->andThrow($purgeException); $container = m::mock(Container::class); $container->expects('resolved')->with('db.resolver')->andReturnTrue(); $container->expects('make')->with('db.resolver')->andReturn($resolver); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); try { (new DatabaseConnectionLifecycleListener($container))->discardProcessConnections(); diff --git a/tests/Database/DatabaseManagerSetDefaultConnectionTest.php b/tests/Database/DatabaseManagerSetDefaultConnectionTest.php index b2d0bda4da..512f660ead 100644 --- a/tests/Database/DatabaseManagerSetDefaultConnectionTest.php +++ b/tests/Database/DatabaseManagerSetDefaultConnectionTest.php @@ -8,8 +8,9 @@ use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Database\ConnectionResolver; +use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\DatabaseManager; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Engine\Coroutine; use Hypervel\Tests\TestCase; use Mockery as m; @@ -30,7 +31,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testSetDefaultConnectionWritesToCoroutineContext() + public function testSetDefaultConnectionWritesToCoroutineContext(): void { $manager = $this->makeManager(['default' => 'pgsql']); @@ -43,7 +44,7 @@ public function testSetDefaultConnectionWritesToCoroutineContext() ); } - public function testSetDefaultConnectionWithNullClearsContextOverride() + public function testSetDefaultConnectionWithNullClearsContextOverride(): void { $manager = $this->makeManager(['default' => 'pgsql']); @@ -57,7 +58,7 @@ public function testSetDefaultConnectionWithNullClearsContextOverride() ); } - public function testSetDefaultConnectionDoesNotMutateConfig() + public function testSetDefaultConnectionDoesNotMutateConfig(): void { $config = new Repository(['database' => ['default' => 'pgsql']]); $manager = $this->makeManager([], $config); @@ -71,7 +72,7 @@ public function testSetDefaultConnectionDoesNotMutateConfig() ); } - public function testGetDefaultConnectionReturnsContextOverrideWhenSet() + public function testGetDefaultConnectionReturnsContextOverrideWhenSet(): void { $manager = $this->makeManager(['default' => 'pgsql']); @@ -80,7 +81,7 @@ public function testGetDefaultConnectionReturnsContextOverrideWhenSet() $this->assertSame('reporting', $manager->getDefaultConnection()); } - public function testGetDefaultConnectionFallsBackToConfigWhenContextIsCleared() + public function testGetDefaultConnectionFallsBackToConfigWhenContextIsCleared(): void { $manager = $this->makeManager(['default' => 'pgsql']); @@ -90,7 +91,7 @@ public function testGetDefaultConnectionFallsBackToConfigWhenContextIsCleared() $this->assertSame('pgsql', $manager->getDefaultConnection()); } - public function testOverrideInOneCoroutineIsNotVisibleInSibling() + public function testOverrideInOneCoroutineIsNotVisibleInSibling(): void { $manager = $this->makeManager(['default' => 'pgsql']); @@ -115,8 +116,7 @@ public function testOverrideInOneCoroutineIsNotVisibleInSibling() } /** - * Build a DatabaseManager wired up enough to exercise the setter/getter. - * The pool/factory machinery isn't needed since no connection is opened. + * Create a database manager without opening connections. */ protected function makeManager(array $databaseConfig, ?Repository $config = null): DatabaseManager { @@ -124,9 +124,9 @@ protected function makeManager(array $databaseConfig, ?Repository $config = null $app = Container::getInstance(); $app->instance('config', $config); - $app->instance(PoolFactory::class, m::mock(PoolFactory::class)); + $app->instance(PoolManager::class, m::mock(PoolManager::class)); - $factory = m::mock(\Hypervel\Database\Connectors\ConnectionFactory::class); + $factory = m::mock(ConnectionFactory::class); return new DatabaseManager($app, $factory); } diff --git a/tests/Database/PackageMetadataTest.php b/tests/Database/PackageMetadataTest.php index d00cc4ab40..290c103763 100644 --- a/tests/Database/PackageMetadataTest.php +++ b/tests/Database/PackageMetadataTest.php @@ -50,7 +50,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'hypervel/http', 'hypervel/macroable', 'hypervel/pagination', - 'hypervel/pool', + 'hypervel/connection-pool', 'hypervel/prompts', 'hypervel/queue', 'hypervel/support', diff --git a/tests/Database/PoolFactoryTest.php b/tests/Database/PoolFactoryTest.php deleted file mode 100644 index cad6bc3935..0000000000 --- a/tests/Database/PoolFactoryTest.php +++ /dev/null @@ -1,503 +0,0 @@ -mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $pool1 = $factory->getPool('default'); - $pool2 = $factory->getPool('default'); - - $this->assertSame($pool1, $pool2); - } - - public function testGetPoolReturnsDifferentInstancesForDifferentNames(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $pool1 = $factory->getPool('default'); - $pool2 = $factory->getPool('cache'); - - $this->assertNotSame($pool1, $pool2); - } - - public function testPoolsReturnsOnlyExistingPhysicalPools(): void - { - $factory = new PoolFactory($this->mockContainerWithPools()); - - $this->assertSame([], $factory->pools()); - - $default = $factory->getPool('default'); - $cache = $factory->getPool('cache'); - - $this->assertSame([ - 'default' => $default, - 'cache' => $cache, - ], $factory->pools()); - } - - public function testHasPool(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $this->assertFalse($factory->hasPool('default')); - - $factory->getPool('default'); - - $this->assertTrue($factory->hasPool('default')); - $this->assertFalse($factory->hasPool('cache')); - } - - public function testFlushAll(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $pool1 = $factory->getPool('default'); - $pool2 = $factory->getPool('cache'); - - $connection1 = $pool1->get(); - $connection2 = $pool1->get(); - $connection3 = $pool2->get(); - - $pool1->release($connection1); - $pool1->release($connection2); - $pool2->release($connection3); - - $this->assertSame(2, $pool1->getConnectionsInChannel()); - $this->assertSame(1, $pool2->getConnectionsInChannel()); - - $factory->flushAll(); - - $this->assertSame(0, $pool1->getConnectionsInChannel()); - $this->assertSame(0, $pool2->getConnectionsInChannel()); - } - - public function testFlushAllClearsCachedPools(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $original = $factory->getPool('default'); - - $factory->flushAll(); - - // After flushAll, the cached pool entry should be evicted so the next - // getPool() returns a fresh instance. This lets the previous Pool's - // Channel/Connection graph be refcount-collected instead of trapped. - $fresh = $factory->getPool('default'); - - $this->assertNotSame($original, $fresh); - } - - public function testFlushAllDetachesPoolsBeforeClosingThem(): void - { - $container = m::mock(ContainerContract::class); - $original = m::mock(DbPool::class); - $replacement = m::mock(DbPool::class); - $container->shouldReceive('make') - ->with(DbPool::class, ['name' => 'default']) - ->twice() - ->andReturn($original, $replacement); - $factory = new PoolFactory($container); - $resolvedDuringClose = null; - $original->shouldReceive('close')->once()->andReturnUsing( - function () use ($factory, &$resolvedDuringClose): void { - $resolvedDuringClose = $factory->getPool('default'); - } - ); - - $this->assertSame($original, $factory->getPool('default')); - - $factory->flushAll(); - - $this->assertSame($replacement, $resolvedDuringClose); - $this->assertSame($replacement, $factory->getPool('default')); - } - - public function testFlushAllContinuesClosingAndPreservesFirstFailure(): void - { - $firstFailure = new RuntimeException('first close failed'); - $secondFailure = new RuntimeException('second close failed'); - $firstPool = m::mock(DbPool::class); - $secondPool = m::mock(DbPool::class); - $thirdPool = m::mock(DbPool::class); - $firstPool->shouldReceive('close')->once()->andThrow($firstFailure); - $secondPool->shouldReceive('close')->once()->andThrow($secondFailure); - $thirdPool->shouldReceive('close')->once(); - - $container = m::mock(ContainerContract::class); - $container->shouldReceive('make')->with(DbPool::class, ['name' => 'first'])->once()->andReturn($firstPool); - $container->shouldReceive('make')->with(DbPool::class, ['name' => 'second'])->once()->andReturn($secondPool); - $container->shouldReceive('make')->with(DbPool::class, ['name' => 'third'])->once()->andReturn($thirdPool); - - $factory = new PoolFactory($container); - $factory->getPool('first'); - $factory->getPool('second'); - $factory->getPool('third'); - - try { - $factory->flushAll(); - $this->fail('Expected the first pool close failure to propagate.'); - } catch (RuntimeException $exception) { - $this->assertSame($firstFailure, $exception); - } - - $this->assertSame([], $factory->pools()); - } - - public function testFlushPoolOnlyFlushesNamedPool(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $defaultPool = $factory->getPool('default'); - $cachePool = $factory->getPool('cache'); - - $defaultConn1 = $defaultPool->get(); - $defaultConn2 = $defaultPool->get(); - $cacheConn = $cachePool->get(); - - $defaultPool->release($defaultConn1); - $defaultPool->release($defaultConn2); - $cachePool->release($cacheConn); - - $this->assertSame(2, $defaultPool->getConnectionsInChannel()); - $this->assertSame(1, $cachePool->getConnectionsInChannel()); - - $factory->flushPool('default'); - - // Default pool should be flushed - $this->assertSame(0, $defaultPool->getConnectionsInChannel()); - - // Cache pool should be untouched - $this->assertSame(1, $cachePool->getConnectionsInChannel()); - $this->assertSame($cachePool, $factory->getPool('cache')); - - // Getting default pool again should return a fresh instance - $freshDefaultPool = $factory->getPool('default'); - $this->assertNotSame($defaultPool, $freshDefaultPool); - } - - public function testFlushPoolDetachesPoolBeforeClosingIt(): void - { - $container = m::mock(ContainerContract::class); - $original = m::mock(DbPool::class); - $replacement = m::mock(DbPool::class); - $container->shouldReceive('make') - ->with(DbPool::class, ['name' => 'default']) - ->twice() - ->andReturn($original, $replacement); - $factory = new PoolFactory($container); - $resolvedDuringClose = null; - $original->shouldReceive('close')->once()->andReturnUsing( - function () use ($factory, &$resolvedDuringClose): void { - $resolvedDuringClose = $factory->getPool('default'); - } - ); - - $this->assertSame($original, $factory->getPool('default')); - - $factory->flushPool('default'); - - $this->assertSame($replacement, $resolvedDuringClose); - $this->assertSame($replacement, $factory->getPool('default')); - } - - public function testFlushPoolGivesReplacementPoolIndependentCapacity(): void - { - $container = $this->mockContainerWithPools([ - 'default' => $this->connectionConfig([ - 'pool' => [ - 'min_connections' => 1, - 'max_connections' => 1, - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => -1, - 'max_idle_time' => 60.0, - ], - ]), - ]); - $factory = new PoolFactory($container); - $oldPool = $factory->getPool('default'); - $oldConnection = $oldPool->get(); - - $factory->flushPool('default'); - - $newPool = $factory->getPool('default'); - $newConnection = $newPool->get(); - - $this->assertTrue($oldPool->isClosed()); - $this->assertNotSame($oldPool, $newPool); - $this->assertSame(1, $oldPool->getCurrentConnections()); - $this->assertSame(1, $newPool->getCurrentConnections()); - - $oldPool->release($oldConnection); - - $this->assertSame(0, $oldPool->getCurrentConnections()); - $this->assertSame(1, $oldConnection->closeCount); - - $newPool->release($newConnection); - } - - public function testWriteConnectionUsesBasePool(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - $pool = $factory->getPool('default::write'); - - $this->assertSame( - $factory->getPool('default'), - $pool - ); - $this->assertTrue($factory->hasPool('default::write')); - } - - public function testReadConnectionUsesSeparatePoolWhenReadConfigExists(): void - { - $container = $this->mockContainerWithPools([ - 'default' => $this->connectionConfig([ - 'read' => [ - 'host' => '127.0.0.2', - ], - ]), - ]); - - $factory = new PoolFactory($container); - - $this->assertNotSame( - $factory->getPool('default'), - $factory->getPool('default::read') - ); - $this->assertTrue($factory->hasPool('default')); - $this->assertTrue($factory->hasPool('default::read')); - } - - public function testReadConnectionUsesBasePoolWhenReadConfigIsMissingOrNull(): void - { - $container = $this->mockContainerWithPools([ - 'default' => $this->connectionConfig([ - 'read' => null, - ]), - ]); - - $factory = new PoolFactory($container); - - $this->assertSame( - $factory->getPool('default'), - $factory->getPool('default::read') - ); - $this->assertTrue($factory->hasPool('default::read')); - } - - public function testPoolConnectTimeoutIsExposedWithoutLosingFractionalPrecision(): void - { - foreach (['mysql', 'mariadb', 'pgsql', 'sqlite'] as $driver) { - $config = $this->connectionConfig(['driver' => $driver]); - $config['pool']['connect_timeout'] = 1.25; - $pool = (new PoolFactory($this->mockContainerWithPools(['default' => $config])))->getPool('default'); - - $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); - $this->assertSame(1.25, $pool->configForTest()['connect_timeout']); - - $config['connect_timeout'] = 7.5; - $pool = (new PoolFactory($this->mockContainerWithPools(['default' => $config])))->getPool('default'); - - $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); - $this->assertSame(7.5, $pool->configForTest()['connect_timeout']); - } - } - - public function testFlushPoolResolvesWriteAliasToBasePool(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $pool = $factory->getPool('default::write'); - $pool->release($pool->get()); - - $factory->flushPool('default::write'); - - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertNotSame($pool, $factory->getPool('default')); - } - - public function testFlushPoolsForConnectionFlushesBaseAndRolePools(): void - { - $container = $this->mockContainerWithPools([ - 'default' => $this->connectionConfig([ - 'read' => [ - 'host' => '127.0.0.2', - ], - ]), - 'cache' => $this->connectionConfig(), - ]); - - $factory = new PoolFactory($container); - - $defaultPool = $factory->getPool('default'); - $readPool = $factory->getPool('default::read'); - $cachePool = $factory->getPool('cache'); - - $defaultPool->release($defaultPool->get()); - $readPool->release($readPool->get()); - $cachePool->release($cachePool->get()); - - $factory->flushPoolsForConnection('default::read'); - - $this->assertSame(0, $defaultPool->getConnectionsInChannel()); - $this->assertSame(0, $readPool->getConnectionsInChannel()); - $this->assertSame(1, $cachePool->getConnectionsInChannel()); - $this->assertNotSame($defaultPool, $factory->getPool('default')); - $this->assertNotSame($readPool, $factory->getPool('default::read')); - $this->assertSame($cachePool, $factory->getPool('cache')); - } - - public function testFlushPoolsForConnectionDetachesSelectionAndPrioritizesCancellation(): void - { - $ordinaryFailure = new RuntimeException('write pool close failed'); - $cancellation = new CanceledException; - $writePool = m::mock(DbPool::class); - $readPool = m::mock(DbPool::class); - $cachePool = m::mock(DbPool::class); - $factory = new PoolFactory(m::mock(ContainerContract::class)); - $detachedPools = null; - - $writePool->shouldReceive('close')->once()->andReturnUsing( - function () use ($factory, &$detachedPools, $ordinaryFailure): never { - $detachedPools = $factory->pools(); - - throw $ordinaryFailure; - } - ); - $readPool->shouldReceive('close')->once()->andThrow($cancellation); - $cachePool->shouldNotReceive('close'); - - $pools = new ReflectionProperty($factory, 'pools'); - $pools->setValue($factory, [ - 'default' => $writePool, - 'default::read' => $readPool, - 'cache' => $cachePool, - ]); - - try { - $factory->flushPoolsForConnection('default::read'); - $this->fail('Expected pool close cancellation to propagate.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); - } - - $this->assertSame(['cache' => $cachePool], $detachedPools); - $this->assertSame(['cache' => $cachePool], $factory->pools()); - } - - private function mockContainerWithPools(?array $connections = null): m\MockInterface|ContainerContract - { - $connections ??= [ - 'default' => $this->connectionConfig(), - 'cache' => $this->connectionConfig(), - ]; - - $config = new Repository([ - 'database' => [ - 'connections' => $connections, - ], - ]); - - $container = m::mock(ContainerContract::class); - $factory = new ConnectionFactory($container); - - $container->shouldReceive('make')->with('config')->andReturn($config); - $container->shouldReceive('make')->with('db.factory')->andReturn($factory); - $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(false); - $container->shouldReceive('bound')->with('events')->andReturn(false); - $container->shouldReceive('make')->with(DbPool::class, m::any())->andReturnUsing( - fn ($class, $args) => new PoolFactoryTestPool($container, $args['name']) - ); - - return $container; - } - - private function connectionConfig(array $overrides = []): array - { - return array_merge([ - 'driver' => 'mysql', - 'host' => '127.0.0.1', - 'port' => 3306, - 'database' => 'test', - 'pool' => [ - 'min_connections' => 1, - 'max_connections' => 10, - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => -1, - 'max_idle_time' => 60.0, - ], - ], $overrides); - } -} - -class PoolFactoryTestPool extends DbPool -{ - public function configForTest(): array - { - return $this->config; - } - - protected function createConnection(): ConnectionInterface - { - return new PoolFactoryTestConnection($this->container, $this); - } -} - -class PoolFactoryTestConnection extends Connection -{ - public int $closeCount = 0; - - public function close(): bool - { - ++$this->closeCount; - - return true; - } - - public function reconnect(): bool - { - return true; - } - - public function getActiveConnection(): mixed - { - return $this; - } -} diff --git a/tests/Database/PoolManagerTest.php b/tests/Database/PoolManagerTest.php new file mode 100644 index 0000000000..e0b48ae978 --- /dev/null +++ b/tests/Database/PoolManagerTest.php @@ -0,0 +1,872 @@ +mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $pool1 = $poolManager->pool('default'); + $pool2 = $poolManager->pool('default'); + + $this->assertSame($pool1, $pool2); + } + + public function testPoolReturnsDifferentInstancesForDifferentNames(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $pool1 = $poolManager->pool('default'); + $pool2 = $poolManager->pool('cache'); + + $this->assertNotSame($pool1, $pool2); + } + + #[DataProvider('closedPoolNames')] + public function testDirectlyClosedPoolIsReplaced(string $requested, string $physical, bool $hasReadConfig): void + { + $container = $this->mockContainerWithPools([ + 'default' => $this->connectionConfig(['read' => $hasReadConfig ? ['host' => '127.0.0.2'] : null]), + ]); + $manager = new PoolManager($container); + + try { + $original = $manager->pool($requested); + $original->close(); + $replacement = $manager->pool($requested); + + $this->assertNotSame($original, $replacement); + $this->assertFalse($replacement->isClosed()); + $this->assertSame($replacement, $manager->pool($physical)); + $this->assertSame($replacement, $manager->pool($requested)); + $this->assertSame([$physical => $replacement], $manager->getPools()); + } finally { + $manager->purgeAll(); + } + } + + public static function closedPoolNames(): array + { + return [ + ['default', 'default', false], + ['default::write', 'default', false], + ['default::read', 'default', false], + ['default::read', 'default::read', true], + ]; + } + + #[DataProvider('publicationCleanup')] + public function testConcurrentResolutionCleansUpTheLoserAndRechecksTheWinner(string $cleanup): void + { + $candidates = []; + $container = $this->publicationContainer($candidates); + $manager = new PoolManager($container); + $resolving = new Channel(1); + $resumeResolution = new Channel(1); + $closing = new Channel(1); + $resumeClose = new Channel(1); + $completed = new Channel(1); + $first = true; + $failure = match ($cleanup) { + 'error' => new RuntimeException('loser cleanup failed'), + 'cancellation' => new CanceledException('loser cleanup canceled'), + default => null, + }; + $container->afterResolving(DatabasePool::class, function () use (&$first, $resolving, $resumeResolution): void { + if ($first) { + $first = false; + $resolving->push(true); + $this->assertTrue($resumeResolution->pop(1)); + } + }); + $timerCount = Timer::stats()['num']; + $child = Coroutine::create(static function () use ($manager, $completed): void { + try { + $completed->push([$manager->pool('default'), null]); + } catch (Throwable $exception) { + $completed->push([null, $exception]); + } + }); + + try { + $this->assertTrue($resolving->pop(1)); + $winner = $manager->pool('default'); + $loser = $candidates[0]; + $this->assertCount(2, $candidates); + $this->assertSame($timerCount + 1, Timer::stats()['num']); + $loser->closing = function () use ($cleanup, $failure, $closing, $resumeClose): void { + if ($failure !== null) { + throw $failure; + } + + if ($cleanup !== 'normal') { + $closing->push(true); + $this->assertTrue($resumeClose->pop(1)); + } + }; + $resumeResolution->push(true); + + if (in_array($cleanup, ['close winner', 'replace winner'], true)) { + $this->assertTrue($closing->pop(1)); + + if ($cleanup === 'close winner') { + $winner->close(); + } else { + $manager->purge('default'); + $winner = $manager->pool('default'); + } + + $resumeClose->push(true); + } + + $result = $completed->pop(1); + $this->assertIsArray($result); + $this->assertSame($failure, $result[1]); + $this->assertSame(1, $loser->closeCount); + + if ($failure === null) { + $this->assertSame($manager->getPools()['default'], $result[0]); + $this->assertFalse($result[0]->isClosed()); + $this->assertTrue($loser->isClosed()); + + if ($cleanup === 'close winner') { + $this->assertNotSame($winner, $result[0]); + } else { + $this->assertSame($winner, $result[0]); + } + } else { + $this->assertSame($winner, $manager->pool('default')); + $this->assertFalse($winner->isClosed()); + } + + $loser->closing = null; + if (! $loser->isClosed()) { + $loser->close(); + } + $manager->purgeAll(); + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + $resumeResolution->close(); + $resumeClose->close(); + Coroutine::join([$child], 1); + foreach ($candidates as $candidate) { + $candidate->closing = null; + $candidate->close(); + } + $manager->purgeAll(); + $resolving->close(); + $closing->close(); + $completed->close(); + } + } + + public static function publicationCleanup(): array + { + return array_map(static fn (string $cleanup): array => [$cleanup], [ + 'normal', 'close winner', 'replace winner', 'error', 'cancellation', + ]); + } + + #[DataProvider('publicationEntries')] + public function testPublicationHandlesAnEntryCreatedDuringResolution(bool $sameInstance): void + { + $candidates = []; + $container = $this->publicationContainer($candidates); + $manager = new PoolManager($container); + $first = true; + $published = null; + $container->extend(DatabasePool::class, function (DatabasePool $candidate) use ($manager, &$first, &$published, $sameInstance): DatabasePool { + if (! $first) { + return $candidate; + } + + $first = false; + $published = $manager->pool('default'); + + if ($sameInstance) { + $candidate->close(); + + return $published; + } + + $published->close(); + + return $candidate; + }); + + try { + $result = $manager->pool('default'); + $this->assertSame($sameInstance ? $published : $candidates[0], $result); + $this->assertFalse($result->isClosed()); + $this->assertSame(0, $result->closeCount); + $this->assertSame($result, $manager->pool('default')); + } finally { + foreach ($candidates as $candidate) { + $candidate->close(); + } + $manager->purgeAll(); + } + } + + public static function publicationEntries(): array + { + return ['closed entry' => [false], 'identical candidate' => [true]]; + } + + #[DataProvider('initializationFailures')] + public function testFailedInitializationDoesNotRetainTheCandidate(bool $warm): void + { + $candidates = []; + $container = $this->publicationContainer($candidates, ['idle_check_interval' => 60.0]); + $manager = new PoolManager($container); + $failure = new RuntimeException('initialization failed'); + $weak = null; + $caught = null; + $timerCount = Timer::stats()['num']; + $container->afterResolving(DatabasePool::class, static function (DatabasePool $pool) use (&$weak, $failure, $warm): void { + $weak = WeakReference::create($pool); + + if ($warm) { + $pool->release($pool->borrow()); + } + + throw $failure; + }); + + try { + try { + $manager->pool('default'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertSame([], $manager->getPools()); + $candidates = []; + gc_collect_cycles(); + $this->assertSame($timerCount, Timer::stats()['num']); + $this->assertNotNull($weak); + $this->assertNull($weak->get()); + } finally { + $weak?->get()?->close(); + foreach ($candidates as $candidate) { + $candidate->close(); + } + $manager->purgeAll(); + } + } + + public static function initializationFailures(): array + { + return ['cold candidate' => [false], 'warmed candidate' => [true]]; + } + + #[DataProvider('activationFailures')] + public function testActivationFailureClosesTheCandidateAndPreservesFailurePrecedence(string $activationClass, ?string $cleanupClass): void + { + $candidates = []; + $container = $this->publicationContainer($candidates, ['idle_check_interval' => 60.0]); + $manager = new PoolManager($container); + $failure = new $activationClass('activation failed'); + $cleanupFailure = $cleanupClass === null ? null : new $cleanupClass('cleanup failed'); + $timerCount = Timer::stats()['num']; + $container->afterResolving(DatabasePool::class, static function (PublicationDatabasePool $pool) use ($failure, $cleanupFailure): void { + $pool->release($pool->borrow()); + $pool->starting = static fn () => throw $failure; + $pool->closing = static function () use ($cleanupFailure): void { + if ($cleanupFailure !== null) { + throw $cleanupFailure; + } + }; + }); + $caught = null; + + try { + try { + $manager->pool('default'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $expected = ! $failure instanceof CanceledException && $cleanupFailure instanceof CanceledException + ? $cleanupFailure : $failure; + $this->assertSame($expected, $caught); + $this->assertCount(1, $candidates); + $this->assertTrue($candidates[0]->isClosed()); + $this->assertSame(1, $candidates[0]->closeCount); + $this->assertSame([], $manager->getPools()); + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + foreach ($candidates as $candidate) { + $candidate->closing = null; + $candidate->close(); + } + $manager->purgeAll(); + } + } + + public static function activationFailures(): array + { + return [ + [RuntimeException::class, null], + [RuntimeException::class, RuntimeException::class], + [RuntimeException::class, CanceledException::class], + [CanceledException::class, null], + [CanceledException::class, RuntimeException::class], + [CanceledException::class, CanceledException::class], + ]; + } + + public function testGetPoolsReturnsOnlyExistingPhysicalPools(): void + { + $poolManager = new PoolManager($this->mockContainerWithPools()); + + $this->assertSame([], $poolManager->getPools()); + + $default = $poolManager->pool('default'); + $cache = $poolManager->pool('cache'); + + $this->assertSame([ + 'default' => $default, + 'cache' => $cache, + ], $poolManager->getPools()); + } + + public function testHas(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $this->assertFalse($poolManager->has('default')); + + $poolManager->pool('default'); + + $this->assertTrue($poolManager->has('default')); + $this->assertFalse($poolManager->has('cache')); + } + + public function testPurgeAll(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $pool1 = $poolManager->pool('default'); + $pool2 = $poolManager->pool('cache'); + + $connection1 = $pool1->borrow(); + $connection2 = $pool1->borrow(); + $connection3 = $pool2->borrow(); + + $pool1->release($connection1); + $pool1->release($connection2); + $pool2->release($connection3); + + $this->assertSame(2, $pool1->getIdleCount()); + $this->assertSame(1, $pool2->getIdleCount()); + + $poolManager->purgeAll(); + + $this->assertSame(0, $pool1->getIdleCount()); + $this->assertSame(0, $pool2->getIdleCount()); + } + + public function testPurgeAllClearsCachedPools(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $original = $poolManager->pool('default'); + + $poolManager->purgeAll(); + + $fresh = $poolManager->pool('default'); + + $this->assertNotSame($original, $fresh); + } + + public function testPurgeAllDetachesPoolsBeforeClosingThem(): void + { + $container = m::mock(ContainerContract::class); + $original = m::mock(DatabasePool::class); + $replacement = m::mock(DatabasePool::class); + $original->shouldReceive('start')->once(); + $replacement->shouldReceive('start')->once(); + $replacement->shouldReceive('isClosed')->andReturnFalse(); + $container->shouldReceive('make') + ->with(DatabasePool::class, ['name' => 'default']) + ->twice() + ->andReturn($original, $replacement); + $poolManager = new PoolManager($container); + $resolvedDuringClose = null; + $original->shouldReceive('close')->once()->andReturnUsing( + function () use ($poolManager, &$resolvedDuringClose): void { + $resolvedDuringClose = $poolManager->pool('default'); + } + ); + + $this->assertSame($original, $poolManager->pool('default')); + + $poolManager->purgeAll(); + + $this->assertSame($replacement, $resolvedDuringClose); + $this->assertSame($replacement, $poolManager->pool('default')); + } + + public function testPurgeAllContinuesClosingAndPreservesFirstFailure(): void + { + $firstFailure = new RuntimeException('first close failed'); + $secondFailure = new RuntimeException('second close failed'); + $firstPool = m::mock(DatabasePool::class); + $secondPool = m::mock(DatabasePool::class); + $thirdPool = m::mock(DatabasePool::class); + $firstPool->shouldReceive('start')->once(); + $secondPool->shouldReceive('start')->once(); + $thirdPool->shouldReceive('start')->once(); + $firstPool->shouldReceive('close')->once()->andThrow($firstFailure); + $secondPool->shouldReceive('close')->once()->andThrow($secondFailure); + $thirdPool->shouldReceive('close')->once(); + + $container = m::mock(ContainerContract::class); + $container->shouldReceive('make')->with(DatabasePool::class, ['name' => 'first'])->once()->andReturn($firstPool); + $container->shouldReceive('make')->with(DatabasePool::class, ['name' => 'second'])->once()->andReturn($secondPool); + $container->shouldReceive('make')->with(DatabasePool::class, ['name' => 'third'])->once()->andReturn($thirdPool); + + $poolManager = new PoolManager($container); + $poolManager->pool('first'); + $poolManager->pool('second'); + $poolManager->pool('third'); + + try { + $poolManager->purgeAll(); + $this->fail('Expected the first pool close failure to propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame($firstFailure, $exception); + } + + $this->assertSame([], $poolManager->getPools()); + } + + public function testPurgeOnlyRemovesNamedPool(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $defaultPool = $poolManager->pool('default'); + $cachePool = $poolManager->pool('cache'); + + $defaultConnection1 = $defaultPool->borrow(); + $defaultConnection2 = $defaultPool->borrow(); + $cacheConnection = $cachePool->borrow(); + + $defaultPool->release($defaultConnection1); + $defaultPool->release($defaultConnection2); + $cachePool->release($cacheConnection); + + $this->assertSame(2, $defaultPool->getIdleCount()); + $this->assertSame(1, $cachePool->getIdleCount()); + + $poolManager->purge('default'); + + $this->assertSame(0, $defaultPool->getIdleCount()); + + $this->assertSame(1, $cachePool->getIdleCount()); + $this->assertSame($cachePool, $poolManager->pool('cache')); + + $freshDefaultPool = $poolManager->pool('default'); + $this->assertNotSame($defaultPool, $freshDefaultPool); + } + + public function testPurgeDetachesPoolBeforeClosingIt(): void + { + $container = m::mock(ContainerContract::class); + $original = m::mock(DatabasePool::class); + $replacement = m::mock(DatabasePool::class); + $original->shouldReceive('start')->once(); + $replacement->shouldReceive('start')->once(); + $replacement->shouldReceive('isClosed')->andReturnFalse(); + $container->shouldReceive('make') + ->with(DatabasePool::class, ['name' => 'default']) + ->twice() + ->andReturn($original, $replacement); + $poolManager = new PoolManager($container); + $resolvedDuringClose = null; + $original->shouldReceive('close')->once()->andReturnUsing( + function () use ($poolManager, &$resolvedDuringClose): void { + $resolvedDuringClose = $poolManager->pool('default'); + } + ); + + $this->assertSame($original, $poolManager->pool('default')); + + $poolManager->purge('default'); + + $this->assertSame($replacement, $resolvedDuringClose); + $this->assertSame($replacement, $poolManager->pool('default')); + } + + public function testPurgeGivesReplacementPoolIndependentCapacity(): void + { + $container = $this->mockContainerWithPools([ + 'default' => $this->connectionConfig([ + 'pool' => [ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat_interval' => null, + 'max_idle_time' => 60.0, + ], + ]), + ]); + $poolManager = new PoolManager($container); + $oldPool = $poolManager->pool('default'); + $oldConnection = $oldPool->borrow(); + + $poolManager->purge('default'); + + $newPool = $poolManager->pool('default'); + $newConnection = $newPool->borrow(); + + $this->assertTrue($oldPool->isClosed()); + $this->assertNotSame($oldPool, $newPool); + $this->assertSame(1, $oldPool->getManagedCount()); + $this->assertSame(1, $newPool->getManagedCount()); + + $oldPool->release($oldConnection); + + $this->assertSame(0, $oldPool->getManagedCount()); + $this->assertSame(1, $oldConnection->closeCount); + + $newPool->release($newConnection); + } + + public function testWriteConnectionUsesBasePool(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + $pool = $poolManager->pool('default::write'); + + $this->assertSame( + $poolManager->pool('default'), + $pool + ); + $this->assertTrue($poolManager->has('default::write')); + } + + public function testReadConnectionUsesSeparatePoolWhenReadConfigExists(): void + { + $container = $this->mockContainerWithPools([ + 'default' => $this->connectionConfig([ + 'read' => [ + 'host' => '127.0.0.2', + ], + ]), + ]); + + $poolManager = new PoolManager($container); + + $this->assertNotSame( + $poolManager->pool('default'), + $poolManager->pool('default::read') + ); + $this->assertTrue($poolManager->has('default')); + $this->assertTrue($poolManager->has('default::read')); + } + + public function testReadConnectionUsesBasePoolWhenReadConfigIsMissingOrNull(): void + { + $container = $this->mockContainerWithPools([ + 'default' => $this->connectionConfig([ + 'read' => null, + ]), + ]); + + $poolManager = new PoolManager($container); + + $this->assertSame( + $poolManager->pool('default'), + $poolManager->pool('default::read') + ); + $this->assertTrue($poolManager->has('default::read')); + } + + public function testPoolConnectTimeoutIsExposedWithoutLosingFractionalPrecision(): void + { + foreach (['mysql', 'mariadb', 'pgsql', 'sqlite'] as $driver) { + $config = $this->connectionConfig(['driver' => $driver]); + $config['pool']['connect_timeout'] = 1.25; + $pool = (new PoolManager($this->mockContainerWithPools(['default' => $config])))->pool('default'); + + $this->assertInstanceOf(PoolManagerTestPool::class, $pool); + $this->assertSame(1.25, $pool->configForTest()['connect_timeout']); + + $config['connect_timeout'] = 7.5; + $pool = (new PoolManager($this->mockContainerWithPools(['default' => $config])))->pool('default'); + + $this->assertInstanceOf(PoolManagerTestPool::class, $pool); + $this->assertSame(7.5, $pool->configForTest()['connect_timeout']); + } + } + + public function testPurgeResolvesWriteAliasToBasePool(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $pool = $poolManager->pool('default::write'); + $pool->release($pool->borrow()); + + $poolManager->purge('default::write'); + + $this->assertSame(0, $pool->getIdleCount()); + $this->assertNotSame($pool, $poolManager->pool('default')); + } + + public function testPurgeForConnectionRemovesBaseAndRolePools(): void + { + $container = $this->mockContainerWithPools([ + 'default' => $this->connectionConfig([ + 'read' => [ + 'host' => '127.0.0.2', + ], + ]), + 'cache' => $this->connectionConfig(), + ]); + + $poolManager = new PoolManager($container); + + $defaultPool = $poolManager->pool('default'); + $readPool = $poolManager->pool('default::read'); + $cachePool = $poolManager->pool('cache'); + + $defaultPool->release($defaultPool->borrow()); + $readPool->release($readPool->borrow()); + $cachePool->release($cachePool->borrow()); + + $poolManager->purgeForConnection('default::read'); + + $this->assertSame(0, $defaultPool->getIdleCount()); + $this->assertSame(0, $readPool->getIdleCount()); + $this->assertSame(1, $cachePool->getIdleCount()); + $this->assertNotSame($defaultPool, $poolManager->pool('default')); + $this->assertNotSame($readPool, $poolManager->pool('default::read')); + $this->assertSame($cachePool, $poolManager->pool('cache')); + } + + public function testPurgeForConnectionDetachesSelectionAndPrioritizesCancellation(): void + { + $ordinaryFailure = new RuntimeException('write pool close failed'); + $cancellation = new CanceledException; + $writePool = m::mock(DatabasePool::class); + $readPool = m::mock(DatabasePool::class); + $cachePool = m::mock(DatabasePool::class); + $poolManager = new PoolManager(m::mock(ContainerContract::class)); + $detachedPools = null; + + $writePool->shouldReceive('close')->once()->andReturnUsing( + function () use ($poolManager, &$detachedPools, $ordinaryFailure): never { + $detachedPools = $poolManager->getPools(); + + throw $ordinaryFailure; + } + ); + $readPool->shouldReceive('close')->once()->andThrow($cancellation); + $cachePool->shouldNotReceive('close'); + + $pools = new ReflectionProperty($poolManager, 'pools'); + $pools->setValue($poolManager, [ + 'default' => $writePool, + 'default::read' => $readPool, + 'cache' => $cachePool, + ]); + + try { + $poolManager->purgeForConnection('default::read'); + $this->fail('Expected pool close cancellation to propagate.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame(['cache' => $cachePool], $detachedPools); + $this->assertSame(['cache' => $cachePool], $poolManager->getPools()); + } + + /** + * Build a container that records independently constructed pools. + */ + private function publicationContainer(array &$candidates, array $poolOptions = []): Container + { + $container = new Container; + $container->instance(ContainerContract::class, $container); + $container->instance('config', new Repository(['database' => ['connections' => [ + 'default' => $this->connectionConfig(['pool' => ['heartbeat_interval' => 60.0, ...$poolOptions]]), + ]]])); + $container->instance('db.factory', new ConnectionFactory($container)); + $container->bind(DatabasePool::class, static function (Container $container, array $parameters) use (&$candidates): DatabasePool { + return $candidates[] = new PublicationDatabasePool($container, $parameters['name']); + }); + + return $container; + } + + private function mockContainerWithPools(?array $connections = null): m\MockInterface|ContainerContract + { + $connections ??= [ + 'default' => $this->connectionConfig(), + 'cache' => $this->connectionConfig(), + ]; + + $config = new Repository([ + 'database' => [ + 'connections' => $connections, + ], + ]); + + $container = m::mock(ContainerContract::class); + $factory = new ConnectionFactory($container); + + $container->shouldReceive('make')->with('config')->andReturn($config); + $container->shouldReceive('make')->with('db.factory')->andReturn($factory); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(false); + $container->shouldReceive('bound')->with('events')->andReturn(false); + $container->shouldReceive('make')->with(DatabasePool::class, m::any())->andReturnUsing( + fn ($class, $arguments) => new PoolManagerTestPool($container, $arguments['name']) + ); + + return $container; + } + + private function connectionConfig(array $overrides = []): array + { + return array_merge([ + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => 3306, + 'database' => 'test', + 'pool' => [ + 'min_retained_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat_interval' => null, + 'max_idle_time' => 60.0, + ], + ], $overrides); + } +} + +class PublicationDatabasePool extends DatabasePool +{ + public int $closeCount = 0; + + public ?Closure $starting = null; + + public ?Closure $closing = null; + + /** + * Start maintenance before running the controlled activation callback. + */ + public function start(): void + { + parent::start(); + + if ($this->starting !== null) { + ($this->starting)(); + } + } + + /** + * Close the pool after running the controlled cleanup callback. + */ + public function close(): void + { + ++$this->closeCount; + + try { + if ($this->closing !== null) { + ($this->closing)(); + } + } finally { + parent::close(); + } + } + + /** + * Create an inert connection for initialization and lifecycle tests. + */ + protected function createConnection(): PoolConnection + { + return new PoolManagerTestConnection($this->container, $this); + } +} + +class PoolManagerTestPool extends DatabasePool +{ + public function configForTest(): array + { + return $this->config; + } + + protected function createConnection(): PoolConnection + { + return new PoolManagerTestConnection($this->container, $this); + } +} + +class PoolManagerTestConnection extends Connection +{ + public int $closeCount = 0; + + public function close(): bool + { + ++$this->closeCount; + + return true; + } + + public function reconnect(): bool + { + return true; + } + + public function getActiveConnection(): mixed + { + return $this; + } +} diff --git a/tests/Filesystem/ClientPooledFilesystemTest.php b/tests/Filesystem/ClientPooledFilesystemTest.php index 7985452200..9b95957ce8 100644 --- a/tests/Filesystem/ClientPooledFilesystemTest.php +++ b/tests/Filesystem/ClientPooledFilesystemTest.php @@ -10,15 +10,15 @@ use Hypervel\Container\Container; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; +use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract; use Hypervel\Filesystem\ClientPooledFilesystem; use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\Image\ImageException; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; -use Hypervel\ObjectPool\Contracts\ObjectPool as ObjectPoolContract; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolManager; use Hypervel\ObjectPool\PoolOptions; @@ -57,7 +57,7 @@ protected function setUp(): void protected function tearDownInCoroutine(): void { - $this->pools->flush(); + $this->pools->purgeAll(); } protected function tearDown(): void @@ -80,8 +80,8 @@ public function testSynchronousOperationsBuildFreshStacksAroundOnePooledClient() $this->assertSame(1, $clientCreations); $this->assertSame(3, $stackCreations); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); - $this->assertSame(1, $this->pools->get('filesystem:test')->getObjectNumberInPool()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); + $this->assertSame(1, $this->pools->get('filesystem:test')->getIdleCount()); } public function testImageDefersAndBalancesItsClientBorrowUntilMaterialization(): void @@ -98,7 +98,7 @@ public function testImageDefersAndBalancesItsClientBorrowUntilMaterialization(): $this->assertSame('contents', $image->toBytes()); $this->assertSame(1, $clientCreations); $this->assertSame(1, $stackCreations); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); $this->assertSame('contents', $image->toBytes()); $this->assertSame(1, $stackCreations); @@ -120,7 +120,7 @@ public function testMissingImageReleasesItsClientBorrowAndReportsTheCallerPath() $this->assertSame(1, $clientCreations); $this->assertSame(1, $stackCreations); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); } public function testSynchronousFlysystemMethodsAndConditionableUseTheProxyBoundary(): void @@ -145,7 +145,7 @@ public function testSynchronousFlysystemMethodsAndConditionableUseTheProxyBounda })); $this->assertSame(1, $clientCreations); $this->assertSame(6, $stackCreations); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); } public function testCallbacksAreStoredPerDiskAndAppliedToEveryFreshStack(): void @@ -208,7 +208,7 @@ public function testBorrowScopedAccessorsExposeOnlyTheCurrentBorrow(): void $disk = $this->disk($clientCreations, $stackCreations); $client = $disk->withClient(function (object $client): object { - $this->assertSame(1, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(1, $this->pools->get('filesystem:test')->getBorrowedCount()); return $client; }); @@ -220,7 +220,7 @@ public function testBorrowScopedAccessorsExposeOnlyTheCurrentBorrow(): void $this->assertSame($this->adapter, $adapter); $this->assertSame(1, $clientCreations); $this->assertSame(3, $stackCreations); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); } #[DataProvider('rejectedInternalProvider')] @@ -273,13 +273,13 @@ function (object $client) use (&$releaseCalls): void { $stream = $disk->readStream('file.txt'); $this->assertIsResource($stream); - $this->assertSame(1, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(1, $this->pools->get('filesystem:test')->getBorrowedCount()); $this->assertSame(0, $releaseCalls); $this->assertSame('streamed', stream_get_contents($stream)); fclose($stream); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); $this->assertSame(1, $releaseCalls); } @@ -296,8 +296,8 @@ public function testNonResourceReadStreamResultReleasesImmediately(): void ); $this->assertNull($disk->readStream('missing.txt')); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); - $this->assertSame(1, $this->pools->get('filesystem:test')->getObjectNumberInPool()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); + $this->assertSame(1, $this->pools->get('filesystem:test')->getIdleCount()); } public function testInvalidStackFactoryResultDiscardsTheBorrowedClient(): void @@ -317,8 +317,8 @@ public function testInvalidStackFactoryResultDiscardsTheBorrowedClient(): void $this->assertStringContainsString('stack factories must return', $exception->getMessage()); } - $this->assertSame(0, $this->pools->get('filesystem:test')->getCurrentObjectNumber()); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getManagedCount()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); } public function testDiscardFailureDoesNotMaskAStackFactoryFailure(): void @@ -332,7 +332,7 @@ public function testDiscardFailureDoesNotMaskAStackFactoryFailure(): void $container->instance(ExceptionHandler::class, $handler); $pool = m::mock(ObjectPoolContract::class); - $pool->shouldReceive('get')->once()->andReturn($client); + $pool->shouldReceive('borrow')->once()->andReturn($client); $pool->shouldReceive('discard')->once()->with($client)->andThrow($discardFailure); $factory = m::mock(Factory::class); $factory->shouldReceive('getOrCreate')->once()->andReturn($pool); @@ -395,7 +395,7 @@ function (object $client) use (&$releaseCalls): void { $this->assertInstanceOf(IterableStreamedResponse::class, $result); $this->assertSame(206, $result->getStatusCode()); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); $this->assertSame(1, $clientCreations); $this->assertSame(2, $stackCreations); $this->assertSame(2, $releaseCalls); @@ -410,7 +410,7 @@ static function (string $chunk) use (&$content): bool { )); $this->assertSame('456', $content); - $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount()); $this->assertSame(1, $clientCreations); $this->assertSame(3, $stackCreations); $this->assertSame(3, $releaseCalls); @@ -444,7 +444,7 @@ static function (object $client) use ($releaseFailure): never { $this->assertSame($operationFailure, $exception); } - $this->assertSame(0, $this->pools->get('filesystem:test')->getCurrentObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getManagedCount()); } public function testReleaseCancellationSupersedesAnOperationFailure(): void @@ -471,7 +471,7 @@ static function () use ($releaseCancellation): never { $this->assertSame($releaseCancellation, $exception); } - $this->assertSame(0, $this->pools->get('filesystem:test')->getCurrentObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:test')->getManagedCount()); } private function definition(): PoolDefinition @@ -481,8 +481,8 @@ private function definition(): PoolDefinition 's3', 'auto:test', PoolOptions::fromArray([ - 'max_lifetime' => 0, - 'idle_ttl' => null, + 'max_lifetime' => null, + 'pool_idle_timeout' => null, ]), ); } diff --git a/tests/Filesystem/FileResponseBuilderTest.php b/tests/Filesystem/FileResponseBuilderTest.php index fabdae175c..b337ac14cd 100644 --- a/tests/Filesystem/FileResponseBuilderTest.php +++ b/tests/Filesystem/FileResponseBuilderTest.php @@ -11,9 +11,9 @@ use Hypervel\Filesystem\LeasedStream; use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\Request; +use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Tests\TestCase; use League\Flysystem\UnableToReadFile; use Mockery as m; @@ -445,7 +445,7 @@ static function (string $chunk) use (&$writeCalls, &$bytesAttempted): bool { public function testAStreamBackedByALeaseReleasesAfterEmission(): void { - $pool = new SimpleObjectPool( + $pool = new CallbackObjectPool( static fn (): object => new stdClass, PoolOptions::fromArray([]), ); @@ -454,17 +454,17 @@ public function testAStreamBackedByALeaseReleasesAfterEmission(): void $response = $this->build( Request::create('/file.txt', 'GET'), function (?int $start, ?int $end) use ($pool): mixed { - $lease = new Lease($pool, $pool->get()); + $lease = new Lease($pool, $pool->borrow()); return LeasedStream::wrap($this->stream('leased'), $lease); }, 6, ); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); $this->assertSame('leased', $this->streamedContent($response)); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } finally { $pool->close(); } diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 629980f7cb..c082f14dca 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -13,13 +13,13 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Filesystem\Filesystem; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Filesystem\AwsS3V3Adapter; use Hypervel\Filesystem\ClientPooledFilesystem; use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Filesystem\FilesystemManager; use Hypervel\Filesystem\FilesystemPoolProxy; use Hypervel\Filesystem\GoogleCloudStorageAdapter; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\PoolFingerprint; use Hypervel\ObjectPool\PoolManager; use Hypervel\Testing\ParallelTesting; @@ -73,7 +73,7 @@ protected function setUp(): void protected function tearDownInCoroutine(): void { foreach ($this->poolManagers as $poolManager) { - $poolManager->flush(); + $poolManager->purgeAll(); } } @@ -348,7 +348,7 @@ public function testScopedDiskPreservesViewBehaviorAndPoolOverrides(): void $exceptionHandler = m::mock(ExceptionHandler::class); $exceptionHandler->shouldReceive('report')->once()->with(m::type(UnableToWriteFile::class)); $container->instance(ExceptionHandler::class, $exceptionHandler); - $filesystem = (new FilesystemManager($container))->addPoolable('local'); + $filesystem = (new FilesystemManager($container))->addPoolableDriver('local'); $disk = $filesystem->disk('archive'); $this->assertInstanceOf(FilesystemPoolProxy::class, $disk); @@ -363,7 +363,7 @@ public function testScopedDiskPreservesViewBehaviorAndPoolOverrides(): void public function testDisksDoNotExposePoolConstructionMetadata(): void { - $manager = (new FilesystemManager($this->getContainer()))->addPoolable('local'); + $manager = (new FilesystemManager($this->getContainer()))->addPoolableDriver('local'); $direct = (new FilesystemManager($this->getContainer()))->build([ 'driver' => 'local', 'root' => $this->tempDir . '/unpooled-local', @@ -638,7 +638,7 @@ public function testPoolableDriver(): void ], ]); $filesystem = (new FilesystemManager($container)) - ->addPoolable('local'); + ->addPoolableDriver('local'); Container::setInstance($container); @@ -666,7 +666,7 @@ public function testS3DisksWithTheSameClientConfigShareOneClientPoolAcrossBucket $archivesClient = $archives->withClient(static fn (object $client): object => $client); $this->assertSame($documentsClient, $archivesClient); - $this->assertCount(1, $container->make(PoolFactory::class)->pools()); + $this->assertCount(1, $container->make(PoolFactory::class)->getPools()); } public function testS3DisksWithDifferentCredentialsUseDifferentClientPools(): void @@ -687,7 +687,7 @@ public function testS3DisksWithDifferentCredentialsUseDifferentClientPools(): vo $first->withClient(static fn (object $client): object => $client), $second->withClient(static fn (object $client): object => $client), ); - $this->assertCount(2, $container->make(PoolFactory::class)->pools()); + $this->assertCount(2, $container->make(PoolFactory::class)->getPools()); } public function testRepeatedOnDemandS3BuildsConvergeWithoutNameCollisions(): void @@ -949,7 +949,7 @@ public function testForgottenScopedPurgeUsesTheConfiguredNameForWholeDriverPools ], ]); Container::setInstance($container); - $manager = (new FilesystemManager($container))->addPoolable('local'); + $manager = (new FilesystemManager($container))->addPoolableDriver('local'); $disk = $manager->disk('target'); $this->assertFalse($disk->exists('missing.txt')); @@ -979,7 +979,7 @@ public function testPurgingNamedScopedDiskLeavesAnonymousWholeDriverPoolAlone(): ], ]); Container::setInstance($container); - $manager = (new FilesystemManager($container))->addPoolable('local'); + $manager = (new FilesystemManager($container))->addPoolableDriver('local'); $named = $manager->disk('target'); $anonymous = $manager->build($scopedConfig); @@ -1263,7 +1263,7 @@ public function testPoolableBuiltInDriversIncludeTheLogicalNameInConstructionIde 'ondemand' => $config, ], ]); - $manager = (new FilesystemManager($container))->addPoolable('local'); + $manager = (new FilesystemManager($container))->addPoolableDriver('local'); $first = $manager->disk('first'); $second = $manager->disk('second'); @@ -1289,7 +1289,7 @@ public function testWholeDriverPoolsIncludeRouteOwnershipNotImpliedByEffectiveCo 'served' => $base, ], ]); - $manager = (new FilesystemManager($container))->addPoolable('local'); + $manager = (new FilesystemManager($container))->addPoolableDriver('local'); $inlineParent = $manager->build([ 'driver' => 'scoped', 'disk' => $base, @@ -1313,7 +1313,7 @@ public function testWholeDriverPoolsDistinguishAnonymousServingIntent(): void 'driver' => 'local', 'root' => $this->tempDir . '/anonymous-serving-pools', ]; - $manager = (new FilesystemManager($this->getContainer()))->addPoolable('local'); + $manager = (new FilesystemManager($this->getContainer()))->addPoolableDriver('local'); $unserved = $manager->build($config); $served = $manager->build([...$config, 'serve' => true]); diff --git a/tests/Filesystem/FilesystemPoolProxyTest.php b/tests/Filesystem/FilesystemPoolProxyTest.php index b4054e3ba9..fdef382ff1 100644 --- a/tests/Filesystem/FilesystemPoolProxyTest.php +++ b/tests/Filesystem/FilesystemPoolProxyTest.php @@ -51,7 +51,7 @@ protected function setUp(): void protected function tearDownInCoroutine(): void { - $this->pools->flush(); + $this->pools->purgeAll(); } protected function tearDown(): void @@ -75,8 +75,8 @@ public function testSynchronousOperationsUseAndReleaseAWholeDriver(): void $this->assertTrue($proxy->exists('file.txt')); $this->assertSame('contents', $proxy->get('file.txt')); $this->assertSame(1, $creations); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); - $this->assertSame(1, $this->pools->get('filesystem:driver')->getObjectNumberInPool()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); + $this->assertSame(1, $this->pools->get('filesystem:driver')->getIdleCount()); } public function testJsonReturnsScalarDataAndReleasesTheDriver(): void @@ -85,7 +85,7 @@ public function testJsonReturnsScalarDataAndReleasesTheDriver(): void $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); $this->assertSame('value', $proxy->json('value.json')); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); } public function testImageDefersAndBalancesTheWholeDriverLease(): void @@ -111,7 +111,7 @@ function (object $filesystem) use (&$releaseCalls): void { $this->assertSame('image bytes', $image->toBytes()); $this->assertSame(1, $creations); $this->assertSame(1, $releaseCalls); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->assertSame('image bytes', $image->toBytes()); $this->assertSame(1, $releaseCalls); @@ -139,7 +139,7 @@ function (object $filesystem) use (&$releaseCalls): void { } $this->assertSame(1, $releaseCalls); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); } public function testAssertEmptyReturnsTheProxyAndReleasesTheDriver(): void @@ -147,7 +147,7 @@ public function testAssertEmptyReturnsTheProxyAndReleasesTheDriver(): void $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); $this->assertSame($proxy, $proxy->assertEmpty()); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); } public function testSynchronousFlysystemMethodsAndConditionableUseTheProxyBoundary(): void @@ -166,7 +166,7 @@ public function testSynchronousFlysystemMethodsAndConditionableUseTheProxyBounda $this->assertSame($proxy, $candidate); $this->assertSame($proxy, $candidate->unless(false, static fn (): null => null)); })); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); } public function testEveryCallbackSlotIsWrittenOnEveryBorrowAcrossSharedProxies(): void @@ -208,13 +208,13 @@ function (object $filesystem) use (&$releaseCalls): void { $stream = $proxy->readStream('file.txt'); $this->assertIsResource($stream); - $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->assertSame(0, $releaseCalls); $this->assertSame('streamed', stream_get_contents($stream)); fclose($stream); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->assertSame(1, $releaseCalls); } @@ -232,13 +232,13 @@ function (object $filesystem) use (&$releaseCalls): void { $stream = $proxy->readStreamRange('file.txt', 3, 5); $this->assertIsResource($stream); - $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->assertSame(0, $releaseCalls); $this->assertSame('345', stream_get_contents($stream)); fclose($stream); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->assertSame(1, $releaseCalls); } @@ -247,7 +247,7 @@ public function testBorrowScopedAccessorsExposeOnlyTheCurrentDriverBorrow(): voi $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); $driver = $proxy->withDriver(function (object $driver): object { - $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedCount()); return $driver; }); @@ -255,7 +255,7 @@ public function testBorrowScopedAccessorsExposeOnlyTheCurrentDriverBorrow(): voi $this->assertSame($this->driver, $driver); $this->assertSame($this->adapter, $adapter); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('does not support [getClient] access'); @@ -299,8 +299,8 @@ public function testContractOnlyFilesystemPoolsWhenCallbacksAreUnset(): void $proxy = $this->proxy(static fn (): FilesystemContract => $filesystem); $this->assertTrue($proxy->exists('file.txt')); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); - $this->assertSame(1, $this->pools->get('filesystem:driver')->getObjectNumberInPool()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); + $this->assertSame(1, $this->pools->get('filesystem:driver')->getIdleCount()); } public function testSettingCallbacksOnAContractOnlyFilesystemFailsAndDiscardsIt(): void @@ -317,7 +317,7 @@ public function testSettingCallbacksOnAContractOnlyFilesystemFailsAndDiscardsIt( $this->assertStringContainsString($filesystem::class, $exception->getMessage()); } - $this->assertSame(0, $this->pools->get('filesystem:driver')->getCurrentObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getManagedCount()); } public function testResponseUsesShortBorrowsAndClosesTheStreamLease(): void @@ -337,7 +337,7 @@ function (object $filesystem) use (&$releaseCalls): void { $this->assertInstanceOf(IterableStreamedResponse::class, $result); $this->assertSame(206, $result->getStatusCode()); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->assertSame(2, $releaseCalls); $content = ''; @@ -350,7 +350,7 @@ static function (string $chunk) use (&$content): bool { )); $this->assertSame('234', $content); - $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount()); $this->assertSame(3, $releaseCalls); } @@ -393,7 +393,7 @@ static function () use ($releaseCancellation): never { $this->assertSame($releaseCancellation, $exception); } - $this->assertSame(0, $this->pools->get('filesystem:driver')->getCurrentObjectNumber()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getManagedCount()); } private function definition(): PoolDefinition @@ -403,17 +403,17 @@ private function definition(): PoolDefinition 'custom', 'auto:driver', PoolOptions::fromArray([ - 'max_lifetime' => 0, - 'idle_ttl' => null, + 'max_lifetime' => null, + 'pool_idle_timeout' => null, ]), ); } - private function proxy(Closure $resolver, ?Closure $releaseCallback = null): FilesystemPoolProxy + private function proxy(Closure $createCallback, ?Closure $releaseCallback = null): FilesystemPoolProxy { return new FilesystemPoolProxy( $this->definition(), - $resolver, + $createCallback, $this->pools, ['driver' => 'custom'], $releaseCallback, diff --git a/tests/Filesystem/LeasedStreamTest.php b/tests/Filesystem/LeasedStreamTest.php index cf9f138bd7..5798812e96 100644 --- a/tests/Filesystem/LeasedStreamTest.php +++ b/tests/Filesystem/LeasedStreamTest.php @@ -8,9 +8,9 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Filesystem\LeasedStream; +use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; @@ -21,7 +21,7 @@ class LeasedStreamTest extends TestCase { - /** @var list */ + /** @var list */ private array $pools = []; protected function tearDownInCoroutine(): void @@ -38,15 +38,15 @@ public function testReadEofAndRewindDoNotReleaseUntilClose(): void $this->assertSame('contents', stream_get_contents($stream)); $this->assertTrue(feof($stream)); - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); rewind($stream); $this->assertSame('contents', stream_get_contents($stream)); - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); fclose($stream); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testExplicitCloseAndStreamResourceDestructionReleaseExactlyOnce(): void @@ -65,7 +65,7 @@ function () use (&$releaseCount): void { gc_collect_cycles(); $this->assertSame(1, $releaseCount); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testAbandonedWrapperClosesInnerStreamAndReleasesLease(): void @@ -77,8 +77,8 @@ public function testAbandonedWrapperClosesInnerStreamAndReleasesLease(): void gc_collect_cycles(); $this->assertFalse(is_resource($inner)); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testSeekTellAndStatForwardToTheInnerStream(): void @@ -101,7 +101,7 @@ public function testStreamCastKeepsStreamSelectWorking(): void $this->assertIsArray($sockets); [$inner, $writer] = $sockets; $pool = $this->pool(); - $lease = new Lease($pool, $pool->get()); + $lease = new Lease($pool, $pool->borrow()); $stream = LeasedStream::wrap($inner, $lease); fwrite($writer, 'ready'); $read = [$stream]; @@ -125,7 +125,7 @@ public function testSupportedStreamOptionsForwardAndUnsupportedOptionsFail(): vo $inner = fopen(RecordingStreamWrapper::PROTOCOL . '://stream', 'r+'); $this->assertIsResource($inner); $pool = $this->pool(); - $stream = LeasedStream::wrap($inner, new Lease($pool, $pool->get())); + $stream = LeasedStream::wrap($inner, new Lease($pool, $pool->borrow())); $this->assertTrue(stream_set_blocking($stream, false)); $this->assertTrue(stream_set_timeout($stream, 1, 500_000)); @@ -154,7 +154,7 @@ public function testSupportedStreamOptionsForwardAndUnsupportedOptionsFail(): vo public function testInvalidResourceIsRejectedWithoutTakingLeaseOwnership(): void { $pool = $this->pool(); - $lease = new Lease($pool, $pool->get()); + $lease = new Lease($pool, $pool->borrow()); try { LeasedStream::wrap('not-a-resource', $lease); @@ -163,7 +163,7 @@ public function testInvalidResourceIsRejectedWithoutTakingLeaseOwnership(): void $this->assertSame('LeasedStream::wrap() expects an open stream resource.', $exception->getMessage()); } - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); $lease->release(); } @@ -176,7 +176,7 @@ public function testReleaseFailureDuringCloseIsReportedAndSwallowed(): void $handler->shouldReceive('report')->once()->with($failure); $container->instance(ExceptionHandler::class, $handler); $pool = $this->pool(); - $lease = new Lease($pool, $pool->get(), function () use ($failure): never { + $lease = new Lease($pool, $pool->borrow(), function () use ($failure): never { throw $failure; }); $inner = fopen('php://temp', 'r+'); @@ -185,8 +185,8 @@ public function testReleaseFailureDuringCloseIsReportedAndSwallowed(): void fclose($stream); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testProtocolCollisionClosesResourceAndFinalizesLeaseTransactionally(): void @@ -269,7 +269,7 @@ private function runFailureProbe( use Hypervel\Filesystem\LeasedStream; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; - use Hypervel\ObjectPool\SimpleObjectPool; + use Hypervel\ObjectPool\CallbackObjectPool; class ForeignLeasedStreamWrapper { @@ -279,11 +279,11 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ } } - $pool = new SimpleObjectPool( + $pool = new CallbackObjectPool( static fn (): object => new stdClass, PoolOptions::fromArray([]), ); - $lease = new Lease($pool, $pool->get(), __RELEASE_CALLBACK__); + $lease = new Lease($pool, $pool->borrow(), __RELEASE_CALLBACK__); $resource = \fopen('php://temp', 'r+'); __SETUP__ $class = RuntimeException::class; @@ -298,8 +298,8 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ echo json_encode([ 'resource_closed' => ! is_resource($resource), - 'borrowed' => $pool->getBorrowedObjectNumber(), - 'idle' => $pool->getObjectNumberInPool(), + 'borrowed' => $pool->getBorrowedCount(), + 'idle' => $pool->getIdleCount(), 'class' => $class, 'message' => $message, ], JSON_THROW_ON_ERROR); @@ -331,12 +331,12 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ } /** - * @return array{0: SimpleObjectPool, 1: Lease, 2: resource} + * @return array{0: CallbackObjectPool, 1: Lease, 2: resource} */ private function leaseWithStream(string $contents, ?Closure $releaseCallback = null): array { $pool = $this->pool(); - $lease = new Lease($pool, $pool->get(), $releaseCallback); + $lease = new Lease($pool, $pool->borrow(), $releaseCallback); $inner = fopen('php://temp', 'r+'); $this->assertIsResource($inner); fwrite($inner, $contents); @@ -348,9 +348,9 @@ private function leaseWithStream(string $contents, ?Closure $releaseCallback = n /** * Create a tracked object pool. */ - private function pool(): SimpleObjectPool + private function pool(): CallbackObjectPool { - $this->pools[] = $pool = new SimpleObjectPool( + $this->pools[] = $pool = new CallbackObjectPool( static fn (): object => new stdClass, PoolOptions::fromArray([]), ); diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index a313d9977b..6ed8f8f574 100644 --- a/tests/Foundation/FoundationConfigTest.php +++ b/tests/Foundation/FoundationConfigTest.php @@ -5,11 +5,12 @@ namespace Hypervel\Tests\Foundation; use Hypervel\Config\Repository; +use Hypervel\ConnectionPool\PoolOptions; use Hypervel\Container\Container; use Hypervel\Foundation\Application; -use Hypervel\Pool\PoolOption; use Hypervel\Redis\RedisConfig; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Swoole\Constant; class FoundationConfigTest extends TestCase @@ -159,12 +160,13 @@ public function testShippedRedisConnectionsExposeCanonicalSettingsAndUseOptional 'events', ]; $visiblePoolMembers = [ - 'min_connections', + 'min_retained_connections', 'max_connections', 'connect_timeout', 'wait_timeout', - 'heartbeat', + 'heartbeat_interval', 'heartbeat_timeout', + 'idle_check_interval', 'max_idle_time', 'max_lifetime', ]; @@ -198,9 +200,9 @@ public function testRedisOmissionDefaultsMatchShippedDefaults(): void 'REDIS_BACKOFF_ALGORITHM' => null, 'REDIS_BACKOFF_BASE' => null, 'REDIS_BACKOFF_CAP' => null, - 'REDIS_MIN_CONNECTIONS' => null, + 'REDIS_MIN_RETAINED_CONNECTIONS' => null, 'REDIS_MAX_CONNECTIONS' => null, - 'REDIS_HEARTBEAT' => null, + 'REDIS_HEARTBEAT_INTERVAL' => null, 'REDIS_HEARTBEAT_TIMEOUT' => null, 'REDIS_MAX_IDLE_TIME' => null, 'REDIS_MAX_LIFETIME' => null, @@ -224,17 +226,102 @@ public function testRedisOmissionDefaultsMatchShippedDefaults(): void $this->assertSame($shippedConnection[$option], $effectiveConnection[$option]); } - $pool = new PoolOption; + $pool = PoolOptions::fromArray([]); $shippedPool = $shippedConnection['pool']; - $this->assertSame($shippedPool['min_connections'], $pool->getMinConnections()); - $this->assertSame($shippedPool['max_connections'], $pool->getMaxConnections()); - $this->assertSame($shippedPool['connect_timeout'], $pool->getConnectTimeout()); - $this->assertSame($shippedPool['wait_timeout'], $pool->getWaitTimeout()); - $this->assertSame($shippedPool['heartbeat'], $pool->getHeartbeat()); - $this->assertSame($shippedPool['heartbeat_timeout'], $pool->getHeartbeatTimeout()); - $this->assertSame($shippedPool['max_idle_time'], $pool->getMaxIdleTime()); - $this->assertSame($shippedPool['max_lifetime'], $pool->getMaxLifetime()); + $this->assertSame($shippedPool['min_retained_connections'], $pool->minRetainedConnections); + $this->assertSame($shippedPool['max_connections'], $pool->maxConnections); + $this->assertSame($shippedPool['connect_timeout'], $pool->connectTimeout); + $this->assertSame($shippedPool['wait_timeout'], $pool->waitTimeout); + $this->assertSame($shippedPool['heartbeat_interval'], $pool->heartbeatInterval); + $this->assertSame($shippedPool['heartbeat_timeout'], $pool->heartbeatTimeout); + $this->assertSame($shippedPool['idle_check_interval'], $pool->idleCheckInterval); + $this->assertSame($shippedPool['max_idle_time'], $pool->maxIdleTime); + $this->assertSame($shippedPool['max_lifetime'], $pool->maxLifetime); + } + + #[DataProvider('nullablePoolDurations')] + public function testDatabasePoolDurationsPreserveNullAndNormalizeNumbers(?string $value, ?float $expected): void + { + $environment = []; + + foreach (['DB', 'DB_POOLED'] as $prefix) { + foreach (['HEARTBEAT_INTERVAL', 'MAX_IDLE_TIME', 'MAX_LIFETIME'] as $option) { + $environment["{$prefix}_{$option}"] = $value; + } + } + + $config = $this->withEnvironmentValues($environment, function (): array { + return require dirname(__DIR__, 2) . '/src/foundation/config/database.php'; + }); + + foreach (['mysql', 'mariadb', 'pgsql', 'pgsql-pooled'] as $name) { + $options = PoolOptions::fromArray($config['connections'][$name]['pool']); + + $this->assertSame($expected, $options->heartbeatInterval); + $this->assertSame($value === null ? 60.0 : $expected, $options->maxIdleTime); + $this->assertSame($expected, $options->maxLifetime); + $this->assertNull($options->idleCheckInterval); + } + } + + /** + * Supply omitted, disabled and enabled environment durations. + */ + public static function nullablePoolDurations(): array + { + return [ + 'omitted' => [null, null], + 'null' => ['null', null], + 'parenthesized null' => ['(null)', null], + 'positive' => ['12.5', 12.5], + ]; + } + + #[DataProvider('inheritedPoolDurations')] + public function testRedisPoolDurationsPreserveInheritanceAndExplicitNull( + ?string $inherited, + ?string $override, + ?float $expected, + ): void { + $environment = []; + + foreach (['HEARTBEAT_INTERVAL', 'MAX_IDLE_TIME', 'MAX_LIFETIME'] as $option) { + $environment["REDIS_{$option}"] = $inherited; + + foreach (['CACHE', 'SESSION', 'QUEUE', 'REVERB'] as $prefix) { + $environment["REDIS_{$prefix}_{$option}"] = $override; + } + } + + $config = $this->withEnvironmentValues($environment, function (): array { + return require dirname(__DIR__, 2) . '/src/foundation/config/database.php'; + }); + + foreach (['cache', 'session', 'queue', 'reverb'] as $name) { + $options = PoolOptions::fromArray($config['redis'][$name]['pool']); + + $this->assertSame($expected, $options->heartbeatInterval); + $this->assertSame($inherited === null && $override === null ? 60.0 : $expected, $options->maxIdleTime); + $this->assertSame($expected, $options->maxLifetime); + $this->assertNull($options->idleCheckInterval); + } + } + + /** + * Supply inherited durations and connection-specific overrides. + */ + public static function inheritedPoolDurations(): array + { + return [ + 'omitted' => [null, null, null], + 'inherited positive' => ['12.5', null, 12.5], + 'explicit null' => ['12.5', 'null', null], + 'explicit parenthesized null' => ['12.5', '(null)', null], + 'inherited null' => ['null', null, null], + 'positive override' => ['12.5', '25.5', 25.5], + 'positive override of null' => ['(null)', '25.5', 25.5], + ]; } public function testShippedFilesystemDisksDeclareVisibilityAndFailurePolicy(): void diff --git a/tests/Foundation/Testing/ApplicationBootstrapTest.php b/tests/Foundation/Testing/ApplicationBootstrapTest.php index 4166ca4c3b..60b0113c46 100644 --- a/tests/Foundation/Testing/ApplicationBootstrapTest.php +++ b/tests/Foundation/Testing/ApplicationBootstrapTest.php @@ -99,7 +99,7 @@ protected function setUp(): void 'driver' => 'sqlite', 'database' => ':memory:', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, 'wait_timeout' => 0.05, ], diff --git a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php index 1da8a96d96..c2cdf4c348 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Contracts\Pool\ConnectionInterface as PoolConnectionInterface; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Foundation\Testing\TestCase as FoundationTestCase; @@ -47,7 +47,7 @@ public function testFoundationTeardownAttemptsEveryPhaseAndPreservesTheEarliestF $parallelTesting = $this->app->make(ParallelTestingService::class); try { - $pooledConnection = m::mock(PoolConnectionInterface::class); + $pooledConnection = m::mock(PoolConnection::class); $pooledConnection->shouldReceive('discard')->once()->andReturnUsing( function () use (&$steps, $databaseException): never { $steps[] = 'database'; @@ -59,8 +59,8 @@ function () use (&$steps, $databaseException): never { (new ReflectionProperty(DatabaseConnectionResolver::class, 'pooledConnections')) ->setValue(null, ['default' => $pooledConnection]); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('flushAll')->once()->andReturnUsing( + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('purgeAll')->once()->andReturnUsing( function () use (&$steps, $poolException): never { $steps[] = 'pool'; @@ -69,8 +69,8 @@ function () use (&$steps, $poolException): never { ); $app = m::mock(ApplicationContract::class); - $app->shouldReceive('resolved')->once()->with(PoolFactory::class)->andReturnTrue(); - $app->shouldReceive('make')->once()->with(PoolFactory::class)->andReturn($poolFactory); + $app->shouldReceive('resolved')->once()->with(PoolManager::class)->andReturnTrue(); + $app->shouldReceive('make')->once()->with(PoolManager::class)->andReturn($poolManager); $app->shouldReceive('flush')->once()->andReturnUsing( function () use (&$steps, $applicationException): never { $steps[] = 'application'; diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index aaef10ad4b..465f0dad09 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -8,7 +8,7 @@ use Hypervel\Database\Connection; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Pool\PooledConnection; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -252,8 +252,8 @@ public function testTerminalFlushDiscardsEveryCachedWrapper(): void public function testDiscardInvalidatesOnlyItsBareSharedSqliteConnection(): void { - $pool = $this->app->make(PoolFactory::class)->getPool('testing'); - $pooled = $pool->get(); + $pool = $this->app->make(PoolManager::class)->pool('testing'); + $pooled = $pool->borrow(); $this->assertInstanceOf(PooledConnection::class, $pooled); $connection = $pooled->getConnection(); $connection->statement('create table ownership_test (value varchar)'); @@ -262,7 +262,7 @@ public function testDiscardInvalidatesOnlyItsBareSharedSqliteConnection(): void $pooled->discard(); $this->assertNull($connection->getRawPdo()); - $replacement = $pool->get(); + $replacement = $pool->borrow(); $this->assertSame( 'preserved', $replacement->getConnection()->selectOne('select value from ownership_test')->value, @@ -272,17 +272,17 @@ public function testDiscardInvalidatesOnlyItsBareSharedSqliteConnection(): void public function testDiscardAndReconnectRollBackSharedSqliteTransactions(): void { - $pool = $this->app->make(PoolFactory::class)->getPool('testing'); + $pool = $this->app->make(PoolManager::class)->pool('testing'); $sharedPdo = $pool->getSharedInMemorySqlitePdo(); $this->assertNotNull($sharedPdo); - $discarded = $pool->get(); + $discarded = $pool->borrow(); $discarded->getConnection()->beginTransaction(); $this->assertTrue($sharedPdo->inTransaction()); $discarded->discard(); $this->assertFalse($sharedPdo->inTransaction()); - $reconnected = $pool->get(); + $reconnected = $pool->borrow(); $reconnected->getConnection()->beginTransaction(); $this->assertTrue($sharedPdo->inTransaction()); $this->assertTrue($reconnected->reconnect()); diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 9278fb9f8f..027c3fea04 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -31,11 +31,11 @@ use Hypervel\Contracts\Cache\Store as CacheStore; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Foundation\CachesRoutes; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Request; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\PoolManager; use Hypervel\Redis\RedisProxy; use Hypervel\Routing\Route; @@ -541,7 +541,7 @@ public function testAuthenticatedUserResolverWorksThroughPooledManagerDriver(): 'custom', fn () => new ManagerUserAuthenticationBroadcaster($app) ); - $broadcastManager->addPoolable('custom'); + $broadcastManager->addPoolableDriver('custom'); $broadcastManager->resolveAuthenticatedUserUsing(function (Request $request): array { return ['id' => 'user-' . $request->input('socket_id')]; @@ -578,7 +578,7 @@ function (ContainerContract $container, array $config) use (&$received): Broadca return new ManagerUserAuthenticationBroadcaster($container); } ); - $manager->addPoolable('custom'); + $manager->addPoolableDriver('custom'); $first = $manager->connection('first'); $second = $manager->connection('second'); @@ -624,8 +624,8 @@ public function testBuiltInSdkDriversResolveDirectlyWithoutDefaultPools(): void $this->assertInstanceOf(PusherBroadcaster::class, $pusherBroadcaster); $this->assertInstanceOf(AblyBroadcaster::class, $ablyBroadcaster); - $this->assertSame([], $app->make(PoolFactory::class)->pools()); - $this->assertSame([], $manager->getPoolables()); + $this->assertSame([], $app->make(PoolFactory::class)->getPools()); + $this->assertSame([], $manager->getPoolableDrivers()); $replacementPusher = m::mock(Pusher::class); $manager->setDefaultDriver('pusher'); @@ -659,7 +659,7 @@ public function testPurgeInvalidatesCachedAndUncachedBroadcasterPoolsWhileForget 'custom', fn (ContainerContract $container) => new ManagerUserAuthenticationBroadcaster($container) ); - $manager->addPoolable('custom'); + $manager->addPoolableDriver('custom'); $driver = $manager->connection('custom'); $this->assertInstanceOf(BroadcastPoolProxy::class, $driver); @@ -693,7 +693,7 @@ public function testPooledConstructionFailureNamesTheDriverNotAConvergedConnecti ]); $app->singleton('redis', fn () => throw new Exception('Redis unavailable.')); $manager = new BroadcastManager($app); - $manager->addPoolable('redis'); + $manager->addPoolableDriver('redis'); $first = $manager->connection('first'); $second = $manager->connection('second'); diff --git a/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php b/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php index 798727fdb9..1f1e1eb765 100644 --- a/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php +++ b/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php @@ -24,7 +24,7 @@ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - $app->make('config')->set('database.redis.cache.pool.min_connections', 1); + $app->make('config')->set('database.redis.cache.pool.min_retained_connections', 1); $app->make('config')->set('database.redis.cache.pool.max_connections', 1); $app->make('config')->set('database.redis.cache.pool.wait_timeout', 0.25); } diff --git a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php index 40b8c2cfde..4b5ee27459 100644 --- a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php +++ b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php @@ -13,7 +13,7 @@ use Hypervel\Database\DatabaseManager; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\PdoConnection; -use Hypervel\Database\Pool\DbPool; +use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\SessionConfigurator; @@ -86,7 +86,7 @@ protected function defineEnvironment($app): void 'pool' => [ 'testing_enabled' => true, 'max_connections' => 5, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); @@ -95,9 +95,9 @@ protected function defineEnvironment($app): void 'database' => static::$sessionPath, 'pool' => [ 'testing_enabled' => true, - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); } @@ -555,7 +555,7 @@ public function testSessionConfiguratorReadsCoroutineContextOnEachPooledHandOut( { $configurator = new CoroutineSessionConfigurator('session_context_pool'); PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'session_context_pool'); + $pool = new DatabasePool($this->app, 'session_context_pool'); $firstFinished = new Channel(1); try { @@ -564,7 +564,7 @@ function () use ($pool, $firstFinished): int { CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '101'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { return (int) $pooledConnection->getConnection() @@ -580,7 +580,7 @@ function () use ($pool, $firstFinished): int { CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '202'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { return (int) $pooledConnection->getConnection() @@ -594,7 +594,7 @@ function () use ($pool, $firstFinished): int { CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '202'); /** @var PooledConnection $matchingPooledConnection */ - $matchingPooledConnection = $pool->get(); + $matchingPooledConnection = $pool->borrow(); try { $matchingValue = (int) $matchingPooledConnection->getConnection() diff --git a/tests/Integration/Database/ConnectionLockTimeoutTest.php b/tests/Integration/Database/ConnectionLockTimeoutTest.php index 76569a9e16..0d1405bd25 100644 --- a/tests/Integration/Database/ConnectionLockTimeoutTest.php +++ b/tests/Integration/Database/ConnectionLockTimeoutTest.php @@ -7,7 +7,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\ConcurrencyErrorDetector; use Hypervel\Database\Connection; -use Hypervel\Database\Pool\DbPool; +use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\QueryException; use Hypervel\Database\Schema\Blueprint; @@ -32,9 +32,9 @@ protected function defineEnvironment(ApplicationContract $app): void $connection['lock_timeout'] = 1; $connection['pool'] = [ 'testing_enabled' => true, - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ]; $config->set('database.connections.' . self::CONNECTION_NAME, $connection); @@ -46,10 +46,10 @@ public function testLockTimeoutIsAppliedWhenPooledConnectionsAreCreatedAndReconn $this->markTestSkipped('SQLite uses its existing busy_timeout connection option.'); } - $pool = new DbPool($this->app, self::CONNECTION_NAME); + $pool = new DatabasePool($this->app, self::CONNECTION_NAME); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $connection = $pooledConnection->getConnection(); @@ -83,11 +83,11 @@ public function testLockTimeoutErrorsAreClassifiedAndRetried(): void $this->markTestSkipped('SQLite uses its existing busy_timeout connection option.'); } - $holderPool = new DbPool($this->app, self::CONNECTION_NAME); - $contenderPool = new DbPool($this->app, self::CONNECTION_NAME); + $holderPool = new DatabasePool($this->app, self::CONNECTION_NAME); + $contenderPool = new DatabasePool($this->app, self::CONNECTION_NAME); /** @var PooledConnection $setupConnection */ - $setupConnection = $holderPool->get(); + $setupConnection = $holderPool->borrow(); try { $schema = $setupConnection->getConnection()->getSchemaBuilder(); @@ -107,7 +107,7 @@ public function testLockTimeoutErrorsAreClassifiedAndRetried(): void [$holderCompleted, $contenderResult] = parallel([ function () use ($holderPool, $lockAcquired, $releaseLock): bool { /** @var PooledConnection $pooledConnection */ - $pooledConnection = $holderPool->get(); + $pooledConnection = $holderPool->borrow(); $connection = $pooledConnection->getConnection(); try { @@ -129,7 +129,7 @@ function () use ($contenderPool, $lockAcquired, $releaseLock): array { $lockAcquired->pop(5); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $contenderPool->get(); + $pooledConnection = $contenderPool->borrow(); $connection = $pooledConnection->getConnection(); try { @@ -180,7 +180,7 @@ function (Connection $connection) use (&$attempts, $releaseLock): int { $this->assertLessThan(3.0, $contenderResult['lock_wait_seconds']); } finally { /** @var PooledConnection $cleanupConnection */ - $cleanupConnection = $holderPool->get(); + $cleanupConnection = $holderPool->borrow(); try { $cleanupConnection->getConnection()->getSchemaBuilder()->dropIfExists(self::LOCK_TABLE); diff --git a/tests/Integration/Database/DatabasePoolTeardownLifecycleTest.php b/tests/Integration/Database/DatabasePoolTeardownLifecycleTest.php new file mode 100644 index 0000000000..ba6713a53f --- /dev/null +++ b/tests/Integration/Database/DatabasePoolTeardownLifecycleTest.php @@ -0,0 +1,78 @@ +make('config'); + $default = $config->string('database.default'); + $config->set("database.connections.{$default}.pool.testing_enabled", true); + } + + public function testTearDownLifecyclePurgesDatabasePools(): void + { + // Exercise the application path that creates the pool owned by teardown. + DB::statement('SELECT 1'); + + $poolManager = $this->app->make(PoolManager::class); + $defaultName = $this->app->make('config')->string('database.default'); + $pool = $poolManager->pool($defaultName); + + $this->assertGreaterThan(0, $pool->getManagedCount()); + + self::$capturedManager = $poolManager; + self::$capturedPool = $pool; + } + + protected function tearDown(): void + { + // Assert after the framework's pool cleanup has run. + parent::tearDown(); + + if (self::$capturedManager === null || self::$capturedPool === null) { + return; + } + + try { + $this->assertSame( + 0, + self::$capturedPool->getIdleCount(), + 'Pool channel should be empty after lifecycle teardown' + ); + $this->assertSame( + 0, + self::$capturedPool->getManagedCount(), + 'Pool managed count should be 0 after lifecycle teardown' + ); + + $this->assertSame( + [], + self::$capturedManager->getPools(), + 'The pool registry should be empty after lifecycle teardown' + ); + } finally { + self::$capturedManager = null; + self::$capturedPool = null; + } + } +} diff --git a/tests/Integration/Database/DbPoolTeardownLifecycleTest.php b/tests/Integration/Database/DbPoolTeardownLifecycleTest.php deleted file mode 100644 index 4b49643f03..0000000000 --- a/tests/Integration/Database/DbPoolTeardownLifecycleTest.php +++ /dev/null @@ -1,103 +0,0 @@ -app->flush() runs. - * - * Captures the PoolFactory and a live DbPool during the test body, then - * asserts post-teardown state in a custom tearDown() that runs AFTER - * parent::tearDown(). Without the lifecycle pool flush, the captured pool - * would still hold its PDO socket and the factory would still cache the - * pool, which is the FD/memory leak path that affects long ParaTest runs. - * - * Opts into pool.testing_enabled because the default DatabaseConnectionResolver - * caches bare Connections and bypasses the pool's checkout/release cycle, - * which would leave nothing in the channel for flushAll() to drain. With - * testing_enabled = true, the resolver falls through to the parent - * ConnectionResolver and the real pool lifecycle is exercised. - * - * Mirrors RedisPoolTeardownLifecycleTest. - */ -class DbPoolTeardownLifecycleTest extends DatabaseTestCase -{ - private static ?PoolFactory $capturedFactory = null; - - private static ?DbPool $capturedPool = null; - - protected function defineEnvironment(ApplicationContract $app): void - { - parent::defineEnvironment($app); - - $config = $app->make('config'); - $default = $config->get('database.default'); - $config->set("database.connections.{$default}.pool.testing_enabled", true); - } - - public function testTearDownLifecyclePurgesDbPools(): void - { - // Real query forces the manager/resolver path to instantiate a live - // pool with a real PDO connection. Going through - // DB::statement (rather than poking the factory directly) proves the - // normal application path creates the pool the trait must clean up. - DB::statement('SELECT 1'); - - $factory = $this->app->make(PoolFactory::class); - $defaultName = $this->app->make('config')->get('database.default'); - $pool = $factory->getPool($defaultName); - - // Sanity: the pool actually has a real connection - $this->assertGreaterThan(0, $pool->getCurrentConnections()); - - self::$capturedFactory = $factory; - self::$capturedPool = $pool; - } - - protected function tearDown(): void - { - // parent::tearDown() runs tearDownTheTestEnvironment, where the - // pool-purge lifecycle hook lives. After it returns, the captured - // references should reflect a fully torn-down pool layer. - parent::tearDown(); - - if (self::$capturedFactory === null || self::$capturedPool === null) { - return; - } - - try { - $this->assertSame( - 0, - self::$capturedPool->getConnectionsInChannel(), - 'Pool channel should be empty after lifecycle teardown' - ); - $this->assertSame( - 0, - self::$capturedPool->getCurrentConnections(), - 'Pool currentConnections should be 0 after lifecycle teardown' - ); - - // The factory's $pools cache should be cleared so the previous - // pool object can be refcount-collected (no public accessor for - // this, so reflection is the only way to verify it directly). - $reflection = new ReflectionClass(self::$capturedFactory); - $poolsProperty = $reflection->getProperty('pools'); - $this->assertSame( - [], - $poolsProperty->getValue(self::$capturedFactory), - 'PoolFactory $pools should be empty after lifecycle teardown' - ); - } finally { - self::$capturedFactory = null; - self::$capturedPool = null; - } - } -} diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 263df8d558..5c0a6a7c16 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -7,26 +7,26 @@ use Closure; use Exception; use Generator; +use Hypervel\ConnectionPool\Events\ConnectionReleasing; +use Hypervel\ConnectionPool\PoolOptions; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\ConnectionInterface as PoolConnectionInterface; use Hypervel\Coroutine\Coroutine as FrameworkCoroutine; use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\Events\ConnectionEstablished; use Hypervel\Database\MySqlConnection; use Hypervel\Database\PdoConnection; -use Hypervel\Database\Pool\DbPool; +use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\SessionConfigurator; use Hypervel\Database\SQLiteConnection; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Filesystem\Filesystem; -use Hypervel\Pool\Events\ReleaseConnection; -use Hypervel\Pool\PoolOption; use Hypervel\Testing\ParallelTesting; use InvalidArgumentException; use Mockery as m; @@ -55,21 +55,21 @@ protected function defineEnvironment(ApplicationContract $app): void 'database' => ':memory:', 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 2, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ], ]); } public function testConstructorSetsEventDispatcher(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $dispatcher = new ReflectionProperty(PooledConnection::class, 'dispatcher'); @@ -92,7 +92,7 @@ function (ConnectionEstablished $event) use (&$fired) { } ); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $this->createPooledConnection($pool); $this->assertTrue($fired, 'ConnectionEstablished event should be fired when a pooled connection is created'); @@ -101,7 +101,7 @@ function (ConnectionEstablished $event) use (&$fired) { public function testPassiveObserversDoNotCausePooledLifecycleEventsToDispatch(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); $events = $this->app->make(Dispatcher::class); $establishedConnections = []; @@ -113,15 +113,15 @@ static function (ConnectionEstablished $event) use (&$establishedConnections): v } ); $events->observe( - ReleaseConnection::class, - static function (ReleaseConnection $event) use (&$releasedConnections): void { + ConnectionReleasing::class, + static function (ConnectionReleasing $event) use (&$releasedConnections): void { $releasedConnections[] = $event->connection; } ); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $pooledConnection->reconnect(); @@ -136,7 +136,7 @@ static function (ReleaseConnection $event) use (&$releasedConnections): void { public function testGetConnectionReturnsConnection(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $connection = $pooledConnection->getConnection(); @@ -158,16 +158,16 @@ public function testPoolParsesUrlConfigurationBeforeCreatingConnection(): void $this->app->make('config')->set('database.connections.url_pool_test', [ 'url' => 'sqlite:///' . $databasePath, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); - $pool = new DbPool($this->app, 'url_pool_test'); + $pool = new DatabasePool($this->app, 'url_pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $this->assertSame('sqlite', $connection->getConfig('driver')); @@ -188,9 +188,9 @@ public function testDerivedReadPoolForInMemorySqliteIsRejected(): void 'database' => ':memory:', ], 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); @@ -199,7 +199,7 @@ public function testDerivedReadPoolForInMemorySqliteIsRejected(): void 'Database connection [memory_read_pool_test::read] cannot use a derived read pool for in-memory SQLite.' ); - new DbPool($this->app, 'memory_read_pool_test::read'); + new DatabasePool($this->app, 'memory_read_pool_test::read'); } public function testDerivedReadPoolForInMemorySqliteReadUrlIsRejected(): void @@ -220,9 +220,9 @@ public function testDerivedReadPoolForInMemorySqliteReadUrlIsRejected(): void 'url' => 'sqlite:///:memory:', ], 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); @@ -231,7 +231,7 @@ public function testDerivedReadPoolForInMemorySqliteReadUrlIsRejected(): void 'Database connection [memory_read_url_pool_test::read] cannot use a derived read pool for in-memory SQLite.' ); - new DbPool($this->app, 'memory_read_url_pool_test::read'); + new DatabasePool($this->app, 'memory_read_url_pool_test::read'); } finally { $filesystem->deleteDirectory($directory); } @@ -264,16 +264,16 @@ public function testDerivedReadPoolForFileBackedSqliteUsesReadConfig(): void 'prefix' => 'write_', ], 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); - $pool = new DbPool($this->app, 'file_read_pool_test::read'); + $pool = new DatabasePool($this->app, 'file_read_pool_test::read'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $this->assertSame('file_read_pool_test', $connection->getName()); @@ -289,7 +289,7 @@ public function testDerivedReadPoolForFileBackedSqliteUsesReadConfig(): void $pooledConnection = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $this->assertSame($releasedConnection, $pooledConnection); @@ -305,7 +305,7 @@ public function testDerivedReadPoolForFileBackedSqliteUsesReadConfig(): void public function testGetConnectionReturnsSameInstanceWhileValid(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $first = $pooledConnection->getConnection(); @@ -316,7 +316,7 @@ public function testGetConnectionReturnsSameInstanceWhileValid(): void public function testConnectionEstablishedEventFiredOnReconnect(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $count = 0; @@ -335,7 +335,7 @@ function () use (&$count) { public function testReconnectCreatesNewConnection(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $before = $pooledConnection->getConnection(); @@ -349,7 +349,7 @@ public function testReconnectCreatesNewConnection(): void public function testReconnectSetsEventDispatcherOnConnection(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $connection = $pooledConnection->getConnection(); @@ -360,7 +360,7 @@ public function testReconnectSetsEventDispatcherOnConnection(): void public function testCheckReturnsFalseWhenNoConnection(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $pooledConnection->close(); @@ -370,7 +370,7 @@ public function testCheckReturnsFalseWhenNoConnection(): void public function testCheckReturnsTrueForFreshConnection(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $this->assertTrue($pooledConnection->check()); @@ -378,7 +378,7 @@ public function testCheckReturnsTrueForFreshConnection(): void public function testCloseDisconnectsAndNullsConnection(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $result = $pooledConnection->close(); @@ -389,10 +389,10 @@ public function testCloseDisconnectsAndNullsConnection(): void public function testCloseForgetsTheConnectionWhenTransactionCleanupFails(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $failure = new RuntimeException('Transaction cleanup failed.'); $connection->beginTransaction(); @@ -413,10 +413,10 @@ public function testCloseForgetsTheConnectionWhenTransactionCleanupFails(): void public function testCloseForgetsTheConnectionWhenTransactionCleanupIsCanceled(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $cancellation = new CanceledException('Transaction cleanup was canceled.'); $connection->beginTransaction(); @@ -437,7 +437,7 @@ public function testCloseForgetsTheConnectionWhenTransactionCleanupIsCanceled(): public function testGetActiveConnectionReconnectsWhenStale(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $pooledConnection->close(); @@ -450,11 +450,11 @@ public function testGetActiveConnectionReconnectsWhenStale(): void public function testReleaseResetsConnectionState(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); // Get a connection through the pool to test proper release /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); @@ -465,17 +465,17 @@ public function testReleaseResetsConnectionState(): void // After release, getting the connection again from pool should work /** @var PooledConnection $newPooledConnection */ - $newPooledConnection = $pool->get(); + $newPooledConnection = $pool->borrow(); $this->assertInstanceOf(Connection::class, $newPooledConnection->getConnection()); $newPooledConnection->release(); } public function testReleaseRollsBackOpenTransactions(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); // Create a table and start a transaction @@ -494,7 +494,7 @@ public function testReleaseRollsBackOpenTransactions(): void // Get a new connection and verify the data was rolled back /** @var PooledConnection $newPooledConnection */ - $newPooledConnection = $pool->get(); + $newPooledConnection = $pool->borrow(); $newConnection = $newPooledConnection->getConnection(); $this->assertSame(0, $newConnection->transactionLevel()); @@ -507,10 +507,10 @@ public function testCleanReleasePreservesMatchingPhysicalSessionState(): void { $configurator = new PoolSessionConfigurator; PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $firstPooledConnection = $pooledConnection; @@ -521,7 +521,7 @@ public function testCleanReleasePreservesMatchingPhysicalSessionState(): void $pooledConnection = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $nextConnection = $pooledConnection->getConnection(); $this->assertSame($firstPooledConnection, $pooledConnection); @@ -543,10 +543,10 @@ public function testAbandonedTransactionRollbackInvalidatesPhysicalSessionState( { $configurator = new PoolSessionConfigurator; PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $connection = $pooledConnection->getConnection(); @@ -556,7 +556,7 @@ public function testAbandonedTransactionRollbackInvalidatesPhysicalSessionState( $pooledConnection = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $pooledConnection->getConnection()->getPdo(); $this->assertSame($applyCalls + 1, $configurator->applyCalls); @@ -570,10 +570,10 @@ public function testUnknownSessionIsMarkedInvalidAtFinalReleaseBoundary(): void { $configurator = new PoolSessionConfigurator; PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $configurator->desiredState = 'fail'; @@ -601,10 +601,10 @@ public function testUnknownReadSessionIsDetectedWithoutResolvingUnopenedPdos(): { $configurator = new PoolSessionConfigurator; PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $connection = $pooledConnection->getConnection(); @@ -638,16 +638,16 @@ public function testUnknownReadSessionIsDetectedWithoutResolvingUnopenedPdos(): public function testUnknownStateCaughtByReleaseListenerIsStillMarkedInvalid(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); $configurator = new PoolSessionConfigurator; PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $configurator->desiredState = 'fail'; $configurator->applyCallback = static fn () => throw new Exception('Configuration failed.'); $this->app->make(Dispatcher::class)->listen( - ReleaseConnection::class, - static function (ReleaseConnection $event): void { + ConnectionReleasing::class, + static function (ConnectionReleasing $event): void { try { $event->connection->getConnection()->getPdo(); } catch (Exception) { @@ -656,7 +656,7 @@ static function (ReleaseConnection $event): void { ); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $releasedConnection = $pooledConnection; @@ -683,21 +683,21 @@ public function testInvalidNormalConnectionReconnectsAndConfiguresAFreshPdo(): v 'database' => $databasePath, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); $configurator = new PoolSessionConfigurator('session_reconnect_test'); $configurationException = new Exception('Configuration failed.'); $configurator->applyCallback = static fn () => throw $configurationException; PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'session_reconnect_test'); + $pool = new DatabasePool($this->app, 'session_reconnect_test'); $pooledConnection = null; try { /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $caughtException = null; @@ -717,7 +717,7 @@ public function testInvalidNormalConnectionReconnectsAndConfiguresAFreshPdo(): v $configurator->applyCallback = null; /** @var PooledConnection $nextPooledConnection */ - $nextPooledConnection = $pool->get(); + $nextPooledConnection = $pool->borrow(); $pooledConnection = $nextPooledConnection; $newPdo = $nextPooledConnection->getConnection()->getPdo(); @@ -744,17 +744,17 @@ public function testLeakedForeignKeySuppressionScopeReconnectsANormalPoolWithout 'database' => $databasePath, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); - $pool = new DbPool($this->app, 'suppression_reconnect_test'); + $pool = new DatabasePool($this->app, 'suppression_reconnect_test'); $pooledConnection = null; try { /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $oldPdo = $connection->getPdo(); $connection->beginForeignKeyConstraintSuppression(); @@ -763,7 +763,7 @@ public function testLeakedForeignKeySuppressionScopeReconnectsANormalPoolWithout $pooledConnection = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $newPdo = $pooledConnection->getConnection()->getPdo(); $this->assertSame($firstPooledConnection, $pooledConnection); @@ -788,19 +788,19 @@ public function testFailedRefreshPreservesTheCurrentGenerationAndMarksItInvalid( 'database' => $databasePath, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); $configurator = new PoolSessionConfigurator('session_refresh_failure_test'); PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'session_refresh_failure_test'); + $pool = new DatabasePool($this->app, 'session_refresh_failure_test'); $pooledConnection = null; try { /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $oldPdo = $connection->getPdo(); $configurationException = new Exception('Replacement configuration failed.'); @@ -832,7 +832,7 @@ public function testFailedRefreshPreservesTheCurrentGenerationAndMarksItInvalid( $configurator->applyCallback = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $newPdo = $pooledConnection->getConnection()->getPdo(); $this->assertSame($firstPooledConnection, $pooledConnection); @@ -847,10 +847,10 @@ public function testFailedRefreshPreservesTheCurrentGenerationAndMarksItInvalid( public function testSharedInMemorySqliteUnknownSessionFailsClosedWithoutDiscardingTheDatabase(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $connection = $pooledConnection->getConnection(); @@ -863,7 +863,7 @@ public function testSharedInMemorySqliteUnknownSessionFailsClosedWithoutDiscardi $pooledConnection = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connectionEstablished = 0; $this->app->make(Dispatcher::class)->listen( ConnectionEstablished::class, @@ -895,12 +895,12 @@ public function testHeartbeatDoesNotComputeOrInvalidateSessionState(): void { $configurator = new PoolSessionConfigurator; PdoConnection::configureSessionUsing($configurator); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $stateCallsAfterCreation = $configurator->stateCalls; $applyCallsAfterCreation = $configurator->applyCalls; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $pooledConnection->getConnection()->getPdo(); @@ -922,47 +922,47 @@ public function testHeartbeatDoesNotComputeOrInvalidateSessionState(): void } } - public function testReleaseDispatchesReleaseEventWhenConfigured(): void + public function testReleaseDispatchesConnectionReleasingWhenConfigured(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $fired = false; $this->app->make(Dispatcher::class)->listen( - ReleaseConnection::class, + ConnectionReleasing::class, function () use (&$fired) { $fired = true; } ); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $pooledConnection->release(); - $this->assertTrue($fired, 'ReleaseConnection event should be dispatched when configured'); + $this->assertTrue($fired, 'ConnectionReleasing event should be dispatched when configured'); } public function testOrdinaryReleaseListenerFailureStillReturnsAnInvalidConnection(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); $failure = new RuntimeException('Release listener failed.'); $this->app->make(Dispatcher::class)->listen( - ReleaseConnection::class, + ConnectionReleasing::class, static fn () => throw $failure ); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $pooledConnection->release(); $this->assertTrue($this->isInvalid($pooledConnection)); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getIdleCount()); $pool->close(); } @@ -970,7 +970,7 @@ public function testOrdinaryReleaseListenerFailureStillReturnsAnInvalidConnectio public function testReleasePreservesTheFirstOrdinaryCleanupFailure(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); $listenerFailure = new RuntimeException('Release listener failed.'); $loggingFailure = new RuntimeException('Release failure logging failed.'); @@ -979,14 +979,14 @@ public function testReleasePreservesTheFirstOrdinaryCleanupFailure(): void $logger->shouldReceive('error')->once()->andThrow($loggingFailure); $this->app->instance(StdoutLoggerInterface::class, $logger); $this->app->make(Dispatcher::class)->listen( - ReleaseConnection::class, + ConnectionReleasing::class, static fn () => throw $listenerFailure ); - $pool = new FailingReleaseDbPool($this->app, 'pool_test'); + $pool = new FailingReleaseDatabasePool($this->app, 'pool_test'); $pool->releaseFailure = $poolReleaseFailure; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $pooledConnection->release(); @@ -995,17 +995,17 @@ public function testReleasePreservesTheFirstOrdinaryCleanupFailure(): void $this->assertSame($loggingFailure, $exception); } - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getIdleCount()); $pool->close(); } public function testRollbackCancellationStillReturnsTheConnectionAndEscapesExactly(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $cancellation = new CanceledException('Rollback was canceled.'); $connection->beginTransaction(); @@ -1019,7 +1019,7 @@ public function testRollbackCancellationStillReturnsTheConnectionAndEscapesExact } $this->assertTrue($this->isInvalid($pooledConnection)); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getIdleCount()); $pool->close(); } @@ -1027,17 +1027,17 @@ public function testRollbackCancellationStillReturnsTheConnectionAndEscapesExact public function testReleaseListenerCancellationStillReturnsTheConnectionAndEscapesExactly(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); $cancellation = new CanceledException('Release listener was canceled.'); $this->app->make(Dispatcher::class)->listen( - ReleaseConnection::class, + ConnectionReleasing::class, static fn () => throw $cancellation ); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); try { $pooledConnection->release(); @@ -1047,17 +1047,17 @@ public function testReleaseListenerCancellationStillReturnsTheConnectionAndEscap } $this->assertTrue($this->isInvalid($pooledConnection)); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getIdleCount()); $pool->close(); } public function testPoolReleaseCancellationEscapesAfterReturningTheConnectionOnce(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $cancellation = new CanceledException('Pool release was canceled.'); $this->stageRollbackCallback($connection, static fn () => throw $cancellation); @@ -1070,24 +1070,24 @@ public function testPoolReleaseCancellationEscapesAfterReturningTheConnectionOnc $this->assertSame($cancellation, $throwable); } - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getManagedCount()); } public function testOperationCancellationRemainsPrimaryOverPoolReleaseCancellation(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); $operationCancellation = new CanceledException('Release listener was canceled.'); $cleanupCancellation = new CanceledException('Pool release was canceled.'); $this->app->make(Dispatcher::class)->listen( - ReleaseConnection::class, + ConnectionReleasing::class, static fn () => throw $operationCancellation ); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $this->stageRollbackCallback( $pooledConnection->getConnection(), static fn () => throw $cleanupCancellation @@ -1101,24 +1101,24 @@ public function testOperationCancellationRemainsPrimaryOverPoolReleaseCancellati $this->assertSame($operationCancellation, $throwable); } - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getManagedCount()); } public function testPoolReleaseCancellationSupersedesAnOrdinaryListenerFailure(): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ - ReleaseConnection::class, + ConnectionReleasing::class, ]); $listenerFailure = new RuntimeException('Release listener failed.'); $cleanupCancellation = new CanceledException('Pool release was canceled.'); $this->app->make(Dispatcher::class)->listen( - ReleaseConnection::class, + ConnectionReleasing::class, static fn () => throw $listenerFailure ); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $this->stageRollbackCallback( $pooledConnection->getConnection(), static fn () => throw $cleanupCancellation @@ -1132,15 +1132,15 @@ public function testPoolReleaseCancellationSupersedesAnOrdinaryListenerFailure() $this->assertSame($cleanupCancellation, $throwable); } - $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getManagedCount()); } public function testReuseCheckDoesNotResetLastUseTime(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $pooledConnection->getConnection(); $initialTime = $pooledConnection->getLastUseTime(); @@ -1150,7 +1150,7 @@ public function testReuseCheckDoesNotResetLastUseTime(): void usleep(10000); // 10ms /** @var PooledConnection $nextPooledConnection */ - $nextPooledConnection = $pool->get(); + $nextPooledConnection = $pool->borrow(); $nextPooledConnection->getConnection(); $this->assertSame($pooledConnection, $nextPooledConnection); @@ -1161,7 +1161,7 @@ public function testReuseCheckDoesNotResetLastUseTime(): void public function testInvalidConnectionReconnectsEvenWithFreshReleaseTime(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $originalConnection = $pooledConnection->getConnection(); @@ -1175,11 +1175,11 @@ public function testExpiredLifetimeDoesNotReconnectDuringActiveBorrow(): void { $this->app->make('config')->set('database.connections.pool_test.pool.max_lifetime', 1.0); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $originalConnection = $pooledConnection->getConnection(); - $this->assertSame(1.0, $pool->getOption()->getMaxLifetime()); + $this->assertSame(1.0, $pool->getOptions()->maxLifetime); $originalConnection->beginTransaction(); $this->ageConnectionGeneration($pooledConnection); @@ -1195,7 +1195,7 @@ public function testExpiredIdleTimeDoesNotReconnectDuringActiveBorrow(): void { $this->app->make('config')->set('database.connections.pool_test.pool.max_idle_time', 1.0); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $originalConnection = $pooledConnection->getConnection(); @@ -1209,17 +1209,17 @@ public function testExpiredLifetimeReconnectsWhenBorrowedFromPoolAgainWithoutHea { $this->app->make('config')->set('database.connections.pool_test.pool.max_lifetime', 1.0); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $originalConnection = $pooledConnection->getConnection(); $pooledConnection->release(); $this->ageConnectionGeneration($pooledConnection); /** @var PooledConnection $nextPooledConnection */ - $nextPooledConnection = $pool->get(); + $nextPooledConnection = $pool->borrow(); $this->assertSame($pooledConnection, $nextPooledConnection); $this->assertNotSame($originalConnection, $nextPooledConnection->getConnection()); @@ -1227,13 +1227,44 @@ public function testExpiredLifetimeReconnectsWhenBorrowedFromPoolAgainWithoutHea $nextPooledConnection->release(); } + public function testNullIdleTimeoutKeepsAnAgedReleasedConnection(): void + { + config()->set('database.connections.pool_test.pool.max_idle_time', null); + $pool = new DatabasePool($this->app, 'pool_test'); + + try { + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->borrow(); + $originalConnection = $pooledConnection->getConnection(); + $pooledConnection->release(); + + (new ReflectionProperty(PooledConnection::class, 'lastReleaseTime'))->setValue($pooledConnection, 1.0); + (new ReflectionProperty(PooledConnection::class, 'lastUseTime'))->setValue($pooledConnection, 1.0); + + $this->assertFalse($pooledConnection->isIdleExpired()); + $this->assertTrue($pooledConnection->check()); + + /** @var PooledConnection $nextPooledConnection */ + $nextPooledConnection = $pool->borrow(); + + try { + $this->assertSame($pooledConnection, $nextPooledConnection); + $this->assertSame($originalConnection, $nextPooledConnection->getConnection()); + } finally { + $nextPooledConnection->release(); + } + } finally { + $pool->close(); + } + } + public function testDisabledMaxLifetimeDoesNotRecycleAgedConnectionGeneration(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $originalConnection = $pooledConnection->getConnection(); - $this->assertSame(-1.0, $pool->getOption()->getMaxLifetime()); + $this->assertNull($pool->getOptions()->maxLifetime); $this->ageConnectionGeneration($pooledConnection); @@ -1244,7 +1275,7 @@ public function testDisabledMaxLifetimeDoesNotRecycleAgedConnectionGeneration(): public function testPingDoesNotExtendConnectionLifetime(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $pooledConnection->getConnection()->getPdo(); @@ -1256,7 +1287,7 @@ public function testPingDoesNotExtendConnectionLifetime(): void public function testPingCancellationStopsTheHeartbeatChildAndEscapesExactly(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $pingStarted = new Channel(1); $blocker = new Channel(1); @@ -1284,7 +1315,7 @@ public function testPingCancellationDuringStartupReportingStopsThePublishedHeart { $handler = m::mock(ExceptionHandlerContract::class); $this->app->instance(ExceptionHandlerContract::class, $handler); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $connection = new NeutralPoolConnection(1, ':memory:', '', []); (new ReflectionProperty(PooledConnection::class, 'connection'))->setValue($pooledConnection, $connection); @@ -1348,7 +1379,7 @@ public function testConnectionGenerationLifetimeIsJitteredWithinConfiguredUpperB { $this->app->make('config')->set('database.connections.pool_test.pool.max_lifetime', 60.0); - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $before = hrtime(true) / 1e9; $pooledConnection = $this->createPooledConnection($pool); $after = hrtime(true) / 1e9; @@ -1360,7 +1391,7 @@ public function testConnectionGenerationLifetimeIsJitteredWithinConfiguredUpperB $this->assertGreaterThanOrEqual($before, $createdAt); $this->assertLessThanOrEqual($after, $createdAt); $this->assertGreaterThanOrEqual( - $createdAt + (60.0 * PoolOption::MIN_LIFETIME_JITTER_BASIS / PoolOption::LIFETIME_JITTER_SCALE), + $createdAt + (60.0 * PoolOptions::MIN_LIFETIME_JITTER_BASIS / PoolOptions::LIFETIME_JITTER_SCALE), $lifetimeExpiresAt ); $this->assertLessThanOrEqual($createdAt + 60.0, $lifetimeExpiresAt); @@ -1370,7 +1401,7 @@ public function testConnectionGenerationLifetimeIsJitteredWithinConfiguredUpperB public function testConnectionRefreshResetsLifetime(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $pooledConnection = $this->createPooledConnection($pool); $connection = $pooledConnection->getConnection(); @@ -1384,10 +1415,10 @@ public function testConnectionRefreshResetsLifetime(): void public function testReleaseSnapshotsErrorCountBeforeResettingConnection(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); (new ReflectionProperty(Connection::class, 'errorCount'))->setValue($connection, 101); @@ -1397,7 +1428,7 @@ public function testReleaseSnapshotsErrorCountBeforeResettingConnection(): void $this->assertSame(0, $connection->getErrorCount()); /** @var PooledConnection $nextPooledConnection */ - $nextPooledConnection = $pool->get(); + $nextPooledConnection = $pool->borrow(); $this->assertNotSame($connection, $nextPooledConnection->getConnection()); @@ -1406,10 +1437,10 @@ public function testReleaseSnapshotsErrorCountBeforeResettingConnection(): void public function testReleaseResetsErrorCountForNextBorrowWindow(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); (new ReflectionProperty(Connection::class, 'errorCount'))->setValue($connection, 1); @@ -1419,7 +1450,7 @@ public function testReleaseResetsErrorCountForNextBorrowWindow(): void $this->assertSame(0, $connection->getErrorCount()); /** @var PooledConnection $nextPooledConnection */ - $nextPooledConnection = $pool->get(); + $nextPooledConnection = $pool->borrow(); $this->assertSame($connection, $nextPooledConnection->getConnection()); @@ -1428,47 +1459,46 @@ public function testReleaseResetsErrorCountForNextBorrowWindow(): void public function testSharedPdoPersistsAcrossInMemorySqliteBorrows(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); $this->assertNotNull($pool->getSharedInMemorySqlitePdo()); - /** @var PooledConnection $conn1 */ - $conn1 = $pool->get(); - $pdo1 = $conn1->getConnection()->getPdo(); - $conn1->release(); + /** @var PooledConnection $firstConnection */ + $firstConnection = $pool->borrow(); + $firstPdo = $firstConnection->getConnection()->getPdo(); + $firstConnection->release(); - /** @var PooledConnection $conn2 */ - $conn2 = $pool->get(); - $pdo2 = $conn2->getConnection()->getPdo(); + /** @var PooledConnection $secondConnection */ + $secondConnection = $pool->borrow(); + $secondPdo = $secondConnection->getConnection()->getPdo(); - $this->assertSame($pdo1, $pdo2, 'In-memory SQLite borrows should share the same PDO'); - $conn2->release(); + $this->assertSame($firstPdo, $secondPdo, 'In-memory SQLite borrows should share the same PDO'); + $secondConnection->release(); } public function testSharedPdoDataVisibleAcrossConnections(): void { - $pool = new DbPool($this->app, 'pool_test'); + $pool = new DatabasePool($this->app, 'pool_test'); - /** @var PooledConnection $conn1 */ - $conn1 = $pool->get(); - $db1 = $conn1->getConnection(); + /** @var PooledConnection $firstPooledConnection */ + $firstPooledConnection = $pool->borrow(); + $firstConnection = $firstPooledConnection->getConnection(); - $db1->getSchemaBuilder()->create('shared_test', function ($table) { + $firstConnection->getSchemaBuilder()->create('shared_test', function ($table) { $table->id(); $table->string('value'); }); - $db1->table('shared_test')->insert(['value' => 'hello']); - $conn1->release(); + $firstConnection->table('shared_test')->insert(['value' => 'hello']); + $firstPooledConnection->release(); - // Second connection should see the same data - /** @var PooledConnection $conn2 */ - $conn2 = $pool->get(); - $db2 = $conn2->getConnection(); + /** @var PooledConnection $secondPooledConnection */ + $secondPooledConnection = $pool->borrow(); + $secondConnection = $secondPooledConnection->getConnection(); - $this->assertSame(1, $db2->table('shared_test')->count()); - $this->assertSame('hello', $db2->table('shared_test')->value('value')); + $this->assertSame(1, $secondConnection->table('shared_test')->count()); + $this->assertSame('hello', $secondConnection->table('shared_test')->value('value')); - $conn2->release(); + $secondPooledConnection->release(); } public function testReconnectHonoursFactoryExtensions(): void @@ -1491,11 +1521,11 @@ public function testReconnectHonoursFactoryExtensions(): void 'database' => $databasePath, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, ], ]); @@ -1514,7 +1544,7 @@ public function testReconnectHonoursFactoryExtensions(): void ); }); - $pool = new DbPool($this->app, 'extension_test'); + $pool = new DatabasePool($this->app, 'extension_test'); $pooledConnection = $this->createPooledConnectionForName($pool, 'extension_test'); $connection = $pooledConnection->getConnection(); $firstPdo = $connection->getPdo(); @@ -1539,9 +1569,9 @@ public function testConfigFirstNonPdoExtensionSupportsTheCompletePoolLifecycle() 'database' => 'first', 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); @@ -1552,12 +1582,12 @@ public function testConfigFirstNonPdoExtensionSupportsTheCompletePoolLifecycle() return new NeutralPoolConnection(++$resolutions, $config['database'], $config['prefix'], $config); }); - $pool = new DbPool($this->app, 'neutral_pool_test'); + $pool = new DatabasePool($this->app, 'neutral_pool_test'); $pooledConnection = null; try { /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $this->assertInstanceOf(NeutralPoolConnection::class, $connection); @@ -1577,7 +1607,7 @@ public function testConfigFirstNonPdoExtensionSupportsTheCompletePoolLifecycle() $pooledConnection = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $this->assertSame($connection, $pooledConnection->getConnection()); $pooledConnection->release(); @@ -1598,9 +1628,9 @@ public function testReleaseClearsCapturedMySqlInsertIdBeforeReborrow(): void 'database' => 'unused', 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ]); @@ -1616,12 +1646,12 @@ public function testReleaseClearsCapturedMySqlInsertIdBeforeReborrow(): void ) ); - $pool = new DbPool($this->app, 'mysql_insert_id_pool_test'); + $pool = new DatabasePool($this->app, 'mysql_insert_id_pool_test'); $pooledConnection = null; try { /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $connection = $pooledConnection->getConnection(); $this->assertInstanceOf(PoolMySqlConnection::class, $connection); $connection->rememberLastInsertId(42); @@ -1631,7 +1661,7 @@ public function testReleaseClearsCapturedMySqlInsertIdBeforeReborrow(): void $pooledConnection = null; /** @var PooledConnection $pooledConnection */ - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $this->assertSame($connection, $pooledConnection->getConnection()); $exception = null; @@ -1651,9 +1681,9 @@ public function testReleaseClearsCapturedMySqlInsertIdBeforeReborrow(): void } /** - * Create a PooledConnection directly (bypassing pool.get() for unit-style tests). + * Create a pooled wrapper without registering it in the pool. */ - private function createPooledConnection(DbPool $pool): PooledConnection + private function createPooledConnection(DatabasePool $pool): PooledConnection { return $this->createPooledConnectionForName($pool, 'pool_test'); } @@ -1661,7 +1691,7 @@ private function createPooledConnection(DbPool $pool): PooledConnection /** * Create a PooledConnection for a named connection config. */ - private function createPooledConnectionForName(DbPool $pool, string $name): PooledConnection + private function createPooledConnectionForName(DatabasePool $pool, string $name): PooledConnection { $config = $this->app->make('config')->get("database.connections.{$name}"); $config['name'] = $name; @@ -1685,7 +1715,7 @@ private function ageConnectionGeneration(PooledConnection $connection): void $lifetimeExpiresAt = new ReflectionProperty(PooledConnection::class, 'lifetimeExpiresAt'); - if ($lifetimeExpiresAt->getValue($connection) > 0.0) { + if ($lifetimeExpiresAt->getValue($connection) !== null) { $lifetimeExpiresAt->setValue($connection, hrtime(true) / 1e9 - 1.0); } } @@ -1735,14 +1765,14 @@ public function apply(PDO $pdo, string $state, PdoConnection $connection): void } } -class FailingReleaseDbPool extends DbPool +class FailingReleaseDatabasePool extends DatabasePool { public ?RuntimeException $releaseFailure = null; /** * Release a connection back to the pool. */ - public function release(PoolConnectionInterface $connection): void + public function release(PoolConnection $connection): void { parent::release($connection); diff --git a/tests/Integration/Database/Postgres/PooledConnectionStateTest.php b/tests/Integration/Database/Postgres/PooledConnectionStateTest.php index ec6f2312f5..596a183e19 100644 --- a/tests/Integration/Database/Postgres/PooledConnectionStateTest.php +++ b/tests/Integration/Database/Postgres/PooledConnectionStateTest.php @@ -6,7 +6,7 @@ use Hypervel\Database\Connection; use Hypervel\Database\Pool\PooledConnection; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use ReflectionProperty; use function Hypervel\Coroutine\go; @@ -20,14 +20,14 @@ class PooledConnectionStateTest extends PostgresTestCase { /** - * Helper to get a PooledConnection directly from the pool. + * Borrow a connection directly from the pool. */ protected function getPooledConnection(): PooledConnection { - $factory = $this->app->make(PoolFactory::class); - $pool = $factory->getPool($this->driver); + $poolManager = $this->app->make(PoolManager::class); + $pool = $poolManager->pool($this->driver); - return $pool->get(); + return $pool->borrow(); } public function testQueryLoggingStateDoesNotLeakBetweenCoroutines(): void diff --git a/tests/Integration/Database/Postgres/SessionConfiguratorTest.php b/tests/Integration/Database/Postgres/SessionConfiguratorTest.php index d745df09ba..e2ff5b0098 100644 --- a/tests/Integration/Database/Postgres/SessionConfiguratorTest.php +++ b/tests/Integration/Database/Postgres/SessionConfiguratorTest.php @@ -8,7 +8,7 @@ use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\PdoConnection; -use Hypervel\Database\Pool\DbPool; +use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\QueryException; use Hypervel\Database\SessionConfigurator; @@ -19,7 +19,7 @@ class SessionConfiguratorTest extends PostgresTestCase { private const string CONNECTION_NAME = 'postgres_session_configurator_test'; - private DbPool $sessionPool; + private DatabasePool $sessionPool; private PostgresSessionConfigurator $configurator; @@ -39,9 +39,9 @@ protected function defineEnvironment(ApplicationContract $app): void $connectionConfig = $this->postgresConfig; $connectionConfig['pool'] = [ 'testing_enabled' => true, - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ]; $config->set('database.connections.' . self::CONNECTION_NAME, $connectionConfig); @@ -53,7 +53,7 @@ protected function setUp(): void $this->configurator = new PostgresSessionConfigurator(self::CONNECTION_NAME); PdoConnection::configureSessionUsing($this->configurator); - $this->sessionPool = new DbPool($this->app, self::CONNECTION_NAME); + $this->sessionPool = new DatabasePool($this->app, self::CONNECTION_NAME); } protected function tearDown(): void @@ -217,8 +217,7 @@ public function testDeadIdlePdoReconnectsWhenConfigurationIsTheFirstFailingSql() private function borrow(): PooledConnection { - /** @var PooledConnection $pooledConnection */ - return $this->sessionPool->get(); + return $this->sessionPool->borrow(); } } diff --git a/tests/Integration/Database/SessionConfiguratorTest.php b/tests/Integration/Database/SessionConfiguratorTest.php index dee5d09bc8..aa2b87083f 100644 --- a/tests/Integration/Database/SessionConfiguratorTest.php +++ b/tests/Integration/Database/SessionConfiguratorTest.php @@ -10,7 +10,7 @@ use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Events\StatementPrepared; use Hypervel\Database\PdoConnection; -use Hypervel\Database\Pool\DbPool; +use Hypervel\Database\Pool\DatabasePool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\SessionConfigurator; use Hypervel\Filesystem\Filesystem; @@ -22,7 +22,7 @@ class SessionConfiguratorTest extends DatabaseTestCase { private const string CONNECTION_NAME = 'session_configurator_test'; - private DbPool $sessionPool; + private DatabasePool $sessionPool; private CrossDriverSessionConfigurator $configurator; @@ -47,9 +47,9 @@ protected function defineEnvironment(ApplicationContract $app): void $connectionConfig['pool'] = [ 'testing_enabled' => true, - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ]; $config->set('database.connections.' . self::CONNECTION_NAME, $connectionConfig); @@ -61,7 +61,7 @@ protected function setUp(): void $this->configurator = new CrossDriverSessionConfigurator(self::CONNECTION_NAME, $this->driver); PdoConnection::configureSessionUsing($this->configurator); - $this->sessionPool = new DbPool($this->app, self::CONNECTION_NAME); + $this->sessionPool = new DatabasePool($this->app, self::CONNECTION_NAME); } protected function tearDown(): void @@ -212,8 +212,7 @@ public function testConfigurationSqlDoesNotCreateIndependentFrameworkInstrumenta private function borrow(): PooledConnection { - /** @var PooledConnection $pooledConnection */ - return $this->sessionPool->get(); + return $this->sessionPool->borrow(); } } diff --git a/tests/Integration/Database/Sqlite/DatabasePoolHeartbeatTest.php b/tests/Integration/Database/Sqlite/DatabasePoolHeartbeatTest.php new file mode 100644 index 0000000000..24b1e8cfe2 --- /dev/null +++ b/tests/Integration/Database/Sqlite/DatabasePoolHeartbeatTest.php @@ -0,0 +1,846 @@ +make('config')->set('app.stdout_log.level', []); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->databaseDirectory = ParallelTesting::tempDir('DatabasePoolHeartbeatTest'); + $files = new Filesystem; + $files->deleteDirectory($this->databaseDirectory); + $files->ensureDirectoryExists($this->databaseDirectory); + $this->databasePath = $this->databaseDirectory . '/database.sqlite'; + touch($this->databasePath); + + $this->app->instance('db.connector.sqlite', new SQLiteConnector); + } + + protected function tearDown(): void + { + foreach ($this->pools as $pool) { + run(fn () => $pool->close()); + } + + (new Filesystem)->deleteDirectory($this->databaseDirectory); + + parent::tearDown(); + } + + public function testDisabledHeartbeatDoesNotStartTimer(): void + { + $pool = $this->createPool([ + 'heartbeat_interval' => null, + ]); + run(fn () => $pool->start()); + + $this->assertSame(0, $pool->heartbeatTimerCount()); + } + + public function testEnabledHeartbeatStartsTimerAndCloseClearsIt(): void + { + run(function (): void { + $pool = $this->createPool(['heartbeat_interval' => 60.0]); + + try { + $this->assertSame(0, $pool->heartbeatTimerCount()); + $pool->start(); + $pool->start(); + $this->assertSame(1, $pool->heartbeatTimerCount()); + } finally { + $pool->close(); + } + + $pool->start(); + $this->assertSame(0, $pool->heartbeatTimerCount()); + }); + } + + public function testHeartbeatStartupCanBeRetriedAfterTimerCreationFails(): void + { + run(function (): void { + $pool = $this->createPool(['heartbeat_interval' => 60.0]); + $property = new ReflectionProperty(DatabasePool::class, 'heartbeatTimer'); + $timer = $property->getValue($pool); + $failure = new RuntimeException('Timer creation failed.'); + $failingTimer = m::mock(Timer::class); + $failingTimer->shouldReceive('tick')->once()->andThrow($failure); + $property->setValue($pool, $failingTimer); + $caught = null; + + try { + $pool->start(); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + $property->setValue($pool, $timer); + } + + $this->assertSame($failure, $caught); + + try { + $pool->start(); + $this->assertSame(1, $pool->heartbeatTimerCount()); + } finally { + $pool->close(); + } + }); + } + + public function testReentrantHeartbeatStartupCreatesOnlyOneTimer(): void + { + run(function (): void { + $pool = $this->createPool(['heartbeat_interval' => 60.0]); + FrameworkCoroutine::afterCreated(static fn () => $pool->start()); + + try { + $pool->start(); + $this->assertSame(1, $pool->heartbeatTimerCount()); + } finally { + $pool->close(); + } + }); + } + + public function testCloseDuringHeartbeatStartupClearsTheUnpublishedTimer(): void + { + run(function (): void { + $pool = $this->createPool(['heartbeat_interval' => 60.0]); + $timers = Timer::stats()['num']; + FrameworkCoroutine::afterCreated(static fn () => $pool->close()); + + try { + $pool->start(); + $pool->start(); + + $this->assertTrue($pool->isClosed()); + $this->assertSame(0, $pool->heartbeatTimerCount()); + $this->assertSame($timers, Timer::stats()['num']); + } finally { + $pool->close(); + } + }); + } + + public function testHeartbeatKeepsMinimumConnectionsWarmAndEvictsExpiredExtras(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 3, + 'heartbeat_interval' => null, + 'max_idle_time' => 1.0, + ]); + + $connections = [ + $pool->borrow(), + $pool->borrow(), + $pool->borrow(), + ]; + + foreach ($connections as $connection) { + $connection->getConnection()->getPdo(); + $connection->release(); + $this->ageReleasedConnection($connection); + } + + $pool->runHeartbeatForTest(); + + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); + }); + } + + public function testHeartbeatValidationKeepsMinimumConnectionCheckoutValid(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + 'max_idle_time' => 1.0, + ]); + + $pooledConnection = $pool->borrow(); + $connection = $pooledConnection->getConnection(); + $pdo = $connection->getPdo(); + + $pooledConnection->release(); + $this->ageReleasedConnection($pooledConnection); + + $pool->runHeartbeatForTest(); + + /** @var PooledConnection $nextPooledConnection */ + $nextPooledConnection = $pool->borrow(); + + $this->assertSame($connection, $nextPooledConnection->getConnection()); + $this->assertSame($pdo, $nextPooledConnection->getConnection()->getPdo()); + + $nextPooledConnection->release(); + }); + } + + public function testHeartbeatDiscardsLifetimeExpiredIdleConnectionBeforePinging(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + 'max_lifetime' => 1.0, + ], LifetimeExpiredPingTrackingDatabasePool::class); + + $pooledConnection = $pool->borrow(); + $this->assertInstanceOf(LifetimeExpiredPingTrackingPooledConnection::class, $pooledConnection); + + $connection = $pooledConnection->getConnection(); + $pooledConnection->release(); + + $this->ageConnectionGeneration($pooledConnection); + + $pool->runHeartbeatForTest(); + + $this->assertFalse($pooledConnection->pingCalled); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + + $nextPooledConnection = $pool->borrow(); + + $this->assertNotSame($connection, $nextPooledConnection->getConnection()); + + $nextPooledConnection->release(); + }); + } + + public function testHeartbeatDoesNotRecycleBorrowedLifetimeExpiredConnection(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + 'max_lifetime' => 1.0, + ]); + + $borrowed = $pool->borrow(); + + $this->ageConnectionGeneration($borrowed); + + $pool->runHeartbeatForTest(); + + $this->assertSame(1, $borrowed->getConnection()->selectOne('SELECT 1 as result')->result); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + + $borrowed->release(); + }); + } + + public function testHeartbeatDoesNotRealizeLazyPdoClosures(): void + { + run(function () { + $pool = $this->createPool([ + 'heartbeat_interval' => null, + ]); + + $pooledConnection = $pool->borrow(); + $connection = $pooledConnection->getConnection(); + + $this->assertInstanceOf(Closure::class, $connection->getRawPdo()); + + $pooledConnection->release(); + $pool->runHeartbeatForTest(); + + $this->assertInstanceOf(Closure::class, $connection->getRawPdo()); + }); + } + + public function testHeartbeatPingDoesNotFireQueryInstrumentation(): void + { + run(function () { + $pool = $this->createPool([ + 'heartbeat_interval' => null, + ]); + + $events = 0; + $this->app->make(Dispatcher::class)->listen(QueryExecuted::class, function () use (&$events) { + ++$events; + }); + + $pooledConnection = $pool->borrow(); + $connection = $pooledConnection->getConnection(); + $connection->getPdo(); + $pooledConnection->release(); + + $connection->enableQueryLog(); + $connection->whenQueryingForLongerThan(-1, function () use (&$events) { + ++$events; + }); + + $pool->runHeartbeatForTest(); + + $this->assertSame(0, $events); + $this->assertSame([], $connection->getQueryLog()); + $this->assertSame(0.0, $connection->totalQueryDuration()); + }); + } + + public function testHeartbeatOnlyTouchesIdleConnections(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 2, + 'heartbeat_interval' => null, + 'max_idle_time' => 1.0, + ]); + + $borrowed = $pool->borrow(); + $idle = $pool->borrow(); + $idle->getConnection()->getPdo(); + $idle->release(); + $this->ageReleasedConnection($idle); + + $pool->runHeartbeatForTest(); + + $this->assertSame(1, $borrowed->getConnection()->selectOne('SELECT 1 as result')->result); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + + $borrowed->release(); + }); + } + + public function testFailedHeartbeatPingDiscardsConnectionBelowMinimum(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + ], FailingHeartbeatDatabasePool::class); + + $pooledConnection = $pool->borrow(); + $pooledConnection->release(); + + $pool->runHeartbeatForTest(); + + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + }); + } + + public function testHeartbeatDiscardsInvalidIdleConnectionBelowMinimum(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + ]); + + $pooledConnection = $pool->borrow(); + $pooledConnection->release(); + + (new ReflectionProperty(PooledConnection::class, 'invalid'))->setValue($pooledConnection, true); + + $pool->runHeartbeatForTest(); + + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + }); + } + + public function testHeartbeatPingTimeoutDiscardsWithoutRequeueingLateCompletion(): void + { + run(function () { + SlowHeartbeatConnection::$coroutineId = null; + + $this->app->make('db.factory')->extend( + 'heartbeat_test', + static fn (array $config): SlowHeartbeatConnection => new SlowHeartbeatConnection( + static fn () => throw new RuntimeException('The slow heartbeat test must not resolve its PDO.'), + $config['database'], + $config['prefix'], + $config, + ) + ); + + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + 'heartbeat_timeout' => 0.001, + ]); + + $pooledConnection = $pool->borrow(); + $pooledConnection->release(); + + $startedAt = microtime(true); + $pool->runHeartbeatForTest(); + $elapsed = microtime(true) - $startedAt; + + $this->assertLessThan(0.2, $elapsed); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + $this->assertIsInt(SlowHeartbeatConnection::$coroutineId); + $deadline = microtime(true) + 0.1; + while (Coroutine::exists(SlowHeartbeatConnection::$coroutineId) && microtime(true) < $deadline) { + usleep(1000); + } + $this->assertFalse(Coroutine::exists(SlowHeartbeatConnection::$coroutineId)); + + usleep(100000); + + $this->assertSame(0, $pool->getIdleCount()); + }); + } + + public function testSuccessfulHeartbeatPingAfterCloseDiscardsConnection(): void + { + run(function () { + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + ], ClosingHeartbeatDatabasePool::class); + + $pooledConnection = $pool->borrow(); + $pooledConnection->release(); + + $pool->runHeartbeatForTest(); + + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + }); + } + + public function testHeartbeatDiscardOnlyDecrementsOnceWhenLoggerThrows(): void + { + run(function () { + $this->app->instance(StdoutLoggerInterface::class, new ThrowingHeartbeatLogger); + + $pool = $this->createPool([ + 'min_retained_connections' => 1, + 'max_connections' => 1, + 'heartbeat_interval' => null, + ], OpenTransactionFailingHeartbeatDatabasePool::class); + + $pooledConnection = $pool->borrow(); + $pooledConnection->release(); + + $pool->runHeartbeatForTest(); + + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + }); + } + + #[DataProvider('heartbeatCancellationPaths')] + public function testHeartbeatCancellationDisposesOnceAndLeavesLaterIdleConnections(string $path): void + { + run(function () use ($path): void { + $cancellation = new CanceledException('heartbeat canceled'); + $secondary = $path === 'evaluation with failed close' + ? new RuntimeException('close failed') + : new CanceledException('secondary close cancellation'); + $logger = m::mock(StdoutLoggerInterface::class); + + if ($path === 'evaluation with failed close') { + $logger->shouldReceive('error')->once()->with((string) $secondary); + } else { + $logger->shouldNotReceive('error'); + } + + $this->app->instance(StdoutLoggerInterface::class, $logger); + $pool = $this->createPool([], CancellableDisposalDatabasePool::class); + $first = $pool->borrow(); + $later = $pool->borrow(); + $this->assertInstanceOf(CancellableDisposalPooledConnection::class, $first); + $this->assertInstanceOf(CancellableDisposalPooledConnection::class, $later); + + if ($path === 'disposal') { + $first->healthy = false; + $first->closeFailure = $cancellation; + } else { + $first->pingFailure = $cancellation; + $first->closeFailure = $path === 'evaluation' ? null : $secondary; + } + + $first->release(); + $later->release(); + $first->closeCount = 0; + $later->closeCount = 0; + $caught = null; + + try { + $pool->runHeartbeatForTest(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cancellation, $caught); + $this->assertSame(1, $first->closeCount); + $this->assertSame(0, $later->closeCount); + $this->assertSame(0, $later->pingCount); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); + }); + } + + public static function heartbeatCancellationPaths(): array + { + return [ + ['evaluation'], + ['evaluation with canceled close'], + ['evaluation with failed close'], + ['disposal'], + ]; + } + + #[DataProvider('nativeCancellationModes')] + public function testCanceledHeartbeatSweepStopsItsChildAndPreservesRemainingIdleConnections(bool $throwException): void + { + $observed = []; + + run(function () use ($throwException, &$observed): void { + $this->app->make('db.factory')->extend( + 'heartbeat_test', + static fn (array $config): CancellableHeartbeatConnection => new CancellableHeartbeatConnection( + static fn () => throw new RuntimeException('The heartbeat test must not resolve its PDO.'), + $config['database'], + $config['prefix'], + $config, + ), + ); + $pool = $this->createPool(); + $first = $pool->borrow(); + $later = $pool->borrow(); + $firstConnection = $first->getConnection(); + $laterConnection = $later->getConnection(); + $firstConnection->pingStarted = new Channel(1); + $firstConnection->blocker = new Channel(1); + $first->release(); + $later->release(); + $caught = null; + $parent = Coroutine::create(static function () use ($pool, &$caught): void { + try { + $pool->runHeartbeatForTest(); + } catch (Throwable $exception) { + $caught = $exception; + } + }); + + try { + $observed['started'] = $firstConnection->pingStarted->pop(1.0); + $observed['canceled'] = Coroutine::cancelById($parent->getId(), throwException: $throwException); + $observed['exception'] = $caught; + $observed['child_exception'] = $firstConnection->cancellation; + $observed['child_running'] = Coroutine::exists($firstConnection->coroutineId); + $observed['parent_running'] = Coroutine::exists($parent->getId()); + $observed['managed'] = $pool->getManagedCount(); + $observed['idle'] = $pool->getIdleCount(); + $observed['later_started'] = $laterConnection->coroutineId; + } finally { + if (Coroutine::exists($parent->getId())) { + Coroutine::cancelById($parent->getId(), throwException: true); + FrameworkCoroutine::join([$parent->getId()], 1.0); + } + + $pool->close(); + } + }); + + $this->assertTrue($observed['started']); + $this->assertTrue($observed['canceled']); + $this->assertInstanceOf(CanceledException::class, $observed['exception']); + $this->assertInstanceOf(CanceledException::class, $observed['child_exception']); + $this->assertFalse($observed['child_running']); + $this->assertFalse($observed['parent_running']); + $this->assertSame(1, $observed['managed']); + $this->assertSame(1, $observed['idle']); + $this->assertNull($observed['later_started']); + } + + public static function nativeCancellationModes(): array + { + return [[false], [true]]; + } + + /** + * @param array $poolOptions + */ + protected function createPool(array $poolOptions = [], string $poolClass = InspectableHeartbeatDatabasePool::class): InspectableHeartbeatDatabasePool + { + $this->app->make('config')->set('database.connections.heartbeat_test', [ + 'driver' => 'sqlite', + 'database' => $this->databasePath, + 'prefix' => '', + 'pool' => [ + 'min_retained_connections' => 1, + 'max_connections' => 2, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat_interval' => null, + 'heartbeat_timeout' => 1.0, + 'max_idle_time' => 60.0, + 'max_lifetime' => null, + ...$poolOptions, + ], + ]); + + $pool = new $poolClass($this->app, 'heartbeat_test'); + $this->pools[] = $pool; + + return $pool; + } + + protected function ageReleasedConnection(PooledConnection $connection): void + { + $lastReleaseTime = new ReflectionProperty(PooledConnection::class, 'lastReleaseTime'); + $lastUseTime = new ReflectionProperty(PooledConnection::class, 'lastUseTime'); + + $lastReleaseTime->setValue($connection, hrtime(true) / 1e9 - 5.0); + $lastUseTime->setValue($connection, hrtime(true) / 1e9 - 5.0); + } + + protected function ageConnectionGeneration(PooledConnection $connection): void + { + (new ReflectionProperty(PooledConnection::class, 'createdAt'))->setValue($connection, hrtime(true) / 1e9 - 5.0); + + $lifetimeExpiresAt = new ReflectionProperty(PooledConnection::class, 'lifetimeExpiresAt'); + + if ($lifetimeExpiresAt->getValue($connection) !== null) { + $lifetimeExpiresAt->setValue($connection, hrtime(true) / 1e9 - 1.0); + } + } +} + +class InspectableHeartbeatDatabasePool extends DatabasePool +{ + public function runHeartbeatForTest(): void + { + $this->heartbeat(); + } + + public function heartbeatTimerCount(): int + { + $timer = (new ReflectionProperty(DatabasePool::class, 'heartbeatTimer'))->getValue($this); + + return $timer === null ? 0 : count((new ClassInvoker($timer))->coroutines); + } +} + +class CancellableDisposalDatabasePool extends InspectableHeartbeatDatabasePool +{ + protected function createConnection(): Connection + { + return new CancellableDisposalPooledConnection($this->container, $this, $this->config); + } +} + +class CancellableDisposalPooledConnection extends PooledConnection +{ + public ?Throwable $pingFailure = null; + + public ?Throwable $closeFailure = null; + + public bool $healthy = true; + + public int $pingCount = 0; + + public int $closeCount = 0; + + public function ping(float $timeout): bool + { + ++$this->pingCount; + + if ($this->pingFailure !== null) { + throw $this->pingFailure; + } + + return $this->healthy; + } + + public function close(): bool + { + ++$this->closeCount; + parent::close(); + + if ($this->closeFailure !== null) { + throw $this->closeFailure; + } + + return true; + } +} + +class FailingHeartbeatDatabasePool extends InspectableHeartbeatDatabasePool +{ + protected function createConnection(): Connection + { + return new FailingHeartbeatPooledConnection($this->container, $this, $this->config); + } +} + +class FailingHeartbeatPooledConnection extends PooledConnection +{ + public function ping(float $timeout): bool + { + return false; + } +} + +class LifetimeExpiredPingTrackingDatabasePool extends InspectableHeartbeatDatabasePool +{ + protected function createConnection(): Connection + { + return new LifetimeExpiredPingTrackingPooledConnection($this->container, $this, $this->config); + } +} + +class LifetimeExpiredPingTrackingPooledConnection extends PooledConnection +{ + public bool $pingCalled = false; + + public function ping(float $timeout): bool + { + $this->pingCalled = true; + + return true; + } +} + +class ClosingHeartbeatDatabasePool extends InspectableHeartbeatDatabasePool +{ + protected function createConnection(): Connection + { + return new ClosingHeartbeatPooledConnection($this->container, $this, $this->config); + } +} + +class ClosingHeartbeatPooledConnection extends PooledConnection +{ + public function ping(float $timeout): bool + { + $this->pool->close(); + + return true; + } +} + +class OpenTransactionFailingHeartbeatDatabasePool extends InspectableHeartbeatDatabasePool +{ + protected function createConnection(): Connection + { + return new OpenTransactionFailingHeartbeatPooledConnection($this->container, $this, $this->config); + } +} + +class OpenTransactionFailingHeartbeatPooledConnection extends FailingHeartbeatPooledConnection +{ + public function hasOpenTransaction(): bool + { + return true; + } +} + +class ThrowingHeartbeatLogger extends AbstractLogger implements StdoutLoggerInterface +{ + public function log($level, string|Stringable $message, array $context = []): void + { + throw new RuntimeException('Logger failed.'); + } +} + +class CancellableHeartbeatConnection extends SQLiteConnection +{ + public Channel $pingStarted; + + public Channel $blocker; + + public ?CanceledException $cancellation = null; + + public ?int $coroutineId = null; + + public function ping(): bool + { + $this->coroutineId = Coroutine::id(); + $this->pingStarted->push(true); + + try { + $this->blocker->pop(); + } catch (CanceledException $exception) { + $this->cancellation = $exception; + + throw $exception; + } + + return true; + } +} + +class SlowHeartbeatConnection extends SQLiteConnection +{ + public static ?int $coroutineId = null; + + public function ping(): bool + { + self::$coroutineId = Coroutine::id(); + + usleep(500000); + + return false; + } +} diff --git a/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php b/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php deleted file mode 100644 index 254bda7e4a..0000000000 --- a/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php +++ /dev/null @@ -1,564 +0,0 @@ -make('config')->set('app.stdout_log.level', []); - } - - protected function setUp(): void - { - parent::setUp(); - - $this->databaseDirectory = ParallelTesting::tempDir('DbPoolHeartbeatTest'); - $files = new Filesystem; - $files->deleteDirectory($this->databaseDirectory); - $files->ensureDirectoryExists($this->databaseDirectory); - $this->databasePath = $this->databaseDirectory . '/database.sqlite'; - touch($this->databasePath); - - $this->app->instance('db.connector.sqlite', new SQLiteConnector); - } - - protected function tearDown(): void - { - foreach ($this->pools as $pool) { - run(fn () => $pool->close()); - } - - (new Filesystem)->deleteDirectory($this->databaseDirectory); - - parent::tearDown(); - } - - public function testDisabledHeartbeatDoesNotStartTimer(): void - { - $pool = $this->createPool([ - 'heartbeat' => -1, - ]); - - $this->assertSame(0, $pool->heartbeatTimerCount()); - } - - public function testEnabledHeartbeatStartsTimerAndCloseClearsIt(): void - { - $pool = $this->createPool([ - 'heartbeat' => 0.001, - ]); - - $this->assertSame(1, $pool->heartbeatTimerCount()); - - run(fn () => $pool->close()); - - $this->assertSame(0, $pool->heartbeatTimerCount()); - } - - public function testHeartbeatKeepsMinimumConnectionsWarmAndEvictsExpiredExtras(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 3, - 'heartbeat' => -1, - 'max_idle_time' => 1.0, - ]); - - $connections = [ - $pool->get(), - $pool->get(), - $pool->get(), - ]; - - foreach ($connections as $connection) { - $connection->getConnection()->getPdo(); - $connection->release(); - $this->ageReleasedConnection($connection); - } - - $pool->runHeartbeatForTest(); - - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(1, $pool->getConnectionsInChannel()); - }); - } - - public function testHeartbeatValidationKeepsMinimumConnectionCheckoutValid(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - 'max_idle_time' => 1.0, - ]); - - $pooledConnection = $pool->get(); - $connection = $pooledConnection->getConnection(); - $pdo = $connection->getPdo(); - - $pooledConnection->release(); - $this->ageReleasedConnection($pooledConnection); - - $pool->runHeartbeatForTest(); - - /** @var PooledConnection $nextPooledConnection */ - $nextPooledConnection = $pool->get(); - - $this->assertSame($connection, $nextPooledConnection->getConnection()); - $this->assertSame($pdo, $nextPooledConnection->getConnection()->getPdo()); - - $nextPooledConnection->release(); - }); - } - - public function testHeartbeatDiscardsLifetimeExpiredIdleConnectionBeforePinging(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - 'max_lifetime' => 1.0, - ], LifetimeExpiredPingTrackingDbPool::class); - - $pooledConnection = $pool->get(); - $this->assertInstanceOf(LifetimeExpiredPingTrackingPooledConnection::class, $pooledConnection); - - $connection = $pooledConnection->getConnection(); - $pooledConnection->release(); - - $this->ageConnectionGeneration($pooledConnection); - - $pool->runHeartbeatForTest(); - - $this->assertFalse($pooledConnection->pingCalled); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - - $nextPooledConnection = $pool->get(); - - $this->assertNotSame($connection, $nextPooledConnection->getConnection()); - - $nextPooledConnection->release(); - }); - } - - public function testHeartbeatDoesNotRecycleBorrowedLifetimeExpiredConnection(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - 'max_lifetime' => 1.0, - ]); - - $borrowed = $pool->get(); - - $this->ageConnectionGeneration($borrowed); - - $pool->runHeartbeatForTest(); - - $this->assertSame(1, $borrowed->getConnection()->selectOne('SELECT 1 as result')->result); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - - $borrowed->release(); - }); - } - - public function testHeartbeatDoesNotRealizeLazyPdoClosures(): void - { - run(function () { - $pool = $this->createPool([ - 'heartbeat' => -1, - ]); - - $pooledConnection = $pool->get(); - $connection = $pooledConnection->getConnection(); - - $this->assertInstanceOf(Closure::class, $connection->getRawPdo()); - - $pooledConnection->release(); - $pool->runHeartbeatForTest(); - - $this->assertInstanceOf(Closure::class, $connection->getRawPdo()); - }); - } - - public function testHeartbeatPingDoesNotFireQueryInstrumentation(): void - { - run(function () { - $pool = $this->createPool([ - 'heartbeat' => -1, - ]); - - $events = 0; - $this->app->make(Dispatcher::class)->listen(QueryExecuted::class, function () use (&$events) { - ++$events; - }); - - $pooledConnection = $pool->get(); - $connection = $pooledConnection->getConnection(); - $connection->getPdo(); - $pooledConnection->release(); - - $connection->enableQueryLog(); - $connection->whenQueryingForLongerThan(-1, function () use (&$events) { - ++$events; - }); - - $pool->runHeartbeatForTest(); - - $this->assertSame(0, $events); - $this->assertSame([], $connection->getQueryLog()); - $this->assertSame(0.0, $connection->totalQueryDuration()); - }); - } - - public function testHeartbeatOnlyTouchesIdleConnections(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 2, - 'heartbeat' => -1, - 'max_idle_time' => 1.0, - ]); - - $borrowed = $pool->get(); - $idle = $pool->get(); - $idle->getConnection()->getPdo(); - $idle->release(); - $this->ageReleasedConnection($idle); - - $pool->runHeartbeatForTest(); - - $this->assertSame(1, $borrowed->getConnection()->selectOne('SELECT 1 as result')->result); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - - $borrowed->release(); - }); - } - - public function testFailedHeartbeatPingDiscardsConnectionBelowMinimum(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - ], FailingHeartbeatDbPool::class); - - $pooledConnection = $pool->get(); - $pooledConnection->release(); - - $pool->runHeartbeatForTest(); - - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - }); - } - - public function testHeartbeatDiscardsInvalidIdleConnectionBelowMinimum(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - ]); - - $pooledConnection = $pool->get(); - $pooledConnection->release(); - - (new ReflectionProperty(PooledConnection::class, 'invalid'))->setValue($pooledConnection, true); - - $pool->runHeartbeatForTest(); - - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - }); - } - - public function testHeartbeatPingTimeoutDiscardsWithoutRequeueingLateCompletion(): void - { - run(function () { - SlowHeartbeatConnection::$coroutineId = null; - - $this->app->make('db.factory')->extend( - 'heartbeat_test', - static fn (array $config): SlowHeartbeatConnection => new SlowHeartbeatConnection( - static fn () => throw new RuntimeException('The slow heartbeat test must not resolve its PDO.'), - $config['database'], - $config['prefix'], - $config, - ) - ); - - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - 'heartbeat_timeout' => 0.001, - ]); - - $pooledConnection = $pool->get(); - $pooledConnection->release(); - - $startedAt = microtime(true); - $pool->runHeartbeatForTest(); - $elapsed = microtime(true) - $startedAt; - - $this->assertLessThan(0.2, $elapsed); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertIsInt(SlowHeartbeatConnection::$coroutineId); - $deadline = microtime(true) + 0.1; - while (Coroutine::exists(SlowHeartbeatConnection::$coroutineId) && microtime(true) < $deadline) { - usleep(1000); - } - $this->assertFalse(Coroutine::exists(SlowHeartbeatConnection::$coroutineId)); - - usleep(100000); - - $this->assertSame(0, $pool->getConnectionsInChannel()); - }); - } - - public function testSuccessfulHeartbeatPingAfterCloseDiscardsConnection(): void - { - run(function () { - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - ], ClosingHeartbeatDbPool::class); - - $pooledConnection = $pool->get(); - $pooledConnection->release(); - - $pool->runHeartbeatForTest(); - - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - }); - } - - public function testHeartbeatDiscardOnlyDecrementsOnceWhenLoggerThrows(): void - { - run(function () { - $this->app->instance(StdoutLoggerInterface::class, new ThrowingHeartbeatLogger); - - $pool = $this->createPool([ - 'min_connections' => 1, - 'max_connections' => 1, - 'heartbeat' => -1, - ], OpenTransactionFailingHeartbeatDbPool::class); - - $pooledConnection = $pool->get(); - $pooledConnection->release(); - - $pool->runHeartbeatForTest(); - - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - }); - } - - /** - * @param array $poolOptions - */ - protected function createPool(array $poolOptions = [], string $poolClass = InspectableHeartbeatDbPool::class): InspectableHeartbeatDbPool - { - $this->app->make('config')->set('database.connections.heartbeat_test', [ - 'driver' => 'sqlite', - 'database' => $this->databasePath, - 'prefix' => '', - 'pool' => [ - 'min_connections' => 1, - 'max_connections' => 2, - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => -1, - 'heartbeat_timeout' => 1.0, - 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, - ...$poolOptions, - ], - ]); - - $pool = new $poolClass($this->app, 'heartbeat_test'); - $this->pools[] = $pool; - - return $pool; - } - - protected function ageReleasedConnection(PooledConnection $connection): void - { - $lastReleaseTime = new ReflectionProperty(PooledConnection::class, 'lastReleaseTime'); - $lastUseTime = new ReflectionProperty(PooledConnection::class, 'lastUseTime'); - - $lastReleaseTime->setValue($connection, hrtime(true) / 1e9 - 5.0); - $lastUseTime->setValue($connection, hrtime(true) / 1e9 - 5.0); - } - - protected function ageConnectionGeneration(PooledConnection $connection): void - { - (new ReflectionProperty(PooledConnection::class, 'createdAt'))->setValue($connection, hrtime(true) / 1e9 - 5.0); - - $lifetimeExpiresAt = new ReflectionProperty(PooledConnection::class, 'lifetimeExpiresAt'); - - if ($lifetimeExpiresAt->getValue($connection) > 0.0) { - $lifetimeExpiresAt->setValue($connection, hrtime(true) / 1e9 - 1.0); - } - } -} - -class InspectableHeartbeatDbPool extends DbPool -{ - public function runHeartbeatForTest(): void - { - $this->heartbeat(); - } - - public function heartbeatTimerCount(): int - { - $timer = (new ReflectionProperty(DbPool::class, 'heartbeatTimer'))->getValue($this); - - return $timer === null ? 0 : count((new ClassInvoker($timer))->coroutines); - } -} - -class FailingHeartbeatDbPool extends InspectableHeartbeatDbPool -{ - protected function createConnection(): ConnectionInterface - { - return new FailingHeartbeatPooledConnection($this->container, $this, $this->config); - } -} - -class FailingHeartbeatPooledConnection extends PooledConnection -{ - public function ping(float $timeout): bool - { - return false; - } -} - -class LifetimeExpiredPingTrackingDbPool extends InspectableHeartbeatDbPool -{ - protected function createConnection(): ConnectionInterface - { - return new LifetimeExpiredPingTrackingPooledConnection($this->container, $this, $this->config); - } -} - -class LifetimeExpiredPingTrackingPooledConnection extends PooledConnection -{ - public bool $pingCalled = false; - - public function ping(float $timeout): bool - { - $this->pingCalled = true; - - return true; - } -} - -class ClosingHeartbeatDbPool extends InspectableHeartbeatDbPool -{ - protected function createConnection(): ConnectionInterface - { - return new ClosingHeartbeatPooledConnection($this->container, $this, $this->config); - } -} - -class ClosingHeartbeatPooledConnection extends PooledConnection -{ - public function ping(float $timeout): bool - { - $this->pool->close(); - - return true; - } -} - -class OpenTransactionFailingHeartbeatDbPool extends InspectableHeartbeatDbPool -{ - protected function createConnection(): ConnectionInterface - { - return new OpenTransactionFailingHeartbeatPooledConnection($this->container, $this, $this->config); - } -} - -class OpenTransactionFailingHeartbeatPooledConnection extends FailingHeartbeatPooledConnection -{ - public function hasOpenTransaction(): bool - { - return true; - } -} - -class ThrowingHeartbeatLogger extends AbstractLogger implements StdoutLoggerInterface -{ - public function log($level, string|Stringable $message, array $context = []): void - { - throw new RuntimeException('Logger failed.'); - } -} - -class SlowHeartbeatConnection extends SQLiteConnection -{ - public static ?int $coroutineId = null; - - public function ping(): bool - { - self::$coroutineId = Coroutine::id(); - - usleep(500000); - - return false; - } -} diff --git a/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php index d5c8484b53..80c44b79e1 100644 --- a/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php +++ b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php @@ -4,20 +4,23 @@ namespace Hypervel\Tests\Integration\Database\Sqlite; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\Connectors\SQLiteConnector; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\DatabasePool; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Engine\Channel; use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use InvalidArgumentException; +use Mockery as m; use PDO; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Throwable; -use TypeError; use function Hypervel\Coroutine\parallel; use function Hypervel\Coroutine\run; @@ -50,11 +53,11 @@ protected function configureInMemoryDatabase(): void 'database' => ':memory:', 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 5, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, ], ]; @@ -62,9 +65,9 @@ protected function configureInMemoryDatabase(): void $config->set('database.connections.memory_test', $connectionConfig); } - protected function getPoolFactory(): PoolFactory + protected function poolManager(): PoolManager { - return $this->app->make(PoolFactory::class); + return $this->app->make(PoolManager::class); } #[DataProvider('inMemoryDatabaseProvider')] @@ -77,7 +80,7 @@ public function testPoolCapacityFollowsSQLiteClassification(string $database, bo 'database' => $database, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 2, ], ]; @@ -85,12 +88,12 @@ public function testPoolCapacityFollowsSQLiteClassification(string $database, bo $configKey = 'in_memory_test_' . hash('xxh128', $database); $config->set("database.connections.{$configKey}", $connectionConfig); - $factory = $this->getPoolFactory(); - $pool = $factory->getPool($configKey); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool($configKey); - $this->assertSame($inMemory ? 1 : 2, $pool->getOption()->getMaxConnections()); + $this->assertSame($inMemory ? 1 : 2, $pool->getOptions()->maxConnections); $this->assertSame($inMemory, $pool->getSharedInMemorySqlitePdo() instanceof PDO); - $factory->flushPool($configKey); + $poolManager->purge($configKey); } /** @@ -122,20 +125,20 @@ public function testNonSqliteDriverIsNotInMemorySqlite(): void 'database' => ':memory:', // Even with :memory: database name 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 2, ], ]; $config->set('database.connections.mysql_memory_test', $connectionConfig); - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('mysql_memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('mysql_memory_test'); - $this->assertSame(2, $pool->getOption()->getMaxConnections()); + $this->assertSame(2, $pool->getOptions()->maxConnections); $this->assertNull($pool->getSharedInMemorySqlitePdo()); - $factory->flushPool('mysql_memory_test'); + $poolManager->purge('mysql_memory_test'); } public function testDerivedReadPoolRejectsUriInMemoryDatabase(): void @@ -155,7 +158,7 @@ public function testDerivedReadPoolRejectsUriInMemoryDatabase(): void 'Database connection [uri_read_memory_test::read] cannot use a derived read pool for in-memory SQLite.' ); - $this->getPoolFactory()->getPool('uri_read_memory_test::read'); + $this->poolManager()->pool('uri_read_memory_test::read'); } public function testInMemoryPoolPreservesAZeroManagedConnectionFloor(): void @@ -166,15 +169,15 @@ public function testInMemoryPoolPreservesAZeroManagedConnectionFloor(): void 'database' => ':memory:', 'prefix' => '', 'pool' => [ - 'min_connections' => 0, + 'min_retained_connections' => 0, 'max_connections' => 5, ], ]); - $pool = $this->getPoolFactory()->getPool('zero_floor_memory_test'); + $pool = $this->poolManager()->pool('zero_floor_memory_test'); - $this->assertSame(0, $pool->getOption()->getMinConnections()); - $this->assertSame(1, $pool->getOption()->getMaxConnections()); + $this->assertSame(0, $pool->getOptions()->minRetainedConnections); + $this->assertSame(1, $pool->getOptions()->maxConnections); } /** @@ -197,7 +200,7 @@ public function testInMemoryPoolDoesNotMaskInvalidConnectionCounts( $this->expectException($exception); - $this->getPoolFactory()->getPool($connection); + $this->poolManager()->pool($connection); } /** @@ -207,20 +210,28 @@ public static function invalidPoolOptionProvider(): array { return [ 'negative minimum' => [ - ['min_connections' => -1, 'max_connections' => 5], + ['min_retained_connections' => -1, 'max_connections' => 5], InvalidArgumentException::class, ], 'zero maximum' => [ - ['min_connections' => 0, 'max_connections' => 0], + ['min_retained_connections' => 0, 'max_connections' => 0], InvalidArgumentException::class, ], 'minimum exceeds maximum' => [ - ['min_connections' => 2, 'max_connections' => 1], + ['min_retained_connections' => 2, 'max_connections' => 1], InvalidArgumentException::class, ], 'non-integer minimum' => [ - ['min_connections' => '1', 'max_connections' => 5], - TypeError::class, + ['min_retained_connections' => '1', 'max_connections' => 5], + InvalidArgumentException::class, + ], + 'null minimum' => [ + ['min_retained_connections' => null, 'max_connections' => 5], + InvalidArgumentException::class, + ], + 'null maximum' => [ + ['max_connections' => null], + InvalidArgumentException::class, ], ]; } @@ -231,8 +242,8 @@ public static function invalidPoolOptionProvider(): array public function testInMemorySqlitePoolHasSharedPdo(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); $sharedPdo = $pool->getSharedInMemorySqlitePdo(); @@ -256,19 +267,19 @@ public function testFileSqlitePoolDoesNotHaveSharedPdo(): void 'database' => $tempFile, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 2, ], ]; $config->set('database.connections.file_sqlite_test', $connectionConfig); - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('file_sqlite_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('file_sqlite_test'); $this->assertNull($pool->getSharedInMemorySqlitePdo()); - $factory->flushPool('file_sqlite_test'); + $poolManager->purge('file_sqlite_test'); } finally { (new Filesystem)->deleteDirectory($tempDirectory); } @@ -276,8 +287,8 @@ public function testFileSqlitePoolDoesNotHaveSharedPdo(): void public function testInMemorySqlitePoolSerializesOneSharedPdoOwner(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool): void { $secondAttempted = new Channel(1); @@ -285,7 +296,7 @@ public function testInMemorySqlitePoolSerializesOneSharedPdoOwner(): void [$firstPdo, $secondPdo] = parallel([ function () use ($pool, $secondAttempted, $secondAcquired): PDO { - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $pdo = $pooledConnection->getConnection()->getPdo(); $secondAttempted->pop(); @@ -296,7 +307,7 @@ function () use ($pool, $secondAttempted, $secondAcquired): PDO { }, function () use ($pool, $secondAttempted, $secondAcquired): PDO { $secondAttempted->push(true); - $pooledConnection = $pool->get(); + $pooledConnection = $pool->borrow(); $secondAcquired->push(true); try { @@ -307,19 +318,19 @@ function () use ($pool, $secondAttempted, $secondAcquired): PDO { }, ]); - $this->assertSame(1, $pool->getOption()->getMaxConnections()); + $this->assertSame(1, $pool->getOptions()->maxConnections); $this->assertSame($firstPdo, $secondPdo); }); } public function testSharedPdoMaintainsDataAcrossPoolSlots(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool) { // Create table and insert data using first connection - $pooled1 = $pool->get(); + $pooled1 = $pool->borrow(); $connection1 = $pooled1->getConnection(); $connection1->statement('CREATE TABLE IF NOT EXISTS shared_test (id INTEGER PRIMARY KEY, name TEXT)'); @@ -328,7 +339,7 @@ public function testSharedPdoMaintainsDataAcrossPoolSlots(): void $pooled1->release(); // Verify data is visible from second connection - $pooled2 = $pool->get(); + $pooled2 = $pool->borrow(); $connection2 = $pooled2->getConnection(); $result = $connection2->selectOne('SELECT name FROM shared_test WHERE id = 1'); @@ -342,8 +353,8 @@ public function testSharedPdoMaintainsDataAcrossPoolSlots(): void public function testCloseClearsSharedPdo(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); // Verify shared PDO exists $this->assertInstanceOf(PDO::class, $pool->getSharedInMemorySqlitePdo()); @@ -354,6 +365,34 @@ public function testCloseClearsSharedPdo(): void $this->assertNull($pool->getSharedInMemorySqlitePdo()); } + public function testCanceledCloseClearsSharedPdo(): void + { + $cancellation = new CanceledException('connection close canceled'); + $connection = m::mock(PoolConnection::class); + $connection->shouldReceive('close')->once()->andThrow($cancellation); + $pool = m::mock(DatabasePool::class, [$this->app, 'memory_test']) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $pool->shouldReceive('createConnection')->once()->andReturn($connection); + $pool->release($pool->borrow()); + $caught = null; + + try { + $this->assertInstanceOf(PDO::class, $pool->getSharedInMemorySqlitePdo()); + + try { + $pool->close(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cancellation, $caught); + $this->assertNull($pool->getSharedInMemorySqlitePdo()); + } finally { + $pool->close(); + } + } + // ========================================================================= // ConnectionFactory::makeSqliteFromSharedPdo() tests // ========================================================================= @@ -409,8 +448,8 @@ public function testMakeSqliteFromSharedPdoUsesWriteConfigWhenReadWritePresent() public function testPooledConnectionCloseDoesNotDisconnectSharedPdo(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool) { $sharedPdo = $pool->getSharedInMemorySqlitePdo(); @@ -420,7 +459,7 @@ public function testPooledConnectionCloseDoesNotDisconnectSharedPdo(): void $sharedPdo->exec('INSERT INTO close_test (id) VALUES (1)'); // Get a pooled connection - $pooled = $pool->get(); + $pooled = $pool->borrow(); $connection = $pooled->getConnection(); // Verify we can see the data @@ -433,7 +472,7 @@ public function testPooledConnectionCloseDoesNotDisconnectSharedPdo(): void // The shared PDO should still be functional // Get another pooled connection and verify data still exists - $pooled2 = $pool->get(); + $pooled2 = $pool->borrow(); $connection2 = $pooled2->getConnection(); $result2 = $connection2->selectOne('SELECT id FROM close_test WHERE id = 1'); @@ -445,8 +484,8 @@ public function testPooledConnectionCloseDoesNotDisconnectSharedPdo(): void public function testPooledConnectionRefreshRebindsToSharedPdo(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool) { $sharedPdo = $pool->getSharedInMemorySqlitePdo(); @@ -455,7 +494,7 @@ public function testPooledConnectionRefreshRebindsToSharedPdo(): void $sharedPdo->exec('CREATE TABLE IF NOT EXISTS refresh_test (id INTEGER PRIMARY KEY, value TEXT)'); $sharedPdo->exec("INSERT INTO refresh_test (id, value) VALUES (1, 'original')"); - $pooled = $pool->get(); + $pooled = $pool->borrow(); $connection = $pooled->getConnection(); // Trigger a refresh via the reconnector @@ -476,12 +515,12 @@ public function testPooledConnectionRefreshRebindsToSharedPdo(): void public function testPooledConnectionRefreshCleansUpSharedPdoTransaction(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool): void { $sharedPdo = $pool->getSharedInMemorySqlitePdo(); - $pooled = $pool->get(); + $pooled = $pool->borrow(); $connection = $pooled->getConnection(); $rolledBack = false; @@ -531,12 +570,12 @@ public function testPooledConnectionRefreshCleansUpSharedPdoTransaction(): void public function testPooledConnectionRefreshRebindsSharedPdoAfterRollbackCallbackFailure(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool): void { $sharedPdo = $pool->getSharedInMemorySqlitePdo(); - $pooled = $pool->get(); + $pooled = $pool->borrow(); $connection = $pooled->getConnection(); $failure = new RuntimeException('rollback callback failure'); @@ -584,13 +623,13 @@ public function testPooledConnectionRefreshRebindsSharedPdoAfterRollbackCallback public function testReconnectUsesSharedPdoForInMemorySqlite(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool) { $sharedPdo = $pool->getSharedInMemorySqlitePdo(); - $pooled = $pool->get(); + $pooled = $pool->borrow(); $connection = $pooled->getConnection(); // Connection should be using the shared PDO @@ -607,11 +646,11 @@ public function testReconnectUsesSharedPdoForInMemorySqlite(): void public function testCapsuleConnectionsAreIsolatedFromPooledConnections(): void { // First, create data via pooled connection - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('memory_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('memory_test'); run(function () use ($pool) { - $pooled = $pool->get(); + $pooled = $pool->borrow(); $connection = $pooled->getConnection(); $connection->statement('CREATE TABLE IF NOT EXISTS capsule_isolation_test (id INTEGER PRIMARY KEY, source TEXT)'); diff --git a/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php b/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php index bf0659458e..4243abea8d 100644 --- a/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php +++ b/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php @@ -10,7 +10,7 @@ use Hypervel\Database\DatabaseManager; use Hypervel\Database\Events\ConnectionEstablished; use Hypervel\Database\Pool\PooledConnection; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -19,15 +19,6 @@ use function Hypervel\Coroutine\run; -/** - * Tests for pool connection management fixes (DB-01 through DB-04). - * - * These tests verify: - * - DB-01: Nested transactions are fully rolled back on connection release - * - DB-02: Pool flushAll() closes all connections properly - * - DB-03: DatabaseManager disconnect/reconnect/purge work correctly in pooled mode - * - DB-04: ConnectionEstablished event is dispatched for pooled connections - */ class PoolConnectionManagementTest extends TestCase { protected bool $runTestsInCoroutine = false; @@ -78,11 +69,11 @@ protected function configureDatabase(): void 'database' => self::$databasePath, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 5, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, ], ]; @@ -100,17 +91,17 @@ protected function createTestTable(): void }); } - protected function getPoolFactory(): PoolFactory + protected function poolManager(): PoolManager { - return $this->app->make(PoolFactory::class); + return $this->app->make(PoolManager::class); } protected function getPooledConnection(): PooledConnection { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('pool_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('pool_test'); - return $pool->get(); + return $pool->borrow(); } // ========================================================================= @@ -225,63 +216,58 @@ public function testNestedTransactionsAreFullyRolledBackOnRelease(): void } // ========================================================================= - // DB-02: Pool flush semantics + // Pool purge semantics // ========================================================================= /** - * Test that flushPool closes all connections in the pool. + * Test that purge closes all connections in the pool. */ - public function testFlushPoolClosesAllConnections(): void + public function testPurgeClosesAllConnections(): void { - $factory = $this->getPoolFactory(); - $pool = $factory->getPool('pool_test'); + $poolManager = $this->poolManager(); + $pool = $poolManager->pool('pool_test'); // Get and release a few connections to populate the pool run(function () use ($pool) { $connections = []; for ($i = 0; $i < 3; ++$i) { - $connections[] = $pool->get(); + $connections[] = $pool->borrow(); } - foreach ($connections as $conn) { - $conn->release(); + foreach ($connections as $connection) { + $connection->release(); } }); - $connectionsBeforeFlush = $pool->getCurrentConnections(); - $this->assertGreaterThan(0, $connectionsBeforeFlush, 'Pool should have connections before flush'); + $connectionsBeforePurge = $pool->getManagedCount(); + $this->assertGreaterThan(0, $connectionsBeforePurge, 'Pool should have connections before purge'); - // Flush the pool - $factory->flushPool('pool_test'); + $poolManager->purge('pool_test'); - // Pool should be removed from factory - // Getting pool again should create a fresh one - $newPool = $factory->getPool('pool_test'); - $this->assertEquals(0, $newPool->getCurrentConnections(), 'Fresh pool should have no connections'); + $newPool = $poolManager->pool('pool_test'); + $this->assertSame(0, $newPool->getManagedCount(), 'Fresh pool should have no connections'); } /** - * Test that flushAll closes all connections in all pools. + * Test that purgeAll closes all connections in all pools. */ - public function testFlushAllClosesAllPoolConnections(): void + public function testPurgeAllClosesAllPoolConnections(): void { - $factory = $this->getPoolFactory(); + $poolManager = $this->poolManager(); // Get pool and create some connections - $pool = $factory->getPool('pool_test'); + $pool = $poolManager->pool('pool_test'); run(function () use ($pool) { - $conn = $pool->get(); - $conn->release(); + $connection = $pool->borrow(); + $connection->release(); }); - $this->assertGreaterThan(0, $pool->getCurrentConnections()); + $this->assertGreaterThan(0, $pool->getManagedCount()); - // Flush all pools - $factory->flushAll(); + $poolManager->purgeAll(); - // Getting pool again should give fresh pool - $newPool = $factory->getPool('pool_test'); - $this->assertEquals(0, $newPool->getCurrentConnections()); + $newPool = $poolManager->pool('pool_test'); + $this->assertSame(0, $newPool->getManagedCount()); } // ========================================================================= @@ -379,15 +365,11 @@ public function testReconnectGetsFreshConnectionWhenNoneExists(): void } /** - * Test that purge() flushes the pool. - * - * Note: We test purge by verifying the pool is flushed after calling purge. - * The context clearing is tested implicitly - if context wasn't cleared, - * the old connection would still be returned. + * Test that DatabaseManager::purge() removes the pool. */ - public function testPurgeFlushesPool(): void + public function testDatabaseManagerPurgeRemovesPool(): void { - $factory = $this->getPoolFactory(); + $poolManager = $this->poolManager(); // First, populate the pool with some connections run(function () { @@ -398,8 +380,8 @@ public function testPurgeFlushesPool(): void }); // Pool should have connections now - $pool = $factory->getPool('pool_test'); - $connectionsBefore = $pool->getCurrentConnections(); + $pool = $poolManager->pool('pool_test'); + $connectionsBefore = $pool->getManagedCount(); $this->assertGreaterThan(0, $connectionsBefore, 'Pool should have connections before purge'); // Purge @@ -407,9 +389,8 @@ public function testPurgeFlushesPool(): void $manager = $this->app->make(DatabaseManager::class); $manager->purge('pool_test'); - // Pool should be flushed (getting pool again gives fresh one with no connections) - $newPool = $factory->getPool('pool_test'); - $this->assertEquals(0, $newPool->getCurrentConnections(), 'Pool should be empty after purge'); + $newPool = $poolManager->pool('pool_test'); + $this->assertSame(0, $newPool->getManagedCount(), 'Pool should be empty after purge'); } // ========================================================================= @@ -433,9 +414,8 @@ function (ConnectionEstablished $event) use (&$eventDispatched, &$dispatchedConn } ); - // Flush pool to ensure we get a fresh connection (which triggers reconnect) - $factory = $this->getPoolFactory(); - $factory->flushPool('pool_test'); + $poolManager = $this->poolManager(); + $poolManager->purge('pool_test'); run(function () { $pooled = $this->getPooledConnection(); @@ -463,9 +443,8 @@ function (ConnectionEstablished $event) use (&$capturedConnectionName) { } ); - // Flush pool to ensure fresh connection - $factory = $this->getPoolFactory(); - $factory->flushPool('pool_test'); + $poolManager = $this->poolManager(); + $poolManager->purge('pool_test'); run(function () { $pooled = $this->getPooledConnection(); diff --git a/tests/Integration/Database/Sqlite/QueryDurationThresholdPooledTest.php b/tests/Integration/Database/Sqlite/QueryDurationThresholdPooledTest.php index a02f0ff2ad..46e9ea13f8 100644 --- a/tests/Integration/Database/Sqlite/QueryDurationThresholdPooledTest.php +++ b/tests/Integration/Database/Sqlite/QueryDurationThresholdPooledTest.php @@ -202,11 +202,11 @@ protected function connectionConfig(string $databasePath): array 'database' => $databasePath, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 5, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, 'testing_enabled' => true, ], diff --git a/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php b/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php index be18a7ea89..c7cd57f670 100644 --- a/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php +++ b/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php @@ -6,9 +6,11 @@ use Hypervel\Database\Connectors\SQLiteConnector; use Hypervel\Database\Pool\PooledConnection; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Schema; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\ParallelTesting; use function Hypervel\Coroutine\go; use function Hypervel\Coroutine\run; @@ -27,38 +29,34 @@ class SQLiteFilePoolingTest extends TestCase { protected bool $runTestsInCoroutine = false; - protected static string $databasePath; + protected string $databasePath; - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - - self::$databasePath = sys_get_temp_dir() . '/hypervel_sqlite_pool_test.db'; - - // Ensure clean state - if (file_exists(self::$databasePath)) { - @unlink(self::$databasePath); - } - touch(self::$databasePath); - } - - public static function tearDownAfterClass(): void - { - if (file_exists(self::$databasePath)) { - @unlink(self::$databasePath); - } - - parent::tearDownAfterClass(); - } + protected string $databaseDirectory; protected function setUp(): void { parent::setUp(); + $this->databaseDirectory = ParallelTesting::tempDir('SQLiteFilePoolingTest'); + $files = new Filesystem; + $files->deleteDirectory($this->databaseDirectory); + $files->ensureDirectoryExists($this->databaseDirectory); + $this->databasePath = $this->databaseDirectory . '/database.sqlite'; + touch($this->databasePath); + $this->configureDatabase(); $this->createTestTable(); } + protected function tearDown(): void + { + try { + parent::tearDown(); + } finally { + (new Filesystem)->deleteDirectory($this->databaseDirectory); + } + } + protected function configureDatabase(): void { $config = $this->app->make('config'); @@ -67,14 +65,14 @@ protected function configureDatabase(): void $connectionConfig = [ 'driver' => 'sqlite', - 'database' => self::$databasePath, + 'database' => $this->databasePath, 'prefix' => '', 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 5, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, ], ]; @@ -95,10 +93,10 @@ protected function createTestTable(): void protected function getPooledConnection(): PooledConnection { - $factory = $this->app->make(PoolFactory::class); - $pool = $factory->getPool('sqlite_file'); + $poolManager = $this->app->make(PoolManager::class); + $pool = $poolManager->pool('sqlite_file'); - return $pool->get(); + return $pool->borrow(); } /** diff --git a/tests/Integration/OpenTelemetry/Redis/RedisInstrumentationIntegrationTest.php b/tests/Integration/OpenTelemetry/Redis/RedisInstrumentationIntegrationTest.php index 11a850f4a4..f277494c8c 100644 --- a/tests/Integration/OpenTelemetry/Redis/RedisInstrumentationIntegrationTest.php +++ b/tests/Integration/OpenTelemetry/Redis/RedisInstrumentationIntegrationTest.php @@ -12,7 +12,7 @@ use Hypervel\OpenTelemetry\OpenTelemetryManager; use Hypervel\OpenTelemetry\OpenTelemetryServiceProvider; use Hypervel\OpenTelemetry\Support\ProcessIdentity; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; use OpenTelemetry\SDK\Logs\Exporter\InMemoryExporter as InMemoryLogExporter; @@ -34,7 +34,7 @@ protected function getPackageProviders(ApplicationContract $app): array public function testBindingRefreshesAWarmedPoolAndRecordsTheNextRealCommand(): void { - $initialPool = $this->app->make(PoolFactory::class)->getPool('default'); + $initialPool = $this->app->make(PoolManager::class)->pool('default'); $exporters = new RedisInstrumentationExporterFactory; $config = $this->app->make('config'); $config->set('opentelemetry.metrics.exporter', 'none'); @@ -61,14 +61,14 @@ public function testBindingRefreshesAWarmedPoolAndRecordsTheNextRealCommand(): v try { $manager->bind(ProcessIdentity::cli()); - $this->assertArrayNotHasKey('default', $this->app->make(PoolFactory::class)->pools()); + $this->assertArrayNotHasKey('default', $this->app->make(PoolManager::class)->getPools()); Redis::set('opentelemetry-redis-integration', 'value'); $this->assertTrue($manager->flush()); $this->assertNotSame( $initialPool, - $this->app->make(PoolFactory::class)->pools()['default'], + $this->app->make(PoolManager::class)->getPools()['default'], ); $this->assertSame( ['SET'], diff --git a/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php b/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php index e3b2ca6a58..aa4f49aee9 100644 --- a/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php +++ b/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php @@ -14,7 +14,7 @@ use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\Timer; use Hypervel\Coroutine\Waiter; -use Hypervel\Database\Pool\PoolFactory; +use Hypervel\Database\Pool\PoolManager; use Hypervel\Queue\Events\JobPopping; use Hypervel\Queue\Events\Looping; use Hypervel\Queue\Events\WorkerIdle; @@ -39,7 +39,7 @@ protected function defineEnvironment(ApplicationContract $app): void $connection = $config->string('database.default'); $config->set("database.connections.{$connection}.pool", [ 'testing_enabled' => true, - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, ]); } @@ -47,7 +47,7 @@ protected function defineEnvironment(ApplicationContract $app): void public function testLifecycleCallbacksReleasePooledConnectionsBeforeTheDaemonAdvances(): void { $connectionName = $this->app->make('config')->string('database.default'); - $poolFactory = $this->app->make(PoolFactory::class); + $poolManager = $this->app->make(PoolManager::class); $events = $this->app->make(EventDispatcher::class); $observed = []; @@ -55,23 +55,23 @@ public function testLifecycleCallbacksReleasePooledConnectionsBeforeTheDaemonAdv DB::selectOne('SELECT 1'); $observed[] = 'starting'; }); - $events->listen(Looping::class, function () use ($poolFactory, $connectionName, &$observed): void { - $this->assertSame(1, $poolFactory->getPool($connectionName)->getConnectionsInChannel()); + $events->listen(Looping::class, function () use ($poolManager, $connectionName, &$observed): void { + $this->assertSame(1, $poolManager->pool($connectionName)->getIdleCount()); DB::selectOne('SELECT 1'); $observed[] = 'looping'; }); - $events->listen(JobPopping::class, function () use ($poolFactory, $connectionName, &$observed): void { - $this->assertSame(1, $poolFactory->getPool($connectionName)->getConnectionsInChannel()); + $events->listen(JobPopping::class, function () use ($poolManager, $connectionName, &$observed): void { + $this->assertSame(1, $poolManager->pool($connectionName)->getIdleCount()); DB::selectOne('SELECT 1'); $observed[] = 'popping'; }); - $events->listen(WorkerIdle::class, function () use ($poolFactory, $connectionName, &$observed): void { - $this->assertSame(0, $poolFactory->getPool($connectionName)->getConnectionsInChannel()); + $events->listen(WorkerIdle::class, function () use ($poolManager, $connectionName, &$observed): void { + $this->assertSame(0, $poolManager->pool($connectionName)->getIdleCount()); DB::selectOne('SELECT 1'); $observed[] = 'idle'; }); - $events->listen(WorkerStopping::class, function () use ($poolFactory, $connectionName, &$observed): void { - $this->assertSame(1, $poolFactory->getPool($connectionName)->getConnectionsInChannel()); + $events->listen(WorkerStopping::class, function () use ($poolManager, $connectionName, &$observed): void { + $this->assertSame(1, $poolManager->pool($connectionName)->getIdleCount()); $observed[] = 'stopping'; }); @@ -92,18 +92,18 @@ protected function supportsAsyncSignals(): bool $worker->daemon('default', 'queue', new WorkerOptions(stopWhenEmpty: true, memory: 1024)), ); $this->assertSame(['starting', 'looping', 'popping', 'idle', 'stopping'], $observed); - $this->assertSame(1, $poolFactory->getPool($connectionName)->getConnectionsInChannel()); + $this->assertSame(1, $poolManager->pool($connectionName)->getIdleCount()); } public function testStoppingWaitsForAdmittedJobDeferredCleanup(): void { $connectionName = $this->app->make('config')->string('database.default'); - $pool = $this->app->make(PoolFactory::class)->getPool($connectionName); + $pool = $this->app->make(PoolManager::class)->pool($connectionName); $events = $this->app->make(EventDispatcher::class); $stopped = false; $events->listen(WorkerStopping::class, function () use ($pool, &$stopped): void { - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getIdleCount()); DB::selectOne('SELECT 1'); $stopped = true; }); @@ -141,7 +141,7 @@ protected function supportsAsyncSignals(): bool public function testTimeoutAndSignalCallbacksReleasePooledConnectionsAfterEachBatch(): void { $connectionName = $this->app->make('config')->string('database.default'); - $pool = $this->app->make(PoolFactory::class)->getPool($connectionName); + $pool = $this->app->make(PoolManager::class)->pool($connectionName); $events = $this->app->make(EventDispatcher::class); $timer = new WorkerResourceTimer; $events->listen(WorkerPausing::class, static function (): void { @@ -168,10 +168,10 @@ protected function terminateTimeoutJobs(WorkerOptions $options): void $worker->startMonitorForTest($options); $timer->fire(); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getIdleCount()); $worker->pauseForTest($options); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getIdleCount()); } } diff --git a/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php index b3e5735024..934ae48b18 100644 --- a/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php +++ b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php @@ -40,7 +40,7 @@ protected function defineEnvironment(ApplicationContract $app): void $config->set("database.connections.{$connection}.pool.testing_enabled", true); $config->set("database.connections.{$connection}.pool.max_connections", 10); - $config->set("database.connections.{$connection}.pool.heartbeat", -1); + $config->set("database.connections.{$connection}.pool.heartbeat_interval", null); } public function testFixedWindowOperationsUseNumericDatabaseState(): void diff --git a/tests/Integration/Redis/RedisEventsIntegrationTest.php b/tests/Integration/Redis/RedisEventsIntegrationTest.php index a763f810b4..264be2a221 100644 --- a/tests/Integration/Redis/RedisEventsIntegrationTest.php +++ b/tests/Integration/Redis/RedisEventsIntegrationTest.php @@ -8,7 +8,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Redis\Events\CommandExecuted; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisManager; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; @@ -35,35 +35,35 @@ static function (CommandExecuted $event) use (&$commands): void { ); $manager = $this->app->make(RedisManager::class); - $poolFactory = $this->app->make(PoolFactory::class); - $initialPool = $poolFactory->getPool('default'); + $poolManager = $this->app->make(PoolManager::class); + $initialPool = $poolManager->pool('default'); Redis::ping(); $this->assertSame([], $commands); $manager->enableEvents(); - $this->assertArrayNotHasKey('default', $poolFactory->pools()); + $this->assertArrayNotHasKey('default', $poolManager->getPools()); Redis::set('redis-events-integration', 'value'); - $enabledPool = $poolFactory->pools()['default']; + $enabledPool = $poolManager->getPools()['default']; $this->assertNotSame($initialPool, $enabledPool); $this->assertSame(['set'], $commands); $manager->enableEvents(); - $this->assertSame($enabledPool, $poolFactory->pools()['default']); + $this->assertSame($enabledPool, $poolManager->getPools()['default']); Redis::get('redis-events-integration'); $this->assertSame(['set', 'get'], $commands); $manager->disableEvents(); - $this->assertArrayNotHasKey('default', $poolFactory->pools()); + $this->assertArrayNotHasKey('default', $poolManager->getPools()); Redis::get('redis-events-integration'); - $disabledPool = $poolFactory->pools()['default']; + $disabledPool = $poolManager->getPools()['default']; $this->assertNotSame($enabledPool, $disabledPool); $this->assertSame(['set', 'get'], $commands); $manager->disableEvents(); - $this->assertSame($disabledPool, $poolFactory->pools()['default']); + $this->assertSame($disabledPool, $poolManager->getPools()['default']); } } diff --git a/tests/Integration/Redis/RedisPoolTeardownLifecycleTest.php b/tests/Integration/Redis/RedisPoolTeardownLifecycleTest.php index 24db9772ec..e44bfce09d 100644 --- a/tests/Integration/Redis/RedisPoolTeardownLifecycleTest.php +++ b/tests/Integration/Redis/RedisPoolTeardownLifecycleTest.php @@ -5,82 +5,65 @@ namespace Hypervel\Tests\Integration\Redis; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; -use ReflectionClass; /** - * Verifies that InteractsWithRedis::tearDownInteractsWithRedis() flushes the - * Redis connection pool before $this->app->flush() runs. - * - * Captures the PoolFactory and a live pool during the test body, then asserts - * post-teardown state in a custom tearDown() that runs AFTER parent::tearDown(). - * Without the trait-level pool flush, the captured pool would still hold its - * socket and the factory would still cache the pool - the FD leak path that - * trips long ParaTest runs. + * Retains a live pool across application teardown to verify that cleanup + * closes its sockets instead of leaving them for cycle collection. */ class RedisPoolTeardownLifecycleTest extends TestCase { use InteractsWithRedis; - private static ?PoolFactory $capturedFactory = null; + private static ?PoolManager $capturedManager = null; private static ?RedisPool $capturedPool = null; public function testTearDownLifecyclePurgesRedisPools(): void { - // Run a real command so the manager caches a proxy AND the factory - // creates a live pool with a phpredis connection in its channel. + // Exercise the application path that creates the pool owned by teardown. Redis::ping(); - $factory = $this->app->make(PoolFactory::class); - $pool = $factory->getPool('default'); + $poolManager = $this->app->make(PoolManager::class); + $pool = $poolManager->pool('default'); - // Sanity: the pool actually has a real connection - $this->assertGreaterThan(0, $pool->getCurrentConnections()); + $this->assertGreaterThan(0, $pool->getManagedCount()); - self::$capturedFactory = $factory; + self::$capturedManager = $poolManager; self::$capturedPool = $pool; } protected function tearDown(): void { - // parent::tearDown() runs tearDownTheTestEnvironment, where the - // pool-purge lifecycle hook lives. After it returns, the captured - // references should reflect a fully torn-down pool layer. + // Assert after the framework's pool cleanup has run. parent::tearDown(); - if (self::$capturedFactory === null || self::$capturedPool === null) { + if (self::$capturedManager === null || self::$capturedPool === null) { return; } try { - // The pool's channel should be drained $this->assertSame( 0, - self::$capturedPool->getConnectionsInChannel(), + self::$capturedPool->getIdleCount(), 'Pool channel should be empty after lifecycle teardown' ); $this->assertSame( 0, - self::$capturedPool->getCurrentConnections(), - 'Pool currentConnections should be 0 after lifecycle teardown' + self::$capturedPool->getManagedCount(), + 'Pool managed count should be 0 after lifecycle teardown' ); - // The factory's $pools cache should be cleared so the previous - // pool object can be refcount-collected (no public accessor for - // this - reflection is the only way to verify it directly). - $reflection = new ReflectionClass(self::$capturedFactory); - $poolsProperty = $reflection->getProperty('pools'); $this->assertSame( [], - $poolsProperty->getValue(self::$capturedFactory), - 'PoolFactory $pools should be empty after lifecycle teardown' + self::$capturedManager->getPools(), + 'The pool registry should be empty after lifecycle teardown' ); } finally { - self::$capturedFactory = null; + self::$capturedManager = null; self::$capturedPool = null; } } diff --git a/tests/Mail/MailFailoverTransportTest.php b/tests/Mail/MailFailoverTransportTest.php index d3c29e709e..076c410e1f 100644 --- a/tests/Mail/MailFailoverTransportTest.php +++ b/tests/Mail/MailFailoverTransportTest.php @@ -42,7 +42,7 @@ public function testGetFailoverTransportWithConfiguredTransports(): void ]); $transport = $this->app->make('mail.manager') - ->removePoolable('failover') + ->removePoolableDriver('failover') ->getSymfonyTransport(); $this->assertInstanceOf(FailoverTransport::class, $transport); } @@ -70,7 +70,7 @@ public function testGetFailoverTransportWithConfiguredTransportsUsingDefaultMail ]); $transport = $this->app->make('mail.manager') - ->removePoolable('failover') + ->removePoolableDriver('failover') ->getSymfonyTransport(); $this->assertInstanceOf(FailoverTransport::class, $transport); } diff --git a/tests/Mail/MailLogTransportTest.php b/tests/Mail/MailLogTransportTest.php index ae3c679b6b..37824dc6c7 100644 --- a/tests/Mail/MailLogTransportTest.php +++ b/tests/Mail/MailLogTransportTest.php @@ -44,7 +44,7 @@ public function testGetLogTransportWithConfiguredChannel(): void ]); $transport = $this->app->make('mail.manager') - ->removePoolable('log') + ->removePoolableDriver('log') ->getSymfonyTransport(); $this->assertInstanceOf(LogTransport::class, $transport); diff --git a/tests/Mail/MailManagerTest.php b/tests/Mail/MailManagerTest.php index 03033d927c..c3053e8a36 100644 --- a/tests/Mail/MailManagerTest.php +++ b/tests/Mail/MailManagerTest.php @@ -6,13 +6,13 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\View\Factory as ViewFactory; use Hypervel\Log\LogManager; use Hypervel\Mail\Mailable; use Hypervel\Mail\MailManager; use Hypervel\Mail\Transport\LogTransport; use Hypervel\Mail\TransportPoolProxy; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\Support\ClassInvoker; use Hypervel\Support\Testing\Fakes\MailFake; use Hypervel\Testbench\TestCase; @@ -131,7 +131,7 @@ public function testMailUrlConfig(?string $scheme, int $port): void ]); $transport = (new MailManager($this->app)) - ->removePoolable('smtp') + ->removePoolableDriver('smtp') ->mailer('smtp_url') ->getSymfonyTransport(); // @phpstan-ignore-line @@ -159,7 +159,7 @@ public function testMailUrlConfigWithAutoTls(?string $scheme, int $port): void ]); $transport = (new MailManager($this->app)) - ->removePoolable('smtp') + ->removePoolableDriver('smtp') ->mailer('smtp_url') ->getSymfonyTransport(); // @phpstan-ignore-line @@ -187,7 +187,7 @@ public function testMailUrlConfigWithAutoTlsDisabled(?string $scheme, int $port) ]); $transport = (new MailManager($this->app)) - ->removePoolable('smtp') + ->removePoolableDriver('smtp') ->mailer('smtp_url') ->getSymfonyTransport(); // @phpstan-ignore-line diff --git a/tests/Mail/MailRoundRobinTransportTest.php b/tests/Mail/MailRoundRobinTransportTest.php index 57f4456acd..0d35b48354 100644 --- a/tests/Mail/MailRoundRobinTransportTest.php +++ b/tests/Mail/MailRoundRobinTransportTest.php @@ -42,7 +42,7 @@ public function testGetRoundRobinTransportWithConfiguredTransports(): void ]); $transport = $this->app->make('mail.manager') - ->removePoolable('roundrobin') + ->removePoolableDriver('roundrobin') ->getSymfonyTransport(); $this->assertInstanceOf(RoundRobinTransport::class, $transport); } diff --git a/tests/Mail/MailSesV2TransportTest.php b/tests/Mail/MailSesV2TransportTest.php index ea3fdcd586..77d0bfa972 100644 --- a/tests/Mail/MailSesV2TransportTest.php +++ b/tests/Mail/MailSesV2TransportTest.php @@ -215,7 +215,7 @@ public function testSesV2LocalConfiguration(): void $manager = new MailManager($this->app); /** @var \Hypervel\Mail\Mailer $mailer */ - $mailer = $manager->removePoolable('ses-v2')->mailer('ses'); + $mailer = $manager->removePoolableDriver('ses-v2')->mailer('ses'); /** @var \Hypervel\Mail\Transport\SesV2Transport $transport */ $transport = $mailer->getSymfonyTransport(); diff --git a/tests/ObjectPool/SimpleObjectPoolTest.php b/tests/ObjectPool/CallbackObjectPoolTest.php similarity index 74% rename from tests/ObjectPool/SimpleObjectPoolTest.php rename to tests/ObjectPool/CallbackObjectPoolTest.php index 2c17f22354..0bd4f2fbc7 100644 --- a/tests/ObjectPool/SimpleObjectPoolTest.php +++ b/tests/ObjectPool/CallbackObjectPoolTest.php @@ -4,18 +4,18 @@ namespace Hypervel\Tests\ObjectPool; +use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Tests\TestCase; use stdClass; -class SimpleObjectPoolTest extends TestCase +class CallbackObjectPoolTest extends TestCase { public function testCreateObject(): void { $object = new stdClass; - $pool = new SimpleObjectPool(fn () => $object, PoolOptions::fromArray([])); - $borrowed = $pool->get(); + $pool = new CallbackObjectPool(fn () => $object, PoolOptions::fromArray([])); + $borrowed = $pool->borrow(); try { $this->assertSame($object, $borrowed); @@ -28,14 +28,14 @@ public function testCreateObject(): void public function testDestroyCallbackRunsWhenThePoolCloses(): void { $destroyed = []; - $pool = new SimpleObjectPool( + $pool = new CallbackObjectPool( fn () => new stdClass, PoolOptions::fromArray([]), function (object $object) use (&$destroyed): void { $destroyed[] = $object; }, ); - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); $pool->close(); diff --git a/tests/ObjectPool/HasPoolProxyTest.php b/tests/ObjectPool/HasPoolProxyTest.php index 1fffd1247a..a30fd45636 100644 --- a/tests/ObjectPool/HasPoolProxyTest.php +++ b/tests/ObjectPool/HasPoolProxyTest.php @@ -5,12 +5,12 @@ namespace Hypervel\Tests\ObjectPool; use Closure; -use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolFingerprint; use Hypervel\ObjectPool\PoolManager; use Hypervel\ObjectPool\PoolProxy; -use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\Tests\TestCase; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -29,7 +29,7 @@ protected function setUp(): void protected function tearDownInCoroutine(): void { - $this->manager->factory->flush(); + $this->manager->factory->purgeAll(); } public function testAutomaticDefinitionIsNamespacedAndFingerprintsConstructionInput(): void @@ -116,12 +116,12 @@ public function testProxyCreationUsesTheDefinitionAndConfiguredReleaseCallback() public function testPoolableDriverMutatorsKeepAListShape(): void { - $this->manager->setPoolables(['first', 'second']); - $this->manager->removePoolable('first'); - $this->manager->addPoolable('second'); - $this->manager->addPoolable('third'); + $this->manager->setPoolableDrivers(['first', 'second']); + $this->manager->removePoolableDriver('first'); + $this->manager->addPoolableDriver('second'); + $this->manager->addPoolableDriver('third'); - $this->assertSame(['second', 'third'], $this->manager->getPoolables()); + $this->assertSame(['second', 'third'], $this->manager->getPoolableDrivers()); } } @@ -129,7 +129,7 @@ class PoolTraitManager { use HasPoolProxy; - protected array $poolables = []; + protected array $poolableDrivers = []; public function __construct( public PoolManager $factory, @@ -141,9 +141,9 @@ public function definition(string $resource, array $poolConfig, array $fingerpri return $this->poolDefinition($resource, $poolConfig, $fingerprintSource); } - public function proxy(string $driver, Closure $resolver, PoolDefinition $definition): TraitPoolProxy + public function proxy(string $driver, Closure $createCallback, PoolDefinition $definition): TraitPoolProxy { - return $this->createPoolProxy($driver, $resolver, $definition, TraitPoolProxy::class); + return $this->createPoolProxy($driver, $createCallback, $definition, TraitPoolProxy::class); } protected function poolFactory(): Factory diff --git a/tests/ObjectPool/LeaseTest.php b/tests/ObjectPool/LeaseTest.php index 21bb6e9a87..be41bdb3ab 100644 --- a/tests/ObjectPool/LeaseTest.php +++ b/tests/ObjectPool/LeaseTest.php @@ -7,10 +7,10 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; -use Hypervel\ObjectPool\Contracts\ObjectPool as ObjectPoolContract; +use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract; +use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -21,7 +21,7 @@ class LeaseTest extends TestCase { - /** @var list */ + /** @var list */ private array $pools = []; protected function tearDownInCoroutine(): void @@ -34,7 +34,7 @@ protected function tearDownInCoroutine(): void public function testGetAndReleaseFinalizeExactlyOnce(): void { $pool = $this->pool(); - $object = $pool->get(); + $object = $pool->borrow(); $lease = new Lease($pool, $object); $this->assertSame($object, $lease->get()); @@ -42,14 +42,14 @@ public function testGetAndReleaseFinalizeExactlyOnce(): void $lease->release(); $lease->release(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testGetRejectsAReleasedLease(): void { $pool = $this->pool(); - $lease = new Lease($pool, $pool->get()); + $lease = new Lease($pool, $pool->borrow()); $lease->release(); $this->expectException(RuntimeException::class); @@ -61,7 +61,7 @@ public function testGetRejectsAReleasedLease(): void public function testReleaseCallbackRunsBeforeTheObjectReturnsToThePool(): void { $pool = $this->pool(); - $object = $pool->get(); + $object = $pool->borrow(); $callbackObject = null; $borrowedDuringCallback = null; $lease = new Lease( @@ -69,7 +69,7 @@ public function testReleaseCallbackRunsBeforeTheObjectReturnsToThePool(): void $object, function (object $released) use ($pool, &$callbackObject, &$borrowedDuringCallback): void { $callbackObject = $released; - $borrowedDuringCallback = $pool->getBorrowedObjectNumber(); + $borrowedDuringCallback = $pool->getBorrowedCount(); }, ); @@ -77,7 +77,7 @@ function (object $released) use ($pool, &$callbackObject, &$borrowedDuringCallba $this->assertSame($object, $callbackObject); $this->assertSame(1, $borrowedDuringCallback); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testThrowingReleaseCallbackDiscardsTheObjectAndPropagates(): void @@ -86,7 +86,7 @@ public function testThrowingReleaseCallbackDiscardsTheObjectAndPropagates(): voi $pool = $this->pool(function (object $object) use (&$destroyed): void { $destroyed[] = $object; }); - $object = $pool->get(); + $object = $pool->borrow(); $expected = new RuntimeException('reset failed'); $lease = new Lease($pool, $object, function () use ($expected): never { throw $expected; @@ -100,8 +100,8 @@ public function testThrowingReleaseCallbackDiscardsTheObjectAndPropagates(): voi } $this->assertSame([$object], $destroyed); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testDiscardFailureDoesNotMaskAReleaseCallbackFailure(): void @@ -175,7 +175,7 @@ public function testDiscardDestroysExactlyOnce(): void $pool = $this->pool(function (object $object) use (&$destroyed): void { $destroyed[] = $object; }); - $object = $pool->get(); + $object = $pool->borrow(); $lease = new Lease($pool, $object); $lease->discard(); @@ -183,21 +183,21 @@ public function testDiscardDestroysExactlyOnce(): void $lease->release(); $this->assertSame([$object], $destroyed); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); } public function testDestructorReleasesAnAbandonedBorrow(): void { $pool = $this->pool(); - $object = $pool->get(); + $object = $pool->borrow(); $lease = new Lease($pool, $object); unset($lease); gc_collect_cycles(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); - $borrowed = $pool->get(); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); + $borrowed = $pool->borrow(); $this->assertSame($object, $borrowed); $pool->release($borrowed); } @@ -211,14 +211,14 @@ public function testDestructorReportsAndSwallowsFinalizationFailures(): void $container->instance(ExceptionHandler::class, $handler); $pool = $this->pool(); - $lease = new Lease($pool, $pool->get(), function () use ($expected): never { + $lease = new Lease($pool, $pool->borrow(), function () use ($expected): never { throw $expected; }); unset($lease); gc_collect_cycles(); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); } public function testLeaseAcceptsAContractImplementationThatDoesNotExtendTheBasePool(): void @@ -283,9 +283,9 @@ public static function failureFinalizerProvider(): array /** * Create a tracked object pool. */ - private function pool(?Closure $destroyCallback = null): SimpleObjectPool + private function pool(?Closure $destroyCallback = null): CallbackObjectPool { - $pool = new SimpleObjectPool( + $pool = new CallbackObjectPool( static fn (): object => new stdClass, PoolOptions::fromArray([]), $destroyCallback, @@ -315,7 +315,7 @@ class ContractOnlyObjectPool implements ObjectPoolContract public ?Throwable $discardException = null; - public function get(): object + public function borrow(): object { return new stdClass; } @@ -355,27 +355,27 @@ public function isClosed(): bool return false; } - public function isIdle(): bool + public function isIdleExpired(): bool { return false; } - public function getBorrowedObjectNumber(): int + public function getBorrowedCount(): int { return 0; } - public function getCurrentObjectNumber(): int + public function getManagedCount(): int { return 0; } - public function getObjectNumberInPool(): int + public function getIdleCount(): int { return 0; } - public function getWaiters(): int + public function getWaitingCount(): int { return 0; } @@ -387,6 +387,6 @@ public function getOptions(): PoolOptions public function getStats(): array { - return ['total' => 0, 'idle' => 0, 'borrowed' => 0, 'waiters' => 0, 'closed' => false]; + return ['managed' => 0, 'borrowed' => 0, 'idle' => 0, 'waiting' => 0, 'closed' => false]; } } diff --git a/tests/ObjectPool/ObjectPoolNonCoroutineTest.php b/tests/ObjectPool/ObjectPoolNonCoroutineTest.php index 31d92ae7b8..ac95c0c8e6 100644 --- a/tests/ObjectPool/ObjectPoolNonCoroutineTest.php +++ b/tests/ObjectPool/ObjectPoolNonCoroutineTest.php @@ -20,11 +20,11 @@ class ObjectPoolNonCoroutineTest extends TestCase public function testDeadlineReleaseRemainsCommittedWhenItsWakeCannotBeCreated(): void { $pool = $this->createPool(); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $replacement = null; SwooleCoroutine::set(['max_coroutine' => 1]); SwooleCoroutine::create(function () use ($pool, &$replacement): void { - $replacement = $pool->get(); + $replacement = $pool->borrow(); }); $pool->release($borrowed); @@ -38,11 +38,11 @@ public function testDeadlineReleaseRemainsCommittedWhenItsWakeCannotBeCreated(): public function testDeadlineDiscardRemainsCommittedWhenItsWakeCannotBeCreated(): void { $pool = $this->createPool(); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $replacement = null; SwooleCoroutine::set(['max_coroutine' => 1]); SwooleCoroutine::create(function () use ($pool, &$replacement): void { - $replacement = $pool->get(); + $replacement = $pool->borrow(); }); $pool->discard($borrowed); diff --git a/tests/ObjectPool/ObjectPoolServiceProviderTest.php b/tests/ObjectPool/ObjectPoolServiceProviderTest.php index 29349f00e5..cb18a554d8 100644 --- a/tests/ObjectPool/ObjectPoolServiceProviderTest.php +++ b/tests/ObjectPool/ObjectPoolServiceProviderTest.php @@ -6,13 +6,14 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\Recycler; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\BeforeServerFork; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\Recycler; use Hypervel\ObjectPool\Listeners\StartRecycler; use Hypervel\ObjectPool\ObjectPoolServiceProvider; use Hypervel\ObjectPool\PoolManager; +use Hypervel\ObjectPool\PoolOptions; use Hypervel\ObjectPool\PoolRecycler; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -38,13 +39,40 @@ public function testConcreteManagerAndFactoryShareOnePoolRegistry(): void public function testConcreteRecyclerAndContractShareOneTimerOwner(): void { $recycler = $this->app->make(PoolRecycler::class); - $recycler->setInterval(2.5); $this->assertSame($recycler, $this->app->make(Recycler::class)); - $this->assertSame(2.5, $this->app->make(Recycler::class)->getInterval()); + $this->assertSame(10.0, $recycler->getInterval()); } - public function testLifecycleFlushesResolvedMasterPoolsBeforeForkAndStartsTheWorkerRecycler(): void + public function testRecyclerConstructionCanBeCustomizedThroughItsBinding(): void + { + $this->app->singleton(PoolRecycler::class, fn ($app) => new PoolRecycler( + $app->make(Factory::class), + interval: 2.5, + )); + + $recycler = $this->app->make(Recycler::class); + + $this->assertInstanceOf(PoolRecycler::class, $recycler); + $this->assertSame(2.5, $recycler->getInterval()); + $this->assertSame($recycler, $this->app->make(PoolRecycler::class)); + } + + public function testShippedPoolConfigurationMatchesNormalizedDefaults(): void + { + $defaults = PoolOptions::fromArray([])->toArray(); + + foreach ([ + 'filesystems.disks.s3.pool', + 'filesystems.disks.gcs.pool', + 'queue.connections.beanstalkd.pool', + 'queue.connections.sqs.pool', + ] as $key) { + $this->assertSame($defaults, config($key), $key); + } + } + + public function testLifecyclePurgesResolvedMasterPoolsBeforeForkAndStartsTheWorkerRecycler(): void { $listeners = []; $events = m::mock(Dispatcher::class); @@ -54,7 +82,7 @@ public function testLifecycleFlushesResolvedMasterPoolsBeforeForkAndStartsTheWor $listeners[$event] = $listener; }); $manager = m::mock(PoolManager::class); - $manager->shouldReceive('flush')->once(); + $manager->shouldReceive('purgeAll')->once(); $recycler = m::mock(StartRecycler::class); $server = m::mock(Server::class); $afterWorkerStart = new AfterWorkerStart($server, 0); diff --git a/tests/ObjectPool/ObjectPoolTest.php b/tests/ObjectPool/ObjectPoolTest.php index 8abdffbe05..f4bd38b443 100644 --- a/tests/ObjectPool/ObjectPoolTest.php +++ b/tests/ObjectPool/ObjectPoolTest.php @@ -8,7 +8,9 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Coroutine\Coroutine; -use Hypervel\ObjectPool\Channel as ObjectPoolChannel; +use Hypervel\Coroutine\PoolChannel; +use Hypervel\ObjectPool\Exceptions\PoolClosedException; +use Hypervel\ObjectPool\Exceptions\PoolExhaustedException; use Hypervel\ObjectPool\ObjectPool; use Hypervel\ObjectPool\PoolOptions; use Hypervel\Tests\TestCase; @@ -30,7 +32,7 @@ public function testCloseDrainsIdleObjectsAndIsIdempotent(): void $destroyed[] = $object; }, ); - $objects = [$pool->get(), $pool->get()]; + $objects = [$pool->borrow(), $pool->borrow()]; foreach ($objects as $object) { $pool->release($object); @@ -40,8 +42,8 @@ public function testCloseDrainsIdleObjectsAndIsIdempotent(): void $pool->close(); $this->assertTrue($pool->isClosed()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); $this->assertEqualsCanonicalizing($objects, $destroyed); } @@ -58,7 +60,7 @@ public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellati throw count($destroyed) === 1 ? $firstCancellation : $secondCancellation; }, ); - $objects = [$pool->get(), $pool->get()]; + $objects = [$pool->borrow(), $pool->borrow()]; foreach ($objects as $object) { $pool->release($object); @@ -73,8 +75,8 @@ public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellati $this->assertTrue($pool->isClosed()); $this->assertSame($objects, $destroyed); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); } public function testBorrowFromClosedPoolThrows(): void @@ -82,10 +84,10 @@ public function testBorrowFromClosedPoolThrows(): void $pool = $this->pool(); $pool->close(); - $this->expectException(RuntimeException::class); + $this->expectException(PoolClosedException::class); $this->expectExceptionMessage('Cannot borrow from a closed pool.'); - $pool->get(); + $pool->borrow(); } public function testObjectReleasedAfterCloseIsDestroyed(): void @@ -96,37 +98,37 @@ public function testObjectReleasedAfterCloseIsDestroyed(): void $destroyed[] = $object; }, ); - $object = $pool->get(); + $object = $pool->borrow(); $pool->close(); $pool->release($object); $this->assertSame([$object], $destroyed); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); } public function testCloseWakesEveryParkedBorrower(): void { $pool = $this->pool(['max_objects' => 1, 'wait_timeout' => 0.2]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $messages = []; foreach ([0, 1] as $index) { Coroutine::create(function () use ($pool, &$messages, $index): void { try { - $pool->get(); - } catch (RuntimeException $exception) { + $pool->borrow(); + } catch (PoolClosedException $exception) { $messages[$index] = $exception->getMessage(); } }); } usleep(5_000); - $this->assertSame(2, $pool->getWaiters()); + $this->assertSame(2, $pool->getWaitingCount()); $pool->close(); usleep(5_000); - $this->assertSame(0, $pool->getWaiters()); + $this->assertSame(0, $pool->getWaitingCount()); $pool->release($borrowed); ksort($messages); @@ -154,8 +156,8 @@ public function testCloseDuringSuspendedFactoryDestroysTheOrphan(): void Coroutine::create(function () use ($pool, &$message): void { try { - $pool->get(); - } catch (RuntimeException $exception) { + $pool->borrow(); + } catch (PoolClosedException $exception) { $message = $exception->getMessage(); } }); @@ -166,19 +168,20 @@ public function testCloseDuringSuspendedFactoryDestroysTheOrphan(): void $this->assertSame('Cannot borrow from a closed pool.', $message); $this->assertSame([$object], $destroyed); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); } public function testForeignAndDoubleReleasesAreRejected(): void { $pool = $this->pool(); - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); try { $pool->release($object); $this->fail('A double release must throw.'); } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::class, $exception::class); $this->assertStringContainsString('not checked out', $exception->getMessage()); } @@ -186,18 +189,19 @@ public function testForeignAndDoubleReleasesAreRejected(): void $pool->release(new stdClass); $this->fail('A foreign release must throw.'); } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::class, $exception::class); $this->assertStringContainsString('does not manage', $exception->getMessage()); } - $this->assertSame(1, $pool->getCurrentObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); $pool->close(); } public function testDoubleDestroyIsRejectedBeforeStateChanges(): void { $pool = $this->pool(); - $object = $pool->get(); + $object = $pool->borrow(); $pool->discard($object); try { @@ -207,7 +211,7 @@ public function testDoubleDestroyIsRejectedBeforeStateChanges(): void $this->assertSame('Cannot destroy an object this pool does not manage.', $exception->getMessage()); } - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); } public function testDuplicateFactoryOutputIsRejectedAndWakesAWaiter(): void @@ -232,12 +236,12 @@ function () use (&$calls, $shared): object { return new stdClass; }, ); - $first = $pool->get(); + $first = $pool->borrow(); $results = parallel([ function () use ($pool): string { try { - $pool->get(); + $pool->borrow(); } catch (RuntimeException $exception) { return $exception->getMessage(); } @@ -245,7 +249,7 @@ function () use ($pool): string { return 'unexpected'; }, function () use ($pool): string { - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); return 'borrowed'; @@ -277,7 +281,7 @@ function () use (&$factoriesRunning, &$maximumFactoriesRunning): object { ); $results = parallel(array_fill(0, 8, function () use ($pool): bool { - $object = $pool->get(); + $object = $pool->borrow(); usleep(2_000); $pool->release($object); @@ -286,7 +290,7 @@ function () use (&$factoriesRunning, &$maximumFactoriesRunning): object { $this->assertSame(array_fill(0, 8, true), $results); $this->assertSame(2, $maximumFactoriesRunning); - $this->assertSame(2, $pool->getCurrentObjectNumber()); + $this->assertSame(2, $pool->getManagedCount()); $pool->close(); } @@ -310,7 +314,7 @@ function () use (&$calls): object { $results = parallel([ function () use ($pool): string { try { - $pool->get(); + $pool->borrow(); } catch (RuntimeException $exception) { return $exception->getMessage(); } @@ -318,7 +322,7 @@ function () use ($pool): string { return 'unexpected'; }, function () use ($pool): string { - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); return 'borrowed'; @@ -332,11 +336,11 @@ function () use ($pool): string { public function testDiscardWakesAWaitingBorrowerToCreateAReplacement(): void { $pool = $this->pool(['max_objects' => 1, 'wait_timeout' => 0.2]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $replacement = null; Coroutine::create(function () use ($pool, &$replacement): void { - $replacement = $pool->get(); + $replacement = $pool->borrow(); $pool->release($replacement); }); @@ -360,7 +364,7 @@ public function testMaintenanceDestroyWakesAWaitingBorrower(): void $destroying = false; }, ); - $expired = $pool->get(); + $expired = $pool->borrow(); $pool->release($expired); $pool->ageCreation($expired, 2.0); $replacement = null; @@ -373,7 +377,7 @@ public function testMaintenanceDestroyWakesAWaitingBorrower(): void $this->assertTrue($destroying); Coroutine::create(function () use ($pool, &$replacement): void { - $replacement = $pool->get(); + $replacement = $pool->borrow(); $pool->release($replacement); }); @@ -386,24 +390,29 @@ public function testMaintenanceDestroyWakesAWaitingBorrower(): void public function testExhaustedPoolUsesOneWaitTimeoutFailurePath(): void { $pool = $this->pool(['max_objects' => 1, 'wait_timeout' => 0.001]); - $pool->get(); + $borrowed = $pool->borrow(); - $this->expectException(RuntimeException::class); + $this->expectException(PoolExhaustedException::class); $this->expectExceptionMessage('Object pool exhausted. Cannot create new object before wait_timeout.'); - $pool->get(); + try { + $pool->borrow(); + } finally { + $pool->release($borrowed); + $pool->close(); + } } public function testCheckoutPerformsOneFinalPassAfterADeadlineRelease(): void { $pool = $this->pool(['max_objects' => 1, 'wait_timeout' => 0.001]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $channel = new DeadlineObjectPoolChannel(function () use ($borrowed, $pool): void { $pool->release($borrowed); }); $pool->replaceChannel($channel); - $this->assertSame($borrowed, $returned = $pool->get()); + $this->assertSame($borrowed, $returned = $pool->borrow()); $this->assertSame(1, $channel->waitCount); $pool->release($returned); @@ -413,13 +422,13 @@ public function testCheckoutPerformsOneFinalPassAfterADeadlineRelease(): void public function testCheckoutPerformsOneFinalPassAfterADeadlineDiscard(): void { $pool = $this->pool(['max_objects' => 1, 'wait_timeout' => 0.001]); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $channel = new DeadlineObjectPoolChannel(function () use ($borrowed, $pool): void { $pool->discard($borrowed); }); $pool->replaceChannel($channel); - $this->assertNotSame($borrowed, $replacement = $pool->get()); + $this->assertNotSame($borrowed, $replacement = $pool->borrow()); $this->assertSame(1, $channel->waitCount); $pool->release($replacement); @@ -433,14 +442,14 @@ public function testSweepExpiredDestroysBelowTheRetentionFloor(): void 'max_objects' => 1, 'max_lifetime' => 1, ]); - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); $pool->ageCreation($object, 2.0); $pool->sweepExpired(); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); $pool->close(); } @@ -451,7 +460,7 @@ public function testTrimIdleRespectsTheRetentionFloor(): void 'max_objects' => 3, 'max_idle_time' => 1, ]); - $objects = [$pool->get(), $pool->get(), $pool->get()]; + $objects = [$pool->borrow(), $pool->borrow(), $pool->borrow()]; foreach ($objects as $object) { $pool->release($object); @@ -460,28 +469,51 @@ public function testTrimIdleRespectsTheRetentionFloor(): void $pool->trimIdle(); - $this->assertSame(1, $pool->getCurrentObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); $pool->close(); } + public function testNullLifetimeDisablesSweepingAndCheckoutReplacement(): void + { + $pool = $this->pool(['max_lifetime' => null]); + $object = $pool->borrow(); + $pool->release($object); + $pool->ageCreation($object, 10_000.0); + $borrowed = null; + + try { + $pool->sweepExpired(); + + $this->assertSame(1, $pool->getIdleCount()); + $borrowed = $pool->borrow(); + $this->assertSame($object, $borrowed); + } finally { + if ($borrowed !== null) { + $pool->release($borrowed); + } + + $pool->close(); + } + } + public function testIdleTrimmingCanBeDisabled(): void { - $pool = $this->pool(['max_idle_time' => 0]); - $object = $pool->get(); + $pool = $this->pool(['max_idle_time' => null]); + $object = $pool->borrow(); $pool->release($object); $pool->ageRelease($object, 10_000.0); $pool->trimIdle(); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(1, $pool->getIdleCount()); $pool->close(); } public function testMaintenanceRequeuesPreserveReleaseTimestamps(): void { $pool = $this->pool(['max_lifetime' => 60, 'max_idle_time' => 60]); - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); $pool->ageRelease($object, 10.0); $releasedAt = $pool->releaseTime($object); @@ -493,23 +525,23 @@ public function testMaintenanceRequeuesPreserveReleaseTimestamps(): void $pool->close(); } - public function testPoolIdleTtlRequiresNoBorrowedOrInFlightObjects(): void + public function testPoolIdleTimeoutRequiresNoBorrowedOrInFlightObjects(): void { - $pool = $this->pool(['idle_ttl' => 0.001]); + $pool = $this->pool(['pool_idle_timeout' => 0.001]); $pool->agePool(1.0); - $this->assertTrue($pool->isIdle()); + $this->assertTrue($pool->isIdleExpired()); - $object = $pool->get(); + $object = $pool->borrow(); $pool->agePool(1.0); - $this->assertFalse($pool->isIdle()); + $this->assertFalse($pool->isIdleExpired()); $pool->release($object); $pool->agePool(1.0); - $this->assertTrue($pool->isIdle()); + $this->assertTrue($pool->isIdleExpired()); $pool->close(); $suspended = $this->pool( - ['idle_ttl' => 0.001], + ['pool_idle_timeout' => 0.001], function (): object { usleep(10_000); @@ -518,24 +550,24 @@ function (): object { ); Coroutine::create(function () use ($suspended): void { try { - $suspended->get(); + $suspended->borrow(); } catch (RuntimeException) { } }); usleep(2_000); $suspended->agePool(1.0); - $this->assertFalse($suspended->isIdle()); + $this->assertFalse($suspended->isIdleExpired()); $suspended->close(); usleep(12_000); } - public function testPoolIdleTtlCanBeDisabled(): void + public function testPoolIdleTimeoutCanBeDisabled(): void { - $pool = $this->pool(['idle_ttl' => null]); + $pool = $this->pool(['pool_idle_timeout' => null]); $pool->agePool(10_000.0); - $this->assertFalse($pool->isIdle()); + $this->assertFalse($pool->isIdleExpired()); $pool->close(); } @@ -548,19 +580,19 @@ public function testCheckoutReplacesConsecutiveExpiredObjects(): void $destroyed[] = $object; }, ); - $expired = [$pool->get(), $pool->get()]; + $expired = [$pool->borrow(), $pool->borrow()]; foreach ($expired as $object) { $pool->release($object); $pool->ageCreation($object, 2.0); } - $replacement = $pool->get(); + $replacement = $pool->borrow(); $this->assertEqualsCanonicalizing($expired, $destroyed); $this->assertFalse(in_array($replacement, $expired, true)); - $this->assertSame(1, $pool->getCurrentObjectNumber()); - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getBorrowedCount()); $pool->release($replacement); $pool->close(); } @@ -577,12 +609,12 @@ function () use (&$creations): object { }, ); - $first = $pool->get(); + $first = $pool->borrow(); $this->assertSame(1, $creations); $pool->release($first); - $second = $pool->get(); + $second = $pool->borrow(); $this->assertSame(2, $creations); $this->assertNotSame($first, $second); @@ -605,16 +637,16 @@ function () use ($failure): never { throw $failure; }, ); - $discarded = $pool->get(); - $idle = $pool->get(); + $discarded = $pool->borrow(); + $idle = $pool->borrow(); $pool->release($idle); $pool->discard($discarded); $pool->close(); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(0, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(0, $pool->getIdleCount()); } public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(): void @@ -625,7 +657,7 @@ public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(): void throw $cancellation; }, ); - $object = $pool->get(); + $object = $pool->borrow(); try { $pool->discard($object); @@ -634,22 +666,49 @@ public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(): void $this->assertSame($cancellation, $exception); } - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); + } + + public function testManagedCountExcludesReservedCreationCapacity(): void + { + $pool = null; + $duringCreation = null; + $pool = $this->pool(factory: function () use (&$pool, &$duringCreation): object { + $duringCreation = [$pool->getManagedCount(), $pool->getStats()]; + + return new stdClass; + }); + $borrowed = $pool->borrow(); + + try { + $this->assertSame([0, [ + 'managed' => 0, + 'borrowed' => 0, + 'idle' => 0, + 'waiting' => 0, + 'closed' => false, + ]], $duringCreation); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getBorrowedCount()); + } finally { + $pool->release($borrowed); + $pool->close(); + } } public function testStatsUseTrackedOwnershipState(): void { $pool = $this->pool(['max_objects' => 2]); - $borrowed = $pool->get(); - $idle = $pool->get(); + $borrowed = $pool->borrow(); + $idle = $pool->borrow(); $pool->release($idle); $this->assertSame([ - 'total' => 2, - 'idle' => 1, + 'managed' => 2, 'borrowed' => 1, - 'waiters' => 0, + 'idle' => 1, + 'waiting' => 0, 'closed' => false, ], $pool->getStats()); @@ -657,10 +716,10 @@ public function testStatsUseTrackedOwnershipState(): void $pool->close(); $this->assertSame([ - 'total' => 0, - 'idle' => 0, + 'managed' => 0, 'borrowed' => 0, - 'waiters' => 0, + 'idle' => 0, + 'waiting' => 0, 'closed' => true, ], $pool->getStats()); } @@ -674,7 +733,7 @@ public function testHugeFiniteDurationsSaturateWithoutOverflowingLifecycleArithm 'wait_timeout' => PHP_INT_MAX, 'max_lifetime' => PHP_INT_MAX, 'max_idle_time' => PHP_INT_MAX, - 'idle_ttl' => PHP_INT_MAX, + 'pool_idle_timeout' => PHP_INT_MAX, ], factory: function () use (&$creations): object { if (++$creations > 1) { @@ -688,15 +747,15 @@ public function testHugeFiniteDurationsSaturateWithoutOverflowingLifecycleArithm $this->assertSame(PHP_INT_MAX, $pool->nanosecondsForTest((float) PHP_INT_MAX)); $this->assertSame(PHP_INT_MAX, $pool->deadlineForTest((float) PHP_INT_MAX)); - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); $pool->sweepExpired(); $pool->trimIdle(); $this->assertSame(1, $creations); - $this->assertSame(1, $pool->getCurrentObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); - $this->assertFalse($pool->isIdle()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); + $this->assertFalse($pool->isIdleExpired()); $pool->close(); } @@ -766,7 +825,7 @@ public function agePool(float $seconds): void $this->lastUsedAt = hrtime(true) - (int) ($seconds * 1e9); } - public function replaceChannel(ObjectPoolChannel $channel): void + public function replaceChannel(PoolChannel $channel): void { $this->channel = $channel; } @@ -777,7 +836,7 @@ protected function createObject(): object } } -class DeadlineObjectPoolChannel extends ObjectPoolChannel +class DeadlineObjectPoolChannel extends PoolChannel { public int $waitCount = 0; diff --git a/tests/ObjectPool/PoolManagerTest.php b/tests/ObjectPool/PoolManagerTest.php index 0c09c7e0be..50ea274603 100644 --- a/tests/ObjectPool/PoolManagerTest.php +++ b/tests/ObjectPool/PoolManagerTest.php @@ -4,15 +4,20 @@ namespace Hypervel\Tests\ObjectPool; -use Hypervel\ObjectPool\Contracts\ObjectPool; +use Hypervel\Contracts\ObjectPool\ObjectPool; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolFingerprint; use Hypervel\ObjectPool\PoolManager; use Hypervel\ObjectPool\PoolOptions; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; use RuntimeException; use stdClass; +use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -29,7 +34,7 @@ protected function setUp(): void protected function tearDownInCoroutine(): void { - $this->manager->flush(); + $this->manager->purgeAll(); } public function testPoolBuildsDefinitionsFromNamesAndOptions(): void @@ -41,11 +46,11 @@ public function testPoolBuildsDefinitionsFromNamesAndOptions(): void $configuredPool = $this->manager->pool( 'app:exports', static fn (): object => new stdClass, - ['max_objects' => 20, 'idle_ttl' => null], + ['max_objects' => 20, 'pool_idle_timeout' => null], ); - $defaultDefinition = $this->manager->definition('app:reports'); - $configuredDefinition = $this->manager->definition('app:exports'); + $defaultDefinition = $this->manager->getDefinition('app:reports'); + $configuredDefinition = $this->manager->getDefinition('app:exports'); $this->assertInstanceOf(PoolDefinition::class, $defaultDefinition); $this->assertSame('app:reports', $defaultDefinition->identity); @@ -59,7 +64,7 @@ public function testPoolBuildsDefinitionsFromNamesAndOptions(): void $this->assertSame('app:exports', $configuredDefinition->resourceType); $this->assertSame(PoolFingerprint::fromExplicit('app:exports'), $configuredDefinition->fingerprint); $this->assertSame(20, $configuredDefinition->options->maxObjects); - $this->assertNull($configuredDefinition->options->idleTtl); + $this->assertNull($configuredDefinition->options->poolIdleTimeout); $this->assertSame($configuredDefinition->options->toArray(), $configuredPool->getOptions()->toArray()); } @@ -73,7 +78,7 @@ public function testPoolReusesTheNamedPoolAndIgnoresTheNewCallback(): void 'app:reports', static fn (): never => throw new RuntimeException('replacement factory must be ignored'), ); - $object = $second->get(); + $object = $second->borrow(); $second->release($object); $this->assertSame($first, $second); @@ -137,11 +142,11 @@ public function testGetOrCreateRegistersThePoolAndDefinition(): void $this->assertInstanceOf(ObjectPool::class, $pool); $this->assertTrue($this->manager->has($definition->identity)); $this->assertSame($pool, $this->manager->get($definition->identity)); - $this->assertSame([$definition->identity => $pool], $this->manager->pools()); - $this->assertSame($definition, $this->manager->definition($definition->identity)); + $this->assertSame([$definition->identity => $pool], $this->manager->getPools()); + $this->assertSame($definition, $this->manager->getDefinition($definition->identity)); } - public function testMatchingDefinitionReusesPoolAndIgnoresNewConstructionResolver(): void + public function testMatchingDefinitionReusesPoolAndIgnoresNewCreateCallback(): void { $definition = $this->definition(); $first = $this->manager->getOrCreate( @@ -152,7 +157,7 @@ public function testMatchingDefinitionReusesPoolAndIgnoresNewConstructionResolve $this->definition(), static fn (): never => throw new RuntimeException('replacement factory must be ignored'), ); - $object = $second->get(); + $object = $second->borrow(); $second->release($object); $this->assertSame($first, $second); @@ -176,7 +181,7 @@ public function testClosedRegisteredPoolIsReplacedAsAnAbsentIdentity(): void $this->assertNotSame($first, $replacement); $this->assertSame($replacement, $this->manager->get($replacementDefinition->identity)); - $this->assertSame($replacementDefinition, $this->manager->definition($replacementDefinition->identity)); + $this->assertSame($replacementDefinition, $this->manager->getDefinition($replacementDefinition->identity)); } public function testResourceTypeMismatchThrows(): void @@ -226,9 +231,9 @@ public function testOptionsMismatchNamesOnlyDifferingFields(): void $this->fail('Expected mismatched options to throw.'); } catch (RuntimeException $exception) { $this->assertStringContainsString('"max_objects":{"registered":10,"requested":20}', $exception->getMessage()); - $this->assertStringContainsString('"max_idle_time":{"registered":0,"requested":5}', $exception->getMessage()); + $this->assertStringContainsString('"max_idle_time":{"registered":null,"requested":5}', $exception->getMessage()); $this->assertStringNotContainsString('wait_timeout', $exception->getMessage()); - $this->assertStringNotContainsString('idle_ttl', $exception->getMessage()); + $this->assertStringNotContainsString('pool_idle_timeout', $exception->getMessage()); } } @@ -240,25 +245,25 @@ public function testGetThrowsForAMissingIdentity(): void $this->manager->get('missing'); } - public function testRemoveUnregistersBeforeClosingAndReturnsWhetherItRemoved(): void + public function testPurgeUnregistersBeforeClosingAndReturnsWhetherItRemoved(): void { $definition = $this->definition(); $pool = $this->manager->getOrCreate( $definition, static fn (): object => new stdClass, ); - $object = $pool->get(); + $object = $pool->borrow(); $pool->release($object); - $this->assertTrue($this->manager->remove($definition->identity)); - $this->assertFalse($this->manager->remove($definition->identity)); + $this->assertTrue($this->manager->purge($definition->identity)); + $this->assertFalse($this->manager->purge($definition->identity)); $this->assertFalse($this->manager->has($definition->identity)); - $this->assertNull($this->manager->definition($definition->identity)); + $this->assertNull($this->manager->getDefinition($definition->identity)); $this->assertTrue($pool->isClosed()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); } - public function testRemoveWithUnexpectedInstanceIsANoOp(): void + public function testPurgeWithUnexpectedInstanceIsANoOp(): void { $definition = $this->definition(); $pool = $this->manager->getOrCreate($definition, static fn (): object => new stdClass); @@ -267,12 +272,12 @@ public function testRemoveWithUnexpectedInstanceIsANoOp(): void static fn (): object => new stdClass, ); - $this->assertFalse($this->manager->remove($definition->identity, $other)); + $this->assertFalse($this->manager->purge($definition->identity, $other)); $this->assertSame($pool, $this->manager->get($definition->identity)); $this->assertFalse($pool->isClosed()); } - public function testFlushClearsDefinitionsAndClosesEveryPool(): void + public function testPurgeAllClearsDefinitionsAndClosesEveryPool(): void { $firstDefinition = $this->definition(); $secondDefinition = $this->definition( @@ -283,15 +288,88 @@ public function testFlushClearsDefinitionsAndClosesEveryPool(): void $first = $this->manager->getOrCreate($firstDefinition, static fn (): object => new stdClass); $second = $this->manager->getOrCreate($secondDefinition, static fn (): object => new stdClass); - $this->manager->flush(); + $this->manager->purgeAll(); - $this->assertSame([], $this->manager->pools()); - $this->assertNull($this->manager->definition($firstDefinition->identity)); - $this->assertNull($this->manager->definition($secondDefinition->identity)); + $this->assertSame([], $this->manager->getPools()); + $this->assertNull($this->manager->getDefinition($firstDefinition->identity)); + $this->assertNull($this->manager->getDefinition($secondDefinition->identity)); $this->assertTrue($first->isClosed()); $this->assertTrue($second->isClosed()); } + #[DataProvider('closeFailures')] + public function testPurgeAllAttemptsEveryDetachedPoolAndPreservesFailurePriority( + Throwable $firstFailure, + Throwable $secondFailure, + Throwable $expectedFailure, + ): void { + $pools = []; + $definitions = []; + $observedRegistries = []; + + foreach ([$firstFailure, $secondFailure, null] as $index => $failure) { + $identity = 'pool:' . $index; + $pool = m::mock(ObjectPool::class); + $pool->shouldReceive('close')->once()->andReturnUsing(function () use ( + $identity, + $failure, + &$observedRegistries, + ): void { + $observedRegistries[] = [$this->manager->getPools(), $this->manager->getDefinition($identity)]; + + if ($failure !== null) { + throw $failure; + } + }); + $pools[$identity] = $pool; + $definitions[$identity] = $this->definition(identity: $identity); + } + + (new ReflectionProperty(PoolManager::class, 'pools'))->setValue($this->manager, $pools); + (new ReflectionProperty(PoolManager::class, 'definitions'))->setValue($this->manager, $definitions); + $actualFailure = null; + + try { + $this->manager->purgeAll(); + } catch (Throwable $exception) { + $actualFailure = $exception; + } + + $this->assertSame($expectedFailure, $actualFailure); + $this->assertSame([[[], null], [[], null], [[], null]], $observedRegistries); + $this->assertSame([], $this->manager->getPools()); + } + + public static function closeFailures(): array + { + $firstFailure = new RuntimeException('first close failed'); + $secondFailure = new RuntimeException('second close failed'); + $firstCancellation = new CanceledException('first close canceled'); + $secondCancellation = new CanceledException('second close canceled'); + + return [ + 'first ordinary failure' => [$firstFailure, $secondFailure, $firstFailure], + 'later cancellation takes priority' => [$firstFailure, $secondCancellation, $secondCancellation], + 'first cancellation stays primary' => [$firstCancellation, $secondCancellation, $firstCancellation], + ]; + } + + public function testPurgeAllPreservesPoolsRegisteredDuringDetachedCleanup(): void + { + $replacement = null; + $pool = m::mock(ObjectPool::class); + $pool->shouldReceive('close')->once()->andReturnUsing(function () use (&$replacement): void { + $replacement = $this->manager->pool('reports', static fn () => new stdClass); + }); + (new ReflectionProperty(PoolManager::class, 'pools'))->setValue($this->manager, ['reports' => $pool]); + + $this->manager->purgeAll(); + + $this->assertSame($replacement, $this->manager->get('reports')); + $this->assertFalse($replacement->isClosed()); + $this->assertNotNull($this->manager->getDefinition('reports')); + } + public function testConcurrentMatchingRegistrationsConverge(): void { $definition = $this->definition(); @@ -305,7 +383,7 @@ public function testConcurrentMatchingRegistrationsConverge(): void foreach ($pools as $pool) { $this->assertSame($first, $pool); } - $this->assertCount(1, $this->manager->pools()); + $this->assertCount(1, $this->manager->getPools()); } private function definition( diff --git a/tests/ObjectPool/PoolOptionsTest.php b/tests/ObjectPool/PoolOptionsTest.php index fb74461345..542d88b784 100644 --- a/tests/ObjectPool/PoolOptionsTest.php +++ b/tests/ObjectPool/PoolOptionsTest.php @@ -20,8 +20,8 @@ public function testDefaultsAreNormalized(): void 'max_objects' => 10, 'wait_timeout' => 3.0, 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => PoolOptions::DEFAULT_IDLE_TTL, + 'max_idle_time' => null, + 'pool_idle_timeout' => PoolOptions::DEFAULT_POOL_IDLE_TIMEOUT, ], $options->toArray()); } @@ -31,36 +31,46 @@ public function testExplicitValuesAreNormalized(): void 'min_retained_objects' => 0, 'max_objects' => 20, 'wait_timeout' => 4, - 'max_lifetime' => 0, + 'max_lifetime' => null, 'max_idle_time' => 15, - 'idle_ttl' => 600, + 'pool_idle_timeout' => 600, ]); $this->assertSame([ 'min_retained_objects' => 0, 'max_objects' => 20, 'wait_timeout' => 4.0, - 'max_lifetime' => 0.0, + 'max_lifetime' => null, 'max_idle_time' => 15.0, - 'idle_ttl' => 600.0, + 'pool_idle_timeout' => 600.0, ], $options->toArray()); } - public function testExplicitNullDisablesIdleTtl(): void + public function testExplicitNullDisablesOptionalDurations(): void { - $this->assertNull(PoolOptions::fromArray(['idle_ttl' => null])->idleTtl); + $options = PoolOptions::fromArray([ + 'max_lifetime' => null, + 'max_idle_time' => null, + 'pool_idle_timeout' => null, + ]); + + $this->assertNull($options->maxLifetime); + $this->assertNull($options->maxIdleTime); + $this->assertNull($options->poolIdleTimeout); $this->assertSame( - PoolOptions::DEFAULT_IDLE_TTL, - PoolOptions::fromArray([])->idleTtl + PoolOptions::DEFAULT_POOL_IDLE_TIMEOUT, + PoolOptions::fromArray([])->poolIdleTimeout ); + $this->assertSame(60.0, PoolOptions::fromArray([])->maxLifetime); + $this->assertFalse($options->equals(PoolOptions::fromArray([]))); } public function testEquivalentInputsCompareEqualRegardlessOfDefaultsAndKeyOrder(): void { $defaults = PoolOptions::fromArray([]); $explicit = PoolOptions::fromArray([ - 'idle_ttl' => 300, - 'max_idle_time' => 0, + 'pool_idle_timeout' => 300, + 'max_idle_time' => null, 'max_lifetime' => 60, 'wait_timeout' => 3, 'max_objects' => 10, @@ -75,7 +85,7 @@ public function testEquivalentInputsCompareEqualRegardlessOfDefaultsAndKeyOrder( public function testUnknownOptionsAreRejectedWithTheKnownOptions(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown pool option(s) [typo]. Known options are [min_retained_objects, max_objects, wait_timeout, max_lifetime, max_idle_time, idle_ttl].'); + $this->expectExceptionMessage('Unknown pool option(s) [typo]. Known options are [min_retained_objects, max_objects, wait_timeout, max_lifetime, max_idle_time, pool_idle_timeout].'); PoolOptions::fromArray(['typo' => true]); } @@ -120,12 +130,10 @@ public static function invalidDurationTypes(): array ['wait_timeout', null], ['max_lifetime', '60'], ['max_lifetime', false], - ['max_lifetime', null], ['max_idle_time', '1'], ['max_idle_time', true], - ['max_idle_time', null], - ['idle_ttl', '300'], - ['idle_ttl', false], + ['pool_idle_timeout', '300'], + ['pool_idle_timeout', false], ]; } @@ -142,7 +150,7 @@ public static function nonFiniteDurations(): array { $cases = []; - foreach (['wait_timeout', 'max_lifetime', 'max_idle_time', 'idle_ttl'] as $name) { + foreach (['wait_timeout', 'max_lifetime', 'max_idle_time', 'pool_idle_timeout'] as $name) { foreach ([NAN, INF, -INF] as $value) { $cases[] = [$name, $value]; } @@ -171,10 +179,12 @@ public static function invalidValues(): array ], [['wait_timeout' => 0], 'Pool option [wait_timeout] must be greater than 0.'], [['wait_timeout' => -1], 'Pool option [wait_timeout] must be greater than 0.'], - [['max_lifetime' => -1], 'Pool option [max_lifetime] must be at least 0.'], - [['max_idle_time' => -1], 'Pool option [max_idle_time] must be at least 0.'], - [['idle_ttl' => 0], 'Pool option [idle_ttl] must be null or greater than 0.'], - [['idle_ttl' => -1], 'Pool option [idle_ttl] must be null or greater than 0.'], + [['max_lifetime' => 0], 'Pool option [max_lifetime] must be null or greater than 0.'], + [['max_lifetime' => -1], 'Pool option [max_lifetime] must be null or greater than 0.'], + [['max_idle_time' => 0], 'Pool option [max_idle_time] must be null or greater than 0.'], + [['max_idle_time' => -1], 'Pool option [max_idle_time] must be null or greater than 0.'], + [['pool_idle_timeout' => 0], 'Pool option [pool_idle_timeout] must be null or greater than 0.'], + [['pool_idle_timeout' => -1], 'Pool option [pool_idle_timeout] must be null or greater than 0.'], ]; } } diff --git a/tests/ObjectPool/PoolProxyTest.php b/tests/ObjectPool/PoolProxyTest.php index 93be2082a2..03bd595115 100644 --- a/tests/ObjectPool/PoolProxyTest.php +++ b/tests/ObjectPool/PoolProxyTest.php @@ -7,9 +7,9 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\InvalidatesPool; -use Hypervel\ObjectPool\Contracts\ObjectPool as ObjectPoolContract; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\InvalidatesPool; +use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolManager; @@ -45,7 +45,7 @@ protected function setUp(): void protected function tearDownInCoroutine(): void { - $this->manager->flush(); + $this->manager->purgeAll(); } public function testInvokeBorrowsConfiguresAndReleases(): void @@ -66,8 +66,8 @@ static function (object $configured): void { $pool = $this->manager->get($this->definition->identity); $this->assertSame([$object], $released); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testPoolIsResolvedPerOperationAfterInvalidation(): void @@ -113,8 +113,8 @@ public function testConfigureFailureDiscardsThePartiallyConfiguredObject(): void $this->assertSame($failure, $exception); } - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testDiscardFailureDoesNotMaskAConfigureFailure(): void @@ -127,7 +127,7 @@ public function testDiscardFailureDoesNotMaskAConfigureFailure(): void $this->container->instance(ExceptionHandler::class, $handler); $pool = m::mock(ObjectPoolContract::class); - $pool->shouldReceive('get')->once()->andReturn($object); + $pool->shouldReceive('borrow')->once()->andReturn($object); $pool->shouldReceive('discard')->once()->with($object)->andThrow($discardFailure); $factory = m::mock(Factory::class); $factory->shouldReceive('getOrCreate')->once()->andReturn($pool); @@ -170,8 +170,8 @@ function () use ($finalizationFailure): never { } $pool = $this->manager->get($this->definition->identity); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testFinalizationFailurePropagatesAfterSuccessfulOperation(): void @@ -191,7 +191,7 @@ function () use ($failure): never { $this->assertSame($failure, $exception); } - $this->assertSame(0, $this->manager->get($this->definition->identity)->getCurrentObjectNumber()); + $this->assertSame(0, $this->manager->get($this->definition->identity)->getManagedCount()); } public function testReleaseCallbackTravelsWithSynchronousAndDeferredLeases(): void @@ -239,13 +239,13 @@ public function testBaseProxyHasNoPublicMagicForwarding(): void } private function proxy( - Closure $resolver, + Closure $createCallback, ?Closure $releaseCallback = null, ?Closure $configure = null, ): InspectablePoolProxy { return new InspectablePoolProxy( $this->definition, - $resolver, + $createCallback, $this->manager, $releaseCallback, $configure, @@ -257,12 +257,12 @@ class InspectablePoolProxy extends PoolProxy { public function __construct( PoolDefinition $definition, - Closure $resolver, + Closure $createCallback, Factory $pools, ?Closure $releaseCallback = null, protected ?Closure $configure = null, ) { - parent::__construct($definition, $resolver, $pools, $releaseCallback); + parent::__construct($definition, $createCallback, $pools, $releaseCallback); } public function handle(string $value): string diff --git a/tests/ObjectPool/PoolRecyclerTest.php b/tests/ObjectPool/PoolRecyclerTest.php index c4eeb25797..aacd1e4110 100644 --- a/tests/ObjectPool/PoolRecyclerTest.php +++ b/tests/ObjectPool/PoolRecyclerTest.php @@ -6,10 +6,10 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\ObjectPool\Factory; +use Hypervel\Contracts\ObjectPool\ObjectPool; use Hypervel\Coordinator\Timer; use Hypervel\Coroutine\Coroutine; -use Hypervel\ObjectPool\Contracts\Factory; -use Hypervel\ObjectPool\Contracts\ObjectPool; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolManager; use Hypervel\ObjectPool\PoolOptions; @@ -20,6 +20,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use stdClass; +use Swoole\Coroutine\CanceledException; class PoolRecyclerTest extends TestCase { @@ -29,17 +30,17 @@ class PoolRecyclerTest extends TestCase protected function tearDownInCoroutine(): void { foreach ($this->poolManagers as $poolManager) { - $poolManager->flush(); + $poolManager->purgeAll(); } } public function testIdlePoolIsEvictedByIdentityAndExactInstance(): void { $pool = m::mock(ObjectPool::class); - $pool->shouldReceive('isIdle')->once()->andReturnTrue(); + $pool->shouldReceive('isIdleExpired')->once()->andReturnTrue(); $manager = m::mock(Factory::class); - $manager->shouldReceive('pools')->once()->andReturn(['idle' => $pool]); - $manager->shouldReceive('remove')->once()->with('idle', $pool)->andReturnTrue(); + $manager->shouldReceive('getPools')->once()->andReturn(['idle' => $pool]); + $manager->shouldReceive('purge')->once()->with('idle', $pool)->andReturnTrue(); (new InspectablePoolRecycler($manager))->maintain(); } @@ -50,13 +51,13 @@ public function testReplacementPoolSurvivesAStaleEvictionSnapshot(): void $definition = $this->definition('shared'); $replacement = $manager->getOrCreate($definition, static fn (): object => new stdClass); $stale = m::mock(ObjectPool::class); - $stale->shouldReceive('isIdle')->once()->andReturnTrue(); + $stale->shouldReceive('isIdleExpired')->once()->andReturnTrue(); $snapshotManager = m::mock(Factory::class); - $snapshotManager->shouldReceive('pools')->once()->andReturn(['shared' => $stale]); - $snapshotManager->shouldReceive('remove') + $snapshotManager->shouldReceive('getPools')->once()->andReturn(['shared' => $stale]); + $snapshotManager->shouldReceive('purge') ->once() ->with('shared', $stale) - ->andReturnUsing(fn (): bool => $manager->remove('shared', $stale)); + ->andReturnUsing(fn (): bool => $manager->purge('shared', $stale)); (new InspectablePoolRecycler($snapshotManager))->maintain(); @@ -71,7 +72,7 @@ public function testSuspendedFactoryPreventsIdleEviction(): void 'suspended', 'service', 'auto:suspended', - PoolOptions::fromArray(['idle_ttl' => 0.001]), + PoolOptions::fromArray(['pool_idle_timeout' => 0.001]), ); $pool = $manager->getOrCreate($definition, function (): object { usleep(10_000); @@ -81,7 +82,7 @@ public function testSuspendedFactoryPreventsIdleEviction(): void $borrowed = null; Coroutine::create(function () use ($pool, &$borrowed): void { - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); }); usleep(3_000); @@ -105,15 +106,15 @@ public function testParkedWaiterAndBorrowedObjectPreventIdleEviction(): void PoolOptions::fromArray([ 'max_objects' => 1, 'wait_timeout' => 0.2, - 'idle_ttl' => 0.001, + 'pool_idle_timeout' => 0.001, ]), ); $pool = $manager->getOrCreate($definition, static fn (): object => new stdClass); - $borrowed = $pool->get(); + $borrowed = $pool->borrow(); $waiterBorrow = null; Coroutine::create(function () use ($pool, &$waiterBorrow): void { - $waiterBorrow = $pool->get(); + $waiterBorrow = $pool->borrow(); }); usleep(3_000); @@ -129,11 +130,11 @@ public function testParkedWaiterAndBorrowedObjectPreventIdleEviction(): void public function testNonIdlePoolsAreSweptAndTrimmed(): void { $pool = m::mock(ObjectPool::class); - $pool->shouldReceive('isIdle')->once()->andReturnFalse(); + $pool->shouldReceive('isIdleExpired')->once()->andReturnFalse(); $pool->shouldReceive('sweepExpired')->once()->ordered(); $pool->shouldReceive('trimIdle')->once()->ordered(); $manager = m::mock(Factory::class); - $manager->shouldReceive('pools')->once()->andReturn(['active' => $pool]); + $manager->shouldReceive('getPools')->once()->andReturn(['active' => $pool]); (new InspectablePoolRecycler($manager))->maintain(); } @@ -153,15 +154,15 @@ public function testPoolFailureIsReportedWithoutSkippingLaterPools(): void Container::setInstance($container); $failingPool = m::mock(ObjectPool::class); - $failingPool->shouldReceive('isIdle')->once()->andReturnFalse(); + $failingPool->shouldReceive('isIdleExpired')->once()->andReturnFalse(); $failingPool->shouldReceive('sweepExpired')->once()->andThrow($failure); $failingPool->shouldNotReceive('trimIdle'); $healthyPool = m::mock(ObjectPool::class); - $healthyPool->shouldReceive('isIdle')->once()->ordered()->andReturnFalse(); + $healthyPool->shouldReceive('isIdleExpired')->once()->ordered()->andReturnFalse(); $healthyPool->shouldReceive('sweepExpired')->once()->ordered(); $healthyPool->shouldReceive('trimIdle')->once()->ordered(); $manager = m::mock(Factory::class); - $manager->shouldReceive('pools')->once()->andReturn([ + $manager->shouldReceive('getPools')->once()->andReturn([ 'failing' => $failingPool, 'healthy' => $healthyPool, ]); @@ -177,19 +178,84 @@ public function testStartIsIdempotentAndStopClearsTheTimer(): void { $timer = m::mock(Timer::class); $timer->shouldReceive('tick') - ->once() + ->twice() ->with(1.0, m::type('Closure')) - ->andReturn(99); + ->andReturn(99, 100); $timer->shouldReceive('clear')->once()->with(99); - $recycler = new PoolRecycler(m::mock(Factory::class), 1.0); - $recycler->setTimer($timer); + $timer->shouldReceive('clear')->once()->with(100); + $recycler = new PoolRecycler(m::mock(Factory::class), 1.0, $timer); $recycler->start(); $recycler->start(); - $this->assertSame(99, $recycler->getTimerId()); - $recycler->stop(); - $this->assertNull($recycler->getTimerId()); + $recycler->stop(); + $recycler->start(); + $recycler->stop(); + } + + #[DataProvider('maintenanceCancellationPaths')] + public function testCancellationStopsMaintenanceWithoutReporting(bool $scheduled, string $operation): void + { + $cancellation = new CanceledException('maintenance canceled'); + $handler = m::mock(ExceptionHandler::class); + $handler->shouldNotReceive('report'); + $container = new Container; + $container->instance(ExceptionHandler::class, $handler); + Container::setInstance($container); + + $pool = m::mock(ObjectPool::class); + $pool->shouldReceive('isIdleExpired')->once()->andReturn($operation === 'purge'); + $manager = m::mock(Factory::class); + $manager->shouldReceive('getPools')->once()->andReturn([ + 'canceled' => $pool, + 'later' => m::mock(ObjectPool::class), + ]); + + if ($operation === 'purge') { + $manager->shouldReceive('purge')->once()->with('canceled', $pool)->andThrow($cancellation); + } else { + $pool->shouldReceive('sweepExpired')->once()->andThrow($cancellation); + $pool->shouldNotReceive('trimIdle'); + } + + $callback = null; + $timer = m::mock(Timer::class); + $recycler = new InspectablePoolRecycler($manager, timer: $timer); + + if ($scheduled) { + $timer->shouldReceive('tick')->once()->andReturnUsing( + function (float $interval, callable $scheduled) use (&$callback): int { + $callback = $scheduled; + + return 99; + }, + ); + $timer->shouldReceive('clear')->once()->with(99); + $recycler->start(); + $this->assertIsCallable($callback); + } + + $caught = null; + + try { + $scheduled ? $callback() : $recycler->maintain(); + } catch (CanceledException $exception) { + $caught = $exception; + } finally { + $recycler->stop(); + } + + $this->assertSame($cancellation, $caught); + } + + public static function maintenanceCancellationPaths(): array + { + return [ + 'direct eviction' => [false, 'purge'], + 'direct sweep' => [false, 'sweepExpired'], + 'scheduled eviction' => [true, 'purge'], + 'scheduled sweep' => [true, 'sweepExpired'], + ]; } public function testTimerReportsMaintenanceFailures(): void @@ -202,7 +268,7 @@ public function testTimerReportsMaintenanceFailures(): void Container::setInstance($container); $manager = m::mock(Factory::class); - $manager->shouldReceive('pools')->once()->andThrow($failure); + $manager->shouldReceive('getPools')->once()->andThrow($failure); $callback = null; $timer = m::mock(Timer::class); $timer->shouldReceive('tick') @@ -212,12 +278,16 @@ public function testTimerReportsMaintenanceFailures(): void return 99; }); - $recycler = new PoolRecycler($manager, 1.0); - $recycler->setTimer($timer); + $timer->shouldReceive('clear')->once()->with(99); + $recycler = new PoolRecycler($manager, 1.0, $timer); $recycler->start(); - $this->assertIsCallable($callback); - $callback(); + try { + $this->assertIsCallable($callback); + $callback(); + } finally { + $recycler->stop(); + } } #[DataProvider('invalidIntervals')] @@ -229,27 +299,14 @@ public function testConstructorRejectsInvalidIntervals(float $interval): void new PoolRecycler(m::mock(Factory::class), $interval); } - #[DataProvider('invalidIntervals')] - public function testSetterRejectsInvalidIntervals(float $interval): void - { - $recycler = new PoolRecycler(m::mock(Factory::class)); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The recycler interval must be a finite number greater than 0.'); - - $recycler->setInterval($interval); - } - public static function invalidIntervals(): array { return [[0.0], [-1.0], [NAN], [INF], [-INF]]; } - public function testFinitePositiveIntervalCanBeChanged(): void + public function testFinitePositiveIntervalCanBeConfiguredAtConstruction(): void { - $recycler = new PoolRecycler(m::mock(Factory::class), 1.0); - - $recycler->setInterval(2.5); + $recycler = new PoolRecycler(m::mock(Factory::class), 2.5); $this->assertSame(2.5, $recycler->getInterval()); } diff --git a/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php index 681ed86460..9ead649a9f 100644 --- a/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php @@ -4,18 +4,20 @@ namespace Hypervel\Tests\OpenTelemetry\Instrumentation; -use Hypervel\Contracts\Pool\PoolOptionInterface; -use Hypervel\Database\Pool\PoolFactory as DatabasePoolFactory; -use Hypervel\ObjectPool\Contracts\Factory as ObjectPoolFactory; -use Hypervel\ObjectPool\Contracts\ObjectPool; +use Hypervel\ConnectionPool\ConnectionPool; +use Hypervel\ConnectionPool\PoolOptions as ConnectionPoolOptions; +use Hypervel\Contracts\ObjectPool\Factory as ObjectPoolFactory; +use Hypervel\Contracts\ObjectPool\ObjectPool; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Database\Pool\PoolManager as DatabasePoolManager; +use Hypervel\ObjectPool\CallbackObjectPool; +use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolFingerprint; use Hypervel\ObjectPool\PoolManager as ObjectPoolManager; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\OpenTelemetry\Instrumentation\PoolInstrumentation; -use Hypervel\Pool\Pool; -use Hypervel\Redis\Pool\PoolFactory as RedisPoolFactory; +use Hypervel\Redis\Pool\PoolManager as RedisPoolManager; use Hypervel\Tests\TestCase; use Mockery as m; use OpenTelemetry\API\Metrics\MeterProviderInterface; @@ -27,6 +29,7 @@ use OpenTelemetry\SDK\Metrics\MetricExporter\InMemoryExporter; use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader; use OpenTelemetry\SemConv\Incubating\Metrics\DbIncubatingMetrics; +use Swoole\Coroutine\Channel; use WeakReference; class PoolInstrumentationTest extends TestCase @@ -55,10 +58,10 @@ public function testRecordsExistingConnectionAndObjectPoolsWithExactWireIdentiti { $databasePool = $this->connectionPool(current: 4, idle: 2, max: 10, waiters: 1); $redisPool = $this->connectionPool(current: 3, idle: 1, max: 8, waiters: 2); - $databasePools = m::mock(DatabasePoolFactory::class); - $databasePools->shouldReceive('pools')->once()->andReturn(['default' => $databasePool]); - $redisPools = m::mock(RedisPoolFactory::class); - $redisPools->shouldReceive('pools')->once()->andReturn(['default' => $redisPool]); + $databasePools = m::mock(DatabasePoolManager::class); + $databasePools->shouldReceive('getPools')->once()->andReturn(['default' => $databasePool]); + $redisPools = m::mock(RedisPoolManager::class); + $redisPools->shouldReceive('getPools')->once()->andReturn(['default' => $redisPool]); $objectPools = new ObjectPoolManager; $definitions = new PoolDefinitionManagerStub($objectPools); $autoDefinition = $definitions->definition( @@ -78,10 +81,10 @@ public function testRecordsExistingConnectionAndObjectPoolsWithExactWireIdentiti static fn (): object => new PoolMetricObject, ['max_objects' => 6], ); - $autoIdle = $autoPool->get(); - $autoBorrowed = $autoPool->get(); - $namedBorrowed = $namedPool->get(); - $directIdle = $directPool->get(); + $autoIdle = $autoPool->borrow(); + $autoBorrowed = $autoPool->borrow(); + $namedBorrowed = $namedPool->borrow(); + $directIdle = $directPool->borrow(); $autoPool->release($autoIdle); $directPool->release($directIdle); @@ -164,18 +167,68 @@ public function testRecordsExistingConnectionAndObjectPoolsWithExactWireIdentiti } finally { $autoPool->release($autoBorrowed); $namedPool->release($namedBorrowed); - $objectPools->flush(); + $objectPools->purgeAll(); } } + public function testUsedObjectCountIncludesCapacityHeldDuringDestruction(): void + { + $destroyStarted = new Channel(1); + $finishDestroy = new Channel(1); + $pool = new CallbackObjectPool( + static fn (): object => new PoolMetricObject, + PoolOptions::fromArray([]), + static function () use ($destroyStarted, $finishDestroy): void { + $destroyStarted->push(true); + $finishDestroy->pop(1.0); + }, + ); + $objectPools = m::mock(ObjectPoolManager::class); + $objectPools->shouldReceive('getPools')->twice()->andReturn(['app:reports' => $pool]); + $databasePools = m::mock(DatabasePoolManager::class); + $databasePools->shouldNotReceive('getPools'); + $redisPools = m::mock(RedisPoolManager::class); + $redisPools->shouldNotReceive('getPools'); + $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); + $instrumentation->register($this->options(enabled: ['hypervel.object_pool.objects'])); + $borrowed = $pool->borrow(); + $child = Coroutine::create(static fn () => $pool->discard($borrowed)); + + try { + $this->assertTrue($destroyStarted->pop(1.0)); + $this->assertSame(0, $pool->getBorrowedCount()); + $during = $this->collect(); + + $this->assertPoint($during['hypervel.object_pool.objects'], 1, [ + 'hypervel.object_pool.name' => 'app:reports', + 'hypervel.object_pool.state' => 'used', + ]); + $this->assertPoint($during['hypervel.object_pool.objects'], 0, [ + 'hypervel.object_pool.name' => 'app:reports', + 'hypervel.object_pool.state' => 'idle', + ]); + } finally { + $finishDestroy->close(); + Coroutine::join([$child], 1.0); + $pool->close(); + } + + $this->assertFalse(Coroutine::exists($child)); + $after = $this->collect(); + $this->assertPoint($after['hypervel.object_pool.objects'], 0, [ + 'hypervel.object_pool.name' => 'app:reports', + 'hypervel.object_pool.state' => 'used', + ]); + } + public function testDisabledMetricsDoNotResolveTheMeterOrInspectPoolRegistries(): void { - $databasePools = m::mock(DatabasePoolFactory::class); - $databasePools->shouldNotReceive('pools'); - $redisPools = m::mock(RedisPoolFactory::class); - $redisPools->shouldNotReceive('pools'); + $databasePools = m::mock(DatabasePoolManager::class); + $databasePools->shouldNotReceive('getPools'); + $redisPools = m::mock(RedisPoolManager::class); + $redisPools->shouldNotReceive('getPools'); $objectPools = m::mock(ObjectPoolManager::class); - $objectPools->shouldNotReceive('pools'); + $objectPools->shouldNotReceive('getPools'); $meterProvider = m::mock(MeterProviderInterface::class); $meterProvider->shouldNotReceive('getMeter'); @@ -192,19 +245,18 @@ public function testDisabledMetricsDoNotResolveTheMeterOrInspectPoolRegistries() public function testIndividualConnectionMetricsReadOnlyTheirRequiredSourceValues(): void { - $option = m::mock(PoolOptionInterface::class); - $option->shouldReceive('getMaxConnections')->once()->andReturn(12); - $pool = m::mock(Pool::class); - $pool->shouldReceive('getOption')->once()->andReturn($option); - $pool->shouldNotReceive('getCurrentConnections'); - $pool->shouldNotReceive('getConnectionsInChannel'); - $pool->shouldNotReceive('getWaiters'); - $databasePools = m::mock(DatabasePoolFactory::class); - $databasePools->shouldReceive('pools')->once()->andReturn(['primary' => $pool]); - $redisPools = m::mock(RedisPoolFactory::class); - $redisPools->shouldReceive('pools')->once()->andReturn([]); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->once()->andReturn(ConnectionPoolOptions::fromArray(['max_connections' => 12])); + $pool->shouldNotReceive('getManagedCount'); + $pool->shouldNotReceive('getIdleCount'); + $pool->shouldNotReceive('getWaitingCount'); + $pool->shouldNotReceive('getStats'); + $databasePools = m::mock(DatabasePoolManager::class); + $databasePools->shouldReceive('getPools')->once()->andReturn(['primary' => $pool]); + $redisPools = m::mock(RedisPoolManager::class); + $redisPools->shouldReceive('getPools')->once()->andReturn([]); $objectPools = m::mock(ObjectPoolManager::class); - $objectPools->shouldNotReceive('pools'); + $objectPools->shouldNotReceive('getPools'); $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); $instrumentation->register($this->options( @@ -224,11 +276,11 @@ public function testIndividualObjectMetricsReadOnlyTheirRequiredSourceValues(): $objectPool->shouldReceive('getOptions')->once()->andReturn(PoolOptions::fromArray(['max_objects' => 7])); $objectPool->shouldNotReceive('getStats'); $objectPools = m::mock(ObjectPoolManager::class); - $objectPools->shouldReceive('pools')->once()->andReturn(['app:bounded' => $objectPool]); - $databasePools = m::mock(DatabasePoolFactory::class); - $databasePools->shouldNotReceive('pools'); - $redisPools = m::mock(RedisPoolFactory::class); - $redisPools->shouldNotReceive('pools'); + $objectPools->shouldReceive('getPools')->once()->andReturn(['app:bounded' => $objectPool]); + $databasePools = m::mock(DatabasePoolManager::class); + $databasePools->shouldNotReceive('getPools'); + $redisPools = m::mock(RedisPoolManager::class); + $redisPools->shouldNotReceive('getPools'); $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); $instrumentation->register($this->options(enabled: ['hypervel.object_pool.max'])); @@ -242,12 +294,12 @@ public function testIndividualObjectMetricsReadOnlyTheirRequiredSourceValues(): public function testBoundCallbacksStopCollectingAfterTheInstrumentationIsDestroyed(): void { - $databasePools = m::mock(DatabasePoolFactory::class); - $databasePools->shouldReceive('pools')->once()->andReturn([]); - $redisPools = m::mock(RedisPoolFactory::class); - $redisPools->shouldReceive('pools')->once()->andReturn([]); + $databasePools = m::mock(DatabasePoolManager::class); + $databasePools->shouldReceive('getPools')->once()->andReturn([]); + $redisPools = m::mock(RedisPoolManager::class); + $redisPools->shouldReceive('getPools')->once()->andReturn([]); $objectPools = m::mock(ObjectPoolManager::class); - $objectPools->shouldNotReceive('pools'); + $objectPools->shouldNotReceive('getPools'); $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); $reference = WeakReference::create($instrumentation); $instrumentation->register($this->options(enabled: [ @@ -264,10 +316,10 @@ public function testBoundCallbacksStopCollectingAfterTheInstrumentationIsDestroy public function testRemovedObjectPoolDisappearsFromTheNextCollection(): void { - $databasePools = m::mock(DatabasePoolFactory::class); - $databasePools->shouldNotReceive('pools'); - $redisPools = m::mock(RedisPoolFactory::class); - $redisPools->shouldNotReceive('pools'); + $databasePools = m::mock(DatabasePoolManager::class); + $databasePools->shouldNotReceive('getPools'); + $redisPools = m::mock(RedisPoolManager::class); + $redisPools->shouldNotReceive('getPools'); $objectPools = new ObjectPoolManager; $objectPools->pool('app:ephemeral', static fn (): object => new PoolMetricObject); $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); @@ -278,7 +330,7 @@ public function testRemovedObjectPoolDisappearsFromTheNextCollection(): void 'hypervel.object_pool.name' => 'app:ephemeral', 'hypervel.object_pool.state' => 'idle', ]); - $objectPools->remove('app:ephemeral'); + $objectPools->purge('app:ephemeral'); $second = $this->collect(); $this->assertInstanceOf(Sum::class, $second['hypervel.object_pool.objects']->data); @@ -289,8 +341,8 @@ public function testRemovedObjectPoolDisappearsFromTheNextCollection(): void * Create pool instrumentation. */ private function instrumentation( - DatabasePoolFactory $databasePools, - RedisPoolFactory $redisPools, + DatabasePoolManager $databasePools, + RedisPoolManager $redisPools, ObjectPoolManager $objectPools, ): PoolInstrumentation { return new PoolInstrumentation( @@ -304,15 +356,13 @@ private function instrumentation( /** * Create a connection pool with one exact snapshot. */ - private function connectionPool(int $current, int $idle, int $max, int $waiters): Pool + private function connectionPool(int $current, int $idle, int $max, int $waiters): ConnectionPool { - $option = m::mock(PoolOptionInterface::class); - $option->shouldReceive('getMaxConnections')->once()->andReturn($max); - $pool = m::mock(Pool::class); - $pool->shouldReceive('getCurrentConnections')->once()->andReturn($current); - $pool->shouldReceive('getConnectionsInChannel')->once()->andReturn($idle); - $pool->shouldReceive('getOption')->once()->andReturn($option); - $pool->shouldReceive('getWaiters')->once()->andReturn($waiters); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getManagedCount')->once()->andReturn($current); + $pool->shouldReceive('getIdleCount')->once()->andReturn($idle); + $pool->shouldReceive('getOptions')->once()->andReturn(ConnectionPoolOptions::fromArray(['max_connections' => $max])); + $pool->shouldReceive('getWaitingCount')->once()->andReturn($waiters); return $pool; } diff --git a/tests/Pool/ChannelTest.php b/tests/Pool/ChannelTest.php deleted file mode 100644 index a4649695ad..0000000000 --- a/tests/Pool/ChannelTest.php +++ /dev/null @@ -1,200 +0,0 @@ -push($outsideConnection); - - run(function () use ($channel, $outsideConnection, $insideConnection): void { - $this->assertSame($outsideConnection, $channel->pop()); - $channel->push($insideConnection); - }); - - $this->assertSame($insideConnection, $channel->pop()); - } - - public function testEmptyPopOutsideCoroutineReturnsFalse(): void - { - $this->assertFalse((new Channel(1))->pop()); - } - - public function testCoroutineWaiterIsWokenByPush(): void - { - $channel = new Channel(1); - $connection = m::mock(ConnectionInterface::class); - - run(function () use ($channel, $connection): void { - $result = null; - - Coroutine::create(function () use ($channel, &$result): void { - $result = [$channel->wait(0.2), $channel->pop()]; - }); - - usleep(5_000); - $this->assertSame(1, $channel->waiters()); - $channel->push($connection); - usleep(5_000); - - $this->assertSame([true, $connection], $result); - $this->assertSame(0, $channel->waiters()); - }); - } - - public function testWaitTimesOut(): void - { - $channel = new Channel(1); - - run(function () use ($channel): void { - $this->assertFalse($channel->wait(0.001)); - }); - } - - public function testWaitConvertsNonThrowingCancellation(): void - { - $channel = new Channel(1); - - run(function () use ($channel): void { - $cancellation = null; - $coroutine = EngineCoroutine::create(function () use ($channel, &$cancellation): void { - try { - $channel->wait(1.0); - } catch (CanceledException $exception) { - $cancellation = $exception; - } - }); - - $this->assertTrue(EngineCoroutine::cancelById($coroutine->getId())); - $this->assertInstanceOf(CanceledException::class, $cancellation); - $this->assertSame('The connection pool wait was canceled.', $cancellation->getMessage()); - }); - } - - public function testSignalNeverBlocksWhenWakeIsAlreadyPending(): void - { - $channel = new FullSignalConnectionPoolChannel; - - run(function () use ($channel): void { - $channel->fillSignal(); - $completed = false; - - Coroutine::create(function () use ($channel, &$completed): void { - $channel->signal(); - $completed = true; - }); - - usleep(5_000); - - $this->assertTrue($completed); - $channel->drainSignal(); - }); - } - - public function testCloseWakesEveryWaiter(): void - { - $channel = new Channel(2); - - run(function () use ($channel): void { - $results = []; - - foreach ([0, 1] as $index) { - Coroutine::create(function () use ($channel, &$results, $index): void { - $results[$index] = $channel->wait(0.2); - }); - } - - usleep(5_000); - $channel->close(); - usleep(5_000); - - ksort($results); - $this->assertSame([true, true], $results); - }); - } - - public function testCloseIsIdempotentAndLaterSignalOperationsUseLocalState(): void - { - $channel = new Channel(1); - - $channel->close(); - $channel->close(); - $channel->signal(); - - $this->assertTrue($channel->wait(0.001)); - } - - public function testPushAfterCloseIsRejectedWithoutRetainingTheConnection(): void - { - $channel = new Channel(1); - - $channel->close(); - - $this->assertFalse($channel->push(m::mock(ConnectionInterface::class))); - $this->assertSame(0, $channel->length()); - $this->assertFalse($channel->pop()); - } - - #[RunInSeparateProcess] - public function testOutsideCoroutinePushCommitsWhenAWakeCoroutineCannotBeCreated(): void - { - SwooleCoroutine::set(['max_coroutine' => 1]); - $channel = new Channel(1); - $waitResult = null; - $connection = m::mock(ConnectionInterface::class); - - SwooleCoroutine::create(function () use ($channel, &$waitResult): void { - $waitResult = $channel->wait(1.0); - }); - - $this->assertTrue($channel->push($connection)); - $this->assertSame($connection, $channel->pop()); - - $channel->close(); - Event::wait(); - - $this->assertTrue($waitResult); - } -} - -class FullSignalConnectionPoolChannel extends Channel -{ - public function __construct() - { - parent::__construct(1); - } - - public function fillSignal(): void - { - $this->waiters = 1; - $this->signal->push(true); - } - - public function drainSignal(): void - { - $this->signal->pop(0.001); - $this->waiters = 0; - } -} diff --git a/tests/Pool/Fixtures/ConstantFrequencyStub.php b/tests/Pool/Fixtures/ConstantFrequencyStub.php deleted file mode 100644 index 1370c12d6a..0000000000 --- a/tests/Pool/Fixtures/ConstantFrequencyStub.php +++ /dev/null @@ -1,12 +0,0 @@ -beginTime = $time; - } - - public function setHits(array $hits): void - { - $this->hits = $hits; - } - - public function getHits(): array - { - return $this->hits; - } -} diff --git a/tests/Pool/Fixtures/HeartbeatPoolStub.php b/tests/Pool/Fixtures/HeartbeatPoolStub.php deleted file mode 100644 index e5594a9aae..0000000000 --- a/tests/Pool/Fixtures/HeartbeatPoolStub.php +++ /dev/null @@ -1,16 +0,0 @@ -container, $this); - } -} diff --git a/tests/Pool/FrequencyTest.php b/tests/Pool/FrequencyTest.php deleted file mode 100644 index 899c6a4e04..0000000000 --- a/tests/Pool/FrequencyTest.php +++ /dev/null @@ -1,99 +0,0 @@ -assertSame(0.0, (new FrequencyStub)->frequency()); - } - - public function testFrequencyHit(): void - { - $frequency = new FrequencyStub; - $now = time(); - $frequency->setBeginTime($now - 4); - $frequency->setHits([ - $now => 1, - $now - 1 => 10, - $now - 2 => 10, - $now - 3 => 10, - $now - 4 => 10, - ]); - - $num = $frequency->frequency(); - $this->assertSame(41 / 5, $num); - - $frequency->hit(); - $num = $frequency->frequency(); - $this->assertSame(42 / 5, $num); - } - - public function testConstantFrequency(): void - { - $pool = m::mock(Pool::class); - $pool->shouldReceive('checkIdleConnection')->atLeast()->once(); - - $stub = new ConstantFrequencyStub($pool); - Coroutine::sleep(0.005); - $stub->clear(); - } - - public function testFrequencyHitOneSecondAfter(): void - { - $frequency = new FrequencyStub; - $now = time(); - - $frequency->setBeginTime($now - 4); - $frequency->setHits([ - $now => 1, - $now - 1 => 10, - $now - 2 => 10, - $now - 4 => 10, - ]); - $num = $frequency->frequency(); - $this->assertSame(31 / 5, $num); - $frequency->hit(); - $num = $frequency->frequency(); - $this->assertSame(32 / 5, $num); - - $frequency->setHits([ - $now => 1, - $now - 1 => 10, - $now - 2 => 10, - $now - 3 => 10, - ]); - $num = $frequency->frequency(); - $this->assertSame(31 / 5, $num); - $frequency->hit(); - $num = $frequency->frequency(); - $this->assertSame(32 / 5, $num); - } - - public function testFrequencyExcludesTheExpiredEleventhBucket(): void - { - do { - $frequency = new FrequencyStub; - $now = time(); - $frequency->setBeginTime($now - 10); - $frequency->setHits(array_fill_keys(range($now - 9, $now), 0) + [ - $now - 10 => 100, - ]); - - $frequencyValue = $frequency->frequency(); - } while (time() !== $now); - - $this->assertSame(0.0, $frequencyValue); - $this->assertCount(10, $frequency->getHits()); - } -} diff --git a/tests/Pool/HeartbeatConnectionTest.php b/tests/Pool/HeartbeatConnectionTest.php deleted file mode 100644 index 7f44bbd7ee..0000000000 --- a/tests/Pool/HeartbeatConnectionTest.php +++ /dev/null @@ -1,220 +0,0 @@ -getContainer(); - $pool = $container->make(HeartbeatPoolStub::class); - $connection = $pool->get(); - - $this->assertInstanceOf(KeepaliveConnectionStub::class, $connection); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - - $connection = $pool->get(); - $this->assertSame(2, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - - $connection->release(); - $this->assertSame(1, $pool->getConnectionsInChannel()); - - $connection = $pool->get(); - $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertSame(2, $pool->getCurrentConnections()); - } - - public function testConnectionAcceptsPoolContract(): void - { - $pool = m::mock(PoolInterface::class); - $connection = new KeepaliveConnectionStub( - m::mock(ContainerContract::class), - $pool, - ); - - $pool->shouldReceive('release')->once()->with($connection); - - $connection->release(); - } - - public function testConnectionCall(): void - { - $container = $this->getContainer(); - $pool = $container->make(HeartbeatPoolStub::class); - /** @var KeepaliveConnectionStub $connection */ - $connection = $pool->get(); - $connection->setActiveConnection(new class { - public function send(string $data): string - { - return str_repeat($data, 2); - } - }); - $str = uniqid(); - $result = $connection->call(function ($connection) use ($str) { - return $connection->send($str); - }); - - $this->assertSame($result, str_repeat($str, 2)); - } - - public function testDiscardDelegatesToOwningPool(): void - { - $container = $this->getContainer(); - $pool = $container->make(HeartbeatPoolStub::class); - $connection = $pool->get(); - - $connection->discard(); - - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); - } - - public function testConnectionHeartbeat(): void - { - $container = $this->getContainer(['heartbeat' => 0.001]); - $pool = $container->make(HeartbeatPoolStub::class); - /** @var KeepaliveConnectionStub $connection */ - $connection = $pool->get(); - $connection->reconnect(); - $timer = $connection->timer; - $this->assertSame(1, count((new ClassInvoker($timer))->coroutines)); - $this->assertTrue($connection->check()); - $connection->close(); - $this->assertSame(0, count((new ClassInvoker($timer))->coroutines)); - $this->assertFalse($connection->check()); - $this->assertSame('close protocol', CoroutineContext::get('test.pool.heartbeat_connection')['close']); - } - - public function testDisabledHeartbeatDoesNotStartTimer(): void - { - $container = $this->getContainer([ - 'heartbeat' => -1, - 'max_idle_time' => 0.001, - ]); - $pool = $container->make(HeartbeatPoolStub::class); - /** @var KeepaliveConnectionStub $connection */ - $connection = $pool->get(); - $connection->reconnect(); - $timer = $connection->timer; - - $this->assertTrue($connection->check()); - $this->assertSame(0, count((new ClassInvoker($timer))->coroutines)); - - Coroutine::sleep(0.01); - - $this->assertTrue($connection->check()); - $this->assertSame(0, $connection->closeCount); - - $connection->close(); - } - - public function testEnabledHeartbeatClosesIdleConnection(): void - { - $container = $this->getContainer([ - 'heartbeat' => 0.001, - 'max_idle_time' => 0.001, - ]); - $pool = $container->make(HeartbeatPoolStub::class); - /** @var KeepaliveConnectionStub $connection */ - $connection = $pool->get(); - $connection->reconnect(); - - Coroutine::sleep(0.01); - - $this->assertFalse($connection->check()); - $this->assertSame(1, $connection->closeCount); - } - - public function testHeartbeatFailureFallsBackToThePhpErrorLogWithoutALogger(): void - { - $directory = ParallelTesting::tempDir('HeartbeatConnectionTest'); - (new Filesystem)->deleteDirectory($directory); - mkdir($directory, 0777, true); - $errorLog = $directory . '/php-error.log'; - $previousErrorLog = ini_set('error_log', $errorLog); - $previousLogErrors = ini_set('log_errors', '1'); - - try { - $container = $this->getContainer(['heartbeat' => 0.001]); - $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); - $pool = $container->make(HeartbeatPoolStub::class); - /** @var KeepaliveConnectionStub $connection */ - $connection = $pool->get(); - $connection->heartbeatFailure = new RuntimeException('heartbeat fallback failed'); - $connection->reconnect(); - - Coroutine::sleep(0.01); - - $this->assertFalse($connection->check()); - $this->assertSame(1, $connection->closeCount); - $contents = file_get_contents($errorLog); - $this->assertIsString($contents); - $this->assertStringContainsString('heartbeat fallback failed', $contents); - } finally { - if ($previousErrorLog !== false) { - ini_set('error_log', $previousErrorLog); - } - - if ($previousLogErrors !== false) { - ini_set('log_errors', $previousLogErrors); - } - - (new Filesystem)->deleteDirectory($directory); - } - } - - public function testConnectionCloseProtocolRunsOnPoolFlush(): void - { - $container = $this->getContainer(); - $pool = $container->make(HeartbeatPoolStub::class); - /** @var KeepaliveConnectionStub $connection */ - $connection = $pool->get(); - $connection->reconnect(); - $connection->release(); - - $connection = $pool->get(); - $connection->reconnect(); - $connection->release(); - - $pool->flush(); - - $this->assertSame('close protocol', CoroutineContext::get('test.pool.heartbeat_connection')['close']); - } - - protected function getContainer(array $poolConfig = []): ContainerContract - { - $container = m::mock(Container::class); - Container::setInstance($container); - - $container->shouldReceive('make')->with(HeartbeatPoolStub::class)->andReturnUsing(function () use ($container, $poolConfig) { - return new HeartbeatPoolStub($container, 'test', $poolConfig); - }); - - return $container; - } -} diff --git a/tests/Pool/PoolOptionTest.php b/tests/Pool/PoolOptionTest.php deleted file mode 100644 index d15081b10d..0000000000 --- a/tests/Pool/PoolOptionTest.php +++ /dev/null @@ -1,162 +0,0 @@ -assertSame(-1.0, $option->getMaxLifetime()); - } - - public function testMaxLifetimeCanBeConfigured(): void - { - $option = new PoolOption(maxLifetime: 120.0); - - $this->assertSame(120.0, $option->getMaxLifetime()); - } - - public function testMaxLifetimeCanBeChanged(): void - { - $option = new PoolOption; - - $this->assertSame($option, $option->setMaxLifetime(30.0)); - $this->assertSame(30.0, $option->getMaxLifetime()); - } - - public function testJitteredLifetimeDeadlineDefaultsToDisabled(): void - { - $this->assertSame(0.0, PoolOption::jitteredLifetimeDeadline(100.0, -1.0)); - } - - public function testJitteredLifetimeDeadlineKeepsConfiguredLifetimeAsUpperBound(): void - { - $createdAt = 100.0; - $maxLifetime = 60.0; - - $deadline = PoolOption::jitteredLifetimeDeadline($createdAt, $maxLifetime); - - $this->assertGreaterThanOrEqual( - $createdAt + ($maxLifetime * PoolOption::MIN_LIFETIME_JITTER_BASIS / PoolOption::LIFETIME_JITTER_SCALE), - $deadline - ); - $this->assertLessThanOrEqual($createdAt + $maxLifetime, $deadline); - } - - public function testValidConstructorAndSetterValuesAreAccepted(): void - { - $option = new PoolOption( - minConnections: 0, - maxConnections: 20, - connectTimeout: 1.5, - waitTimeout: 2.5, - heartbeat: -1.0, - heartbeatTimeout: 0.5, - maxIdleTime: 30.0, - maxLifetime: -1.0, - events: ['borrowed', 'released'], - ); - - $this->assertSame(0, $option->getMinConnections()); - $this->assertSame(20, $option->getMaxConnections()); - $this->assertSame(['borrowed', 'released'], $option->getEvents()); - $this->assertSame($option, $option->setHeartbeat(5.0)); - $this->assertSame($option, $option->setMaxLifetime(60.0)); - } - - #[DataProvider('invalidConstructorOptionProvider')] - public function testConstructorRejectsInvalidOptions(callable $construct, string $field): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("[{$field}]"); - - $construct(); - } - - public static function invalidConstructorOptionProvider(): array - { - return [ - 'negative minimum' => [static fn (): PoolOption => new PoolOption(minConnections: -1), 'min_connections'], - 'zero maximum' => [static fn (): PoolOption => new PoolOption(maxConnections: 0), 'max_connections'], - 'minimum exceeds maximum' => [ - static fn (): PoolOption => new PoolOption(minConnections: 2, maxConnections: 1), - 'min_connections', - ], - 'zero connect timeout' => [static fn (): PoolOption => new PoolOption(connectTimeout: 0.0), 'connect_timeout'], - 'nan connect timeout' => [static fn (): PoolOption => new PoolOption(connectTimeout: NAN), 'connect_timeout'], - 'zero wait timeout' => [static fn (): PoolOption => new PoolOption(waitTimeout: 0.0), 'wait_timeout'], - 'infinite wait timeout' => [static fn (): PoolOption => new PoolOption(waitTimeout: INF), 'wait_timeout'], - 'zero heartbeat' => [static fn (): PoolOption => new PoolOption(heartbeat: 0.0), 'heartbeat'], - 'arbitrary negative heartbeat' => [static fn (): PoolOption => new PoolOption(heartbeat: -2.0), 'heartbeat'], - 'nan heartbeat' => [static fn (): PoolOption => new PoolOption(heartbeat: NAN), 'heartbeat'], - 'zero heartbeat timeout' => [ - static fn (): PoolOption => new PoolOption(heartbeatTimeout: 0.0), - 'heartbeat_timeout', - ], - 'infinite heartbeat timeout' => [ - static fn (): PoolOption => new PoolOption(heartbeatTimeout: INF), - 'heartbeat_timeout', - ], - 'zero maximum idle time' => [static fn (): PoolOption => new PoolOption(maxIdleTime: 0.0), 'max_idle_time'], - 'nan maximum idle time' => [static fn (): PoolOption => new PoolOption(maxIdleTime: NAN), 'max_idle_time'], - 'zero maximum lifetime' => [static fn (): PoolOption => new PoolOption(maxLifetime: 0.0), 'max_lifetime'], - 'arbitrary negative maximum lifetime' => [ - static fn (): PoolOption => new PoolOption(maxLifetime: -2.0), - 'max_lifetime', - ], - 'infinite maximum lifetime' => [static fn (): PoolOption => new PoolOption(maxLifetime: INF), 'max_lifetime'], - 'associative events' => [static fn (): PoolOption => new PoolOption(events: ['event' => 'borrowed']), 'events'], - 'empty event' => [static fn (): PoolOption => new PoolOption(events: ['']), 'events'], - 'non-string event' => [static fn (): PoolOption => new PoolOption(events: [1]), 'events'], - ]; - } - - #[DataProvider('invalidSetterProvider')] - public function testSettersRejectInvalidOptions(callable $mutate, string $field): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("[{$field}]"); - - $mutate(new PoolOption); - } - - public static function invalidSetterProvider(): array - { - return [ - 'negative minimum' => [static fn (PoolOption $option) => $option->setMinConnections(-1), 'min_connections'], - 'minimum exceeds maximum' => [static fn (PoolOption $option) => $option->setMinConnections(11), 'min_connections'], - 'zero maximum' => [static fn (PoolOption $option) => $option->setMaxConnections(0), 'max_connections'], - 'maximum below minimum' => [ - static fn (PoolOption $option) => $option->setMinConnections(5)->setMaxConnections(4), - 'min_connections', - ], - 'connect timeout' => [static fn (PoolOption $option) => $option->setConnectTimeout(NAN), 'connect_timeout'], - 'wait timeout' => [static fn (PoolOption $option) => $option->setWaitTimeout(0.0), 'wait_timeout'], - 'heartbeat' => [static fn (PoolOption $option) => $option->setHeartbeat(0.0), 'heartbeat'], - 'heartbeat timeout' => [ - static fn (PoolOption $option) => $option->setHeartbeatTimeout(-1.0), - 'heartbeat_timeout', - ], - 'maximum idle time' => [static fn (PoolOption $option) => $option->setMaxIdleTime(INF), 'max_idle_time'], - 'maximum lifetime' => [static fn (PoolOption $option) => $option->setMaxLifetime(-2.0), 'max_lifetime'], - 'events' => [static fn (PoolOption $option) => $option->setEvents(['']), 'events'], - ]; - } - - public function testJitteredLifetimeDeadlineRejectsUndocumentedDisableValues(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('[max_lifetime]'); - - PoolOption::jitteredLifetimeDeadline(100.0, 0.0); - } -} diff --git a/tests/Queue/PooledJobWorkerTest.php b/tests/Queue/PooledJobWorkerTest.php index 0a1638c666..8404f7f733 100644 --- a/tests/Queue/PooledJobWorkerTest.php +++ b/tests/Queue/PooledJobWorkerTest.php @@ -8,9 +8,9 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\Factory; +use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Queue\Events\JobAttempted; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobFailed; @@ -32,7 +32,7 @@ class PooledJobWorkerTest extends TestCase { - /** @var list */ + /** @var list */ private array $pools = []; protected function tearDownInCoroutine(): void @@ -70,7 +70,7 @@ public function testProcessedListenerCanReadPrimedAttemptsAfterTerminalDelete(): 'queue', ); $pool = $this->pool(); - $job->withPoolLease(new Lease($pool, $pool->get())); + $job->withPoolLease(new Lease($pool, $pool->borrow())); $attemptsObservedByListener = null; $events = m::mock(Dispatcher::class); @@ -99,8 +99,8 @@ public function testProcessedListenerCanReadPrimedAttemptsAfterTerminalDelete(): $this->assertSame(3, $attemptsObservedByListener); $this->assertTrue($job->isDeleted()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testWorkerExceptionReleasesTheBackendBeforeReturningTheLease(): void @@ -122,7 +122,7 @@ public function testWorkerExceptionReleasesTheBackendBeforeReturningTheLease(): ], JSON_THROW_ON_ERROR)); $job = new BeanstalkdJob($container, $pheanstalk, $rawJob, 'connection', 'queue'); $pool = $this->pool(); - $job->withPoolLease(new Lease($pool, $pool->get())); + $job->withPoolLease(new Lease($pool, $pool->borrow())); $events = m::mock(Dispatcher::class); $events->shouldReceive('hasListeners')->once()->with(JobProcessing::class)->andReturnTrue(); $events->shouldReceive('hasListeners')->once()->with(JobExceptionOccurred::class)->andReturnTrue(); @@ -139,8 +139,8 @@ public function testWorkerExceptionReleasesTheBackendBeforeReturningTheLease(): } $this->assertTrue($job->isReleased()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testWorkerTerminalFailureDeletesBeforeReturningTheLease(): void @@ -162,7 +162,7 @@ public function testWorkerTerminalFailureDeletesBeforeReturningTheLease(): void ], JSON_THROW_ON_ERROR)); $job = new BeanstalkdJob($container, $pheanstalk, $rawJob, 'connection', 'queue'); $pool = $this->pool(); - $job->withPoolLease(new Lease($pool, $pool->get())); + $job->withPoolLease(new Lease($pool, $pool->borrow())); $events = m::mock(Dispatcher::class); $events->shouldReceive('hasListeners')->once()->with(JobProcessing::class)->andReturnTrue(); $events->shouldReceive('hasListeners')->once()->with(JobFailed::class)->andReturnTrue(); @@ -181,16 +181,16 @@ public function testWorkerTerminalFailureDeletesBeforeReturningTheLease(): void $this->assertTrue($job->hasFailed()); $this->assertTrue($job->isDeleted()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } /** * Create a tracked object pool. */ - private function pool(): SimpleObjectPool + private function pool(): CallbackObjectPool { - $pool = new SimpleObjectPool(fn () => new stdClass, PoolOptions::fromArray([])); + $pool = new CallbackObjectPool(fn () => new stdClass, PoolOptions::fromArray([])); $this->pools[] = $pool; return $pool; diff --git a/tests/Queue/QueueBeanstalkdJobTest.php b/tests/Queue/QueueBeanstalkdJobTest.php index 8af7dd48d6..ee9db8671e 100644 --- a/tests/Queue/QueueBeanstalkdJobTest.php +++ b/tests/Queue/QueueBeanstalkdJobTest.php @@ -9,9 +9,9 @@ use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Jobs\BeanstalkdJob; use Hypervel\Queue\TimeoutExceededException; @@ -28,7 +28,7 @@ class QueueBeanstalkdJobTest extends TestCase { - /** @var list */ + /** @var list */ private array $pools = []; protected function tearDownInCoroutine(): void @@ -117,13 +117,13 @@ public function testDeleteReleasesPoolLeaseAfterBackendCall(): void $job->getPheanstalk()->shouldReceive('statsJob')->once()->andReturn($this->stats(1)); $job->getPheanstalk()->shouldReceive('delete')->once()->with($job->getPheanstalkJob()) ->andReturnUsing(function () use ($pool): void { - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); }); $job->withPoolLease($lease)->delete(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testReleaseReleasesPoolLeaseAfterBackendCall(): void @@ -134,13 +134,13 @@ public function testReleaseReleasesPoolLeaseAfterBackendCall(): void $job->getPheanstalk()->shouldReceive('release')->once() ->with($job->getPheanstalkJob(), Pheanstalk::DEFAULT_PRIORITY, 5) ->andReturnUsing(function () use ($pool): void { - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); }); $job->withPoolLease($lease)->release(5); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testBuryReleasesPoolLeaseAfterBackendCall(): void @@ -150,13 +150,13 @@ public function testBuryReleasesPoolLeaseAfterBackendCall(): void $job->getPheanstalk()->shouldReceive('statsJob')->once()->andReturn($this->stats(1)); $job->getPheanstalk()->shouldReceive('bury')->once()->with($job->getPheanstalkJob()) ->andReturnUsing(function () use ($pool): void { - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); }); $job->withPoolLease($lease)->bury(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testBackendFailureDiscardsPoolLeaseAndPreservesTheException(): void @@ -178,8 +178,8 @@ public function testBackendFailureDiscardsPoolLeaseAndPreservesTheException(): v } $this->assertSame(1, $destroyed); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testAttemptsRemainAvailableButBackendAccessIsRejectedAfterFinalization(): void @@ -196,7 +196,7 @@ public function testAttemptsRemainAvailableButBackendAccessIsRejectedAfterFinali $job->delete(); $this->assertSame(2, $job->attempts()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('backend is no longer available'); @@ -207,18 +207,18 @@ public function testAttemptsRemainAvailableButBackendAccessIsRejectedAfterFinali /** * Create a checked-out object under a queue-job lease. * - * @return array{SimpleObjectPool, Lease} + * @return array{CallbackObjectPool, Lease} */ protected function lease(?Closure $destroyCallback = null): array { - $pool = new SimpleObjectPool( + $pool = new CallbackObjectPool( fn () => new stdClass, PoolOptions::fromArray([]), $destroyCallback, ); $this->pools[] = $pool; - return [$pool, new Lease($pool, $pool->get())]; + return [$pool, new Lease($pool, $pool->borrow())]; } /** diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index 08d0cfb2a7..b83cada60b 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -8,9 +8,9 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Encryption\Encrypter; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Contracts\Queue\ClearableQueue; use Hypervel\Contracts\Queue\Queue; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\PoolManager; use Hypervel\Queue\ClearableQueuePoolProxy; use Hypervel\Queue\Connectors\ConnectorInterface; @@ -132,7 +132,7 @@ public function testAddPoolableConnector(): void $manager->addConnector('bar', function () use ($connector) { return $connector; }); - $manager->addPoolable('bar'); + $manager->addPoolableDriver('bar'); $this->assertInstanceOf(QueuePoolProxy::class, $manager->connection('foo')); } @@ -147,7 +147,7 @@ public function testBuiltInClearablePooledDriversResolveClearableProxies( $manager = new QueueManager($container); if (! $poolableByDefault) { - $manager->addPoolable($driver); + $manager->addPoolableDriver($driver); } $connection = $manager->connection('foo'); @@ -190,7 +190,7 @@ public function testPoolableConnectionsConvergeByConstructionConfigAndApplyTheir $config->set('queue.connections.bar', $connectionConfig); $manager = new QueueManager($container); - $manager->addPoolable('custom'); + $manager->addPoolableDriver('custom'); $connector = m::mock(ConnectorInterface::class); $queue = m::mock(NullQueue::class)->makePartial(); $manager->addConnector('custom', fn () => $connector); @@ -229,7 +229,7 @@ public function testPurgeInvalidatesCachedAndUncachedQueuePools(): void ]); $manager = new QueueManager($container); - $manager->addPoolable('custom'); + $manager->addPoolableDriver('custom'); $connector = m::mock(ConnectorInterface::class); $queue = m::mock(NullQueue::class)->makePartial(); $manager->addConnector('custom', fn () => $connector); @@ -273,7 +273,7 @@ public function testConvergedConnectionsApplyTheirLogicalNameToPoppedJobs(): voi $container->make('config')->set('queue.connections.bar', $connectionConfig); $manager = new QueueManager($container); - $manager->addPoolable('custom'); + $manager->addPoolableDriver('custom'); $connector = m::mock(ConnectorInterface::class); $queue = m::mock(NullQueue::class)->makePartial(); $manager->addConnector('custom', fn () => $connector); @@ -318,7 +318,7 @@ public function testSetApplicationEvictsPooledConnectionsAndRebindsTheirFactory( ]); $manager = new QueueManager($oldContainer); - $manager->addPoolable('custom'); + $manager->addPoolableDriver('custom'); $connector = m::mock(ConnectorInterface::class); $queue = m::mock(NullQueue::class)->makePartial(); $manager->addConnector('custom', fn () => $connector); diff --git a/tests/Queue/QueuePoolProxyTest.php b/tests/Queue/QueuePoolProxyTest.php index 4328d4ba0a..092188e1e2 100644 --- a/tests/Queue/QueuePoolProxyTest.php +++ b/tests/Queue/QueuePoolProxyTest.php @@ -45,7 +45,7 @@ class QueuePoolProxyTest extends TestCase protected function tearDownInCoroutine(): void { foreach ($this->poolManagers as $poolManager) { - $poolManager->flush(); + $poolManager->purgeAll(); } } @@ -123,8 +123,8 @@ function (QueuePoolProxyTestQueue $released) use (&$cleanupObservedClearedDispat $pool = $pools->get($proxy->getPoolName()); $this->assertTrue($cleanupObservedClearedDispatcher); $this->assertFalse($queue->hasAfterCommitDispatcher()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testDirectConnectionAccessRejectsNonSqsPoolsBeforeBorrowing(): void @@ -154,13 +154,13 @@ public function testPopDoesNotForwardTheQueueIndexToAnOrdinaryConnectionAndPinsI $this->assertSame(['jobs'], $queue->lastPopArguments); $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(1, $pool->getBorrowedObjectNumber()); - $this->assertSame(0, $pool->getObjectNumberInPool()); + $this->assertSame(1, $pool->getBorrowedCount()); + $this->assertSame(0, $pool->getIdleCount()); $popped->delete(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testPopForwardsTheQueueIndexToAnAwareConnection(): void @@ -172,8 +172,8 @@ public function testPopForwardsTheQueueIndexToAnAwareConnection(): void $this->assertSame(['jobs', 2], $queue->lastIndexedPop); $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testClearUsesOneBorrowAndReleasesItImmediately(): void @@ -188,8 +188,8 @@ public function testClearUsesOneBorrowAndReleasesItImmediately(): void $this->assertSame('jobs', $queue->lastClearedQueue); $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testNullPopReleasesImmediately(): void @@ -199,8 +199,8 @@ public function testNullPopReleasesImmediately(): void $this->assertNull($proxy->pop()); $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testPopFailureStaysPrimaryWhenReleaseCallbackAlsoFails(): void @@ -225,8 +225,8 @@ function () use ($finalizationException): never { } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(0, $pool->getManagedCount()); } public function testReleaseCancellationSupersedesAPopFailure(): void @@ -251,8 +251,8 @@ static function () use ($releaseCancellation): never { } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(0, $pool->getManagedCount()); } public function testNonLeaseAwareJobIsRequeuedBeforeFailingClosed(): void @@ -269,8 +269,8 @@ public function testNonLeaseAwareJobIsRequeuedBeforeFailingClosed(): void } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testFailedRequeueDiscardsTheBackendAndAReplacementIsCreated(): void @@ -293,11 +293,11 @@ public function testFailedRequeueDiscardsTheBackendAndAReplacementIsCreated(): v } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); $this->assertSame(0, $proxy->size()); $this->assertSame(2, $created); - $this->assertSame(1, $pool->getCurrentObjectNumber()); + $this->assertSame(1, $pool->getManagedCount()); } public function testNonLeaseAwareJobRequeueCancellationIsNotWrapped(): void @@ -315,8 +315,8 @@ public function testNonLeaseAwareJobRequeueCancellationIsNotWrapped(): void } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(0, $pool->getManagedCount()); } public function testTerminalBackendFailureDiscardsTheQueueAndCreatesAReplacement(): void @@ -354,11 +354,11 @@ public function testTerminalBackendFailureDiscardsTheQueueAndCreatesAReplacement } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); $this->assertSame(0, $proxy->size()); $this->assertSame(2, $created); - $this->assertSame(1, $pool->getCurrentObjectNumber()); + $this->assertSame(1, $pool->getManagedCount()); } public function testAbandonedJobReleasesThroughItsLeaseDestructor(): void @@ -373,13 +373,13 @@ public function testAbandonedJobReleasesThroughItsLeaseDestructor(): void $popped = $proxy->pop(); $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); unset($popped); gc_collect_cycles(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } #[DataProvider('beanstalkAttachmentFailureDataProvider')] @@ -427,9 +427,9 @@ public function testBeanstalkAttemptPrimingFailureRecoversTheReservedJob( } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame($recoveryFails ? 0 : 1, $pool->getCurrentObjectNumber()); - $this->assertSame($recoveryFails ? 0 : 1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame($recoveryFails ? 0 : 1, $pool->getManagedCount()); + $this->assertSame($recoveryFails ? 0 : 1, $pool->getIdleCount()); } public static function beanstalkAttachmentFailureDataProvider(): array @@ -473,8 +473,8 @@ public function testAttachmentRecoveryCancellationSupersedesTheAttachmentFailure } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(0, $pool->getManagedCount()); } public function testAttachmentCancellationDoesNotStartBackendRecovery(): void @@ -504,8 +504,8 @@ public function testAttachmentCancellationDoesNotStartBackendRecovery(): void } $pool = $pools->get($proxy->getPoolName()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(0, $pool->getManagedCount()); } /** @@ -515,7 +515,7 @@ public function testAttachmentCancellationDoesNotStartBackendRecovery(): void * @return array{QueuePoolProxy, PoolManager} */ protected function proxy( - Closure $resolver, + Closure $createCallback, ?Closure $releaseCallback = null, ?ExceptionHandler $handler = null, string $resourceType = 'queue-test', @@ -538,7 +538,7 @@ protected function proxy( ); return [ - new $proxyClass($definition, $resolver, $pools, $releaseCallback), + new $proxyClass($definition, $createCallback, $pools, $releaseCallback), $pools, ]; } diff --git a/tests/Queue/QueueSqsJobTest.php b/tests/Queue/QueueSqsJobTest.php index 0278ee1d1c..4306e69981 100644 --- a/tests/Queue/QueueSqsJobTest.php +++ b/tests/Queue/QueueSqsJobTest.php @@ -12,9 +12,9 @@ use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Queue\Jobs\SqsJob; use Hypervel\Queue\SqsQueue; use Hypervel\Tests\TestCase; @@ -27,7 +27,7 @@ class QueueSqsJobTest extends TestCase { - /** @var list */ + /** @var list */ private array $pools = []; protected string $key; @@ -334,7 +334,7 @@ public function testDeleteRetainsOverflowPayloadWhenSqsDeletionFails(): void $this->assertSame($expected, $exception); } - $this->assertSame(0, $pool->getCurrentObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); } public function testDeleteCleansOverflowPayloadAfterLeaseReleaseFails(): void @@ -528,13 +528,13 @@ public function testDeleteReleasesPoolLeaseAfterBackendCall(): void $job = $this->getJob(); $job->getSqs()->shouldReceive('deleteMessage')->once() ->andReturnUsing(function () use ($pool): void { - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); }); $job->withPoolLease($lease)->delete(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testReleaseReleasesPoolLeaseAfterBackendCall(): void @@ -543,13 +543,13 @@ public function testReleaseReleasesPoolLeaseAfterBackendCall(): void $job = $this->getJob(); $job->getSqs()->shouldReceive('changeMessageVisibility')->once() ->andReturnUsing(function () use ($pool): void { - $this->assertSame(1, $pool->getBorrowedObjectNumber()); + $this->assertSame(1, $pool->getBorrowedCount()); }); $job->withPoolLease($lease)->release(5); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); - $this->assertSame(1, $pool->getObjectNumberInPool()); + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertSame(1, $pool->getIdleCount()); } public function testBackendFailureDiscardsPoolLeaseAndPreservesTheException(): void @@ -570,8 +570,8 @@ public function testBackendFailureDiscardsPoolLeaseAndPreservesTheException(): v } $this->assertSame(1, $destroyed); - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testDiscardCancellationSupersedesAnOrdinaryBackendFailure(): void @@ -591,8 +591,8 @@ public function testDiscardCancellationSupersedesAnOrdinaryBackendFailure(): voi $this->assertSame($discardCancellation, $exception); } - $this->assertSame(0, $pool->getCurrentObjectNumber()); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getBorrowedCount()); } public function testBackendAccessIsRejectedAfterLeaseFinalization(): void @@ -602,7 +602,7 @@ public function testBackendAccessIsRejectedAfterLeaseFinalization(): void $job->getSqs()->shouldReceive('deleteMessage')->once(); $job->withPoolLease($lease)->delete(); - $this->assertSame(0, $pool->getBorrowedObjectNumber()); + $this->assertSame(0, $pool->getBorrowedCount()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('client is no longer available'); @@ -613,18 +613,18 @@ public function testBackendAccessIsRejectedAfterLeaseFinalization(): void /** * Create a checked-out object under a queue-job lease. * - * @return array{SimpleObjectPool, Lease} + * @return array{CallbackObjectPool, Lease} */ protected function lease(?Closure $destroyCallback = null, ?Closure $releaseCallback = null): array { - $pool = new SimpleObjectPool( + $pool = new CallbackObjectPool( fn () => new stdClass, PoolOptions::fromArray([]), $destroyCallback, ); $this->pools[] = $pool; - return [$pool, new Lease($pool, $pool->get(), $releaseCallback)]; + return [$pool, new Lease($pool, $pool->borrow(), $releaseCallback)]; } protected function getJob(): SqsJob diff --git a/tests/Queue/RetryCommandTest.php b/tests/Queue/RetryCommandTest.php index c919a7fa63..c0d56f562e 100644 --- a/tests/Queue/RetryCommandTest.php +++ b/tests/Queue/RetryCommandTest.php @@ -34,7 +34,7 @@ class RetryCommandTest extends TestCase protected function tearDownInCoroutine(): void { foreach ($this->poolManagers as $poolManager) { - $poolManager->flush(); + $poolManager->purgeAll(); } } @@ -345,7 +345,7 @@ protected function runRetryCommand(): void /** * Create a queue proxy with an isolated pool registry. */ - protected function pooledQueue(string $resourceType, Closure $resolver): QueuePoolProxy + protected function pooledQueue(string $resourceType, Closure $createCallback): QueuePoolProxy { $this->poolManagers[] = $poolManager = new PoolManager; @@ -356,7 +356,7 @@ protected function pooledQueue(string $resourceType, Closure $resolver): QueuePo "auto:retry-command-{$resourceType}", PoolOptions::fromArray(['max_objects' => 1]), ), - $resolver, + $createCallback, $poolManager, ); } diff --git a/tests/Redis/Fixtures/CancelTlsConnection.php b/tests/Redis/Fixtures/CancelTlsConnection.php index 73aa86fda3..0212652757 100644 --- a/tests/Redis/Fixtures/CancelTlsConnection.php +++ b/tests/Redis/Fixtures/CancelTlsConnection.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Hypervel\Container\Container; -use Hypervel\Contracts\Pool\PoolInterface; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Tests\Redis\Fixtures\RespServer; use Mockery as m; @@ -51,7 +51,7 @@ try { new PhpRedisConnection( new Container, - m::mock(PoolInterface::class), + m::mock(ConnectionPool::class), [ 'url' => null, 'scheme' => 'tls', @@ -76,14 +76,14 @@ 'backoff_cap' => 1000, 'sentinel' => ['enabled' => false], 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, 'connect_timeout' => 30.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1.0, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ], ], ); diff --git a/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php b/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php index fb85c281ae..c60a5137c1 100644 --- a/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php +++ b/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Redis\Fixtures; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Redis\PhpRedisClusterConnection; use Mockery as m; use Redis; @@ -21,7 +21,7 @@ class PhpRedisClusterConnectionStub extends PhpRedisClusterConnection * Can be called with no arguments (for simple tests that inject via setActiveConnection()), * or with container/pool/config for tests that need full behavior. */ - public function __construct(?Container $container = null, ?PoolInterface $pool = null, array $config = []) + public function __construct(?Container $container = null, ?ConnectionPool $pool = null, array $config = []) { if ($container !== null && $pool !== null) { // Call the grandparent to store config without reconnecting. diff --git a/tests/Redis/Fixtures/PhpRedisConnectionStub.php b/tests/Redis/Fixtures/PhpRedisConnectionStub.php index 0cc1ae505a..a66abe1cbe 100644 --- a/tests/Redis/Fixtures/PhpRedisConnectionStub.php +++ b/tests/Redis/Fixtures/PhpRedisConnectionStub.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Redis\Fixtures; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Redis\PhpRedisConnection; use Mockery as m; use Redis; @@ -21,7 +21,7 @@ class PhpRedisConnectionStub extends PhpRedisConnection * Can be called with no arguments (for simple tests that inject via setActiveConnection()), * or with container/pool/config for tests that need full behavior. */ - public function __construct(?Container $container = null, ?PoolInterface $pool = null, array $config = []) + public function __construct(?Container $container = null, ?ConnectionPool $pool = null, array $config = []) { if ($container !== null && $pool !== null) { // Call the grandparent to store config without reconnecting. diff --git a/tests/Redis/MultiExecTest.php b/tests/Redis/MultiExecTest.php index c56ba95171..043d4b90e3 100644 --- a/tests/Redis/MultiExecTest.php +++ b/tests/Redis/MultiExecTest.php @@ -6,7 +6,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Redis\PhpRedisConnection; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; @@ -310,13 +310,13 @@ private function createMockConnection(m\MockInterface $phpRedis): m\MockInterfac private function createRedis(m\MockInterface|RedisConnection $connection): RedisProxy { $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->andReturn($connection); + $pool->shouldReceive('borrow')->andReturn($connection); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); return new RedisProxy( - $poolFactory, + $poolManager, 'default', m::mock(RedisSentinelFactory::class), ); diff --git a/tests/Redis/PackageMetadataTest.php b/tests/Redis/PackageMetadataTest.php index ee5ea7f72a..a8de68806d 100644 --- a/tests/Redis/PackageMetadataTest.php +++ b/tests/Redis/PackageMetadataTest.php @@ -41,7 +41,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'hypervel/coroutine', 'hypervel/engine', 'hypervel/macroable', - 'hypervel/pool', + 'hypervel/connection-pool', 'hypervel/support', ] as $dependency) { $this->assertArrayHasKey($dependency, $composer['require']); diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php index 3ddcf0f17e..e4930f56d0 100644 --- a/tests/Redis/PhpRedisClusterConnectionTest.php +++ b/tests/Redis/PhpRedisClusterConnectionTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Redis; +use Hypervel\ConnectionPool\Exceptions\ConnectionException; +use Hypervel\ConnectionPool\PoolOptions; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Contracts\Container\Container as ContainerContract; -use Hypervel\Contracts\Pool\PoolInterface; -use Hypervel\Pool\Exceptions\ConnectionException; -use Hypervel\Pool\PoolOption; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Tests\Redis\Fixtures\FakeRedisClusterClient; @@ -34,7 +34,7 @@ public function testClusterCreationPreservesExactCancellation(): void new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(), $cancellation) extends PhpRedisClusterConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private CanceledException $cancellation, ) { @@ -61,7 +61,7 @@ public function testClusterCreationPreservesTheUnderlyingFailure(): void new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(), $failure) extends PhpRedisClusterConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private RuntimeException $failure, ) { @@ -679,7 +679,7 @@ public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplaying */ public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array $clients, ) { @@ -712,8 +712,8 @@ protected function createRedisCluster(): RedisCluster public function testReconnectClearsCachedDefaultNode(): void { - $pool = m::mock(PoolInterface::class); - $pool->shouldReceive('getOption')->andReturn(new PoolOption); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); $container = m::mock(ContainerContract::class); $container->shouldReceive('has')->andReturn(false); @@ -735,7 +735,7 @@ public function testReconnectClearsCachedDefaultNode(): void $connection = new class($container, $pool, $this->clusterConfig(['cluster' => ['enabled' => true, 'seeds' => ['tcp://10.0.0.1:6379']]]), $clientA, $clientB, $callCount) extends PhpRedisClusterConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private RedisCluster $clientA, private RedisCluster $clientB, @@ -789,14 +789,14 @@ private function clusterConfig(array $overrides = []): array 'backoff_base' => 100, 'backoff_cap' => 1000, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 10, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1.0, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ], 'cluster' => [ 'enabled' => true, @@ -821,10 +821,10 @@ private function expectDefaultConnectionOptions(RedisCluster $redis): void /** * Get a mocked Redis pool. */ - private function getMockedPool(): PoolInterface + private function getMockedPool(): ConnectionPool { - $pool = m::mock(PoolInterface::class); - $pool->shouldReceive('getOption')->andReturn(new PoolOption); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); return $pool; } diff --git a/tests/Redis/PoolFactoryTest.php b/tests/Redis/PoolFactoryTest.php deleted file mode 100644 index b6acd4044d..0000000000 --- a/tests/Redis/PoolFactoryTest.php +++ /dev/null @@ -1,278 +0,0 @@ -mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $pool1 = $factory->getPool('default'); - $pool2 = $factory->getPool('default'); - - $this->assertSame($pool1, $pool2); - } - - public function testGetPoolReturnsDifferentInstancesForDifferentNames(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $pool1 = $factory->getPool('default'); - $pool2 = $factory->getPool('cache'); - - $this->assertNotSame($pool1, $pool2); - } - - public function testPoolsReturnsOnlyExistingPools(): void - { - $factory = new PoolFactory($this->mockContainerWithPools()); - - $this->assertSame([], $factory->pools()); - - $default = $factory->getPool('default'); - $cache = $factory->getPool('cache'); - - $this->assertSame([ - 'default' => $default, - 'cache' => $cache, - ], $factory->pools()); - } - - public function testFlushAll(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $pool1 = $factory->getPool('default'); - $pool2 = $factory->getPool('cache'); - - $connection1 = $pool1->get(); - $connection2 = $pool1->get(); - $connection3 = $pool2->get(); - - $pool1->release($connection1); - $pool1->release($connection2); - $pool2->release($connection3); - - $this->assertSame(2, $pool1->getConnectionsInChannel()); - $this->assertSame(1, $pool2->getConnectionsInChannel()); - - $factory->flushAll(); - - $this->assertSame(0, $pool1->getConnectionsInChannel()); - $this->assertSame(0, $pool2->getConnectionsInChannel()); - } - - public function testFlushAllClearsCachedPools(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $original = $factory->getPool('default'); - - $factory->flushAll(); - - // After flushAll, the cached pool entry should be evicted so the next - // getPool() returns a fresh instance - this lets the previous Pool's - // Channel/Connection graph be refcount-collected instead of trapped. - $fresh = $factory->getPool('default'); - - $this->assertNotSame($original, $fresh); - } - - public function testFlushAllDetachesPoolsBeforeClosingThem(): void - { - $container = m::mock(ContainerContract::class); - $original = m::mock(RedisPool::class); - $replacement = m::mock(RedisPool::class); - $container->shouldReceive('make') - ->with(RedisPool::class, ['name' => 'default']) - ->twice() - ->andReturn($original, $replacement); - $factory = new PoolFactory($container); - $resolvedDuringClose = null; - $original->shouldReceive('close')->once()->andReturnUsing( - function () use ($factory, &$resolvedDuringClose): void { - $resolvedDuringClose = $factory->getPool('default'); - } - ); - - $this->assertSame($original, $factory->getPool('default')); - - $factory->flushAll(); - - $this->assertSame($replacement, $resolvedDuringClose); - $this->assertSame($replacement, $factory->getPool('default')); - } - - public function testFlushAllClosesEveryPoolAndPreservesTheFirstCancellation(): void - { - $ordinaryFailure = new RuntimeException('First close failed.'); - $cancellation = new CanceledException('Second close canceled.'); - $laterCancellation = new CanceledException('Third close canceled.'); - $container = m::mock(ContainerContract::class); - $first = m::mock(RedisPool::class); - $first->expects('close')->andThrow($ordinaryFailure); - $second = m::mock(RedisPool::class); - $second->expects('close')->andThrow($cancellation); - $third = m::mock(RedisPool::class); - $third->expects('close')->andThrow($laterCancellation); - $container->expects('make') - ->with(RedisPool::class, m::type('array')) - ->times(3) - ->andReturn($first, $second, $third); - $factory = new PoolFactory($container); - $factory->getPool('first'); - $factory->getPool('second'); - $factory->getPool('third'); - - try { - $factory->flushAll(); - $this->fail('Expected the first cancellation to propagate.'); - } catch (Throwable $throwable) { - $this->assertSame($cancellation, $throwable); - } - } - - public function testFlushPoolOnlyFlushesNamedPool(): void - { - $container = $this->mockContainerWithPools(); - - $factory = new PoolFactory($container); - - $defaultPool = $factory->getPool('default'); - $cachePool = $factory->getPool('cache'); - - // Add connections to both pools - $defaultConn1 = $defaultPool->get(); - $defaultConn2 = $defaultPool->get(); - $cacheConn = $cachePool->get(); - - $defaultPool->release($defaultConn1); - $defaultPool->release($defaultConn2); - $cachePool->release($cacheConn); - - $this->assertSame(2, $defaultPool->getConnectionsInChannel()); - $this->assertSame(1, $cachePool->getConnectionsInChannel()); - - // Flush only default - $factory->flushPool('default'); - - // Default pool should be flushed - $this->assertSame(0, $defaultPool->getConnectionsInChannel()); - - // Cache pool should be untouched - $this->assertSame(1, $cachePool->getConnectionsInChannel()); - $this->assertSame($cachePool, $factory->getPool('cache')); - - // Getting default pool again should return a fresh instance - $freshDefaultPool = $factory->getPool('default'); - $this->assertNotSame($defaultPool, $freshDefaultPool); - } - - public function testFlushPoolDetachesPoolBeforeClosingIt(): void - { - $container = m::mock(ContainerContract::class); - $original = m::mock(RedisPool::class); - $replacement = m::mock(RedisPool::class); - $container->shouldReceive('make') - ->with(RedisPool::class, ['name' => 'default']) - ->twice() - ->andReturn($original, $replacement); - $factory = new PoolFactory($container); - $resolvedDuringClose = null; - $original->shouldReceive('close')->once()->andReturnUsing( - function () use ($factory, &$resolvedDuringClose): void { - $resolvedDuringClose = $factory->getPool('default'); - } - ); - - $this->assertSame($original, $factory->getPool('default')); - - $factory->flushPool('default'); - - $this->assertSame($replacement, $resolvedDuringClose); - $this->assertSame($replacement, $factory->getPool('default')); - } - - /** - * Mock a container with Redis pools. - */ - private function mockContainerWithPools(): m\MockInterface|ContainerContract - { - $connectionConfig = [ - 'host' => 'localhost', - 'port' => 6379, - 'database' => 0, - 'timeout' => null, - 'pool' => [ - 'min_connections' => 1, - 'max_connections' => 10, - 'connect_timeout' => 10.0, - 'wait_timeout' => 3.0, - 'heartbeat' => -1, - 'max_idle_time' => 60.0, - ], - ]; - - $redisConfig = m::mock(RedisConfig::class); - $redisConfig->shouldReceive('connectionConfig')->andReturn($connectionConfig); - - $container = m::mock(ContainerContract::class); - $container->shouldReceive('make')->with(RedisConfig::class)->andReturn($redisConfig); - $container->shouldReceive('has')->andReturn(false); - $container->shouldReceive('bound')->with('events')->andReturn(false); - $container->shouldReceive('make')->with(RedisPool::class, m::any())->andReturnUsing( - fn ($class, $args) => new PoolFactoryTestPool($container, $args['name']) - ); - - return $container; - } -} - -class PoolFactoryTestPool extends RedisPool -{ - protected function createConnection(): ConnectionInterface - { - return new PoolFactoryTestConnection($this->container, $this); - } -} - -class PoolFactoryTestConnection extends Connection -{ - public function close(): bool - { - return true; - } - - public function reconnect(): bool - { - return true; - } - - public function getActiveConnection(): static - { - return $this; - } -} diff --git a/tests/Redis/PoolManagerTest.php b/tests/Redis/PoolManagerTest.php new file mode 100644 index 0000000000..9ed0cfc673 --- /dev/null +++ b/tests/Redis/PoolManagerTest.php @@ -0,0 +1,630 @@ +mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $pool1 = $poolManager->pool('default'); + $pool2 = $poolManager->pool('default'); + + $this->assertSame($pool1, $pool2); + } + + public function testPoolReturnsDifferentInstancesForDifferentNames(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $pool1 = $poolManager->pool('default'); + $pool2 = $poolManager->pool('cache'); + + $this->assertNotSame($pool1, $pool2); + } + + public function testDirectlyClosedPoolIsReplaced(): void + { + $manager = new PoolManager($this->mockContainerWithPools()); + + try { + $original = $manager->pool('default'); + $original->close(); + $replacement = $manager->pool('default'); + + $this->assertNotSame($original, $replacement); + $this->assertFalse($replacement->isClosed()); + $this->assertSame($replacement, $manager->pool('default')); + $this->assertSame(['default' => $replacement], $manager->getPools()); + } finally { + $manager->purgeAll(); + } + } + + #[DataProvider('publicationCleanup')] + public function testConcurrentResolutionCleansUpTheLoserAndRechecksTheWinner(string $cleanup): void + { + $candidates = []; + $container = $this->publicationContainer($candidates); + $manager = new PoolManager($container); + $resolving = new Channel(1); + $resumeResolution = new Channel(1); + $closing = new Channel(1); + $resumeClose = new Channel(1); + $completed = new Channel(1); + $first = true; + $failure = match ($cleanup) { + 'error' => new RuntimeException('loser cleanup failed'), + 'cancellation' => new CanceledException('loser cleanup canceled'), + default => null, + }; + $container->afterResolving(RedisPool::class, function () use (&$first, $resolving, $resumeResolution): void { + if ($first) { + $first = false; + $resolving->push(true); + $this->assertTrue($resumeResolution->pop(1)); + } + }); + $timerCount = Timer::stats()['num']; + $child = Coroutine::create(static function () use ($manager, $completed): void { + try { + $completed->push([$manager->pool('default'), null]); + } catch (Throwable $exception) { + $completed->push([null, $exception]); + } + }); + + try { + $this->assertTrue($resolving->pop(1)); + $winner = $manager->pool('default'); + $loser = $candidates[0]; + $this->assertCount(2, $candidates); + $this->assertSame($timerCount + 1, Timer::stats()['num']); + $loser->closing = function () use ($cleanup, $failure, $closing, $resumeClose): void { + if ($failure !== null) { + throw $failure; + } + + if ($cleanup !== 'normal') { + $closing->push(true); + $this->assertTrue($resumeClose->pop(1)); + } + }; + $resumeResolution->push(true); + + if (in_array($cleanup, ['close winner', 'replace winner'], true)) { + $this->assertTrue($closing->pop(1)); + + if ($cleanup === 'close winner') { + $winner->close(); + } else { + $manager->purge('default'); + $winner = $manager->pool('default'); + } + + $resumeClose->push(true); + } + + $result = $completed->pop(1); + $this->assertIsArray($result); + $this->assertSame($failure, $result[1]); + $this->assertSame(1, $loser->closeCount); + + if ($failure === null) { + $this->assertSame($manager->getPools()['default'], $result[0]); + $this->assertFalse($result[0]->isClosed()); + $this->assertTrue($loser->isClosed()); + + if ($cleanup === 'close winner') { + $this->assertNotSame($winner, $result[0]); + } else { + $this->assertSame($winner, $result[0]); + } + } else { + $this->assertSame($winner, $manager->pool('default')); + $this->assertFalse($winner->isClosed()); + } + + $loser->closing = null; + if (! $loser->isClosed()) { + $loser->close(); + } + $manager->purgeAll(); + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + $resumeResolution->close(); + $resumeClose->close(); + Coroutine::join([$child], 1); + foreach ($candidates as $candidate) { + $candidate->closing = null; + $candidate->close(); + } + $manager->purgeAll(); + $resolving->close(); + $closing->close(); + $completed->close(); + } + } + + public static function publicationCleanup(): array + { + return array_map(static fn (string $cleanup): array => [$cleanup], [ + 'normal', 'close winner', 'replace winner', 'error', 'cancellation', + ]); + } + + #[DataProvider('publicationEntries')] + public function testPublicationHandlesAnEntryCreatedDuringResolution(bool $sameInstance): void + { + $candidates = []; + $container = $this->publicationContainer($candidates); + $manager = new PoolManager($container); + $first = true; + $published = null; + $container->extend(RedisPool::class, function (RedisPool $candidate) use ($manager, &$first, &$published, $sameInstance): RedisPool { + if (! $first) { + return $candidate; + } + + $first = false; + $published = $manager->pool('default'); + + if ($sameInstance) { + $candidate->close(); + + return $published; + } + + $published->close(); + + return $candidate; + }); + + try { + $result = $manager->pool('default'); + $this->assertSame($sameInstance ? $published : $candidates[0], $result); + $this->assertFalse($result->isClosed()); + $this->assertSame(0, $result->closeCount); + $this->assertSame($result, $manager->pool('default')); + } finally { + foreach ($candidates as $candidate) { + $candidate->close(); + } + $manager->purgeAll(); + } + } + + public static function publicationEntries(): array + { + return ['closed entry' => [false], 'identical candidate' => [true]]; + } + + #[DataProvider('initializationFailures')] + public function testFailedInitializationDoesNotRetainTheCandidate(bool $warm): void + { + $candidates = []; + $container = $this->publicationContainer($candidates, ['idle_check_interval' => 60.0]); + $manager = new PoolManager($container); + $failure = new RuntimeException('initialization failed'); + $weak = null; + $caught = null; + $timerCount = Timer::stats()['num']; + $container->afterResolving(RedisPool::class, static function (RedisPool $pool) use (&$weak, $failure, $warm): void { + $weak = WeakReference::create($pool); + + if ($warm) { + $pool->release($pool->borrow()); + } + + throw $failure; + }); + + try { + try { + $manager->pool('default'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertSame([], $manager->getPools()); + $candidates = []; + gc_collect_cycles(); + $this->assertSame($timerCount, Timer::stats()['num']); + $this->assertNotNull($weak); + $this->assertNull($weak->get()); + } finally { + $weak?->get()?->close(); + foreach ($candidates as $candidate) { + $candidate->close(); + } + $manager->purgeAll(); + } + } + + public static function initializationFailures(): array + { + return ['cold candidate' => [false], 'warmed candidate' => [true]]; + } + + #[DataProvider('activationFailures')] + public function testActivationFailureClosesTheCandidateAndPreservesFailurePrecedence(string $activationClass, ?string $cleanupClass): void + { + $candidates = []; + $container = $this->publicationContainer($candidates, ['idle_check_interval' => 60.0]); + $manager = new PoolManager($container); + $failure = new $activationClass('activation failed'); + $cleanupFailure = $cleanupClass === null ? null : new $cleanupClass('cleanup failed'); + $timerCount = Timer::stats()['num']; + $container->afterResolving(RedisPool::class, static function (PublicationRedisPool $pool) use ($failure, $cleanupFailure): void { + $pool->release($pool->borrow()); + $pool->starting = static fn () => throw $failure; + $pool->closing = static function () use ($cleanupFailure): void { + if ($cleanupFailure !== null) { + throw $cleanupFailure; + } + }; + }); + $caught = null; + + try { + try { + $manager->pool('default'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $expected = ! $failure instanceof CanceledException && $cleanupFailure instanceof CanceledException + ? $cleanupFailure : $failure; + $this->assertSame($expected, $caught); + $this->assertCount(1, $candidates); + $this->assertTrue($candidates[0]->isClosed()); + $this->assertSame(1, $candidates[0]->closeCount); + $this->assertSame([], $manager->getPools()); + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + foreach ($candidates as $candidate) { + $candidate->closing = null; + $candidate->close(); + } + $manager->purgeAll(); + } + } + + public static function activationFailures(): array + { + return [ + [RuntimeException::class, null], + [RuntimeException::class, RuntimeException::class], + [RuntimeException::class, CanceledException::class], + [CanceledException::class, null], + [CanceledException::class, RuntimeException::class], + [CanceledException::class, CanceledException::class], + ]; + } + + public function testGetPoolsReturnsOnlyExistingPools(): void + { + $poolManager = new PoolManager($this->mockContainerWithPools()); + + $this->assertSame([], $poolManager->getPools()); + + $default = $poolManager->pool('default'); + $cache = $poolManager->pool('cache'); + + $this->assertSame([ + 'default' => $default, + 'cache' => $cache, + ], $poolManager->getPools()); + } + + public function testPurgeAll(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $pool1 = $poolManager->pool('default'); + $pool2 = $poolManager->pool('cache'); + + $connection1 = $pool1->borrow(); + $connection2 = $pool1->borrow(); + $connection3 = $pool2->borrow(); + + $pool1->release($connection1); + $pool1->release($connection2); + $pool2->release($connection3); + + $this->assertSame(2, $pool1->getIdleCount()); + $this->assertSame(1, $pool2->getIdleCount()); + + $poolManager->purgeAll(); + + $this->assertSame(0, $pool1->getIdleCount()); + $this->assertSame(0, $pool2->getIdleCount()); + } + + public function testPurgeAllClearsCachedPools(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $original = $poolManager->pool('default'); + + $poolManager->purgeAll(); + + $fresh = $poolManager->pool('default'); + + $this->assertNotSame($original, $fresh); + } + + public function testPurgeAllDetachesPoolsBeforeClosingThem(): void + { + $container = m::mock(ContainerContract::class); + $original = m::mock(RedisPool::class); + $replacement = m::mock(RedisPool::class); + $original->shouldReceive('start')->once(); + $replacement->shouldReceive('start')->once(); + $replacement->shouldReceive('isClosed')->andReturnFalse(); + $container->shouldReceive('make') + ->with(RedisPool::class, ['name' => 'default']) + ->twice() + ->andReturn($original, $replacement); + $poolManager = new PoolManager($container); + $resolvedDuringClose = null; + $original->shouldReceive('close')->once()->andReturnUsing( + function () use ($poolManager, &$resolvedDuringClose): void { + $resolvedDuringClose = $poolManager->pool('default'); + } + ); + + $this->assertSame($original, $poolManager->pool('default')); + + $poolManager->purgeAll(); + + $this->assertSame($replacement, $resolvedDuringClose); + $this->assertSame($replacement, $poolManager->pool('default')); + } + + public function testPurgeAllClosesEveryPoolAndPreservesTheFirstCancellation(): void + { + $ordinaryFailure = new RuntimeException('First close failed.'); + $cancellation = new CanceledException('Second close canceled.'); + $laterCancellation = new CanceledException('Third close canceled.'); + $container = m::mock(ContainerContract::class); + $first = m::mock(RedisPool::class); + $first->shouldReceive('start')->once(); + $first->expects('close')->andThrow($ordinaryFailure); + $second = m::mock(RedisPool::class); + $second->shouldReceive('start')->once(); + $second->expects('close')->andThrow($cancellation); + $third = m::mock(RedisPool::class); + $third->shouldReceive('start')->once(); + $third->expects('close')->andThrow($laterCancellation); + $container->expects('make') + ->with(RedisPool::class, m::type('array')) + ->times(3) + ->andReturn($first, $second, $third); + $poolManager = new PoolManager($container); + $poolManager->pool('first'); + $poolManager->pool('second'); + $poolManager->pool('third'); + + try { + $poolManager->purgeAll(); + $this->fail('Expected the first cancellation to propagate.'); + } catch (Throwable $throwable) { + $this->assertSame($cancellation, $throwable); + } + } + + public function testPurgeOnlyRemovesNamedPool(): void + { + $container = $this->mockContainerWithPools(); + + $poolManager = new PoolManager($container); + + $defaultPool = $poolManager->pool('default'); + $cachePool = $poolManager->pool('cache'); + + $defaultConnection1 = $defaultPool->borrow(); + $defaultConnection2 = $defaultPool->borrow(); + $cacheConnection = $cachePool->borrow(); + + $defaultPool->release($defaultConnection1); + $defaultPool->release($defaultConnection2); + $cachePool->release($cacheConnection); + + $this->assertSame(2, $defaultPool->getIdleCount()); + $this->assertSame(1, $cachePool->getIdleCount()); + + $poolManager->purge('default'); + + $this->assertSame(0, $defaultPool->getIdleCount()); + + $this->assertSame(1, $cachePool->getIdleCount()); + $this->assertSame($cachePool, $poolManager->pool('cache')); + + $freshDefaultPool = $poolManager->pool('default'); + $this->assertNotSame($defaultPool, $freshDefaultPool); + } + + public function testPurgeDetachesPoolBeforeClosingIt(): void + { + $container = m::mock(ContainerContract::class); + $original = m::mock(RedisPool::class); + $replacement = m::mock(RedisPool::class); + $original->shouldReceive('start')->once(); + $replacement->shouldReceive('start')->once(); + $replacement->shouldReceive('isClosed')->andReturnFalse(); + $container->shouldReceive('make') + ->with(RedisPool::class, ['name' => 'default']) + ->twice() + ->andReturn($original, $replacement); + $poolManager = new PoolManager($container); + $resolvedDuringClose = null; + $original->shouldReceive('close')->once()->andReturnUsing( + function () use ($poolManager, &$resolvedDuringClose): void { + $resolvedDuringClose = $poolManager->pool('default'); + } + ); + + $this->assertSame($original, $poolManager->pool('default')); + + $poolManager->purge('default'); + + $this->assertSame($replacement, $resolvedDuringClose); + $this->assertSame($replacement, $poolManager->pool('default')); + } + + /** + * Build a container that records independently constructed pools. + */ + private function publicationContainer(array &$candidates, array $poolOptions = []): Container + { + $container = new Container; + $container->instance(ContainerContract::class, $container); + $container->instance(RedisConfig::class, new RedisConfig(new Repository(['database' => ['redis' => [ + 'options' => [], + 'default' => ['host' => '127.0.0.1', 'port' => 6379, 'pool' => ['heartbeat_interval' => 60.0, ...$poolOptions]], + ]]]))); + $container->bind(RedisPool::class, static function (Container $container, array $parameters) use (&$candidates): RedisPool { + return $candidates[] = new PublicationRedisPool($container, $parameters['name']); + }); + + return $container; + } + + /** + * Mock a container with Redis pools. + */ + private function mockContainerWithPools(): m\MockInterface|ContainerContract + { + $connectionConfig = [ + 'host' => 'localhost', + 'port' => 6379, + 'database' => 0, + 'timeout' => null, + 'pool' => [ + 'min_retained_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat_interval' => null, + 'max_idle_time' => 60.0, + ], + ]; + + $redisConfig = m::mock(RedisConfig::class); + $redisConfig->shouldReceive('connectionConfig')->andReturn($connectionConfig); + + $container = m::mock(ContainerContract::class); + $container->shouldReceive('make')->with(RedisConfig::class)->andReturn($redisConfig); + $container->shouldReceive('has')->andReturn(false); + $container->shouldReceive('bound')->with('events')->andReturn(false); + $container->shouldReceive('make')->with(RedisPool::class, m::any())->andReturnUsing( + fn ($class, $arguments) => new PoolManagerTestPool($container, $arguments['name']) + ); + + return $container; + } +} + +class PublicationRedisPool extends RedisPool +{ + public int $closeCount = 0; + + public ?Closure $starting = null; + + public ?Closure $closing = null; + + /** + * Start maintenance before running the controlled activation callback. + */ + public function start(): void + { + parent::start(); + + if ($this->starting !== null) { + ($this->starting)(); + } + } + + /** + * Close the pool after running the controlled cleanup callback. + */ + public function close(): void + { + ++$this->closeCount; + + try { + if ($this->closing !== null) { + ($this->closing)(); + } + } finally { + parent::close(); + } + } + + /** + * Create an inert connection for initialization and lifecycle tests. + */ + protected function createConnection(): PoolConnection + { + return new PoolManagerTestConnection($this->container, $this); + } +} + +class PoolManagerTestPool extends RedisPool +{ + protected function createConnection(): PoolConnection + { + return new PoolManagerTestConnection($this->container, $this); + } +} + +class PoolManagerTestConnection extends Connection +{ + public function close(): bool + { + return true; + } + + public function reconnect(): bool + { + return true; + } + + public function getActiveConnection(): static + { + return $this; + } +} diff --git a/tests/Redis/RedisCancellationLifecycleTest.php b/tests/Redis/RedisCancellationLifecycleTest.php index 02c5dffd08..b29126df87 100644 --- a/tests/Redis/RedisCancellationLifecycleTest.php +++ b/tests/Redis/RedisCancellationLifecycleTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Redis; +use Hypervel\ConnectionPool\Exceptions\ConnectionException; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; -use Hypervel\Pool\Exceptions\ConnectionException; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisConnection; use Hypervel\Support\Facades\Redis as RedisFacade; use Hypervel\Testbench\TestCase; @@ -46,10 +46,10 @@ public function testBlockedCommandCancellationInvalidatesAndReturnsTheLeaseWitho ++$failed; }); $connection = RedisFacade::connection($connectionName); - $pool = $this->app->make(PoolFactory::class)->getPool($connectionName); + $pool = $this->app->make(PoolManager::class)->pool($connectionName); // The one-connection pool establishes the only socket accepted by the test server, // then the canceled command reuses it. - $eventConnection = $pool->get(); + $eventConnection = $pool->borrow(); try { $this->assertInstanceOf(RedisConnection::class, $eventConnection); @@ -71,9 +71,9 @@ public function testBlockedCommandCancellationInvalidatesAndReturnsTheLeaseWitho $this->assertSame(0, $failed); // The pool already owns this lease, so cancellation returns it invalidated. - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(1, $pool->getConnectionsInChannel()); - $pooledConnection = $pool->get(); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); + $pooledConnection = $pool->borrow(); try { $this->assertInstanceOf(RedisConnection::class, $pooledConnection); @@ -87,7 +87,7 @@ public function testBlockedCommandCancellationInvalidatesAndReturnsTheLeaseWitho try { $server->wait(); } finally { - $this->app->make(PoolFactory::class)->flushPool($connectionName); + $this->app->make(PoolManager::class)->purge($connectionName); } } } @@ -110,7 +110,7 @@ public function testBlockedSelectCancellationRepairsPoolCapacity(): void $connectionName = 'canceled_select'; $this->configureConnection($connectionName, $host, $port, ['database' => 1]); $connection = RedisFacade::connection($connectionName); - $pool = $this->app->make(PoolFactory::class)->getPool($connectionName); + $pool = $this->app->make(PoolManager::class)->pool($connectionName); try { try { @@ -124,8 +124,8 @@ public function testBlockedSelectCancellationRepairsPoolCapacity(): void $this->assertInstanceOf(RedisException::class, $exception->getPrevious()); // Initial SELECT was canceled before admission, so the pool discards the lease. - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); } finally { $releaseServer->push(true); $server->wait(); @@ -134,7 +134,7 @@ public function testBlockedSelectCancellationRepairsPoolCapacity(): void $capacityFailure = null; try { - $pool->get(); + $pool->borrow(); } catch (Throwable $throwable) { $capacityFailure = $throwable; } @@ -147,7 +147,7 @@ public function testBlockedSelectCancellationRepairsPoolCapacity(): void ), ); } finally { - $this->app->make(PoolFactory::class)->flushPool($connectionName); + $this->app->make(PoolManager::class)->purge($connectionName); } } @@ -173,14 +173,14 @@ private function configureConnection(string $name, string $host, int $port, arra 'max_retries' => 0, 'options' => ['prefix' => ''], 'pool' => [ - 'min_connections' => 0, + 'min_retained_connections' => 0, 'max_connections' => 1, 'connect_timeout' => 0.5, 'wait_timeout' => 0.1, - 'heartbeat' => -1.0, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 0.1, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ], ]; diff --git a/tests/Redis/RedisConnectionLifecycleListenerTest.php b/tests/Redis/RedisConnectionLifecycleListenerTest.php index 234d6b68dd..41a3b59af5 100644 --- a/tests/Redis/RedisConnectionLifecycleListenerTest.php +++ b/tests/Redis/RedisConnectionLifecycleListenerTest.php @@ -6,7 +6,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Redis\Listeners\RedisConnectionLifecycleListener; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisManager; use Hypervel\Tests\TestCase; use Mockery as m; @@ -51,39 +51,39 @@ public function testProcessCleanupDoesNotResolveUnusedOwners(): void { $container = m::mock(Container::class); $container->expects('resolved')->with('redis')->andReturnFalse(); - $container->expects('resolved')->with(PoolFactory::class)->andReturnFalse(); + $container->expects('resolved')->with(PoolManager::class)->andReturnFalse(); $container->shouldNotReceive('make'); (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); } - public function testProcessCleanupDiscardsManagerAndFlushesPoolFactory(): void + public function testProcessCleanupDiscardsManagerAndPurgesPools(): void { $manager = m::mock(RedisManager::class); $manager->expects('discardConnections'); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll'); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll'); $container = m::mock(Container::class); $container->expects('resolved')->with('redis')->andReturnTrue(); $container->expects('make')->with('redis')->andReturn($manager); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); } - public function testManagerFailureDoesNotSkipPoolFlushAndRemainsPrimary(): void + public function testManagerFailureDoesNotSkipPoolPurgeAndRemainsPrimary(): void { $managerException = new RuntimeException('Manager discard failed.'); $manager = m::mock(RedisManager::class); $manager->expects('discardConnections')->andThrow($managerException); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll')->andThrow(new RuntimeException('Pool flush failed.')); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll')->andThrow(new RuntimeException('Pool purge failed.')); $container = m::mock(Container::class); $container->expects('resolved')->with('redis')->andReturnTrue(); $container->expects('make')->with('redis')->andReturn($manager); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); try { (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); @@ -93,18 +93,18 @@ public function testManagerFailureDoesNotSkipPoolFlushAndRemainsPrimary(): void } } - public function testPoolFlushCancellationSupersedesOrdinaryManagerFailure(): void + public function testPoolPurgeCancellationSupersedesOrdinaryManagerFailure(): void { - $cancellation = new CanceledException('Pool flush canceled.'); + $cancellation = new CanceledException('Pool purge canceled.'); $manager = m::mock(RedisManager::class); $manager->expects('discardConnections')->andThrow(new RuntimeException('Manager discard failed.')); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll')->andThrow($cancellation); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll')->andThrow($cancellation); $container = m::mock(Container::class); $container->expects('resolved')->with('redis')->andReturnTrue(); $container->expects('make')->with('redis')->andReturn($manager); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); try { (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); @@ -114,22 +114,22 @@ public function testPoolFlushCancellationSupersedesOrdinaryManagerFailure(): voi } } - public function testPoolFactoryFailurePropagatesAfterManagerCleanup(): void + public function testPoolPurgeFailurePropagatesAfterManagerCleanup(): void { - $exception = new RuntimeException('Pool flush failed.'); + $exception = new RuntimeException('Pool purge failed.'); $manager = m::mock(RedisManager::class); $manager->expects('discardConnections'); - $factory = m::mock(PoolFactory::class); - $factory->expects('flushAll')->andThrow($exception); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purgeAll')->andThrow($exception); $container = m::mock(Container::class); $container->expects('resolved')->with('redis')->andReturnTrue(); $container->expects('make')->with('redis')->andReturn($manager); - $container->expects('resolved')->with(PoolFactory::class)->andReturnTrue(); - $container->expects('make')->with(PoolFactory::class)->andReturn($factory); + $container->expects('resolved')->with(PoolManager::class)->andReturnTrue(); + $container->expects('make')->with(PoolManager::class)->andReturn($poolManager); try { (new RedisConnectionLifecycleListener($container))->discardProcessConnections(); - $this->fail('Expected the pool factory failure to propagate.'); + $this->fail('Expected the pool purge failure to propagate.'); } catch (RuntimeException $throwable) { $this->assertSame($exception, $throwable); } diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 69ee7a961b..f5208900fe 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -6,16 +6,16 @@ use BadMethodCallException; use Closure; +use Hypervel\ConnectionPool\Events\ConnectionReleasing; +use Hypervel\ConnectionPool\Exceptions\ConnectionException; +use Hypervel\ConnectionPool\PoolOptions; use Hypervel\Container\Container; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Events\Dispatcher; -use Hypervel\Pool\Events\ReleaseConnection; -use Hypervel\Pool\Exceptions\ConnectionException; -use Hypervel\Pool\PoolOption; use Hypervel\Redis\Exceptions\InvalidRedisOptionException; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Redis\PhpRedisClusterConnection; @@ -118,7 +118,7 @@ public function testReleaseResetsDatabaseToConfiguredDefault(): void $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(['database' => 1]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis ) { @@ -315,7 +315,7 @@ public function testReconnectBeginsWithNoTrackedWatchState(): void $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -340,7 +340,7 @@ public function testReconnectPreservesExactCancellation(): void $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $redis, $cancellation) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $redis, private CanceledException $cancellation, @@ -441,12 +441,12 @@ public function testReleasePreservesExactCancellationWithoutDispatchingReleaseOb $releaseObserved = false; $container = $this->getContainer(); $dispatcher = new Dispatcher($container); - $dispatcher->listen(ReleaseConnection::class, function () use (&$releaseObserved): void { + $dispatcher->listen(ConnectionReleasing::class, function () use (&$releaseObserved): void { $releaseObserved = true; }); $container->instance('events', $dispatcher); - $pool = m::mock(PoolInterface::class); - $pool->shouldReceive('getOption')->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $pool->expects('release')->with(m::type(RedisConnection::class)); $pool->shouldNotReceive('discard'); $redis = m::mock(Redis::class); @@ -475,15 +475,15 @@ public function testReleaseNormalizesWrappedPhpRedisCancellation(): void $releaseObserved = false; $container = $this->getContainer(); $dispatcher = new Dispatcher($container); - $dispatcher->listen(ReleaseConnection::class, function () use (&$releaseObserved): void { + $dispatcher->listen(ConnectionReleasing::class, function () use (&$releaseObserved): void { $releaseObserved = true; }); $container->instance('events', $dispatcher); $logger = m::mock(StdoutLoggerInterface::class); $logger->shouldNotReceive('log'); $container->instance(StdoutLoggerInterface::class, $logger); - $pool = m::mock(PoolInterface::class); - $pool->shouldReceive('getOption')->andReturn(new PoolOption(events: [ReleaseConnection::class])); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray(['events' => [ConnectionReleasing::class]])); $pool->expects('release')->with(m::type(RedisConnection::class)); $pool->shouldNotReceive('discard'); $redis = m::mock(Redis::class); @@ -615,7 +615,7 @@ public function testDatabaseRestoreFailureClosesTheNativeGenerationBeforeReconne */ public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array $clients, ) { @@ -703,7 +703,7 @@ public function testReconnectUsesCurrentDatabaseWhenSet(): void $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis ) { @@ -742,7 +742,7 @@ public function testReconnectCarriesTheConnectedNativeClientsActualDatabaseAcros */ public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array $clients, ) { @@ -779,7 +779,7 @@ public function testReconnectRejectsARefusedDatabaseBeforePublishingTheNewClient */ public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array $clients, ) { @@ -818,7 +818,7 @@ public function testReconnectToDatabaseZeroDoesNotIssueSelect(): void $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $redis, ) { @@ -852,7 +852,7 @@ public function testReconnectDoesNotInspectADisconnectedClientAndUsesTrackedSele */ public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array $clients, ) { @@ -888,7 +888,7 @@ public function testReconnectDoesNotInspectAnInvalidClientAndUsesTrackedSelectio */ public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array $clients, ) { @@ -926,7 +926,7 @@ public function testSentinelResolvedMasterUsesStandaloneDataConnectionSettings() public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -1504,7 +1504,7 @@ public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplaying */ public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array $clients, ) { @@ -1610,7 +1610,7 @@ public function testLogWritesToStdoutLogger(): void $connection = new class($container, $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis ) { @@ -2923,7 +2923,7 @@ public function testEvalReordersArguments(): void $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $captured) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array &$captured, ) { @@ -2964,7 +2964,7 @@ public function testEvalReordersMultipleArguments(): void $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $captured) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array &$captured, ) { @@ -3005,7 +3005,7 @@ public function testEvalWithNoKeysOrArguments(): void $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $captured) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private array &$captured, ) { @@ -3150,7 +3150,7 @@ public function testReconnectSetsSerializerOption(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['serializer' => Redis::SERIALIZER_PHP]]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3177,7 +3177,7 @@ public function testReconnectSetsPrefixOption(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['prefix' => 'myapp:']]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3208,7 +3208,7 @@ public function testReconnectSetsPackIgnoreNumbersOnStandaloneConnection(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['pack_ignore_numbers' => true]]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3238,7 +3238,7 @@ public function testReconnectRejectsNamedPackIgnoreNumbersWhenPhpRedisDoesNotSup new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['pack_ignore_numbers' => true]]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3275,7 +3275,7 @@ public function testReconnectSetsConnectionLevelPhpRedisOptions(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['read_timeout' => 5.0, 'max_retries' => 4, 'backoff_algorithm' => 'constant', 'backoff_base' => 200, 'backoff_cap' => 2000]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3298,7 +3298,7 @@ public function testReconnectDoesNotSetReadTimeoutOptionWhenEmpty(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['read_timeout' => 0.0]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3325,7 +3325,7 @@ public function testReconnectSetsNumericBackoffAlgorithmAsIs(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['backoff_algorithm' => Redis::BACKOFF_ALGORITHM_DEFAULT]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3352,7 +3352,7 @@ public function testReconnectThrowsOnUnknownBackoffAlgorithm(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['backoff_algorithm' => 'bogus']), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3379,7 +3379,7 @@ public function testReconnectThrowsOnUnknownOption(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => ['bogus' => 'value']]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3408,7 +3408,7 @@ public function testReconnectSetsNumericOptions(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['options' => [Redis::OPT_SERIALIZER => Redis::SERIALIZER_JSON]]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3435,7 +3435,7 @@ public function testReconnectAuthenticatesWhenAuthConfigured(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['password' => 'secret']), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3460,7 +3460,7 @@ public function testReconnectDoesNotAuthenticateWhenAuthEmpty(): void new class($this->getContainer(), $pool, $this->standaloneConfig(['password' => '']), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3551,7 +3551,7 @@ public function testReconnectClearsInvalidState(): void $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(['database' => 1]), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3580,8 +3580,8 @@ public function isInvalidForTest(): bool public function testInvalidStateIsNotMaskedByFreshReleaseTime(): void { - $pool = m::mock(PoolInterface::class); - $pool->shouldReceive('getOption')->andReturn(new PoolOption(maxIdleTime: 60.0)); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray(['max_idle_time' => 60.0])); $redis = m::mock(Redis::class); @@ -3590,7 +3590,7 @@ public function testInvalidStateIsNotMaskedByFreshReleaseTime(): void $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3616,8 +3616,8 @@ public function setLastReleaseTimeForTest(float $lastReleaseTime): void public function testCheckDoesNotResetActivityTimestamp(): void { - $pool = m::mock(PoolInterface::class); - $pool->shouldReceive('getOption')->andReturn(new PoolOption(maxIdleTime: 60.0)); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray(['max_idle_time' => 60.0])); $redis = m::mock(Redis::class); $redis->shouldReceive('setOption')->andReturnTrue(); @@ -3625,7 +3625,7 @@ public function testCheckDoesNotResetActivityTimestamp(): void $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { public function __construct( ContainerContract $container, - PoolInterface $pool, + ConnectionPool $pool, array $config, private Redis $fakeRedis, ) { @@ -3742,14 +3742,14 @@ protected function baseConnectionConfig(): array 'backoff_base' => 100, 'backoff_cap' => 1000, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 10, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1.0, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ], ]; } @@ -3770,7 +3770,7 @@ protected function expectDefaultConnectionOptions(Redis $redis): void /** * Create a Redis connection test double. */ - protected function mockRedisConnection(?ContainerContract $container = null, ?PoolInterface $pool = null, array $options = [], bool $transform = false): RedisConnection + protected function mockRedisConnection(?ContainerContract $container = null, ?ConnectionPool $pool = null, array $options = [], bool $transform = false): RedisConnection { $connection = new PhpRedisConnectionStub( $container ?? $this->getContainer(), @@ -3785,11 +3785,11 @@ protected function mockRedisConnection(?ContainerContract $container = null, ?Po return $connection; } - protected function getMockedPool(): PoolInterface + protected function getMockedPool(): ConnectionPool { - $pool = m::mock(PoolInterface::class); - $pool->shouldReceive('getOption') - ->andReturn(new PoolOption); + $pool = m::mock(ConnectionPool::class); + $pool->shouldReceive('getOptions') + ->andReturn(PoolOptions::fromArray([])); return $pool; } diff --git a/tests/Redis/RedisEventsTest.php b/tests/Redis/RedisEventsTest.php index f1083b91e1..dadc0b71a4 100644 --- a/tests/Redis/RedisEventsTest.php +++ b/tests/Redis/RedisEventsTest.php @@ -6,13 +6,13 @@ use Closure; use Exception; +use Hypervel\ConnectionPool\PoolOptions; use Hypervel\Container\Container; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Pool\PoolOption; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\PhpRedisConnection; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; @@ -261,14 +261,14 @@ public function testListenForFailuresNoOpsWhenEventsUnbound(): void private function createRedis(m\MockInterface|RedisConnection $connection): RedisProxy { $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->andReturn($connection); - $pool->shouldReceive('getOption')->andReturn(new PoolOption); + $pool->shouldReceive('borrow')->andReturn($connection); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); return new RedisProxy( - $poolFactory, + $poolManager, 'default', m::mock(RedisSentinelFactory::class), ); diff --git a/tests/Redis/RedisManagerTest.php b/tests/Redis/RedisManagerTest.php index a13ba0a0a9..94db792948 100644 --- a/tests/Redis/RedisManagerTest.php +++ b/tests/Redis/RedisManagerTest.php @@ -11,7 +11,7 @@ use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\PhpRedisConnection; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisManager; @@ -35,7 +35,7 @@ protected function tearDown(): void CoroutineContext::forget(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default'); } - public function testConnectionReturnsRedisProxy() + public function testConnectionReturnsRedisProxy(): void { $manager = $this->createManager(['default']); @@ -44,7 +44,7 @@ public function testConnectionReturnsRedisProxy() $this->assertInstanceOf(RedisProxy::class, $connection); } - public function testConnectionReturnsSameInstanceOnRepeatedCalls() + public function testConnectionReturnsSameInstanceOnRepeatedCalls(): void { $manager = $this->createManager(['default']); @@ -54,7 +54,7 @@ public function testConnectionReturnsSameInstanceOnRepeatedCalls() $this->assertSame($first, $second); } - public function testConnectionThrowsForUnconfiguredConnection() + public function testConnectionThrowsForUnconfiguredConnection(): void { $manager = $this->createManager(['default']); @@ -63,7 +63,7 @@ public function testConnectionThrowsForUnconfiguredConnection() $manager->connection('nonexistent'); } - public function testConnectionDefaultsToDefault() + public function testConnectionDefaultsToDefault(): void { $manager = $this->createManager(['default']); @@ -78,10 +78,10 @@ public function testConnectionDefaultsToDefault() public function testIntegerBackedEnumConnectionNameIsNormalizedForResolutionAndPurge(): void { - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('flushPool')->once()->with('0'); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('purge')->once()->with('0'); - $manager = $this->createManager(['0'], poolFactory: $poolFactory); + $manager = $this->createManager(['0'], poolManager: $poolManager); $connection = $manager->connection(RedisConnectionName::Zero); $this->assertSame('0', $connection->getName()); @@ -93,12 +93,12 @@ public function testIntegerBackedEnumConnectionNameIsNormalizedForResolutionAndP $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . '0')); } - public function testPurgeClearsProxyContextAndPool() + public function testPurgeClearsProxyContextAndPool(): void { - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('flushPool')->once()->with('default'); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('purge')->once()->with('default'); - $manager = $this->createManager(['default'], poolFactory: $poolFactory); + $manager = $this->createManager(['default'], poolManager: $poolManager); $first = $manager->connection('default'); @@ -117,9 +117,9 @@ public function testPurgeDiscardsContextPinnedConnection(): void $pinnedConnection = m::mock(PhpRedisConnection::class); $pinnedConnection->expects('discard'); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->expects('flushPool')->with('default'); - $manager = $this->createManager(['default'], poolFactory: $poolFactory); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purge')->with('default'); + $manager = $this->createManager(['default'], poolManager: $poolManager); $manager->connection('default'); CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $pinnedConnection); @@ -191,16 +191,16 @@ public function testDiscardConnectionsExhaustsEveryCreatedProxy(): void $manager->discardConnections(); } - public function testPurgeFlushesPoolAfterDiscardFailureAndPreservesFirstFailure(): void + public function testPurgeClosesPoolAfterDiscardFailureAndPreservesFirstFailure(): void { $discardException = new RuntimeException('Discard failed.'); $connection = m::mock(PhpRedisConnection::class); $connection->expects('discard')->andThrow($discardException); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->expects('flushPool') + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purge') ->with('alias') - ->andThrow(new RuntimeException('Flush failed.')); - $manager = $this->createManager(['alias'], poolFactory: $poolFactory); + ->andThrow(new RuntimeException('Purge failed.')); + $manager = $this->createManager(['alias'], poolManager: $poolManager); $manager->connection('alias'); CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'alias', $connection); @@ -214,16 +214,16 @@ public function testPurgeFlushesPoolAfterDiscardFailureAndPreservesFirstFailure( $this->assertSame([], $manager->connections()); } - public function testPurgeFlushesPoolAndLetsCancellationSupersedeDiscardFailure(): void + public function testPurgeClosesPoolAndLetsCancellationSupersedeDiscardFailure(): void { - $cancellation = new CanceledException('Flush canceled.'); + $cancellation = new CanceledException('Purge canceled.'); $connection = m::mock(PhpRedisConnection::class); $connection->expects('discard')->andThrow(new RuntimeException('Discard failed.')); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->expects('flushPool') + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('purge') ->with('alias') ->andThrow($cancellation); - $manager = $this->createManager(['alias'], poolFactory: $poolFactory); + $manager = $this->createManager(['alias'], poolManager: $poolManager); $manager->connection('alias'); CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'alias', $connection); @@ -260,23 +260,23 @@ public function testEnableEventsRefreshesOnlyExistingPoolsWithDisabledEvents(): ->globally() ->ordered(); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->expects('getPool')->never(); - $poolFactory->expects('pools') + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->never(); + $poolManager->expects('getPools') ->globally() ->ordered() ->andReturn([ 'disabled' => $disabledPool, 'enabled' => $enabledPool, ]); - $poolFactory->expects('flushPool') + $poolManager->expects('purge') ->with('disabled') ->globally() ->ordered(); $manager = new RedisManager( $app, - $poolFactory, + $poolManager, $config, m::mock(RedisSentinelFactory::class), ); @@ -297,23 +297,23 @@ public function testDisableEventsRefreshesOnlyExistingPoolsWithEnabledEvents(): ->globally() ->ordered(); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->expects('getPool')->never(); - $poolFactory->expects('pools') + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->never(); + $poolManager->expects('getPools') ->globally() ->ordered() ->andReturn([ 'enabled' => $enabledPool, 'disabled' => $disabledPool, ]); - $poolFactory->expects('flushPool') + $poolManager->expects('purge') ->with('enabled') ->globally() ->ordered(); $manager = new RedisManager( $app, - $poolFactory, + $poolManager, $config, m::mock(RedisSentinelFactory::class), ); @@ -321,7 +321,7 @@ public function testDisableEventsRefreshesOnlyExistingPoolsWithEnabledEvents(): $manager->disableEvents(); } - public function testCallDelegatesToDefaultConnection() + public function testCallDelegatesToDefaultConnection(): void { $manager = $this->createManager(['default']); @@ -330,7 +330,7 @@ public function testCallDelegatesToDefaultConnection() $this->assertSame('default', $manager->getName()); } - public function testListenRegistersCommandExecutedListener() + public function testListenRegistersCommandExecutedListener(): void { $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('listen') @@ -343,7 +343,7 @@ public function testListenRegistersCommandExecutedListener() $manager = new RedisManager( $app, - m::mock(PoolFactory::class), + m::mock(PoolManager::class), $this->createRedisConfig(['default']), m::mock(RedisSentinelFactory::class), ); @@ -351,7 +351,7 @@ public function testListenRegistersCommandExecutedListener() $manager->listen(function () {}); } - public function testListenForFailuresRegistersCommandFailedListener() + public function testListenForFailuresRegistersCommandFailedListener(): void { $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('listen') @@ -364,7 +364,7 @@ public function testListenForFailuresRegistersCommandFailedListener() $manager = new RedisManager( $app, - m::mock(PoolFactory::class), + m::mock(PoolManager::class), $this->createRedisConfig(['default']), m::mock(RedisSentinelFactory::class), ); @@ -372,7 +372,7 @@ public function testListenForFailuresRegistersCommandFailedListener() $manager->listenForFailures(function () {}); } - public function testConnectionsReturnsAllCachedProxies() + public function testConnectionsReturnsAllCachedProxies(): void { $manager = $this->createManager(['default', 'cache']); @@ -395,15 +395,15 @@ public function testConnectionsReturnsAllCachedProxies() */ private function createManager( array $configuredConnections, - ?PoolFactory $poolFactory = null + ?PoolManager $poolManager = null ): RedisManager { $app = m::mock(ContainerContract::class); - $poolFactory ??= m::mock(PoolFactory::class); + $poolManager ??= m::mock(PoolManager::class); $config = $this->createRedisConfig($configuredConnections); return new RedisManager( $app, - $poolFactory, + $poolManager, $config, m::mock(RedisSentinelFactory::class), ); diff --git a/tests/Redis/RedisPoolHeartbeatTest.php b/tests/Redis/RedisPoolHeartbeatTest.php index 00defb6181..5d750fce21 100644 --- a/tests/Redis/RedisPoolHeartbeatTest.php +++ b/tests/Redis/RedisPoolHeartbeatTest.php @@ -4,17 +4,19 @@ namespace Hypervel\Tests\Redis\RedisPoolHeartbeatTest; +use Hypervel\ConnectionPool\Connection as BaseConnection; +use Hypervel\ConnectionPool\PoolOptions; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; +use Hypervel\Contracts\ConnectionPool\Connection; +use Hypervel\Contracts\ConnectionPool\ConnectionPool; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; -use Hypervel\Contracts\Pool\ConnectionInterface; -use Hypervel\Contracts\Pool\PoolInterface; +use Hypervel\Contracts\Log\StdoutLoggerInterface; +use Hypervel\Coordinator\Timer; use Hypervel\Coroutine\Coroutine as FrameworkCoroutine; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; -use Hypervel\Pool\Connection as BaseConnection; -use Hypervel\Pool\PoolOption; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisConnection; @@ -23,6 +25,7 @@ use Hypervel\Support\ClassInvoker; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Redis; use RedisCluster; use ReflectionProperty; @@ -54,8 +57,9 @@ public function testDisabledHeartbeatDoesNotStartTimer(): void { run(function () { $pool = $this->createPool([ - 'heartbeat' => -1, + 'heartbeat_interval' => null, ]); + $pool->start(); $this->assertSame(0, $pool->heartbeatTimerCount()); }); @@ -65,31 +69,104 @@ public function testEnabledHeartbeatStartsTimerAndCloseClearsIt(): void { run(function () { $pool = $this->createPool([ - 'heartbeat' => 0.001, + 'heartbeat_interval' => 60.0, ]); - $this->assertSame(1, $pool->heartbeatTimerCount()); + try { + $this->assertSame(0, $pool->heartbeatTimerCount()); + $pool->start(); + $pool->start(); + $this->assertSame(1, $pool->heartbeatTimerCount()); + } finally { + $pool->close(); + } - $pool->close(); + $pool->start(); $this->assertSame(0, $pool->heartbeatTimerCount()); }); } + public function testHeartbeatStartupCanBeRetriedAfterTimerCreationFails(): void + { + run(function (): void { + $pool = $this->createPool(['heartbeat_interval' => 60.0]); + $property = new ReflectionProperty(RedisPool::class, 'heartbeatTimer'); + $timer = $property->getValue($pool); + $failure = new RuntimeException('Timer creation failed.'); + $failingTimer = m::mock(Timer::class); + $failingTimer->shouldReceive('tick')->once()->andThrow($failure); + $property->setValue($pool, $failingTimer); + $caught = null; + + try { + $pool->start(); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + $property->setValue($pool, $timer); + } + + $this->assertSame($failure, $caught); + + try { + $pool->start(); + $this->assertSame(1, $pool->heartbeatTimerCount()); + } finally { + $pool->close(); + } + }); + } + + public function testReentrantHeartbeatStartupCreatesOnlyOneTimer(): void + { + run(function (): void { + $pool = $this->createPool(['heartbeat_interval' => 60.0]); + FrameworkCoroutine::afterCreated(static fn () => $pool->start()); + + try { + $pool->start(); + $this->assertSame(1, $pool->heartbeatTimerCount()); + } finally { + $pool->close(); + } + }); + } + + public function testCloseDuringHeartbeatStartupClearsTheUnpublishedTimer(): void + { + run(function (): void { + $pool = $this->createPool(['heartbeat_interval' => 60.0]); + $timers = Timer::stats()['num']; + FrameworkCoroutine::afterCreated(static fn () => $pool->close()); + + try { + $pool->start(); + $pool->start(); + + $this->assertTrue($pool->isClosed()); + $this->assertSame(0, $pool->heartbeatTimerCount()); + $this->assertSame($timers, Timer::stats()['num']); + } finally { + $pool->close(); + } + }); + } + public function testHeartbeatTrimsExpiredIdleConnectionsToTheManagedCountFloor(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 3, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 1.0, ]); $connections = [ - $pool->get(), - $pool->get(), - $pool->get(), + $pool->borrow(), + $pool->borrow(), + $pool->borrow(), ]; foreach ($connections as $connection) { @@ -99,8 +176,8 @@ public function testHeartbeatTrimsExpiredIdleConnectionsToTheManagedCountFloor() $pool->runHeartbeatForTest(); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); }); } @@ -108,13 +185,13 @@ public function testHeartbeatDiscardsLifetimeExpiredIdleConnectionBeforeChecking { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_lifetime' => 1.0, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $connection->release(); @@ -123,8 +200,8 @@ public function testHeartbeatDiscardsLifetimeExpiredIdleConnectionBeforeChecking $pool->runHeartbeatForTest(); $this->assertSame(0, $connection->heartbeatChecks); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); }); } @@ -132,13 +209,13 @@ public function testHeartbeatDoesNotRecycleBorrowedLifetimeExpiredConnection(): { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_lifetime' => 1.0, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $client = $connection->nativeClientForTest(); @@ -149,31 +226,60 @@ public function testHeartbeatDoesNotRecycleBorrowedLifetimeExpiredConnection(): $connection->getConnection(); $this->assertSame($client, $connection->nativeClientForTest()); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); $connection->release(); }); } + public function testNullIdleTimeoutKeepsAnAgedReleasedConnection(): void + { + run(function (): void { + $pool = $this->createPool(['max_idle_time' => null]); + $connection = $pool->borrow(); + $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); + $client = $connection->nativeClientForTest(); + $connection->release(); + + (new ReflectionProperty(BaseConnection::class, 'lastReleaseTime'))->setValue($connection, 1.0); + (new ReflectionProperty(BaseConnection::class, 'lastUseTime'))->setValue($connection, 1.0); + + $this->assertFalse($connection->isIdleExpired()); + $this->assertTrue($connection->check()); + $pool->runHeartbeatForTest(); + + $nextConnection = $pool->borrow(); + + try { + $nextConnection->getConnection(); + $this->assertSame($connection, $nextConnection); + $this->assertSame($client, $connection->nativeClientForTest()); + $this->assertSame(1, $connection->reconnectCount); + } finally { + $nextConnection->release(); + } + }); + } + public function testMaxLifetimeDisabledDoesNotRecycleAgedConnectionGeneration(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, - 'max_lifetime' => -1.0, + 'heartbeat_interval' => null, + 'max_lifetime' => null, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $client = $connection->nativeClientForTest(); $connection->release(); $this->ageConnectionGeneration($connection); - $nextConnection = $pool->get(); + $nextConnection = $pool->borrow(); $nextConnection->getConnection(); $this->assertSame($connection, $nextConnection); @@ -188,20 +294,20 @@ public function testHeartbeatRefreshedIdleConnectionIsReused(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 1.0, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $client = $connection->nativeClientForTest(); $connection->release(); $this->ageReleaseTimeButKeepLastUseFresh($connection); - $nextConnection = $pool->get(); + $nextConnection = $pool->borrow(); $nextConnection->getConnection(); $this->assertSame($connection, $nextConnection); @@ -216,20 +322,20 @@ public function testLifetimeExpiredConnectionReconnectsBeforeReuse(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_lifetime' => 1.0, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $client = $connection->nativeClientForTest(); $connection->release(); $this->ageConnectionGeneration($connection); - $nextConnection = $pool->get(); + $nextConnection = $pool->borrow(); $nextConnection->getConnection(); $this->assertSame($connection, $nextConnection); @@ -244,14 +350,14 @@ public function testConnectionGenerationLifetimeIsJitteredWithinConfiguredUpperB { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_lifetime' => 60.0, ]); $before = hrtime(true) / 1e9; - $connection = $pool->get(); + $connection = $pool->borrow(); $after = hrtime(true) / 1e9; $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); @@ -262,7 +368,7 @@ public function testConnectionGenerationLifetimeIsJitteredWithinConfiguredUpperB $this->assertGreaterThanOrEqual($before, $createdAt); $this->assertLessThanOrEqual($after, $createdAt); $this->assertGreaterThanOrEqual( - $createdAt + (60.0 * PoolOption::MIN_LIFETIME_JITTER_BASIS / PoolOption::LIFETIME_JITTER_SCALE), + $createdAt + (60.0 * PoolOptions::MIN_LIFETIME_JITTER_BASIS / PoolOptions::LIFETIME_JITTER_SCALE), $lifetimeExpiresAt ); $this->assertLessThanOrEqual($createdAt + 60.0, $lifetimeExpiresAt); @@ -277,18 +383,18 @@ public function testFailedHeartbeatCheckDiscardsConnectionBelowMinimum(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], FailingHeartbeatRedisPool::class); - $connection = $pool->get(); + $connection = $pool->borrow(); $connection->release(); $pool->runHeartbeatForTest(); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); }); } @@ -298,13 +404,13 @@ public function testHeartbeatTimeoutDiscardsWithoutRequeueingLateCompletion(): v SlowHeartbeatRedisConnection::$coroutineId = null; $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 0.001, ], SlowHeartbeatRedisPool::class); - $connection = $pool->get(); + $connection = $pool->borrow(); $connection->release(); $startedAt = microtime(true); @@ -312,8 +418,8 @@ public function testHeartbeatTimeoutDiscardsWithoutRequeueingLateCompletion(): v $elapsed = microtime(true) - $startedAt; $this->assertLessThan(0.2, $elapsed); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); $this->assertIsInt(SlowHeartbeatRedisConnection::$coroutineId); $deadline = microtime(true) + 0.1; @@ -325,7 +431,7 @@ public function testHeartbeatTimeoutDiscardsWithoutRequeueingLateCompletion(): v usleep(100000); - $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(0, $pool->getIdleCount()); }); } @@ -333,11 +439,11 @@ public function testHeartbeatThrowingCancellationStopsTheChildAndEscapes(): void { run(function (): void { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], CancellableHeartbeatRedisPool::class); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(CancellableHeartbeatRedisConnection::class, $connection); $captured = null; @@ -366,11 +472,11 @@ public function testHeartbeatCancellationDuringStartupReportingStopsThePublished $handler = m::mock(ExceptionHandlerContract::class); Container::getInstance()->instance(ExceptionHandlerContract::class, $handler); $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $hookFailure = new RuntimeException('The startup hook failed.'); $reportStarted = new Channel(1); @@ -435,11 +541,11 @@ public function testHeartbeatNonThrowingCancellationStopsTheChildAndEscapes(): v { run(function (): void { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], CancellableHeartbeatRedisPool::class); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(CancellableHeartbeatRedisConnection::class, $connection); $captured = null; @@ -467,31 +573,147 @@ public function testSuccessfulHeartbeatCheckAfterCloseDiscardsConnection(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ClosingHeartbeatRedisPool::class); - $connection = $pool->get(); + $connection = $pool->borrow(); $connection->release(); $pool->runHeartbeatForTest(); - $this->assertSame(0, $pool->getCurrentConnections()); - $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(0, $pool->getManagedCount()); + $this->assertSame(0, $pool->getIdleCount()); + }); + } + + #[DataProvider('heartbeatCancellationPaths')] + public function testHeartbeatCancellationDisposesOnceAndLeavesLaterIdleConnections(string $path): void + { + run(function () use ($path): void { + $cancellation = new CanceledException('heartbeat canceled'); + $secondary = $path === 'evaluation with failed close' + ? new RuntimeException('close failed') + : new CanceledException('secondary close cancellation'); + $pool = $this->createPool([], CancellableDisposalRedisPool::class); + $logger = m::mock(StdoutLoggerInterface::class); + + if ($path === 'evaluation with failed close') { + $logger->shouldReceive('error')->once()->with((string) $secondary); + } else { + $logger->shouldNotReceive('error'); + } + + (new ClassInvoker($pool))->container->instance(StdoutLoggerInterface::class, $logger); + $first = $pool->borrow(); + $later = $pool->borrow(); + $this->assertInstanceOf(CancellableDisposalRedisConnection::class, $first); + $this->assertInstanceOf(CancellableDisposalRedisConnection::class, $later); + + if ($path === 'disposal') { + $first->heartbeatResult = false; + $first->closeFailure = $cancellation; + } else { + $first->heartbeatFailure = $cancellation; + $first->closeFailure = $path === 'evaluation' ? null : $secondary; + } + + $first->release(); + $later->release(); + $caught = null; + + try { + $pool->runHeartbeatForTest(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cancellation, $caught); + $this->assertSame(1, $first->closeCount); + $this->assertSame(0, $later->closeCount); + $this->assertSame(0, $later->heartbeatChecks); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); + }); + } + + public static function heartbeatCancellationPaths(): array + { + return [ + ['evaluation'], + ['evaluation with canceled close'], + ['evaluation with failed close'], + ['disposal'], + ]; + } + + #[DataProvider('nativeCancellationModes')] + public function testCanceledHeartbeatSweepStopsItsChildAndPreservesRemainingIdleConnections(bool $throwException): void + { + $observed = []; + + run(function () use ($throwException, &$observed): void { + $pool = $this->createPool([], CancellableHeartbeatRedisPool::class); + $first = $pool->borrow(); + $later = $pool->borrow(); + $first->release(); + $later->release(); + $caught = null; + $parent = Coroutine::create(static function () use ($pool, &$caught): void { + try { + $pool->runHeartbeatForTest(); + } catch (Throwable $exception) { + $caught = $exception; + } + }); + + try { + $observed['started'] = $first->pingStarted->pop(1.0); + $observed['canceled'] = Coroutine::cancelById($parent->getId(), throwException: $throwException); + $observed['exception'] = $caught; + $observed['child_exception'] = $first->cancellation; + $observed['child_running'] = Coroutine::exists($first->coroutineId); + $observed['parent_running'] = Coroutine::exists($parent->getId()); + $observed['managed'] = $pool->getManagedCount(); + $observed['idle'] = $pool->getIdleCount(); + $observed['later_started'] = $later->coroutineId; + } finally { + if (Coroutine::exists($parent->getId())) { + Coroutine::cancelById($parent->getId(), throwException: true); + FrameworkCoroutine::join([$parent->getId()], 1.0); + } + + $pool->close(); + } }); + + $this->assertTrue($observed['started']); + $this->assertTrue($observed['canceled']); + $this->assertInstanceOf(CanceledException::class, $observed['exception']); + $this->assertInstanceOf(CanceledException::class, $observed['child_exception']); + $this->assertFalse($observed['child_running']); + $this->assertFalse($observed['parent_running']); + $this->assertSame(1, $observed['managed']); + $this->assertSame(1, $observed['idle']); + $this->assertNull($observed['later_started']); + } + + public static function nativeCancellationModes(): array + { + return [[false], [true]]; } public function testReleaseResetFailureReturnsInvalidConnectionToPool(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $redis = m::mock(Redis::class); @@ -505,10 +727,10 @@ public function testReleaseResetFailureReturnsInvalidConnectionToPool(): void $connection->release(); $this->assertNull((new ReflectionProperty(RedisConnection::class, 'database'))->getValue($connection)); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); - $nextConnection = $pool->get(); + $nextConnection = $pool->borrow(); $nextConnection->getConnection(); $this->assertSame($connection, $nextConnection); @@ -522,13 +744,13 @@ public function testWithConnectionReconnectsExpiredGenerationBeforeCallback(): v { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_lifetime' => 1.0, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $client = $connection->nativeClientForTest(); $connection->release(); @@ -548,13 +770,13 @@ public function testPinnedConnectionDoesNotRecycleExpiredGenerationMidBorrow(): { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_lifetime' => 1.0, ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $connection->release(); $this->ageConnectionGeneration($connection); @@ -583,9 +805,9 @@ public function testClusterHeartbeatChecksAllMasters(): void { run(function () { $pool = $this->createPool([ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, - 'heartbeat' => -1, + 'heartbeat_interval' => null, ], ClusterHeartbeatRedisPool::class, [ 'cluster' => [ 'enabled' => true, @@ -593,7 +815,7 @@ public function testClusterHeartbeatChecksAllMasters(): void ], ]); - $connection = $pool->get(); + $connection = $pool->borrow(); $this->assertInstanceOf(ClusterHeartbeatRedisConnection::class, $connection); $connection->release(); @@ -603,8 +825,8 @@ public function testClusterHeartbeatChecksAllMasters(): void ['127.0.0.1', 6379], ['127.0.0.2', 6379], ], $connection->clusterClient->pingedMasters); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); }); } @@ -621,14 +843,14 @@ protected function createPool(array $poolOptions = [], string $poolClass = Inspe 'timeout' => null, 'cluster' => ['enabled' => false], 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 2, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ...$poolOptions, ], ], $config); @@ -646,11 +868,11 @@ protected function createPool(array $poolOptions = [], string $poolClass = Inspe protected function createProxy(RedisPool $pool): RedisProxy { - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('heartbeat_test')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('heartbeat_test')->andReturn($pool); return new RedisProxy( - $poolFactory, + $poolManager, 'heartbeat_test', m::mock(RedisSentinelFactory::class), ); @@ -668,7 +890,7 @@ protected function ageConnectionGeneration(RedisConnection $connection): void $lifetimeExpiresAt = new ReflectionProperty(RedisConnection::class, 'lifetimeExpiresAt'); - if ($lifetimeExpiresAt->getValue($connection) > 0.0) { + if ($lifetimeExpiresAt->getValue($connection) !== null) { $lifetimeExpiresAt->setValue($connection, hrtime(true) / 1e9 - 1.0); } } @@ -694,7 +916,7 @@ public function heartbeatTimerCount(): int return $timer === null ? 0 : count((new ClassInvoker($timer))->coroutines); } - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { return new HeartbeatRedisConnection($this->container, $this, $this->config); } @@ -710,7 +932,7 @@ class HeartbeatRedisConnection extends RedisConnection public bool $useNativeHeartbeat = false; - public function __construct(Container $container, PoolInterface $pool, array $config) + public function __construct(Container $container, ConnectionPool $pool, array $config) { parent::__construct($container, $pool, $config); @@ -762,9 +984,49 @@ private function assertNativeClientForTest(): void } } +class CancellableDisposalRedisPool extends InspectableRedisPool +{ + protected function createConnection(): Connection + { + return new CancellableDisposalRedisConnection($this->container, $this, $this->config); + } +} + +class CancellableDisposalRedisConnection extends HeartbeatRedisConnection +{ + public ?Throwable $heartbeatFailure = null; + + public ?Throwable $closeFailure = null; + + public int $closeCount = 0; + + public function heartbeatCheck(float $timeout): bool + { + ++$this->heartbeatChecks; + + if ($this->heartbeatFailure !== null) { + throw $this->heartbeatFailure; + } + + return $this->heartbeatResult; + } + + public function close(): bool + { + ++$this->closeCount; + parent::close(); + + if ($this->closeFailure !== null) { + throw $this->closeFailure; + } + + return true; + } +} + class FailingHeartbeatRedisPool extends InspectableRedisPool { - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { $connection = new HeartbeatRedisConnection($this->container, $this, $this->config); $connection->heartbeatResult = false; @@ -775,7 +1037,7 @@ protected function createConnection(): ConnectionInterface class SlowHeartbeatRedisPool extends InspectableRedisPool { - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { return new SlowHeartbeatRedisConnection($this->container, $this, $this->config); } @@ -783,7 +1045,7 @@ protected function createConnection(): ConnectionInterface class CancellableHeartbeatRedisPool extends InspectableRedisPool { - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { return new CancellableHeartbeatRedisConnection($this->container, $this, $this->config); } @@ -799,7 +1061,7 @@ class CancellableHeartbeatRedisConnection extends HeartbeatRedisConnection public ?int $coroutineId = null; - public function __construct(Container $container, PoolInterface $pool, array $config) + public function __construct(Container $container, ConnectionPool $pool, array $config) { parent::__construct($container, $pool, $config); @@ -839,7 +1101,7 @@ protected function pingForHeartbeat(): bool class ClosingHeartbeatRedisPool extends InspectableRedisPool { - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { return new ClosingHeartbeatRedisConnection($this->container, $this, $this->config); } @@ -857,7 +1119,7 @@ protected function pingForHeartbeat(): bool class ClusterHeartbeatRedisPool extends InspectableRedisPool { - protected function createConnection(): ConnectionInterface + protected function createConnection(): Connection { return new ClusterHeartbeatRedisConnection($this->container, $this, $this->config); } diff --git a/tests/Redis/RedisPoolTest.php b/tests/Redis/RedisPoolTest.php index f927dab1a2..bcba131d6c 100644 --- a/tests/Redis/RedisPoolTest.php +++ b/tests/Redis/RedisPoolTest.php @@ -5,12 +5,11 @@ namespace Hypervel\Tests\Redis; use Hypervel\Config\Repository; +use Hypervel\ConnectionPool\Connection; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; +use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Log\StdoutLoggerInterface; -use Hypervel\Contracts\Pool\ConnectionInterface; -use Hypervel\Contracts\Pool\FrequencyInterface; -use Hypervel\Pool\Connection; -use Hypervel\Pool\LowFrequencyInterface; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConfig; use Hypervel\Tests\TestCase; @@ -26,11 +25,11 @@ public function testPoolConnectTimeoutConfiguresTheNativeRedisTimeout(): void 'database' => 0, 'timeout' => null, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 30, 'connect_timeout' => 1.25, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 1, ], ]; @@ -51,11 +50,11 @@ public function testPoolConnectTimeoutDoesNotOverrideTheNativeRedisTimeout(): vo 'database' => 0, 'timeout' => 7.0, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 30, 'connect_timeout' => 1.25, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 1, ], ]; @@ -80,11 +79,11 @@ public function testEventOverrideDoesNotRetrofitExistingPoolConfiguration(): voi 'events' => false, 'options' => [], 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 30, 'connect_timeout' => 1.25, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 1, ], ], @@ -102,7 +101,7 @@ public function testEventOverrideDoesNotRetrofitExistingPoolConfiguration(): voi $this->assertTrue($redisConfig->connectionConfig('default')['events']); } - public function testLowFrequencyFlushClosesIdleConnections(): void + public function testUsagePolicyTrimsExcessIdleConnections(): void { TestPoolConnection::reset(); @@ -112,11 +111,11 @@ public function testLowFrequencyFlushClosesIdleConnections(): void 'database' => 0, 'timeout' => null, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 30, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 1, ], ]; @@ -127,28 +126,28 @@ public function testLowFrequencyFlushClosesIdleConnections(): void $pool = new TestRedisPool($container, 'default'); - $connection1 = $pool->get(); - $connection2 = $pool->get(); - $connection3 = $pool->get(); + $connection1 = $pool->borrow(); + $connection2 = $pool->borrow(); + $connection3 = $pool->borrow(); - $this->assertSame(3, $pool->getCurrentConnections()); + $this->assertSame(3, $pool->getManagedCount()); $connection1->release(); $connection2->release(); $connection3->release(); - $this->assertSame(3, $pool->getCurrentConnections()); + $this->assertSame(3, $pool->getManagedCount()); - $pool->setFrequencyForTest(new AlwaysLowFrequency); - $connection = $pool->get(); + $pool->setUsageTrackerForTest(new AlwaysTrimIdle); + $connection = $pool->borrow(); - $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getManagedCount()); $this->assertSame(2, TestPoolConnection::$closeCount); $connection->release(); - $this->assertSame(1, $pool->getCurrentConnections()); - $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getManagedCount()); + $this->assertSame(1, $pool->getIdleCount()); } /** @@ -171,12 +170,13 @@ private function mockContainerWithRedisConfig(array $connectionConfig): m\MockIn class TestRedisPool extends RedisPool { - public function setFrequencyForTest(FrequencyInterface|LowFrequencyInterface $frequency): void + public function setUsageTrackerForTest(UsageTracker $tracker): void { - $this->frequency = $frequency; + $this->usageTracker = $tracker; + $this->usageTrackerInitialized = true; } - protected function createConnection(): ConnectionInterface + protected function createConnection(): PoolConnection { return new TestPoolConnection($this->container, $this); } @@ -209,19 +209,13 @@ public function getActiveConnection(): static } } -class AlwaysLowFrequency implements FrequencyInterface, LowFrequencyInterface +class AlwaysTrimIdle implements UsageTracker { - public function hit(int $number = 1): bool + public function recordBorrow(): void { - return true; - } - - public function frequency(): float - { - return 0.0; } - public function isLowFrequency(): bool + public function shouldTrimExcessIdle(): bool { return true; } diff --git a/tests/Redis/RedisProxyNonCoroutineTest.php b/tests/Redis/RedisProxyNonCoroutineTest.php index 6c758e7ca4..6f18effbc1 100644 --- a/tests/Redis/RedisProxyNonCoroutineTest.php +++ b/tests/Redis/RedisProxyNonCoroutineTest.php @@ -6,7 +6,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Redis\PhpRedisConnection; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisProxy; use Hypervel\Redis\RedisSentinelFactory; @@ -61,11 +61,11 @@ private function assertCommandPinsConnection( $connection->expects('release'); $pool = m::mock(RedisPool::class); - $pool->expects('get')->andReturn($connection); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); + $pool->expects('borrow')->andReturn($connection); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); $redis = new RedisProxy( - $factory, + $poolManager, 'default', m::mock(RedisSentinelFactory::class), ); diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index ad08f26872..63b534ea10 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -6,18 +6,18 @@ use BadMethodCallException; use Exception; +use Hypervel\ConnectionPool\PoolOptions; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine as EngineCoroutine; -use Hypervel\Pool\PoolOption; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\Exceptions\InvalidRedisConnectionException; use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Redis\PhpRedisConnection; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; @@ -71,9 +71,9 @@ public function testCommandIsProxiedToConnection(): void public function testMacroRegistrationMethodsDoNotCheckoutRedis(): void { - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->never(); - $redis = new RedisProxy($factory, 'default', $this->sentinelFactory()); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->never(); + $redis = new RedisProxy($poolManager, 'default', $this->sentinelFactory()); $redis->macro('greeting', fn (string $name) => "Hello {$name}"); $redis->mixin(new class { @@ -94,9 +94,9 @@ protected function farewell(): callable public function testMixedCaseSubscriptionsUseDedicatedProxyRoute(): void { - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->never(); - $redis = new class($factory, 'default', $this->sentinelFactory()) extends RedisProxy { + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->never(); + $redis = new class($poolManager, 'default', $this->sentinelFactory()) extends RedisProxy { public array $subscriptions = []; protected function handleSubscribe(string $name, array $arguments): void @@ -246,7 +246,7 @@ public function testSubscriberLoopRetainsOrdinaryCloseFailurePrecedence(): void public function testConnectionBoundMethodsCannotBeCalledThroughProxy(): void { $redis = new RedisProxy( - m::mock(PoolFactory::class), + m::mock(PoolManager::class), 'default', $this->sentinelFactory(), ); @@ -559,17 +559,17 @@ public function testSelectPinnedConnectionDoesNotLeakAcrossCoroutines(): void $otherCoroutineConnection->shouldReceive('release')->once(); $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->times(3)->andReturn( + $pool->shouldReceive('borrow')->times(3)->andReturn( $setConnection, $selectedConnection, $otherCoroutineConnection, ); - $pool->shouldReceive('getOption')->andReturn(new PoolOption); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); - $redis = new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); + $redis = new RedisProxy($poolManager, 'default', $this->sentinelFactory()); $this->assertSame('db:0 name:set argument:xxxx,yyyy', $redis->set('xxxx', 'yyyy')); $this->assertTrue($redis->select(2)); @@ -598,13 +598,13 @@ public function testPinnedConnectionInOneCoroutineIsNotReusedInAnotherCoroutine( $otherCoroutineConnection->shouldReceive('release')->once(); $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->times(2)->andReturn($pinnedConnection, $otherCoroutineConnection); - $pool->shouldReceive('getOption')->andReturn(new PoolOption); + $pool->shouldReceive('borrow')->times(2)->andReturn($pinnedConnection, $otherCoroutineConnection); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); - $redis = new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); + $redis = new RedisProxy($poolManager, 'default', $this->sentinelFactory()); $redis->multi(); $redis->set('id', '123'); @@ -1613,11 +1613,11 @@ public function testSubscriberUsesTheCompleteStandaloneConfiguration(): void 'timeout' => 2.5, 'options' => ['prefix' => 'app:'], ])); - $pool->shouldNotReceive('get'); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); + $pool->shouldNotReceive('borrow'); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); $subscriber = (new RedisProxy( - $factory, + $poolManager, 'default', $this->sentinelFactory(), ))->subscriber(); @@ -1655,15 +1655,15 @@ public function testSubscriberResolvesSentinelMasterFreshWithConnectionCredentia ]); $pool = m::mock(RedisPool::class); $pool->expects('getConfig')->twice()->andReturn($config); - $pool->shouldNotReceive('get'); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->twice()->with('default')->andReturn($pool); + $pool->shouldNotReceive('borrow'); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->twice()->with('default')->andReturn($pool); $sentinelFactory = m::mock(RedisSentinelFactory::class); $sentinelFactory->expects('resolveMaster') ->twice() ->with($config) ->andReturn([$firstHost, $firstPort], [$secondHost, $secondPort]); - $proxy = new RedisProxy($factory, 'default', $sentinelFactory); + $proxy = new RedisProxy($poolManager, 'default', $sentinelFactory); $first = $proxy->subscriber(); $second = $proxy->subscriber(); @@ -1714,11 +1714,11 @@ public function testClusterSubscriberUsesConnectionTransportAndReleasesDiscovery }); $pool = m::mock(RedisPool::class); $pool->expects('getConfig')->andReturn($config); - $pool->expects('get')->andReturn($connection); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); + $pool->expects('borrow')->andReturn($connection); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); $subscriber = (new RedisProxy( - $factory, + $poolManager, 'default', $this->sentinelFactory(), ))->subscriber(); @@ -1777,11 +1777,11 @@ public function testClusterSubscriberUsesTlsConnectionTransportForMaster(): void }); $pool = m::mock(RedisPool::class); $pool->expects('getConfig')->andReturn($config); - $pool->expects('get')->andReturn($connection); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); + $pool->expects('borrow')->andReturn($connection); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); $subscriber = (new RedisProxy( - $factory, + $poolManager, 'default', $this->sentinelFactory(), ))->subscriber(); @@ -1814,13 +1814,13 @@ public function testClusterSubscriberAggregatesEndpointFailures(): void $connection->expects('release'); $pool = m::mock(RedisPool::class); $pool->expects('getConfig')->andReturn($config); - $pool->expects('get')->andReturn($connection); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); + $pool->expects('borrow')->andReturn($connection); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); try { (new RedisProxy( - $factory, + $poolManager, 'default', $this->sentinelFactory(), ))->subscriber(); @@ -1845,13 +1845,13 @@ public function testClusterDiscoveryFailureRemainsPrimaryOverReleaseFailure(): v 'seeds' => ['tcp://127.0.0.1:6379'], ], ]); - $pool->expects('get')->andReturn($connection); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); + $pool->expects('borrow')->andReturn($connection); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); try { (new RedisProxy( - $factory, + $poolManager, 'default', $this->sentinelFactory(), ))->subscriber(); @@ -1875,10 +1875,10 @@ public function testClusterDiscoveryNormalizesWrappedCancellationAndStillRelease 'seeds' => ['tcp://127.0.0.1:6379'], ], ]); - $pool->expects('get')->andReturn($connection); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); - $redis = new RedisProxy($factory, 'default', $this->sentinelFactory()); + $pool->expects('borrow')->andReturn($connection); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); + $redis = new RedisProxy($poolManager, 'default', $this->sentinelFactory()); $exception = $this->captureCancellationAtBoundary(function () use ($redis): void { $redis->subscriber(); @@ -1902,13 +1902,13 @@ public function testClusterDiscoveryReleaseCancellationSupersedesOrdinaryDiscove 'seeds' => ['tcp://127.0.0.1:6379'], ], ]); - $pool->expects('get')->andReturn($connection); - $factory = m::mock(PoolFactory::class); - $factory->expects('getPool')->with('default')->andReturn($pool); + $pool->expects('borrow')->andReturn($connection); + $poolManager = m::mock(PoolManager::class); + $poolManager->expects('pool')->with('default')->andReturn($pool); try { (new RedisProxy( - $factory, + $poolManager, 'default', $this->sentinelFactory(), ))->subscriber(); @@ -1994,12 +1994,12 @@ public function testIsClusterReturnsFalseForStandardConfig(): void 'host' => '127.0.0.1', 'port' => 6379, ]); - $pool->shouldReceive('get')->never(); + $pool->shouldReceive('borrow')->never(); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); - $redis = new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); + $redis = new RedisProxy($poolManager, 'default', $this->sentinelFactory()); $this->assertFalse($redis->isCluster()); } @@ -2010,12 +2010,12 @@ public function testIsClusterReturnsTrueForClusterConfig(): void $pool->shouldReceive('getConfig')->andReturn([ 'cluster' => ['enabled' => true, 'seeds' => ['tcp://127.0.0.1:6379']], ]); - $pool->shouldReceive('get')->never(); + $pool->shouldReceive('borrow')->never(); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('cache')->andReturn($pool); - $proxy = new RedisProxy($poolFactory, 'cache', $this->sentinelFactory()); + $proxy = new RedisProxy($poolManager, 'cache', $this->sentinelFactory()); $this->assertTrue($proxy->isCluster()); } @@ -2027,13 +2027,12 @@ public function testProxyUsesSpecifiedPoolName(): void $cacheConnection->shouldReceive('release')->once(); $cachePool = m::mock(RedisPool::class); - $cachePool->shouldReceive('get')->andReturn($cacheConnection); + $cachePool->shouldReceive('borrow')->andReturn($cacheConnection); - $poolFactory = m::mock(PoolFactory::class); - // Expect 'cache' pool to be requested, not 'default' - $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($cachePool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('cache')->andReturn($cachePool); - $proxy = new RedisProxy($poolFactory, 'cache', $this->sentinelFactory()); + $proxy = new RedisProxy($poolManager, 'cache', $this->sentinelFactory()); $result = $proxy->get('key'); @@ -2048,12 +2047,12 @@ public function testProxyContextKeyUsesPoolName(): void $connection->shouldReceive('release')->once(); $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->andReturn($connection); + $pool->shouldReceive('borrow')->andReturn($connection); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('cache')->andReturn($pool); - $proxy = new RedisProxy($poolFactory, 'cache', $this->sentinelFactory()); + $proxy = new RedisProxy($poolManager, 'cache', $this->sentinelFactory()); $proxy->pipeline(); @@ -2081,13 +2080,13 @@ private function mockConnection(): m\MockInterface|RedisConnection private function createRedis(m\MockInterface|RedisConnection $connection): RedisProxy { $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->andReturn($connection); - $pool->shouldReceive('getOption')->andReturn(new PoolOption); + $pool->shouldReceive('borrow')->andReturn($connection); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); - return new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); + return new RedisProxy($poolManager, 'default', $this->sentinelFactory()); } /** @@ -2095,17 +2094,17 @@ private function createRedis(m\MockInterface|RedisConnection $connection): Redis */ private function createRedisWithSubscriber(Subscriber $subscriber): RedisProxy { - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldNotReceive('getPool'); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldNotReceive('pool'); - return new class($poolFactory, 'default', $this->sentinelFactory(), $subscriber) extends RedisProxy { + return new class($poolManager, 'default', $this->sentinelFactory(), $subscriber) extends RedisProxy { public function __construct( - PoolFactory $poolFactory, + PoolManager $poolManager, string $name, RedisSentinelFactory $sentinelFactory, private Subscriber $subscriber, ) { - parent::__construct($poolFactory, $name, $sentinelFactory); + parent::__construct($poolManager, $name, $sentinelFactory); } public function subscriber(): Subscriber @@ -2167,14 +2166,14 @@ private function createCountingRedis( m\MockInterface|RedisConnection ...$connections ): RedisProxyReleaseCountingStub { $pool = m::mock(RedisPool::class); - $pool->shouldReceive('get')->andReturn(...$connections); - $pool->shouldReceive('getOption')->andReturn(new PoolOption); + $pool->shouldReceive('borrow')->andReturn(...$connections); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray([])); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('pool')->with('default')->andReturn($pool); return new RedisProxyReleaseCountingStub( - $poolFactory, + $poolManager, 'default', $this->sentinelFactory(), ); @@ -2283,14 +2282,14 @@ private function baseConnectionConfig(): array 'backoff_base' => 100, 'backoff_cap' => 1000, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 10, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1.0, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ], ]; } diff --git a/tests/Reverb/ReverbTestCase.php b/tests/Reverb/ReverbTestCase.php index 8a3d9d2b8d..7e9f8e900b 100644 --- a/tests/Reverb/ReverbTestCase.php +++ b/tests/Reverb/ReverbTestCase.php @@ -66,11 +66,11 @@ protected function defineEnvironment(ApplicationContract $app): void 'port' => 6379, 'database' => 0, 'pool' => array_replace($config->array('database.redis.default.pool'), [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, ]), ]); diff --git a/tests/Reverb/Servers/Hypervel/HypervelServerProviderTest.php b/tests/Reverb/Servers/Hypervel/HypervelServerProviderTest.php index 50dfef9c3e..c0304430d3 100644 --- a/tests/Reverb/Servers/Hypervel/HypervelServerProviderTest.php +++ b/tests/Reverb/Servers/Hypervel/HypervelServerProviderTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Reverb\Servers\Hypervel; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisProxy; use Hypervel\Reverb\Servers\Hypervel\Contracts\SharedState; @@ -101,8 +101,8 @@ public function testRedisClusterScalingIsRejectedWithoutCreatingAPool(): void 'enabled' => true, 'seeds' => ['127.0.0.1:6379'], ]); - $this->app->instance(PoolFactory::class, $poolFactory = m::mock(PoolFactory::class)); - $poolFactory->shouldNotReceive('getPool'); + $this->app->instance(PoolManager::class, $poolManager = m::mock(PoolManager::class)); + $poolManager->shouldNotReceive('pool'); $provider = new HypervelServerProvider( $this->app, [ diff --git a/tests/Sentry/ConfigTest.php b/tests/Sentry/ConfigTest.php index c9a5f834c0..113da9e2e0 100644 --- a/tests/Sentry/ConfigTest.php +++ b/tests/Sentry/ConfigTest.php @@ -6,7 +6,7 @@ use Hypervel\Sentry\Features\RedisFeature; use Hypervel\Sentry\Transport\HttpPoolTransport; -use Hypervel\Sentry\Transport\Pool; +use Hypervel\Sentry\Transport\HttpTransportPool; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; @@ -26,8 +26,6 @@ public function testPoolIsConstructedFromSentryPoolConfig(): void ], ]); - // Verify the Pool is actually constructed with the config values from sentry.pool. - // The old bug read from 'pools.sentry' which didn't exist, so the Pool always got defaults. /** @var ClientBuilder $builder */ $builder = $this->app->make(ClientBuilder::class); @@ -40,8 +38,22 @@ public function testPoolIsConstructedFromSentryPoolConfig(): void $this->assertSame(7, $pool->getOptions()->maxObjects); $this->assertSame(0.05, $pool->getOptions()->waitTimeout); $this->assertSame(120.0, $pool->getOptions()->maxLifetime); - $this->assertSame(0.0, $pool->getOptions()->maxIdleTime); - $this->assertNull($pool->getOptions()->idleTtl); + $this->assertNull($pool->getOptions()->maxIdleTime); + $this->assertNull($pool->getOptions()->poolIdleTimeout); + } + + public function testTransportLifetimeCanBeDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.pool.max_lifetime' => null, + ]); + + $builder = $this->app->make(ClientBuilder::class); + $pool = $this->getPoolFromTransport($this->getTransportFromBuilder($builder)); + + $this->assertNull($pool->getOptions()->maxLifetime); + $this->assertNull($pool->getOptions()->maxIdleTime); + $this->assertNull($pool->getOptions()->poolIdleTimeout); } #[DataProvider('unsupportedPoolOptions')] @@ -63,7 +75,7 @@ public static function unsupportedPoolOptions(): array return [ ['min_retained_objects', 1], ['max_idle_time', 30], - ['idle_ttl', 300], + ['pool_idle_timeout', 300], ['unknown', true], ]; } @@ -157,7 +169,7 @@ private function getTransportFromBuilder(ClientBuilder $builder): TransportInter return $reflection->getValue($builder); } - private function getPoolFromTransport(HttpPoolTransport $transport): Pool + private function getPoolFromTransport(HttpPoolTransport $transport): HttpTransportPool { $reflection = new ReflectionProperty($transport, 'pool'); diff --git a/tests/Sentry/Features/RedisIntegrationTest.php b/tests/Sentry/Features/RedisIntegrationTest.php index fd4cfbec57..9b64358e7a 100644 --- a/tests/Sentry/Features/RedisIntegrationTest.php +++ b/tests/Sentry/Features/RedisIntegrationTest.php @@ -6,22 +6,24 @@ use Error; use Exception; +use Hypervel\ConnectionPool\PoolOptions; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Contracts\Pool\PoolOptionInterface; use Hypervel\Contracts\Session\Session; +use Hypervel\Coordinator\Timer; use Hypervel\Http\Request; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\PhpRedisConnection; -use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolManager; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisConnection; use Hypervel\Sentry\Features\RedisFeature; use Hypervel\Tests\Sentry\SentryTestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\State\HubInterface; @@ -312,7 +314,10 @@ public function testRedisCommandIncludesPoolInformation(): void $this->assertEquals(10, $spanData['db.redis.pool.max']); $this->assertEquals(60.0, $spanData['db.redis.pool.max_idle_time']); $this->assertEquals(5, $spanData['db.redis.pool.idle']); - $this->assertEquals(2, $spanData['db.redis.pool.using']); + $this->assertEquals(7, $spanData['db.redis.pool.managed']); + $this->assertEquals(2, $spanData['db.redis.pool.borrowed']); + $this->assertEquals(1, $spanData['db.redis.pool.waiting']); + $this->assertArrayNotHasKey('db.redis.pool.using', $spanData); } public function testRedisCommandWithDifferentConfiguration(): void @@ -412,21 +417,72 @@ public function testFailedRedisCommandCreatesErrorSpanWithoutTime(): void $this->assertArrayNotHasKey('duration', $redisSpan->getData()); } - private function setupMocks(string $connectionName = 'default', int $database = 0): void + #[TestWith([false])] + #[TestWith([true])] + public function testTracingWithoutARegisteredPoolDoesNotCreateOne(bool $purgePool): void { - $poolOption = m::mock(PoolOptionInterface::class); - $poolOption->shouldReceive('getMaxConnections')->andReturn(10); - $poolOption->shouldReceive('getMaxIdleTime')->andReturn(60.0); + config()->set('database.redis.default.pool.heartbeat_interval', 1.0); + $manager = $this->app->make(PoolManager::class); + $timerCount = Timer::stats()['num']; + + try { + if ($purgePool) { + $pool = $manager->pool('default'); + $manager->purge('default'); + $this->assertTrue($pool->isClosed()); + } + + $this->assertSame([], $manager->getPools()); + $transaction = $this->startTransaction(); + + $this->app->make(Dispatcher::class)->dispatch( + new CommandExecuted('GET', ['test-key'], 0.005, $this->createRedisConnection('default')), + ); + + $spans = $transaction->getSpanRecorder()->getSpans(); + $this->assertCount(2, $spans); + $this->assertSame('GET test-key', $spans[1]->getDescription()); + $this->assertArrayNotHasKey('db.redis.pool.name', $spans[1]->getData()); + $this->assertSame([], $manager->getPools()); + $this->assertSame($timerCount, Timer::stats()['num']); + } finally { + $manager->purgeAll(); + } + } + + public function testRedisSpanRetainsNullIdleTimeout(): void + { + $this->setupMocks(maxIdleTime: null); + $transaction = $this->startTransaction(); + + $this->app->make(Dispatcher::class)->dispatch( + new CommandExecuted('GET', ['test-key'], 0.005, $this->createRedisConnection('default')), + ); + $spans = $transaction->getSpanRecorder()->getSpans(); + $this->assertCount(2, $spans); + $data = $spans[1]->getData(); + $this->assertArrayHasKey('db.redis.pool.max_idle_time', $data); + $this->assertNull($data['db.redis.pool.max_idle_time']); + } + + /** + * Register an observed Redis pool. + */ + private function setupMocks(string $connectionName = 'default', int $database = 0, ?float $maxIdleTime = 60.0): void + { $pool = m::mock(RedisPool::class); - $pool->shouldReceive('getOption')->andReturn($poolOption); - $pool->shouldReceive('getConnectionsInChannel')->andReturn(5); - $pool->shouldReceive('getCurrentConnections')->andReturn(2); + $pool->shouldReceive('getOptions')->andReturn(PoolOptions::fromArray(['max_idle_time' => $maxIdleTime])); + $pool->shouldReceive('getManagedCount')->andReturn(7); + $pool->shouldReceive('getBorrowedCount')->andReturn(2); + $pool->shouldReceive('getIdleCount')->andReturn(5); + $pool->shouldReceive('getWaitingCount')->andReturn(1); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->shouldReceive('getPool')->with($connectionName)->andReturn($pool); + $poolManager = m::mock(PoolManager::class); + $poolManager->shouldReceive('getPools')->andReturn([$connectionName => $pool]); + $poolManager->shouldNotReceive('pool'); - $this->app->instance(PoolFactory::class, $poolFactory); + $this->app->instance(PoolManager::class, $poolManager); $this->app->make('config')->set("database.redis.{$connectionName}.database", $database); } diff --git a/tests/Sentry/Features/StorageIntegrationTest.php b/tests/Sentry/Features/StorageIntegrationTest.php index 69e1bbc288..e875da3680 100644 --- a/tests/Sentry/Features/StorageIntegrationTest.php +++ b/tests/Sentry/Features/StorageIntegrationTest.php @@ -6,11 +6,11 @@ use DateTimeImmutable; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Filesystem\AwsS3V3Adapter; use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Filesystem\FilesystemManager; use Hypervel\Filesystem\FilesystemPoolProxy; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\Sentry\Features\Storage\DecoratedFilesystem; use Hypervel\Sentry\Features\Storage\Integration; use Hypervel\Sentry\Features\Storage\SentryFilesystemAdapter; diff --git a/tests/Sentry/HttpPoolTransportNonCoroutineTest.php b/tests/Sentry/HttpPoolTransportNonCoroutineTest.php index a6ba0c4006..efc2580c56 100644 --- a/tests/Sentry/HttpPoolTransportNonCoroutineTest.php +++ b/tests/Sentry/HttpPoolTransportNonCoroutineTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Sentry; use Hypervel\Sentry\Transport\HttpPoolTransport; -use Hypervel\Sentry\Transport\Pool; +use Hypervel\Sentry\Transport\HttpTransportPool; use Hypervel\Tests\TestCase; use Mockery as m; use Sentry\Event; @@ -23,8 +23,8 @@ public function testRootCoroutineOwnsAndReleasesTheTransport(): void $httpTransport->shouldReceive('send') ->once() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get')->once()->andReturn($httpTransport); + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow')->once()->andReturn($httpTransport); $pool->shouldReceive('release')->once()->with($httpTransport); $transport = new HttpPoolTransport($pool); $event = Event::createEvent(); diff --git a/tests/Sentry/HttpPoolTransportTest.php b/tests/Sentry/HttpPoolTransportTest.php index ac102620ae..a4cc150ef9 100644 --- a/tests/Sentry/HttpPoolTransportTest.php +++ b/tests/Sentry/HttpPoolTransportTest.php @@ -10,12 +10,15 @@ use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Coroutine as EngineCoroutine; +use Hypervel\ObjectPool\Exceptions\PoolExhaustedException; +use Hypervel\ObjectPool\PoolOptions; use Hypervel\Sentry\Transport\HttpPoolTransport; -use Hypervel\Sentry\Transport\Pool; +use Hypervel\Sentry\Transport\HttpTransportPool; use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; use Sentry\Event; +use Sentry\Options; use Sentry\Transport\HttpTransport; use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; @@ -25,10 +28,10 @@ class HttpPoolTransportTest extends TestCase { public function testBackpressureReturnsSkippedWhenPoolExhausted(): void { - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->once() - ->andThrow(new RuntimeException('Object pool exhausted. Cannot create new object before wait_timeout.')); + ->andThrow(new PoolExhaustedException('Object pool exhausted. Cannot create new object before wait_timeout.')); $transport = new HttpPoolTransport($pool); @@ -37,19 +40,25 @@ public function testBackpressureReturnsSkippedWhenPoolExhausted(): void $this->assertSame(ResultStatus::skipped(), $result->getStatus()); } - public function testBackpressureDoesNotBlockOnPoolExhaustion(): void + public function testAcquisitionTimeoutReturnsSkippedWithoutReleasingTheBorrowedTransport(): void { - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') - ->once() - ->andThrow(new RuntimeException('Object pool exhausted.')); - + $pool = m::mock(HttpTransportPool::class, [ + new Options([]), + PoolOptions::fromArray(['max_objects' => 1, 'wait_timeout' => 0.001]), + ])->makePartial()->shouldAllowMockingProtectedMethods(); + $pool->shouldReceive('createObject')->once()->andReturn(m::mock(HttpTransport::class)); + $borrowed = $pool->borrow(); $transport = new HttpPoolTransport($pool); - // Should return immediately without blocking — no exception thrown - $result = $transport->send(Event::createEvent()); + try { + $result = $transport->send(Event::createEvent()); - $this->assertSame(ResultStatus::skipped(), $result->getStatus()); + $this->assertSame(ResultStatus::skipped(), $result->getStatus()); + $this->assertSame(1, $pool->getBorrowedCount()); + } finally { + $pool->release($borrowed); + $transport->shutdown(); + } } public function testAcceptedSendReturnsItsEventAndReleasesTransportAfterCompletion(): void @@ -59,8 +68,8 @@ public function testAcceptedSendReturnsItsEventAndReleasesTransportAfterCompleti ->once() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->once() ->andReturn($httpTransport); $pool->shouldReceive('release') @@ -77,6 +86,38 @@ public function testAcceptedSendReturnsItsEventAndReleasesTransportAfterCompleti $this->assertSame(ResultStatus::success(), $transport->close()->getStatus()); } + public function testClosedPoolReturnsSkipped(): void + { + $pool = new HttpTransportPool(new Options([]), PoolOptions::fromArray([])); + $pool->close(); + + $result = (new HttpPoolTransport($pool))->send(Event::createEvent()); + + $this->assertSame(ResultStatus::skipped(), $result->getStatus()); + } + + public function testTransportCreationFailurePropagatesUnchanged(): void + { + $failure = new RuntimeException('HTTP client creation failed.'); + $pool = m::mock(HttpTransportPool::class, [new Options([]), PoolOptions::fromArray([])]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $pool->shouldReceive('getHttpClient')->once()->andThrow($failure); + $transport = new HttpPoolTransport($pool); + $caught = null; + + try { + $transport->send(Event::createEvent()); + } catch (RuntimeException $exception) { + $caught = $exception; + } finally { + $transport->shutdown(); + } + + $this->assertSame($failure, $caught); + $this->assertSame(0, $pool->getManagedCount()); + } + public function testDeliveryMarkerIsAvailableToChildStartupHooks(): void { $marker = new Channel(1); @@ -87,8 +128,8 @@ public function testDeliveryMarkerIsAvailableToChildStartupHooks(): void $httpTransport->shouldReceive('send') ->once() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get')->once()->andReturn($httpTransport); + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow')->once()->andReturn($httpTransport); $pool->shouldReceive('release')->once()->with($httpTransport); $transport = new HttpPoolTransport($pool); @@ -110,8 +151,8 @@ public function testMultipleSendsThenCloseReleasesAllTransports(): void ->once() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->twice() ->andReturn($httpTransport1, $httpTransport2); $pool->shouldReceive('release') @@ -138,8 +179,8 @@ public function testThreeSendsThenCloseReleasesAllTransports(): void ->andReturn(new Result(ResultStatus::success())); } - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->times(3) ->andReturn($httpTransports[0], $httpTransports[1], $httpTransports[2]); foreach ($httpTransports as $httpTransport) { @@ -163,8 +204,8 @@ public function testUnexpectedChildFailureDiscardsTransport(): void ->once() ->andThrow(new RuntimeException('Send failed')); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->once() ->andReturn($httpTransport); $pool->shouldReceive('discard') @@ -190,8 +231,8 @@ public function testFailedTransportIsReplacedOnTheNextBorrow(): void $replacement->shouldReceive('send') ->once() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get')->twice()->andReturn($failed, $replacement); + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow')->twice()->andReturn($failed, $replacement); $pool->shouldReceive('discard')->once()->with($failed); $pool->shouldReceive('release')->once()->with($replacement); $transport = new HttpPoolTransport($pool); @@ -218,8 +259,8 @@ public function testMixedSuccessAndFailureFinalizesCorrectly(): void ->once() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->times(3) ->andReturn($httpTransport1, $httpTransport2, $httpTransport3); // transport2 is discarded immediately on exception. @@ -268,8 +309,8 @@ static function () use ($secondStarted, $releaseSecond): Result { } ); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get')->twice()->andReturn($first, $second); + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow')->twice()->andReturn($first, $second); $pool->shouldReceive('release')->once()->with($first); $pool->shouldReceive('release')->once()->with($second); @@ -300,8 +341,8 @@ public function testCoroutineCreationFailureBalancesTheGenerationAndReleasesTheT $httpTransport = m::mock(HttpTransport::class); $httpTransport->shouldNotReceive('send'); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get')->once()->andReturn($httpTransport); + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow')->once()->andReturn($httpTransport); $pool->shouldReceive('release')->once()->with($httpTransport); $transport = new FailingCoroutineHttpPoolTransport($pool); @@ -317,8 +358,8 @@ public function testStartupCancellationReleasesAnUntouchedTransport(): void Container::getInstance()->instance(ExceptionHandlerContract::class, $handler); $httpTransport = m::mock(HttpTransport::class); $httpTransport->shouldNotReceive('send'); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get')->once()->andReturn($httpTransport); + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow')->once()->andReturn($httpTransport); $pool->shouldReceive('release')->once()->with($httpTransport); $pool->shouldNotReceive('discard'); $transport = new HttpPoolTransport($pool); @@ -362,7 +403,7 @@ public function testStartupCancellationReleasesAnUntouchedTransport(): void public function testShutdownClosesThePool(): void { - $pool = m::mock(Pool::class); + $pool = m::mock(HttpTransportPool::class); $pool->shouldReceive('close')->once(); (new HttpPoolTransport($pool))->shutdown(); @@ -370,7 +411,7 @@ public function testShutdownClosesThePool(): void public function testCloseWithNoSendsDoesNothing(): void { - $pool = m::mock(Pool::class); + $pool = m::mock(HttpTransportPool::class); $pool->shouldNotReceive('release'); $transport = new HttpPoolTransport($pool); @@ -387,8 +428,8 @@ public function testRepeatedCloseDoesNotReleaseACompletedSendAgain(): void ->once() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->once() ->andReturn($httpTransport); $pool->shouldReceive('release') @@ -411,8 +452,8 @@ public function testChildReleasesTransportWithoutARequestClose(): void $released = new Channel(1); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->once() ->andReturn($httpTransport); $pool->shouldReceive('release') @@ -442,8 +483,8 @@ public function testCloseDoesNotReleaseAnAlreadyCompletedSendAgain(): void $releaseCount = 0; - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + $pool = m::mock(HttpTransportPool::class); + $pool->shouldReceive('borrow') ->once() ->andReturn($httpTransport); $pool->shouldReceive('release') diff --git a/tests/Sentry/PoolTest.php b/tests/Sentry/HttpTransportPoolTest.php similarity index 86% rename from tests/Sentry/PoolTest.php rename to tests/Sentry/HttpTransportPoolTest.php index 8da7315d2d..cbf345b4f0 100644 --- a/tests/Sentry/PoolTest.php +++ b/tests/Sentry/HttpTransportPoolTest.php @@ -8,7 +8,7 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\PackageManifest; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\Sentry\Transport\Pool; +use Hypervel\Sentry\Transport\HttpTransportPool; use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionProperty; @@ -19,7 +19,7 @@ use Sentry\Options; use Sentry\Transport\ResultStatus; -class PoolTest extends TestCase +class HttpTransportPoolTest extends TestCase { public function testCreatesTheSdkHttpClient(): void { @@ -31,7 +31,7 @@ public function testCreatesTheSdkHttpClient(): void Container::getInstance()->singleton(PackageManifest::class, fn () => $manifest); - $pool = new InspectableSentryTransportPool( + $pool = new InspectableHttpTransportPool( new Options, $this->poolOptions(), ); @@ -54,7 +54,7 @@ public function testCreatesTheSdkHttpClientWithTheFrameworkVersionAsFallback(): Container::getInstance()->singleton(PackageManifest::class, fn () => $manifest); - $pool = new InspectableSentryTransportPool(new Options, $this->poolOptions()); + $pool = new InspectableHttpTransportPool(new Options, $this->poolOptions()); $httpClient = $pool->createHttpClient(); $this->assertSame(Application::VERSION, (new ReflectionProperty($httpClient, 'sdkVersion'))->getValue($httpClient)); @@ -70,17 +70,17 @@ public function testPooledTransportRetainsRateLimitsFromRealResponses(): void 'X-Sentry-Rate-Limits' => ['60:error'], ], '')); - $pool = new ScriptedSentryTransportPool( + $pool = new ScriptedHttpTransportPool( new Options(['dsn' => 'https://public@example.com/1']), $this->poolOptions(), $httpClient, ); - $transport = $pool->get(); + $transport = $pool->borrow(); $this->assertSame(ResultStatus::rateLimit(), $transport->send(Event::createEvent())->getStatus()); $pool->release($transport); - $sameTransport = $pool->get(); + $sameTransport = $pool->borrow(); $this->assertSame($transport, $sameTransport); $this->assertSame(ResultStatus::rateLimit(), $sameTransport->send(Event::createEvent())->getStatus()); $pool->release($sameTransport); @@ -96,13 +96,13 @@ private function poolOptions(): PoolOptions 'min_retained_objects' => 0, 'max_objects' => 1, 'wait_timeout' => 0.1, - 'max_lifetime' => 0, - 'idle_ttl' => null, + 'max_lifetime' => null, + 'pool_idle_timeout' => null, ]); } } -class InspectableSentryTransportPool extends Pool +class InspectableHttpTransportPool extends HttpTransportPool { public function createHttpClient(): HttpClientInterface { @@ -110,7 +110,7 @@ public function createHttpClient(): HttpClientInterface } } -class ScriptedSentryTransportPool extends Pool +class ScriptedHttpTransportPool extends HttpTransportPool { public function __construct( Options $sentryOptions, diff --git a/tests/Telescope/Watchers/RedisWatcherTest.php b/tests/Telescope/Watchers/RedisWatcherTest.php index 47f8052a7d..53df727ee4 100644 --- a/tests/Telescope/Watchers/RedisWatcherTest.php +++ b/tests/Telescope/Watchers/RedisWatcherTest.php @@ -44,14 +44,14 @@ class RedisWatcherTest extends FeatureTestCase 'backoff_base' => 100, 'backoff_cap' => 1000, 'pool' => [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 10, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1.0, + 'heartbeat_interval' => null, 'heartbeat_timeout' => 1.0, 'max_idle_time' => 60.0, - 'max_lifetime' => -1.0, + 'max_lifetime' => null, ], ]; diff --git a/tests/Telescope/Watchers/ReverbWatcherTest.php b/tests/Telescope/Watchers/ReverbWatcherTest.php index 5555d80121..4f1bb595e4 100644 --- a/tests/Telescope/Watchers/ReverbWatcherTest.php +++ b/tests/Telescope/Watchers/ReverbWatcherTest.php @@ -79,11 +79,11 @@ protected function defineEnvironment(ApplicationContract $app): void 'port' => 6379, 'database' => 0, 'pool' => array_replace($config->array('database.redis.default.pool'), [ - 'min_connections' => 1, + 'min_retained_connections' => 1, 'max_connections' => 1, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, ]), ]); diff --git a/tests/Testbench/Fixtures/config/database.php b/tests/Testbench/Fixtures/config/database.php index 5577baaf4f..beff3f8a52 100644 --- a/tests/Testbench/Fixtures/config/database.php +++ b/tests/Testbench/Fixtures/config/database.php @@ -9,11 +9,11 @@ 'port' => 6381, 'database' => 9, 'pool' => [ - 'min_connections' => 2, + 'min_retained_connections' => 2, 'max_connections' => 4, 'connect_timeout' => 10.0, 'wait_timeout' => 3.0, - 'heartbeat' => -1, + 'heartbeat_interval' => null, 'max_idle_time' => 60.0, ], ], diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index adc9f82844..940d6b9af2 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -6,8 +6,8 @@ use Carbon\CarbonInterface; use Hypervel\Contracts\Cache\Factory as CacheFactory; +use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Contracts\Pool\ConnectionInterface; use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; use Hypervel\Data\Lazy; @@ -641,7 +641,7 @@ protected function flushFrameworkState(): void public function testDatabaseCleanupFailureDoesNotSkipFrameworkStateReset(): void { $expectedException = new RuntimeException('database cleanup failed'); - $connection = new class($expectedException) implements ConnectionInterface { + $connection = new class($expectedException) implements PoolConnection { public function __construct(private RuntimeException $exception) { }