Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0e4730a
Isolate migration connection and output state by coroutine
binaryfire Oct 21, 2026
51e9002
Reject recursive cache stores and share generated table defaults
binaryfire Oct 21, 2026
5b445e3
Reject recursive failover queue definitions before use
binaryfire Oct 21, 2026
61ba99b
Centralize Redis client and hash expiration requirements
binaryfire Oct 21, 2026
d4f4a85
Expose the owning default for Redis session connections
binaryfire Oct 21, 2026
dc071ea
Add Hypervel Doctor diagnostics and repair commands
binaryfire Oct 21, 2026
af06623
Document Doctor usage and Laravel porting differences
binaryfire Oct 21, 2026
942ffdd
Record the completed Doctor port design
binaryfire Oct 21, 2026
f279439
Expose dotenv-owned variable names for fresh application boots
binaryfire Oct 22, 2026
a6df9c3
Describe exceptions propagated through console and facade calls
binaryfire Oct 22, 2026
f01da73
Add JSON output to config:show
binaryfire Oct 22, 2026
eaff815
Honor startup environment selection during server bootstrap
binaryfire Oct 22, 2026
f900022
Forward explicit environments to cache-building subprocesses
binaryfire Oct 22, 2026
9b5fc6a
Respect current configuration cache state when workers reload
binaryfire Oct 22, 2026
845f6e4
Keep server console-mode overrides local to the current process
binaryfire Oct 22, 2026
1da6bfc
Replace exported application-key assignments during key generation
binaryfire Oct 22, 2026
6475d57
Report empty Beanstalkd tubes as zero queued jobs
binaryfire Oct 22, 2026
7a4703e
Normalize enum connection names in queue failover events
binaryfire Oct 22, 2026
51d86b1
Use migration filtering and runtime configuration in Doctor repairs
binaryfire Oct 22, 2026
879eee1
Repair the storage links inspected by Doctor
binaryfire Oct 22, 2026
6d5c4b0
Verify Doctor environment repairs through fresh application boots
binaryfire Oct 22, 2026
074f3ac
Match Doctor service checks to the owning driver factories
binaryfire Oct 22, 2026
2ae1b83
Require interactive input for prompted Doctor repairs
binaryfire Oct 22, 2026
1581b93
Preserve configuration export errors and coroutine cancellation
binaryfire Oct 22, 2026
94e87e9
Disable Composer scripts during autoload inspection
binaryfire Oct 22, 2026
2320971
Create SQLite database files without truncating existing data
binaryfire Oct 22, 2026
386dc1f
Preserve directory permissions during Doctor storage repairs
binaryfire Oct 22, 2026
f787d95
Document Doctor repairs and server environment selection
binaryfire Oct 22, 2026
8f75a94
Record the final Doctor repair and bootstrap design
binaryfire Oct 22, 2026
3d35c16
Preserve literal diagnostic text in machine output
binaryfire Oct 22, 2026
4ee8b44
Keep route-list JSON free of console formatting
binaryfire Oct 22, 2026
0f3f166
Preserve queue names in JSON command output
binaryfire Oct 22, 2026
f5e102a
Isolate configuration-file locals during Doctor checks
binaryfire Oct 22, 2026
276c5cb
Let explicit Composer repairs finish without a timeout
binaryfire Oct 22, 2026
5e67559
Normalize distribution PHP versions for Doctor requirements
binaryfire Oct 22, 2026
1865012
Preserve trailing dotenv content when generating application keys
binaryfire Oct 22, 2026
a08c8b2
Align Doctor documentation with its final behavior
binaryfire Oct 22, 2026
b6cafe9
Keep migration state tied to its owning instance
binaryfire Oct 22, 2026
2026526
Preserve independent failure history for replacement cache stores
binaryfire Oct 22, 2026
973af47
Report failures from replacement failover queues
binaryfire Oct 22, 2026
76af22c
Distinguish Composer audit policy failures from execution errors
binaryfire Oct 22, 2026
78e134f
Check the database endpoints selected by application configuration
binaryfire Oct 22, 2026
f04c3a8
Follow logging stack configuration when discovering active channels
binaryfire Oct 22, 2026
9222254
Remove ineffective Doctor test setup
binaryfire Oct 22, 2026
cc2cd7f
Document Doctor endpoint checks and audit policy reporting
binaryfire Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"aws/aws-sdk-php": "^3.322.9",
"brick/math": "^1.0",
"chillerlan/php-qrcode": "^6.0",
"composer/semver": "^3.5.1",
"defuse/php-encryption": "^2.4",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
Expand All @@ -60,6 +61,7 @@
"guzzlehttp/uri-template": "^1.0 || ^2.0",
"hypervel/guzzle": "^8.3",
"hypervel/guzzle-promises": "^3.1",
"laravel/agent-detector": "^2.0.5",
"laravel/serializable-closure": "^2.0.11",
"lcobucci/jwt": "^5.6",
"league/commonmark": "^2.10.3",
Expand Down Expand Up @@ -121,7 +123,6 @@
"algolia/algoliasearch-client-php": "^4.49.0",
"brianium/paratest": "^7.27.2",
"composer/composer": "^2.10.3",
"composer/semver": "^3.4",
"fakerphp/faker": "^1.24",
"friendsofphp/php-cs-fixer": "^3.57.2",
"intervention/image": "^4.2",
Expand Down Expand Up @@ -168,6 +169,7 @@
"hypervel/database": "self.version",
"hypervel/di": "self.version",
"hypervel/docs": "self.version",
"hypervel/doctor": "self.version",
"hypervel/encryption": "self.version",
"hypervel/engine": "self.version",
"hypervel/events": "self.version",
Expand Down Expand Up @@ -259,6 +261,7 @@
"Hypervel\\Data\\": "src/data/src/",
"Hypervel\\Database\\": "src/database/src/",
"Hypervel\\Di\\": "src/di/src/",
"Hypervel\\Doctor\\": "src/doctor/src/",
"Hypervel\\Encryption\\": "src/encryption/src/",
"Hypervel\\Engine\\": "src/engine/src/",
"Hypervel\\Events\\": "src/events/src/",
Expand Down Expand Up @@ -399,6 +402,7 @@
"Hypervel\\Cookie\\CookieServiceProvider",
"Hypervel\\Data\\DataServiceProvider",
"Hypervel\\Database\\DatabaseServiceProvider",
"Hypervel\\Doctor\\DoctorServiceProvider",
"Hypervel\\Encryption\\EncryptionServiceProvider",
"Hypervel\\Engine\\EngineServiceProvider",
"Hypervel\\Events\\EventServiceProvider",
Expand Down
384 changes: 384 additions & 0 deletions docs/plans/2026-10-21-doctor-port.md

Large diffs are not rendered by default.

44 changes: 43 additions & 1 deletion src/cache/src/CacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ protected function createDatabaseDriver(array $config): Repository
$config['connection'] ?? null,
$config['table'],
$this->getPrefix($config),
$config['lock_table'] ?? 'cache_locks',
$config['lock_table'] ?? DatabaseStore::DEFAULT_LOCK_TABLE,
$config['lock_lottery'] ?? [2, 100],
$config['lock_timeout'] ?? 86400,
serializableClassPolicy: $this->serializableClassPolicy,
Expand All @@ -235,6 +235,8 @@ protected function createDatabaseDriver(array $config): Repository
*/
protected function createFailoverDriver(array $config): Repository
{
$this->ensureStoresAreAcyclic($config);

return $this->repository(new FailoverStore(
$this,
$this->app->make(DispatcherContract::class),
Expand Down Expand Up @@ -357,6 +359,8 @@ protected function createSwooleDriver(array $config): Repository
*/
protected function createStackDriver(array $config): Repository
{
$this->ensureStoresAreAcyclic($config);

$stores = collect($config['stores'])->map(function ($config, $name) {
if (! is_array($config)) {
$name = $config;
Expand All @@ -371,6 +375,44 @@ protected function createStackDriver(array $config): Repository
return $this->repository(new StackStore($stores), $config);
}

/**
* Reject circular built-in store definitions before resolving their layers.
*
* @param list<string> $path
*/
protected function ensureStoresAreAcyclic(array $config, array $path = []): void
{
$driver = $config['driver'] ?? null;

if (! in_array($driver, ['stack', 'failover'], true)
|| isset($this->customCreators[$driver])
|| ! is_array($config['stores'] ?? null)) {
return;
}

foreach ($config['stores'] as $name => $options) {
$name = $driver === 'stack' && is_array($options) ? $name : $options;
$name = $name instanceof UnitEnum ? (string) enum_value($name) : $name;
$name ??= $this->getDefaultDriver();

if (! is_string($name)) {
continue;
}

if (($cycleStart = array_search($name, $path, true)) !== false) {
$cycle = [...array_slice($path, $cycleStart), $name];

throw new InvalidArgumentException(
'Circular cache store definition detected: ' . implode(' -> ', $cycle) . '.'
);
}

if (($child = $this->getConfig($name)) !== null) {
$this->ensureStoresAreAcyclic($child, [...$path, $name]);
}
}
}

/**
* Create a new cache repository with the given implementation.
*/
Expand Down
9 changes: 6 additions & 3 deletions src/cache/src/Console/CacheTableCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

namespace Hypervel\Cache\Console;

use Hypervel\Cache\DatabaseStore;
use Hypervel\Console\MigrationGeneratorCommand;
use Symfony\Component\Console\Attribute\AsCommand;

#[AsCommand(name: 'make:cache-table', aliases: ['cache:table'])]
class CacheTableCommand extends MigrationGeneratorCommand
{
public const string TABLE = 'cache';

/**
* The name and signature of the console command.
*/
Expand All @@ -33,8 +36,8 @@ class CacheTableCommand extends MigrationGeneratorCommand
public function handle(): int
{
$tables = [
'cache' => __DIR__ . '/stubs/cache.stub',
'cache_locks' => __DIR__ . '/stubs/cache-locks.stub',
self::TABLE => __DIR__ . '/stubs/cache.stub',
DatabaseStore::DEFAULT_LOCK_TABLE => __DIR__ . '/stubs/cache-locks.stub',
];

foreach ($tables as $table => $stub) {
Expand All @@ -56,7 +59,7 @@ public function handle(): int
*/
protected function migrationTableName(): string
{
return 'cache';
return self::TABLE;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/cache/src/DatabaseStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class DatabaseStore implements CanFlushLocks, LockProvider, Store
{
use InteractsWithTime;

public const string DEFAULT_LOCK_TABLE = 'cache_locks';

/**
* The database connection resolver.
*/
Expand Down Expand Up @@ -81,7 +83,7 @@ public function __construct(
?string $connectionName,
string $table,
string $prefix = '',
string $lockTable = 'cache_locks',
string $lockTable = self::DEFAULT_LOCK_TABLE,
array $lockLottery = [2, 100],
int $defaultLockTimeoutInSeconds = 86400,
array|bool|null $serializableClasses = null,
Expand Down
36 changes: 29 additions & 7 deletions src/cache/src/FailoverStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Swoole\Coroutine\CanceledException;
use Throwable;
use UnitEnum;
use WeakReference;

use function Hypervel\Support\enum_value;

Expand All @@ -27,8 +28,8 @@ class FailoverStore extends TaggableStore implements AuthoritativeRawReadable, C
/**
* Context key prefix for the caches which failed on the last action.
*
* Scoped per instance via spl_object_id() so multiple failover stores
* in the same coroutine don't share failure history.
* Entries pair a weak owner with its failure list. A dead or different owner
* means no history, since released object IDs may be reused within a coroutine.
*
* Stored in coroutine Context instead of an instance property because this
* store is a worker-lifetime singleton (cached in CacheManager::$stores).
Expand Down Expand Up @@ -348,8 +349,7 @@ public function getPrefix(): string
protected function attemptOnAllStores(Closure|string $method, array $arguments = []): mixed
{
$contextKey = self::FAILING_CACHES_CONTEXT_PREFIX . spl_object_id($this);

$failingCaches = CoroutineContext::get($contextKey, []);
$failingCaches = $this->failingCaches($contextKey);

[$lastException, $failedCaches] = [null, []];

Expand Down Expand Up @@ -382,7 +382,7 @@ protected function attemptOnAllStores(Closure|string $method, array $arguments =
}
}
} finally {
CoroutineContext::set($contextKey, $failedCaches);
$this->rememberFailingCaches($contextKey, $failedCaches);
}

throw $lastException ?? new RuntimeException('All failover cache stores failed.');
Expand All @@ -396,7 +396,7 @@ protected function attemptOnAllStores(Closure|string $method, array $arguments =
protected function attemptOnEveryStore(string $method, array $arguments): array
{
$contextKey = self::FAILING_CACHES_CONTEXT_PREFIX . spl_object_id($this);
$failingCaches = CoroutineContext::get($contextKey, []);
$failingCaches = $this->failingCaches($contextKey);
$results = [];
$lastException = null;
$failedCaches = [];
Expand Down Expand Up @@ -425,12 +425,34 @@ protected function attemptOnEveryStore(string $method, array $arguments): array
}
}
} finally {
CoroutineContext::set($contextKey, $failedCaches);
$this->rememberFailingCaches($contextKey, $failedCaches);
}

return [$results, $lastException];
}

/**
* Get failure history belonging to this store instance.
*
* @return list<string>
*/
private function failingCaches(string $contextKey): array
{
$state = CoroutineContext::get($contextKey);

return $state !== null && $state[0]->get() === $this ? $state[1] : [];
}

/**
* Remember the stores that failed during the current operation.
*
* @param list<string> $failedCaches
*/
private function rememberFailingCaches(string $contextKey, array $failedCaches): void
{
CoroutineContext::set($contextKey, [WeakReference::create($this), $failedCaches]);
}

/**
* Build failure history after an interrupted failover attempt.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Hypervel\Cache\Redis\Console\Doctor\CheckResult;
use Hypervel\Redis\RedisConnection;
use Hypervel\Redis\RedisRequirements;
use Throwable;

/**
Expand Down Expand Up @@ -82,7 +83,9 @@ public function getFixInstructions(): ?string
}

if (! $this->available) {
return 'Any tagging mode requires Redis 8.0+ or Valkey 9.0+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.';
return 'Any tagging mode requires Redis ' . RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION
. '+ or Valkey ' . RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION
. '+ for hash-field expiration commands such as HSETEX and HEXPIRE. Upgrade your Redis/Valkey server, or switch to all tagging mode.';
}

return null;
Expand Down
9 changes: 3 additions & 6 deletions src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@
namespace Hypervel\Cache\Redis\Console\Doctor\Checks;

use Hypervel\Cache\Redis\Console\Doctor\CheckResult;
use Hypervel\Redis\RedisRequirements;

/**
* Checks that PHPRedis is installed with the version required by the tag mode.
*/
final class PhpRedisCheck implements EnvironmentCheckInterface
{
private const string MINIMUM_VERSION = '6.1.0';

private const string ANY_TAG_MINIMUM_VERSION = '6.3.0';

private ?string $installedVersion = null;

/**
Expand Down Expand Up @@ -84,7 +81,7 @@ public function getFixInstructions(): ?string
private function requiredVersion(): string
{
return $this->taggingMode === 'any'
? self::ANY_TAG_MINIMUM_VERSION
: self::MINIMUM_VERSION;
? RedisRequirements::HASH_FIELD_EXPIRATION_PHPREDIS_VERSION
: RedisRequirements::MINIMUM_PHPREDIS_VERSION;
}
}
22 changes: 9 additions & 13 deletions src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,17 @@

use Hypervel\Cache\Redis\Console\Doctor\CheckResult;
use Hypervel\Redis\RedisConnection;
use Hypervel\Redis\RedisRequirements;
use Throwable;

/**
* Checks that Redis/Valkey version meets requirements.
*
* For any mode: Requires Redis 8.0+ or Valkey 9.0+ for hash-field
* expiration commands including HSETEX and HEXPIRE.
* For any mode: Requires hash-field expiration commands including HSETEX and HEXPIRE.
* For all mode: Any version is acceptable (just verifies connection).
*/
final class RedisVersionCheck implements EnvironmentCheckInterface
{
private const string REDIS_REQUIRED_VERSION = '8.0.0';

private const string VALKEY_REQUIRED_VERSION = '9.0.0';

private ?string $serviceName = null;

private ?string $serviceVersion = null;
Expand Down Expand Up @@ -57,11 +53,11 @@ public function run(): CheckResult
if (isset($info['valkey_version'])) {
$this->serviceName = 'Valkey';
$this->serviceVersion = $info['valkey_version'];
$requiredVersion = self::VALKEY_REQUIRED_VERSION;
$requiredVersion = RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION;
} elseif (isset($info['redis_version'])) {
$this->serviceName = 'Redis';
$this->serviceVersion = $info['redis_version'];
$requiredVersion = self::REDIS_REQUIRED_VERSION;
$requiredVersion = RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION;
} else {
$result->assert(false, 'Could not determine Redis/Valkey version');

Expand All @@ -72,7 +68,7 @@ public function run(): CheckResult

// Version requirement only applies to any mode
if ($this->taggingMode === 'any') {
$versionIsSupported = version_compare($this->serviceVersion, $requiredVersion, '>=');
$versionIsSupported = RedisRequirements::hashFieldExpirationServerError($info) === null;
$result->assert(
$versionIsSupported,
"{$this->serviceName} version >= {$requiredVersion} (required for any tagging mode)"
Expand Down Expand Up @@ -105,14 +101,14 @@ public function getFixInstructions(): ?string
}

if ($this->serviceName === 'Redis' && $this->serviceVersion !== null) {
if (version_compare($this->serviceVersion, self::REDIS_REQUIRED_VERSION, '<')) {
return 'Upgrade to Redis ' . self::REDIS_REQUIRED_VERSION . '+ or Valkey ' . self::VALKEY_REQUIRED_VERSION . '+ for any tagging mode';
if (version_compare($this->serviceVersion, RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION, '<')) {
return 'Upgrade to Redis ' . RedisRequirements::HASH_FIELD_EXPIRATION_REDIS_VERSION . '+ or Valkey ' . RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION . '+ for any tagging mode';
}
}

if ($this->serviceName === 'Valkey' && $this->serviceVersion !== null) {
if (version_compare($this->serviceVersion, self::VALKEY_REQUIRED_VERSION, '<')) {
return 'Upgrade to Valkey ' . self::VALKEY_REQUIRED_VERSION . '+ for any tagging mode';
if (version_compare($this->serviceVersion, RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION, '<')) {
return 'Upgrade to Valkey ' . RedisRequirements::HASH_FIELD_EXPIRATION_VALKEY_VERSION . '+ for any tagging mode';
}
}

Expand Down
1 change: 1 addition & 0 deletions src/console/src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ public static function forgetBootstrappers(): void
* Run an Artisan console command by name.
*
* @throws CommandNotFoundException
* @throws Throwable when command execution fails
*/
public function call(string|SymfonyCommand $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int
{
Expand Down
2 changes: 2 additions & 0 deletions src/contracts/src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;

interface Application
{
Expand Down Expand Up @@ -48,6 +49,7 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu
* Run an Artisan console command by name.
*
* @throws CommandNotFoundException
* @throws Throwable when command execution fails
*/
public function call(string|SymfonyCommand $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int;

Expand Down
Loading
Loading