diff --git a/README.md b/README.md index 720fcf9..5f9cf92 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,206 @@ [![Latest Version on Packagist](https://img.shields.io/packagist/v/flytachi/winter-kernel.svg)](https://packagist.org/packages/flytachi/winter-kernel) [![PHP Version Require](https://img.shields.io/packagist/php-v/flytachi/winter-kernel.svg?style=flat-square)](https://packagist.org/packages/flytachi/winter-kernel) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) + +The kernel of the Winter framework — a PHP 8.4 library that turns a directory of +classes into a running application. It carries the HTTP layer, dependency injection, +the database layer (PPA), managed processes and daemons, scheduling, and the console. + +It is a **library, not a skeleton**: there is nothing to scaffold and no directory tree +to create. You add it to a project, write one class that says what the application +contains, and run it. + +--- + +## Requirements + +| | | +|---|---| +| PHP | **8.4+** | +| Extensions | `pcntl`, `posix`, `fileinfo` (required) · `swoole` (for the HTTP server, coroutines and connection pooling) · `pdo` for a database | + +Everything else is pulled by composer. + +--- + +## Install + +```bash +composer require flytachi/winter-kernel +``` + +## The whole application: two files + +**`bootstrap.php`** — loads the autoloader and declares what the application contains: + +```php + true]; + } +} +``` + +--- + +## What the application contains — `#[Enable*]` + +The attributes on the application class are the manifest. Each one adds a component; +declare none and the boot fails rather than starting an application that does nothing. + +| Attribute | Effect | +|---|---| +| `#[EnableWeb]` | the Swoole HTTP server | +| `#[EnableActuator]` | `/actuator` diagnostics (health, info, metrics, mappings) | +| `#[EnableScheduler]` | runs `#[Scheduled]` methods on their triggers | +| `#[EnableProcess(Foo::class)]` | a managed worker beside the server | +| `#[EnableDaemon(Bar::class)]` | a supervised fleet of workers beside the server | +| `#[EnableAsync]` | proxies `#[Async]` methods so they run off the request | +| `#[Import('vendor/pkg', '/prefix')]` | mounts a plugin package under a URL prefix | + +Everything else is configured by ordinary classes the scan finds — there are no +configuration hooks to override: + +```php +#[Configuration] / #[Bean] // DI factories +WebConfigurer // host, port, Swoole tuning, CORS, static files +LoggingConfigurer // extra log channels +``` + +--- + +## Project layout + +Only two directories are conventional, and both are optional: + +``` +resources/ + static/ web assets — served by Swoole when enabled (see WebConfigurer) + views/ view files — ResponseView's default root +storage/ + logs/ cache/ runnable/ created on demand, never committed +``` + +There is no `public/`: that belongs to the FPM document-root model, and the server here +decides for itself what it serves. Static files are opt-in: + +```php +final class WebConfig extends WebConfigurerAdapter +{ + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + $server->port(8000) + ->staticPath('resources/static'); // resources/static/app.css → /app.css + } +} +``` + +## Configuration + +`.env` is optional; every variable has a working default. + +| Variable | Default | Meaning | +|---|---|---| +| `DEBUG` | `false` | verbose errors and a full rescan on every boot | +| `LOG_LEVEL` | *(empty — logging off)* | `debug`…`emergency` | +| `LOG_OUTPUT` | `auto` → stdout | `stdout`, `stderr`, `file`, `syslog`, `null` | +| `LOG_FILE` | `storage/logs/.log` | absolute path when `LOG_OUTPUT=file` | +| `SERVER_WORKERS` | Swoole default | worker count | +| `WINTER_KEY` | *(none)* | signs payloads handed to background processes | +| `PPA_POOL_TELEMETRY` | `5` | seconds between pool-stat publishes; `0` disables | + +--- + +## Console + +```bash +php call # command list +php call run [dev] # serve +php call make -c UserController # scaffold a class +php call daemon start [-d] | stop | status +php call process start [-d] | stop | status +php call db ping | migrate | sql | pool +php call schedule start [-d] | stop | status +php call cfg completion -i # shell completion +``` + +Class names use dot notation: `main.process.Emails` → `Main\Process\Emails`. + +--- + +## Runtimes + +The kernel runs the same application two ways: + +- **Swoole** — one long-lived server process; coroutines, connection pooling and static + files handled in C. This is the primary target. +- **CLI / plain processes** — the console, and processes or daemons started on their own. + +FPM is not served by the kernel itself; it is moving to a separate `winter-fpm` project. + +--- + +## Documentation + +- [`docs/starter/00-quickstart.md`](docs/starter/00-quickstart.md) — from an empty + directory to a served request, then to a background worker. +- [`docs/`](docs) — the reference for each subsystem (routing, request binding, + responses, PPA, processes and daemons, scheduling, console, configuration). + +## Development + +```bash +vendor/bin/phpunit # the default suite +vendor/bin/phpunit --group integration # real forks, signals and servers +vendor/bin/phpcs # PSR-12 +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json index 8a9b965..3592264 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "autoload": { "psr-4": { "Flytachi\\Winter\\Console\\": "console/", - "Flytachi\\Winter\\K2\\": "src/" + "Flytachi\\Winter\\Kernel\\": "src/" }, "files": [ "function/dependencies.php" @@ -23,17 +23,17 @@ }, "autoload-dev": { "psr-4": { - "Flytachi\\Winter\\K2\\Tests\\": "tests" + "Flytachi\\Winter\\Kernel\\Tests\\": "tests" } }, "bin": ["wKernelRunner"], "require": { "php": ">=8.4", - "flytachi/winter-di": "^1.0", + "flytachi/winter-di": "^2.0", "flytachi/winter-base": "^3.0", "flytachi/winter-logger": "^1.0", "flytachi/winter-thread": "^3.0", - "flytachi/winter-cdo": "^3.0", + "flytachi/winter-cdo": "^4.0", "flytachi/winter-cache": "^1.0", "flytachi/file-store": "^2.0", "vlucas/phpdotenv": "^5.6", diff --git a/console/Command/Cfg.php b/console/Command/Cfg.php index 316e098..88229c0 100644 --- a/console/Command/Cfg.php +++ b/console/Command/Cfg.php @@ -5,9 +5,9 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Cfg extends Cmd +final class Cfg extends Cmd { public static string $title = "manage project configuration, environment and keys"; private string $templatePath; @@ -200,37 +200,18 @@ private function envShow(): void private function dockerArg(): void { - $runtime = array_key_exists('swoole', $this->args['options']) ? 'swoole' : 'fpm'; - - // Single unified template: one stable Dockerfile + docker/ (fpm, swoole, - // dependencies.sh) + compose. The runtime is selected by the RUNTIME - // build arg, not by which files are copied. + // Flat Swoole template: Dockerfile + docker-compose.yml + docker/ + // (entrypoint.sh, php-opcache.ini, dependencies/*.sh). dev vs prod is the + // DEV var in compose; DB drivers / extensions are the dependencies/ scripts. multiCopy($this->templatePath . '/Docker', Kernel::$pathRoot); - $this->setComposeRuntime($runtime); - self::printBadge('runtime', $runtime, 34, 36); self::printBadge('docker/', 'CREATED', 34, 32); - self::printBadge('docker/dependencies.sh', 'CREATED', 34, 32); + self::printBadge('docker/dependencies/', 'CREATED', 34, 32); self::printBadge('.dockerignore', 'CREATED', 34, 32); self::printBadge('docker-compose.yml', 'CREATED', 34, 32); self::printBadge('Dockerfile', 'CREATED', 34, 32); - self::printInfo("Switch runtime anytime: set RUNTIME (fpm|swoole) in docker-compose.yml"); - self::printInfo("Add extensions/cron: edit docker/dependencies.sh"); - } - - /** - * Pin the default RUNTIME build arg in the freshly scaffolded compose file. - * The template ships `RUNTIME: fpm`; on `--swoole` we flip it to swoole so - * `docker compose build` picks the requested runtime out of the box. - */ - private function setComposeRuntime(string $runtime): void - { - $compose = Kernel::$pathRoot . '/docker-compose.yml'; - if (is_file($compose)) { - $content = file_get_contents($compose); - $content = preg_replace('/(RUNTIME:\s*)(?:fpm|swoole)/', '${1}' . $runtime, $content, 1); - file_put_contents($compose, $content); - } + self::printInfo("Run: docker compose up (dev, hot-reload) | DEV=false docker compose up (prod)"); + self::printInfo("DB drivers / extensions: keep or remove docker/dependencies/*.sh"); } private function completionArg(): void @@ -356,11 +337,11 @@ public static function help(): void // docker self::printLabel("docker — scaffold Docker files", $cl); - self::print("--fpm default runtime = PHP-FPM + Nginx (default)", $cl); - self::print("--swoole default runtime = Swoole HTTP server", $cl); - self::print(" one stable Dockerfile serves both; switch via", $cl); - self::print(" RUNTIME in docker-compose.yml. Extra packages,", $cl); - self::print(" redis/pgsql/mysql, cron → docker/dependencies.sh", $cl); + self::print("Swoole image. dev vs prod = DEV in docker-compose.yml:", $cl); + self::print(" docker compose up dev (hot-reload, opcache off)", $cl); + self::print(" DEV=false docker compose up prod (opcache on, no watcher)", $cl); + self::print("DB drivers / extensions / cron → docker/dependencies/*.sh", $cl); + self::print(" (keep or remove; bcmath, pgsql, mysql, redis shipped)", $cl); self::printLabel("docker — scaffold Docker files", $cl); // examples @@ -372,9 +353,7 @@ public static function help(): void self::printInfo("call cfg env -i"); self::printInfo("call cfg env -s"); self::printInfo("call cfg env -s --file"); - self::printInfo("call cfg docker (fpm mode, default)"); - self::printInfo("call cfg docker --fpm (explicit fpm mode)"); - self::printInfo("call cfg docker --swoole (swoole mode)"); + self::printInfo("call cfg docker (scaffold Docker files)"); self::printInfo("call cfg completion (print scripts to stdout)"); self::printInfo("call cfg completion -i (install: ~/.zsh/completions/_call or ~/.bash_completion.d/call)"); self::printInfo("call cfg completion -if (force update installed file)"); diff --git a/console/Command/Complete.php b/console/Command/Complete.php index 9fd46f2..f20fa0e 100644 --- a/console/Command/Complete.php +++ b/console/Command/Complete.php @@ -6,14 +6,13 @@ use Flytachi\Winter\Console\Core; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\Console\Inc\CmdCustom; -use Flytachi\Winter\K2\Collector\ImplementorCollector; -use Flytachi\Winter\K2\Collector\SubclassCollector; -use Flytachi\Winter\K2\Core\ClassScanner; -use Flytachi\Winter\K2\Process\Core\Dispatchable; -use Flytachi\Winter\K2\Process\ThreadDaemon; - -class Complete extends Cmd +use Flytachi\Winter\Console\Stereotype\CmdCustom; +use Flytachi\Winter\Kernel\Collector\SubclassCollector; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon as DaemonUnit; +use Flytachi\Winter\Kernel\Process\Stereotype\Process as ProcessUnit; + +final class Complete extends Cmd { public static string $title = "shell completion endpoint (internal)"; @@ -31,11 +30,8 @@ class Complete extends Cmd '-e:Entity — ORM entity / model', '-d:Dto — Data Transfer Object', '-q:Request — validated request object', - '-p:Response — custom HTTP response', - '-J:Job — async queue job', '-P:Process — long-running process', '-N:Daemon — background daemon', - '-W:WebSocket — WebSocket handler', '-D:DbConfig — database configuration', '-R:RedisConfig — Redis configuration', '-n:Cmd — custom console command', @@ -52,7 +48,7 @@ class Complete extends Cmd ], 'cfg key' => ['-g:generate WINTER_KEY', '-s:show current key'], 'cfg env' => ['-i:create .env from template', '-s:show loaded env vars', '--file:show raw .env file'], - 'cfg docker' => ['--fpm:PHP-FPM + Nginx mode (default)', '--swoole:Swoole HTTP server mode'], + 'cfg docker' => [], 'cfg completion' => ['-i:install globally (once per machine)', '-if:force reinstall', '-f:force flag'], // --- run --- @@ -64,7 +60,7 @@ class Complete extends Cmd '--tasks=:number of Swoole task workers', '--max_request=:max requests per worker', '--max_request_grace=:graceful drain count', - '-w:enable MemoryWatcher', + '-w:enable DevWatcher (memory + hot-reload)', ], 'run dev' => [ '--host=:bind host (default: 0.0.0.0)', @@ -81,11 +77,13 @@ class Complete extends Cmd // --- di --- 'di' => [ - 'build:scan project and write the DI cache file', - 'clean:delete the DI cache file', + 'build:scan project once — write the DI cache and generate #[Async] proxies', + 'clean:delete the DI cache and every generated proxy', 'show:list classes in the DI cache (or a live scan if absent)', + 'async:list #[Async] methods and whether their proxy is built', ], 'di show' => [], // takes optional FQCN substring as argument + 'di async' => [], // takes optional FQCN substring as argument // --- storage --- 'storage' => [ @@ -95,19 +93,34 @@ class Complete extends Cmd 'storage init' => ['-s:storage', '-c:storage/cache', '-l:storage/logs'], 'storage clean' => ['-s:storage', '-c:storage/cache', '-l:storage/logs'], - // --- thread / th --- - 'thread' => [ - 'list:list all Dispatchable classes', - 'daemons:list daemons with live status', + // --- process / proc --- + 'process' => [ + 'list:list all processes with live state', + ], + + // --- daemon / dmn --- + 'daemon' => [ + 'list:list all daemons with live state', ], + // --- schedule / sch --- + 'schedule' => [ + 'list:list all #[Scheduled] tasks and cadence', + 'start:run the scheduler (foreground; -d for background)', + 'stop:send graceful stop (SIGTERM)', + 'status:scheduler run state + task count', + ], + 'schedule start' => ['-d:run detached in background'], + // --- db --- 'db' => [ 'ping:check DB connection and latency', 'migrate:run migrations against connected databases', 'sql:preview generated SQL without executing', + 'pool:show connection-pool utilisation of the running server', ], 'db ping' => [], // auto-scans project + all plugins + 'db pool' => [], // reads what the running workers publish 'db migrate' => [ '-s:schemes only', '-t:tables only', '-i:indexes only', '-c:constraints only', '--plugin=:target a single plugin', '--plugins:target all plugins', @@ -175,28 +188,46 @@ private function suggest(?string $cmd, ?string $sub, ?string $act, string $curre $base = array_merge($base, $this->getScriptClasses()); } - // thread: list + classes at top level; once a class is selected, - // suggestions depend on the action level: - // no action yet → lifecycle commands + -d (no-command/toggle flag) - // after `status` → -v (detailed status flag) - if ($resolved === 'thread' && $sub !== null && !in_array($sub, ['list', 'daemons'], true)) { - $isDaemon = in_array($sub, array_map('strtolower', $this->getDaemonClasses())); + // process: list + classes at top level; once a class is selected, + // suggest lifecycle actions, then flags per action. + if ($resolved === 'process' && $sub !== null && $sub !== 'list') { + if ($act === null) { + $base = [ + 'start:start (foreground; -d for background)', + 'stop:send graceful stop (SIGTERM)', + 'status:show status (-v for detail)', + '-d:start detached in background', + ]; + } elseif ($act === 'status') { + $base = ['-v:detailed status (resources + workers)']; + } elseif ($act === 'start') { + $base = ['-d:start detached in background']; + } else { + $base = []; + } + } elseif ($resolved === 'process' && $sub === null) { + $base = array_merge($this->getProcessClasses(), $base); + } + + // daemon: list + classes at top level; once a class is selected, + // suggest lifecycle actions, then flags per action. + if ($resolved === 'daemon' && $sub !== null && $sub !== 'list') { if ($act === null) { - $base = $isDaemon - ? [ - 'start:start daemon in background', - 'stop:stop running daemon', - 'status:show daemon status (-v for detail)', - '-d:toggle start in background', - ] - : ['-d:dispatch as background process']; - } elseif ($isDaemon && $act === 'status') { - $base = ['-v:detailed status (resources + forks)']; + $base = [ + 'start:supervise (foreground; -d for background)', + 'stop:graceful stop (drains the fleet)', + 'status:show status + worker fleet', + '-d:supervise detached in background', + ]; + } elseif ($act === 'status') { + $base = ['-v:also master resource usage']; + } elseif ($act === 'start') { + $base = ['-d:supervise detached in background']; } else { $base = []; } - } elseif ($resolved === 'thread' && $sub === null) { - $base = array_merge($this->getDispatchableClasses(), $base); + } elseif ($resolved === 'daemon' && $sub === null) { + $base = array_merge($this->getDaemonUnitClasses(), $base); } // help: suggest command names @@ -212,9 +243,10 @@ private function filter(array $items, string $current): array if ($current === '') { return $items; } - return array_values(array_filter($items, function (string $s) use ($current): bool { + $needle = strtolower($current); + return array_values(array_filter($items, function (string $s) use ($needle): bool { $word = strstr($s, ':', true) ?: $s; - return str_starts_with($word, $current); + return str_starts_with(strtolower($word), $needle); })); } @@ -247,20 +279,25 @@ private function getScriptClasses(): array ); } - private function getDispatchableClasses(): array + private function getProcessClasses(): array { - $collector = new ImplementorCollector(Dispatchable::class); + $collector = new SubclassCollector(ProcessUnit::class); ClassScanner::scan($collector); + $bare = array_filter( + $collector->getResult(), + static fn(\ReflectionClass $ref) => !$ref->isSubclassOf(DaemonUnit::class) + ); + return array_map( fn(\ReflectionClass $ref) => str_replace('\\', '.', $ref->getName()), - $collector->getResult() + $bare ); } - private function getDaemonClasses(): array + private function getDaemonUnitClasses(): array { - $collector = new SubclassCollector(ThreadDaemon::class); + $collector = new SubclassCollector(DaemonUnit::class); ClassScanner::scan($collector); return array_map( diff --git a/console/Command/Daemon.php b/console/Command/Daemon.php new file mode 100644 index 0000000..83563e5 --- /dev/null +++ b/console/Command/Daemon.php @@ -0,0 +1,323 @@ +args['arguments']) > 1) { + $this->resolution(); + } else { + self::help(); + } + + self::printTitle("Daemon", 35); + } + + private function resolution(): void + { + $input = $this->args['arguments'][1]; + if ($input === 'list') { + $this->listArg(); + return; + } + + $class = $this->resolveClass($input); + $name = basename(str_replace('\\', '/', $class)); + + if (!class_exists($class)) { + self::printWarning("Class '$name' not found."); + self::printInfo("Resolved: $class"); + self::printInfo("Run 'call daemon list' to see available daemons."); + return; + } + if (!is_subclass_of($class, DaemonUnit::class)) { + self::printWarning("Class '$name' is not a Daemon."); + self::printInfo("Resolved: $class"); + self::printInfo("Bare processes: 'call process " . str_replace('\\', '.', $class) . " ...'."); + return; + } + + match (strtolower($this->args['arguments'][2] ?? '')) { + 'start' => $this->startArg($class), + 'stop' => $this->stopArg($class), + 'status' => $this->statusArg($class, in_array('v', $this->args['flags'])), + '' => $this->startArg($class), + default => self::printWarning("Unknown action (use start|stop|status)."), + }; + } + + /** + * @param class-string $class + */ + private function startArg(string $class): void + { + $info = $class::status(); + if ($info) { + self::printWarning("Already running [PID:{$info->pid}] ({$info->getStartedAt()})."); + return; + } + + if (in_array('d', $this->args['flags'])) { + $pid = $class::dispatch(); + $info = null; + for ($i = 0; $i < 20 && $info === null; $i++) { + usleep(50_000); + $info = $class::status(); + } + self::printSuccess("Dispatched (background): $class"); + self::printKeyValue("PID", (string) ($info->pid ?? $pid), 12, 35, 32); + return; + } + + self::printInfo("Supervising: $class"); + $class::start(); + self::printSuccess("Finished: $class"); + } + + /** + * @param class-string $class + */ + private function stopArg(string $class): void + { + $info = $class::status(); + if (!$info) { + self::printWarning("Daemon is not running."); + return; + } + if ($class::stop()) { + self::printSuccess("Stop signal sent: $class"); + self::printKeyValue("PID", (string) $info->pid, 12, 35, 32); + } else { + self::printWarning("Failed to signal daemon."); + } + } + + /** + * @param class-string $class + */ + private function statusArg(string $class, bool $detailed): void + { + $dot = str_replace('\\', '.', $class); + $info = $class::status($detailed); + + self::printLabel("Daemon Status", 35); + + if (!$info) { + self::printBadge($dot, '○ STOPPED', 35, 31); + self::printInfo("The daemon is not running."); + self::printLabel("Daemon Status", 35); + return; + } + + self::printBadge($dot, 'Daemon ● ' . $info->state->name, 35, 32); + self::printDivider(); + self::printKeyValue("PID", (string) $info->pid, 12, 35, 36); + self::printKeyValue("State", $info->state->name, 12, 35, 36); + self::printKeyValue( + "Activity", + $info->activity->name, + 12, + 35, + $info->activity === Activity::BUSY ? 33 : 90 + ); + self::printKeyValue("Started", $info->getStartedAt(), 12, 35, 36); + self::printKeyValue("Uptime", $this->formatDuration(time() - $info->startedAt), 12, 35, 36); + if ($info->concurrency > 0) { + self::printKeyValue("Concurrency", (string) $info->concurrency, 12, 35, 36); + } + if ($info instanceof DaemonStatus) { + self::printKeyValue("Workers", (string) count($info->workers), 12, 35, 36); + self::printKeyValue("Restarts", (string) $info->restarts, 12, 35, 36); + } + + if ($info instanceof DaemonStatus && $info->workers !== []) { + $this->printWorkers($info->workers); + } + + self::printLabel("Daemon Status", 35); + } + + /** + * @param array $workers + */ + private function printWorkers(array $workers): void + { + self::printDivider(); + self::printLabel("Workers (" . count($workers) . ")", 35); + self::print( + sprintf(" %-6s %-8s %-11s %-6s %-9s %s", 'SLOT', 'PID', 'STATE', 'ACT', 'UPTIME', 'RESTARTS'), + 90 + ); + foreach ($workers as $w) { + $pid = $w->pid > 0 ? (string) $w->pid : '—'; + $uptime = $w->startedAt > 0 ? $this->formatDuration(time() - $w->startedAt) : '—'; + $line = sprintf( + " #%-5d %-8s %-11s %-6s %-9s %d", + $w->slot, + $pid, + $w->state->value, + $w->activity->value, + $uptime, + $w->restarts, + ); + self::print($line, $this->stateColor($w->state)); + } + } + + private function stateColor(SlotState $state): int + { + return match ($state) { + SlotState::RUNNING => 32, // green + SlotState::STARTING => 36, // cyan + SlotState::RETIRING => 33, // yellow + SlotState::KILLING => 31, // red + SlotState::RESTARTING => 35, // magenta + SlotState::RETIRED => 90, // dim + SlotState::EMPTY => 90, // dim + }; + } + + private function listArg(): void + { + $collector = new SubclassCollector(DaemonUnit::class); + ClassScanner::scan($collector); + $daemons = $collector->getResult(); + + self::printLabel("Available Daemons", 35); + if (empty($daemons)) { + self::printWarning("No Daemon classes found."); + self::printInfo("Create one that extends Daemon."); + self::printLabel("Available Daemons", 35); + return; + } + + $running = 0; + foreach ($daemons as $ref) { + if ($this->printRow($ref->getName())) { + $running++; + } + } + + self::printDivider(); + self::printInfo(count($daemons) . " defined, {$running} running."); + self::printLabel("Available Daemons", 35); + } + + /** + * Renders one daemon as a padded, colour-coded row. Returns whether it runs. + * + * @param class-string $class + */ + private function printRow(string $class): bool + { + $dot = str_replace('\\', '.', $class); + $info = $class::status(); + + echo "\033[35m" . str_pad(" |\t [D] {$dot} ", 72, '.') . " "; + if (!$info) { + echo "\033[31m[○ STOPPED]\033[0m\n"; + return false; + } + + $uptime = $this->formatDuration(time() - $info->startedAt); + $workers = $info instanceof DaemonStatus ? "\033[36m [w:" . count($info->workers) . "]" : ''; + echo "\033[32m[● {$info->state->name}]" + . $this->activityTag($info->activity) + . $workers + . "\033[90m {$uptime}\033[0m\n"; + + return true; + } + + /** + * Colour-coded activity label: BUSY is highlighted, IDLE is dim. + */ + private function activityTag(Activity $activity): string + { + return $activity === Activity::BUSY + ? "\033[33m [BUSY]" + : "\033[90m [idle]"; + } + + /** + * Dot/dashed notation → FQCN, e.g. `main.daemon.Emails` → `Main\Daemon\Emails`. + */ + private function resolveClass(string $input): string + { + return str_replace( + '/', + '\\', + implode('/', array_map( + fn($word) => ucfirst($word), + explode('/', str_replace('.', '/', $input)) + )) + ); + } + + /** + * Human-readable duration, e.g. 90061 → "1d 1h". + */ + private function formatDuration(int $seconds): string + { + $seconds = max(0, $seconds); + $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; + + $parts = []; + foreach ($units as $suffix => $size) { + $value = intdiv($seconds, $size); + $seconds %= $size; + if ($value > 0) { + $parts[] = $value . $suffix; + } + } + + return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); + } + + public static function help(): void + { + $cl = 35; + self::printTitle("Daemon Help", $cl); + + self::printLabel("Usage", $cl); + self::print("call daemon [action] -[flags]", $cl); + self::print("call dmn [action] -[flags] (alias)", $cl); + self::printLabel("Usage", $cl); + + self::printLabel("Commands", $cl); + self::printBadge('list', 'list all daemons with live state + worker count', $cl, 36); + self::printBadge('', 'supervise in foreground (default)', $cl, 36); + self::printBadge(' start -d', 'supervise detached in background', $cl, 36); + self::printBadge(' stop', 'graceful stop (drains the whole fleet)', $cl, 36); + self::printBadge(' status', 'status + per-worker fleet table', $cl, 36); + self::printBadge(' status -v', 'also master resource usage', $cl, 36); + self::printLabel("Commands", $cl); + + self::printDivider($cl); + self::printInfo("Worker states: running / starting / retiring / killing / restarting"); + self::printInfo("Bare processes are managed by 'call process'."); + + self::printTitle("Daemon Help", $cl); + } +} diff --git a/console/Command/Db.php b/console/Command/Db.php index eb9f614..40fc4a7 100644 --- a/console/Command/Db.php +++ b/console/Command/Db.php @@ -5,12 +5,13 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Ppa\DeclarationItem; -use Flytachi\Winter\K2\Ppa\PPAMapping; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\Kernel\Ppa\DeclarationItem; +use Flytachi\Winter\Kernel\Ppa\PPAMapping; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Ppa\Pool\PoolTelemetry; +use Flytachi\Winter\Kernel\Plugin; -class Db extends Cmd +final class Db extends Cmd { public static string $title = "manage database migrations and SQL preview"; @@ -42,6 +43,9 @@ private function resolution(): void case 'sql': $this->showSql(); break; + case 'pool': + $this->pool(); + break; default: self::printWarning("Unknown argument '{$this->args['arguments'][1]}'"); self::printInfo("Run 'call db --help' to see available commands."); @@ -114,6 +118,69 @@ private function ping(): void } } + /** + * Shows connection-pool utilisation of the running server. + * + * A pool lives in one worker's memory and the CLI is a separate process, so this + * reads what each worker publishes to the shared store ({@see PoolTelemetry}) — + * the same indirection `call process status` uses. Numbers are therefore as fresh + * as the last publish (see the `age` column), and a stopped worker's record simply + * expires. + */ + private function pool(): void + { + $records = PoolTelemetry::snapshot(); + + if ($records === []) { + self::printWarning('No pool telemetry found.'); + self::printInfo('A pool lives inside the running server, so the CLI reads what workers publish.'); + self::printInfo('Check that the server is running, that it queries the database, and that' + . ' PPA_POOL_TELEMETRY is not 0 (current interval: ' . PoolTelemetry::interval() . 's).'); + return; + } + + self::printTitle('Connection pools'); + foreach (PoolTelemetry::aggregate() as $configClass => $stat) { + self::printLabel($configClass, 34); + self::printKeyValue('active', (string) $stat['active'], 12, 34, 36); + self::printKeyValue('idle', (string) $stat['idle'], 12, 34, 36); + self::printKeyValue('total', (string) $stat['total'], 12, 34, 36); + self::printKeyValue('maximum', (string) $stat['maximum'], 12, 34, 36); + self::printKeyValue('workers', (string) $stat['workers'], 12, 34, 36); + + // Saturation is per worker: a borrow queues on its own worker's pool, so + // one saturated worker matters even when the fleet total shows slack. + if ($stat['saturated'] > 0) { + self::printKeyValue('saturated', "{$stat['saturated']} of {$stat['workers']} workers", 12, 34, 33); + self::printBadge($configClass, 'SATURATED', 34, 33); + } else { + self::printBadge($configClass, 'OK', 34, 32); + } + } + + self::printSplit('per worker'); + $now = time(); + foreach ($records as $record) { + foreach ($record['pools'] as $configClass => $stat) { + self::printKeyValue( + 'worker#' . $record['worker'], + sprintf( + '%-40s active=%d idle=%d total=%d max=%d age=%ds', + $configClass, + $stat['active'], + $stat['idle'], + $stat['total'], + $stat['maximum'], + max(0, $now - (int) ($record['at'] ?? $now)), + ), + 12, + 34, + 36, + ); + } + } + } + private function showSql(): void { foreach ($this->resolveTargets() as $label => $rootDir) { @@ -416,6 +483,7 @@ public static function help(): void self::printBadge('ping', 'check DB connection and latency', $cl, 36); self::printBadge('migrate', 'run migrations against connected databases', $cl, 36); self::printBadge('sql', 'preview generated SQL without executing', $cl, 36); + self::printBadge('pool', 'show connection-pool utilisation of the running server', $cl, 36); self::printLabel("Commands", $cl); self::printLabel("Flags", $cl); diff --git a/console/Command/Di.php b/console/Command/Di.php index dece375..846081b 100644 --- a/console/Command/Di.php +++ b/console/Command/Di.php @@ -8,39 +8,98 @@ use Flytachi\Winter\DI\Container; use Flytachi\Winter\DI\Collector\DICollector; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\App\Attribute\EnableAsync; +use Flytachi\Winter\Kernel\Concurrent\Async\AsyncCollector; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\BypassScanner; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyFactory; +use Flytachi\Winter\Kernel\Concurrent\Async\Proxy\ProxyGenerator; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\WinterApplication; use ReflectionClass; +use ReflectionMethod; -class Di extends Cmd +final class Di extends Cmd { - public static string $title = "manage and inspect DI scanner cache (build, clean, show)"; + public static string $title = "manage and inspect DI scanner cache (build, clean, show, async)"; + + /** Maximum bypass warnings printed before the rest is summarised. */ + private const int BYPASS_REPORT_LIMIT = 20; public function handle(): void { self::printTitle("Di", 34); $sub = $this->args['arguments'][1] ?? ''; + $ok = true; match ($sub) { - 'build' => $this->buildArg(), + 'build' => $ok = $this->buildArg(), 'clean' => $this->cleanArg(), 'show' => $this->showArg($this->args['arguments'][2] ?? ''), + 'async' => $this->asyncArg($this->args['arguments'][2] ?? ''), '' => self::help(), default => $this->showArg($sub), }; self::printTitle("Di", 34); + + // A failed build must be visible to CI — the console layer otherwise + // always exits 0. + if (!$ok) { + exit(1); + } } /** - * Cache file location — kept in sync with BaseBoot::boot(). + * Cache file location — kept in sync with WinterApplication::bootstrap(). The + * cache is the FQCN class list, written by the Scanner independently of any + * collector, so it is the same whichever collectors boot wires. */ private static function cachePath(): string { return Kernel::$pathStorageVolatile . '/di.php'; } + /** + * List of classes carrying #[Async] — kept in sync with WinterApplication::bootstrap(). + */ + private static function asyncCachePath(): string + { + return Kernel::$pathStorageVolatile . '/async.php'; + } + + /** + * Whether #[Async] proxies should be built, mirroring the WinterApplication boot + * decision: enabled when the running app class carries #[EnableAsync]. bootstrap() + * has already run by the time this command dispatches, so the app class is known + * without a scan. A non-WinterApplication entry sets no app class — there + * #[Async] proxying is always on. + */ + private static function asyncEnabled(): bool + { + $app = WinterApplication::getAppClass(); + + return $app === '' || new ReflectionClass($app)->getAttributes(EnableAsync::class) !== []; + } + + /** + * Removes a cache file and drops it from the opcode cache. + */ + private static function forget(string $file): bool + { + if (!file_exists($file)) { + return false; + } + + @unlink($file); + if (function_exists('opcache_invalidate')) { + opcache_invalidate($file, true); + } + + return true; + } + private function showArg(string $pattern): void { try { @@ -88,63 +147,305 @@ private function showArg(string $pattern): void } } - private function buildArg(): void + /** + * Builds every artefact the container needs, in a single filesystem pass. + * + * The class list and the #[Async] proxies come from the same scan on + * purpose — two commands would leave a window where one is stale. + * + * Note that this step can now fail on application code: an #[Async] method + * breaking its contract stops proxy generation. That is the point — the + * error belongs to CI, not to the first request in production. + * + * @return bool False when an artefact could not be produced. + */ + private function buildArg(): bool { + $cachePath = self::cachePath(); + + // Force a rebuild: both caches short-circuit when their file exists. + self::forget($cachePath); + self::forget(self::asyncCachePath()); + + // #[Async] proxying is opt-in (WinterApplication reads #[EnableAsync] at boot); + // build the proxies only when the app enables it, so `di build` mirrors boot. + // When async is off nothing about the proxy factory is touched. + $container = Container::init(); + $factory = null; + $async = null; + if (self::asyncEnabled()) { + $factory = ProxyFactory::forKernel(refresh: true); + $async = new AsyncCollector($container, $factory, self::asyncCachePath()); + } + try { - $cachePath = self::cachePath(); + // Drop proxies of services that no longer exist or lost the attribute. + if ($factory !== null) { + $factory->clear(); + } - // Force a rebuild: Scanner short-circuits when the file already exists. - if (file_exists($cachePath)) { - @unlink($cachePath); - if (function_exists('opcache_invalidate')) { - opcache_invalidate($cachePath, true); - } + // Same scan WinterApplication::bootstrap() makes — populates a fresh + // Container and writes the FQCN list to $cachePath as a side effect. The + // class list and the #[Async] proxies come from the same scan on purpose: + // two commands would leave a window where one is stale. + $scan = ClassScanner::scanner(rootDir: Kernel::$pathRoot, cache: $cachePath) + ->collect(new DICollector($container)); + if ($async !== null) { + $scan->collect($async); + } + $scan->execute(); + + $async?->flush(); + } catch (\Throwable $e) { + // The class list is written before collectors run, so report what did survive. + $this->reportCache($cachePath); + self::printBadge("async proxies", 'FAILED', 34, 31); + self::printWarning($e->getMessage()); + if (env('DEBUG', false)) { + self::printSplit($e->getTraceAsString(), 31); } - // Same call BaseBoot::boot() makes — populates a fresh Container and - // writes the FQCN list to $cachePath as a side effect. - Scanner::run(rootDir: Kernel::$pathRoot, cache: $cachePath) - ->collect(new DICollector(Container::init())) - ->execute(); + return false; + } + + if (!$this->reportCache($cachePath)) { + return false; + } + + if ($async === null) { + self::printBadge("async proxies", 'DISABLED (no #[EnableAsync])', 34, 33); + + return true; + } + + $proxied = $async->proxied(); + if ($proxied === []) { + self::printBadge("async proxies", 'NONE', 34, 33); + + return true; + } + + self::printBadge( + "async proxies", + sprintf('BUILT (%d classes, %d methods)', count($proxied), $this->countAsyncMethods($proxied)), + 34, + 32 + ); + if ($factory !== null) { + self::printInfo($factory->directory()); + } + + $this->reportBypasses(array_keys($proxied)); + + return true; + } + + /** + * Warns about services built with `new` instead of resolved from the container. + * + * Never fails the build — the scan is textual and cannot see dynamic + * construction, so a clean report is not proof of correctness. + * + * @param list $asyncClasses Classes that must come from the container. + */ + private function reportBypasses(array $asyncClasses): void + { + $found = new BypassScanner($asyncClasses, self::bypassExcludes())->scan(Kernel::$pathRoot); + + if ($found === []) { + self::printBadge("async bypass", 'NONE', 34, 32); + return; + } + + self::printBadge("async bypass", count($found) . ' FOUND', 34, 33); + + $shown = array_slice($found, 0, self::BYPASS_REPORT_LIMIT); + foreach ($shown as $hit) { + self::printWarning(sprintf( + '%s:%d — new %s() bypasses the proxy and runs synchronously; inject it instead', + self::relativePath($hit['file']), + $hit['line'], + $hit['class'] + )); + } + + $hidden = count($found) - count($shown); + if ($hidden > 0) { + self::printWarning("… and $hidden more (run `call di async` for the full service list)"); + } + } + + /** + * Directories the bypass scan skips: generated and non-application code. + * + * Test directories are excluded on purpose — constructing a service directly + * is usually what a test wants. + * + * @return list + */ + private static function bypassExcludes(): array + { + return [ + Kernel::$pathStorage, + Kernel::$pathStorageVolatile, + Kernel::$pathRoot . '/tests', + Kernel::$pathRoot . '/test', + ]; + } + + /** + * @param string $file Absolute path. + */ + private static function relativePath(string $file): string + { + $root = rtrim(Kernel::$pathRoot, '/\\') . DIRECTORY_SEPARATOR; + + return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file; + } + + private function cleanArg(): void + { + try { + $cleaned = self::forget(self::cachePath()); + self::forget(self::asyncCachePath()); + self::printBadge("di cache", $cleaned ? 'CLEANED' : 'NOT FOUND', 34, $cleaned ? 32 : 33); + + $removed = ProxyFactory::forKernel()->clear(); + self::printBadge( + "async proxies", + $removed > 0 ? "CLEANED ($removed files)" : 'NOT FOUND', + 34, + $removed > 0 ? 32 : 33 + ); + } catch (\Throwable $e) { + self::printWarning("Clean failed: " . $e->getMessage()); + } + } - if (!is_file($cachePath)) { - self::printWarning("Cache file was not produced at $cachePath"); + /** + * Lists every #[Async] method found in the project and whether its proxy exists. + * + * @param string $pattern Case-insensitive FQCN substring filter. + */ + private function asyncArg(string $pattern): void + { + try { + $found = $this->scanAsync(); + $pattern = trim($pattern); + + if ($pattern !== '') { + $found = array_filter( + $found, + static fn(string $fqcn): bool => stripos($fqcn, $pattern) !== false, + ARRAY_FILTER_USE_KEY + ); + } + + if ($found === []) { + self::printWarning($pattern === '' + ? 'No #[Async] methods found.' + : "No #[Async] methods in classes matching '$pattern'."); return; } - if (function_exists('opcache_invalidate')) { - opcache_invalidate($cachePath, true); + ksort($found); + $factory = ProxyFactory::forKernel(); + $label = 'Async methods (' . count($found) . ' classes)'; + + self::printLabel($label, 34); + foreach ($found as $fqcn => $methods) { + $built = is_file($factory->fileFor($fqcn)); + self::printBadge($fqcn, $built ? 'BUILT' : 'PENDING', 36, $built ? 32 : 33); + foreach ($methods as [$name, $returns]) { + self::print(" {$name}() → {$returns}", 36); + } } + self::printLabel($label, 34); - $count = count((array) (require $cachePath)); - self::printBadge("di cache", "BUILT ($count classes)", 34, 32); - self::printInfo($cachePath); + self::printDivider(34); + self::printInfo($factory->directory()); } catch (\Throwable $e) { - self::printWarning("Build failed: " . $e->getMessage()); + self::printWarning("Async scan failed: " . $e->getMessage()); if (env('DEBUG', false)) { - self::printTitle($e->getMessage(), 31); self::printSplit($e->getTraceAsString(), 31); - self::printTitle($e->getMessage(), 31); } } } - private function cleanArg(): void + /** + * Prints the state of the class-list cache. + * + * @param string $cachePath Absolute path of the cache file. + * @return bool False when the file was not produced. + */ + private function reportCache(string $cachePath): bool { - try { - $cachePath = self::cachePath(); - if (file_exists($cachePath)) { - unlink($cachePath); - if (function_exists('opcache_invalidate')) { - opcache_invalidate($cachePath, true); + if (!is_file($cachePath)) { + self::printWarning("Cache file was not produced at $cachePath"); + return false; + } + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($cachePath, true); + } + + $count = count((array) (require $cachePath)); + self::printBadge("di cache", "BUILT ($count classes)", 34, 32); + self::printInfo($cachePath); + + return true; + } + + /** + * @param array $proxied Original class mapped to its proxy. + */ + private function countAsyncMethods(array $proxied): int + { + $total = 0; + foreach (array_keys($proxied) as $class) { + $total += count(ProxyGenerator::asyncMethods(new ReflectionClass($class))); + } + + return $total; + } + + /** + * Walks the project and collects #[Async] methods without generating anything. + * + * @return array> Class mapped to method name and return type. + */ + private function scanAsync(): array + { + $sink = new class implements CollectorInterface { + /** @var array> */ + public array $found = []; + + public function collect(string $class, ReflectionClass $ref): void + { + if (str_starts_with($class, ProxyGenerator::PROXY_NAMESPACE . '\\')) { + return; } - self::printBadge("di cache", 'CLEANED', 34, 32); - } else { - self::printBadge("di cache", 'NOT FOUND', 34, 33); + + $methods = ProxyGenerator::asyncMethods($ref); + if ($methods === []) { + return; + } + + $this->found[$class] = array_map( + static fn(ReflectionMethod $m): array => [ + $m->getName(), + (string) ($m->getReturnType() ?? 'mixed'), + ], + $methods + ); } - } catch (\Throwable $e) { - self::printWarning("Clean failed: " . $e->getMessage()); - } + }; + + ClassScanner::scanner(rootDir: Kernel::$pathRoot) + ->collect($sink) + ->execute(); + + return $sink->found; } /** @@ -165,7 +466,7 @@ public function collect(string $class, ReflectionClass $ref): void } }; - Scanner::run(rootDir: Kernel::$pathRoot) + ClassScanner::scanner(rootDir: Kernel::$pathRoot) ->collect($sink) ->execute(); @@ -182,10 +483,12 @@ public static function help(): void self::printLabel("Usage", $cl); self::printLabel("Commands", $cl); - self::printBadge('build', 'scan project and write the DI cache file (deletes the existing one)', $cl, 36); - self::printBadge('clean', 'delete the DI cache file', $cl, 36); + self::printBadge('build', 'scan project once: DI cache, #[Async] proxies, bypass check', $cl, 36); + self::printBadge('clean', 'delete the DI cache and every generated proxy', $cl, 36); self::printBadge('show', 'list every class in the DI cache', $cl, 36); self::printBadge('show ', 'filter cached classes by FQCN substring (case-insensitive)', $cl, 36); + self::printBadge('async', 'list #[Async] methods and whether their proxy is built', $cl, 36); + self::printBadge('async ', 'filter by FQCN substring (case-insensitive)', $cl, 36); self::printLabel("Commands", $cl); self::printDivider($cl); @@ -195,11 +498,17 @@ public static function help(): void self::printInfo("call di clean"); self::printInfo("call di show"); self::printInfo("call di show App\\Service"); + self::printInfo("call di async"); self::printLabel("Examples", $cl); self::printDivider($cl); self::printInfo("Cache file: " . Kernel::$pathStorageVolatile . '/di.php'); + self::printInfo("Proxy dir: " . Kernel::$pathStorageVolatile . '/' . ProxyFactory::DIRECTORY); self::printInfo("DEBUG=true disables the cache entirely (always live scan)."); + self::printInfo("build doubles as a contract check — an invalid #[Async] method fails here, not in prod."); + self::printInfo("A failed build exits with code 1."); + self::printInfo("It also warns when an #[Async] service is built with new (skips vendor/ and tests/)."); + self::printInfo("That check is textual: it cannot see 'new \$class' or factories, so it never fails a build."); self::printTitle("Di Help", $cl); } diff --git a/console/Command/Help.php b/console/Command/Help.php index 31bf51d..af6b56a 100644 --- a/console/Command/Help.php +++ b/console/Command/Help.php @@ -6,9 +6,9 @@ use Composer\InstalledVersions; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Help extends Cmd +final class Help extends Cmd { public static string $title = "list commands and show usage information"; diff --git a/console/Command/Make.php b/console/Command/Make.php index d742c16..29046e5 100644 --- a/console/Command/Make.php +++ b/console/Command/Make.php @@ -6,9 +6,9 @@ use Composer\Autoload\ClassLoader; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Make extends Cmd +final class Make extends Cmd { public static string $title = "generate framework component templates"; private string $createPath; @@ -70,21 +70,12 @@ private function resolution(): void if (in_array('d', $this->args['flags'])) { $this->createDto($templateName); } - if (in_array('p', $this->args['flags'])) { - $this->createResponse($templateName); - } - if (in_array('J', $this->args['flags'])) { - $this->createJob($templateName); - } if (in_array('P', $this->args['flags'])) { $this->createProcess($templateName); } if (in_array('N', $this->args['flags'])) { $this->createDaemon($templateName); } - if (in_array('W', $this->args['flags'])) { - $this->createWebSocket($templateName); - } if (in_array('D', $this->args['flags'])) { $this->createConfig($templateName); } @@ -201,63 +192,13 @@ private function createDto(string $name): void $this->createFile($info['className'], $info['path'], $code, 'dto'); } - private function createResponse(string $name): void - { - $info = $this->getInfo($name, '', 'ResponseTemplate'); - $this->smartInfo( - $info, - 'Controllers', - 'Controller', - 'Utilities/Responses', - 'Utilities/Response', - 'Utility/Responses', - 'Utility/Response', - 'Utils/Responses', - 'Utils/Response', - 'Util/Responses', - 'Util/Response', - 'Responses', - 'Response' - ); - $code = file_get_contents($info['template']); - $code = str_replace("__namespace__", $info['namespace'], $code); - $code = str_replace("__className__", $info['className'], $code); - $this->createFile($info['className'], $info['path'], $code, 'response'); - } - - private function createJob(string $name): void - { - $info = $this->getInfo($name, 'Job', 'JobTemplate'); - $this->smartInfo( - $info, - 'Threads/Jobs', - 'Threads/Job', - 'Thread/Jobs', - 'Thread/Job', - 'Jobs', - 'Job', - 'Threads', - 'Thread' - ); - $code = file_get_contents($info['template']); - $code = str_replace("__namespace__", $info['namespace'], $code); - $code = str_replace("__className__", $info['className'], $code); - $this->createFile($info['className'], $info['path'], $code, 'job'); - } - private function createProcess(string $name): void { $info = $this->getInfo($name, 'Process', 'ProcessTemplate'); $this->smartInfo( $info, - 'Threads/Processes', - 'Threads/Process', - 'Thread/Processes', - 'Thread/Process', 'Processes', - 'Process', - 'Threads', - 'Thread' + 'Process' ); $code = file_get_contents($info['template']); $code = str_replace("__namespace__", $info['namespace'], $code); @@ -270,14 +211,8 @@ private function createDaemon(string $name): void $info = $this->getInfo($name, 'Daemon', 'DaemonTemplate'); $this->smartInfo( $info, - 'Threads/Daemons', - 'Threads/Daemon', - 'Thread/Daemons', - 'Thread/Daemon', 'Daemons', - 'Daemon', - 'Threads', - 'Thread' + 'Daemon' ); $code = file_get_contents($info['template']); $code = str_replace("__namespace__", $info['namespace'], $code); @@ -285,26 +220,6 @@ private function createDaemon(string $name): void $this->createFile($info['className'], $info['path'], $code, 'daemon'); } - private function createWebSocket(string $name): void - { - $info = $this->getInfo($name, 'WebSocket', 'WebSocketTemplate'); - $this->smartInfo( - $info, - 'Threads/WebSockets', - 'Threads/WebSocket', - 'Thread/WebSockets', - 'Thread/WebSocket', - 'WebSockets', - 'WebSocket', - 'Threads', - 'Thread' - ); - $code = file_get_contents($info['template']); - $code = str_replace("__namespace__", $info['namespace'], $code); - $code = str_replace("__className__", $info['className'], $code); - $this->createFile($info['className'], $info['path'], $code, 'websocket'); - } - private function createConfig(string $name): void { $info = $this->getInfo($name, 'DbConfig', 'DbConfigTemplate'); @@ -400,9 +315,7 @@ private function getInfo(string $way, string $prefix, string $templateName): arr 'Entity' => 'Entities', 'Dto' => 'Dto', 'Request' => 'Requests', - 'Job' => 'Jobs', 'Daemon', 'Process' => 'Processes', - 'WebSocket' => 'Sockets', 'Cmd' => 'Commands', default => 'Utils', }; @@ -522,7 +435,6 @@ public static function help(): void self::printLabel("Flags — Data", $cl); self::printBadge('-e', 'Entity (no suffix)', $cl, 36); self::printBadge('-d', 'Dto (suffix: Dto)', $cl, 36); - self::printBadge('-p', 'Response (no suffix)', $cl, 36); self::printLabel("Flags — Data", $cl); self::printLabel("Flags — Business", $cl); @@ -531,12 +443,10 @@ public static function help(): void self::printBadge('-t', 'Store (suffix: Store)', $cl, 36); self::printLabel("Flags — Business", $cl); - self::printLabel("Flags — Async / Process", $cl); - self::printBadge('-J', 'Job (suffix: Job)', $cl, 36); + self::printLabel("Flags — Process", $cl); self::printBadge('-P', 'Process (suffix: Process)', $cl, 36); self::printBadge('-N', 'Daemon (suffix: Daemon)', $cl, 36); - self::printBadge('-W', 'WebSocket (suffix: WebSocket)', $cl, 36); - self::printLabel("Flags — Async / Process", $cl); + self::printLabel("Flags — Process", $cl); self::printLabel("Flags — Config / Console", $cl); self::printBadge('-D', 'DbConfig (suffix: DbConfig)', $cl, 36); diff --git a/console/Command/Mapping.php b/console/Command/Mapping.php index 0aaf38e..7d74f60 100644 --- a/console/Command/Mapping.php +++ b/console/Command/Mapping.php @@ -5,10 +5,10 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Route\Router; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Route\Router; -class Mapping extends Cmd +final class Mapping extends Cmd { public static string $title = "manage and inspect route mapping cache (build, clean, show)"; diff --git a/console/Command/Process.php b/console/Command/Process.php new file mode 100644 index 0000000..b3de133 --- /dev/null +++ b/console/Command/Process.php @@ -0,0 +1,295 @@ +args['arguments']) > 1) { + $this->resolution(); + } else { + self::help(); + } + + self::printTitle("Process", 34); + } + + private function resolution(): void + { + $input = $this->args['arguments'][1]; + if ($input === 'list') { + $this->listArg(); + return; + } + + $class = $this->resolveClass($input); + $name = basename(str_replace('\\', '/', $class)); + + if (!class_exists($class)) { + self::printWarning("Class '$name' not found."); + self::printInfo("Resolved: $class"); + self::printInfo("Run 'call process list' to see available processes."); + return; + } + if (!is_subclass_of($class, ProcessUnit::class)) { + self::printWarning("Class '$name' does not extend Process."); + self::printInfo("Resolved: $class"); + return; + } + if (is_subclass_of($class, DaemonUnit::class)) { + self::printWarning("Class '$name' is a Daemon, not a bare Process."); + self::printInfo("Use 'call daemon " . str_replace('\\', '.', $class) . " ...' instead."); + return; + } + + match (strtolower($this->args['arguments'][2] ?? '')) { + 'start' => $this->startArg($class), + 'stop' => $this->stopArg($class), + 'status' => $this->statusArg($class, in_array('v', $this->args['flags'])), + '' => $this->startArg($class), + default => self::printWarning("Unknown action (use start|stop|status)."), + }; + } + + /** + * @param class-string $class + */ + private function startArg(string $class): void + { + $info = $class::status(); + if ($info) { + self::printWarning("Already running [PID:{$info->pid}] ({$info->getStartedAt()})."); + return; + } + + if (in_array('d', $this->args['flags'])) { + $pid = $class::dispatch(); + // The detached double-fork reports the launcher PID; the real process + // registers its own PID in the store. Poll briefly for it. + $info = null; + for ($i = 0; $i < 20 && $info === null; $i++) { + usleep(50_000); + $info = $class::status(); + } + self::printSuccess("Dispatched (background): $class"); + self::printKeyValue("PID", (string) ($info->pid ?? $pid), 12, 34, 32); + return; + } + + self::printInfo("Starting: $class"); + $class::start(); + self::printSuccess("Finished: $class"); + } + + /** + * @param class-string $class + */ + private function stopArg(string $class): void + { + $info = $class::status(); + if (!$info) { + self::printWarning("Process is not running."); + return; + } + if ($class::stop()) { + self::printSuccess("Stop signal sent: $class"); + self::printKeyValue("PID", (string) $info->pid, 12, 34, 32); + } else { + self::printWarning("Failed to signal process."); + } + } + + /** + * @param class-string $class + */ + private function statusArg(string $class, bool $detailed): void + { + $dot = str_replace('\\', '.', $class); + $info = $class::status($detailed); + + self::printLabel("Process Status", 34); + + if (!$info) { + self::printBadge($dot, '○ STOPPED', 34, 31); + self::printInfo("The process is not running."); + self::printLabel("Process Status", 34); + return; + } + + self::printBadge($dot, 'Process ● ' . $info->state->name, 34, 32); + self::printDivider(); + self::printKeyValue("PID", (string) $info->pid, 12, 34, 36); + self::printKeyValue("State", $info->state->name, 12, 34, 36); + self::printKeyValue( + "Activity", + $info->activity->name, + 12, + 34, + $info->activity === Activity::BUSY ? 33 : 90 + ); + self::printKeyValue("Started", $info->getStartedAt(), 12, 34, 36); + self::printKeyValue("Uptime", $this->formatDuration(time() - $info->startedAt), 12, 34, 36); + if ($info->concurrency > 0) { + self::printKeyValue("Concurrency", (string) $info->concurrency, 12, 34, 36); + } + + if ($detailed && $info->usage) { + $u = $info->usage; + self::printDivider(); + self::printLabel("Resources", 34); + self::printKeyValue("User", $u->user, 12, 34, 35); + self::printKeyValue("PPID", (string) $u->ppid, 12, 34, 35); + self::printKeyValue("CPU", $u->cpu . ' %', 12, 34, 35); + self::printKeyValue( + "Memory", + $u->memory . ' % (' . round($u->rssMb(), 1) . ' MB)', + 12, + 34, + 35 + ); + self::printKeyValue("Elapsed", $u->elapsed, 12, 34, 35); + } + + self::printLabel("Process Status", 34); + } + + private function listArg(): void + { + $collector = new SubclassCollector(ProcessUnit::class); + ClassScanner::scan($collector); + $processes = array_filter( + $collector->getResult(), + static fn($ref) => !$ref->isSubclassOf(DaemonUnit::class) + ); + + self::printLabel("Available Processes", 34); + if (empty($processes)) { + self::printWarning("No Process classes found."); + self::printInfo("Create one that extends Process. (Daemons: 'call daemon list'.)"); + self::printLabel("Available Processes", 34); + return; + } + + $running = 0; + foreach ($processes as $ref) { + if ($this->printRow($ref->getName())) { + $running++; + } + } + + self::printDivider(); + self::printInfo(count($processes) . " defined, {$running} running."); + self::printLabel("Available Processes", 34); + } + + /** + * Renders one process as a padded, colour-coded row. Returns whether it runs. + * + * @param class-string $class + */ + private function printRow(string $class): bool + { + $dot = str_replace('\\', '.', $class); + $info = $class::status(); + + echo "\033[34m" . str_pad(" |\t [P] {$dot} ", 72, '.') . " "; + if (!$info) { + echo "\033[31m[○ STOPPED]\033[0m\n"; + return false; + } + + $uptime = $this->formatDuration(time() - $info->startedAt); + echo "\033[32m[● {$info->state->name}]" + . $this->activityTag($info->activity) + . "\033[90m {$uptime}\033[0m\n"; + + return true; + } + + /** + * Colour-coded activity label: BUSY is highlighted, IDLE is dim. + */ + private function activityTag(Activity $activity): string + { + return $activity === Activity::BUSY + ? "\033[33m [BUSY]" + : "\033[90m [idle]"; + } + + /** + * Dot/dashed notation → FQCN, e.g. `main.process.Backup` → `Main\Process\Backup`. + */ + private function resolveClass(string $input): string + { + return str_replace( + '/', + '\\', + implode('/', array_map( + fn($word) => ucfirst($word), + explode('/', str_replace('.', '/', $input)) + )) + ); + } + + /** + * Human-readable duration, e.g. 90061 → "1d 1h". + */ + private function formatDuration(int $seconds): string + { + $seconds = max(0, $seconds); + $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; + + $parts = []; + foreach ($units as $suffix => $size) { + $value = intdiv($seconds, $size); + $seconds %= $size; + if ($value > 0) { + $parts[] = $value . $suffix; + } + } + + return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); + } + + public static function help(): void + { + $cl = 34; + self::printTitle("Process Help", $cl); + + self::printLabel("Usage", $cl); + self::print("call process [action] -[flags]", $cl); + self::print("call proc [action] -[flags] (alias)", $cl); + self::printLabel("Usage", $cl); + + self::printLabel("Commands", $cl); + self::printBadge('list', 'list all bare processes with live state', $cl, 36); + self::printBadge('', 'start in foreground (default)', $cl, 36); + self::printBadge(' start -d', 'start detached in background', $cl, 36); + self::printBadge(' stop', 'send graceful stop signal (SIGTERM)', $cl, 36); + self::printBadge(' status -v', 'detailed: resource usage', $cl, 36); + self::printLabel("Commands", $cl); + + self::printDivider($cl); + self::printInfo("Daemons (supervised fleets) are managed by 'call daemon'."); + self::printInfo("Row tags: [P] process | ● running, ○ stopped | [BUSY]/[idle]"); + + self::printTitle("Process Help", $cl); + } +} diff --git a/console/Command/Run.php b/console/Command/Run.php index 537ba2a..c996b0b 100644 --- a/console/Command/Run.php +++ b/console/Command/Run.php @@ -4,226 +4,59 @@ namespace Flytachi\Winter\Console\Command; -use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\Base\RuntimeMode; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\BaseBoot; -use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; -use Flytachi\Winter\K2\Http\Adapter\SwooleResponse; -use Flytachi\Winter\K2\Route\MemoryWatcher; -use Flytachi\Winter\K2\Route\Router; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\Logger\Context\CoroutineContext; -use Flytachi\Winter\Logger\LoggerFactory; - -class Run extends Cmd -{ - public static string $title = "start HTTP server: Swoole (default) or PHP built-in (dev)"; - public final const string HOST = '0.0.0.0'; - public final const int PORT = 8000; +final class Run extends Cmd +{ + public static string $title = "run the application: web + declared components (Swoole)"; public function handle(): void { self::printTitle("Run", 34); - $sub = $this->args['arguments'][1] ?? null; - match ($sub) { - 'dev' => $this->runDev(), - default => $this->runSwoole(), - }; + // `run` is owned by the application entry: WinterApplication::run() serves the + // app from its own process before the console dispatcher is ever reached. + // Reaching this command means the entry did not intercept `run`. + self::printWarning("`call run` is served by your WinterApplication entry, not this command."); + self::printInfo("Ensure your `call` launcher calls App::main(\$argv), where App extends WinterApplication."); + self::printInfo("Declare components with #[EnableWeb] / #[EnableProcess] / #[EnableDaemon] / #[EnableScheduler]."); + self::printInfo("Docs: doc-new/winter-application.md"); self::printTitle("Run", 34); } - // ── Swoole server ───────────────────────────────────────────────────────── - - private function runSwoole(): void - { - if (!extension_loaded('swoole')) { - self::printWarning("Swoole extension is not loaded."); - self::printInfo("Install swoole: pecl install swoole"); - return; - } - - $host = $this->args['options']['host'] ?? self::HOST; - $port = (int) ($this->args['options']['port'] ?? self::PORT); - $workerNum = $this->args['options']['workers'] ?? null; - $taskWorkers = $this->args['options']['tasks'] ?? null; - $maxRequest = $this->args['options']['max_request'] ?? null; - $maxRequestGrace = $this->args['options']['max_request_grace'] ?? null; - $watcher = in_array('w', $this->args['flags'] ?? []) - || isset($this->args['options']['watcher']); - - if ($this->isPortInUse($host, $port)) { - self::printWarning("Address 'http://$host:$port' is already in use."); - return; - } - - self::printSuccess("Swoole server starting at http://$host:$port"); - self::printKeyValue('Root', Kernel::$pathRoot, 6, 34, 90); - self::printKeyValue('Workers', $workerNum ?? 'auto', 6, 34, 36); - self::printKeyValue('Task-workers', $taskWorkers ?? 'off', 6, 34, 36); - self::printKeyValue('Max-request', $maxRequest ?? 'off', 6, 34, 36); - self::printKeyValue('Max-request-grace', $maxRequestGrace ?? 'off', 6, 34, 36); - self::printKeyValue('Watcher', $watcher ? 'on' : 'off', 6, 34, 36); - - $router = Router::fromScan(Kernel::$pathRoot); - - \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_PROC); - Runtime::boot(RuntimeMode::Swoole); - - // Log — activate Swoole context and HTTP channel - // ------------------------------------------------ - // Kernel::init() registers channels (sys / http / cli) with ProcessContext - // and sets 'sys' as default. Here, before the server starts: - // 1. CoroutineContext — replaces ProcessContext so each coroutine (request) - // gets its own isolated storage. All log fields (request_id, user_id …) - // set via contextStorage()->set() are scoped to the current coroutine - // and never leak across concurrent requests. - // 2. setDefaultChannel('http') — switches the active channel so every - // LoggerFactory::getLogger() and Log::* call writes to 'http'. - // Both calls happen once before server start; worker processes inherit - // the state via fork and do not need to call them again. - LoggerFactory::setContextStorage(new CoroutineContext()); - LoggerFactory::setDefaultChannel('http'); - $router->static(Kernel::$pathPublic); - - $server = new \Swoole\Http\Server($host, $port); - - // Base config from Boot::swooleConfig(), CLI args override - $bootClass = BaseBoot::getBootClass(); - $config = $bootClass !== '' ? $bootClass::swooleConfig() : []; - if ($workerNum !== null) { - $config['worker_num'] = (int) $workerNum; - } - if ($taskWorkers !== null) { - $config['task_worker_num'] = (int) $taskWorkers; - } - if ($maxRequest !== null) { - $config['max_request'] = (int) $maxRequest; - } - if ($maxRequestGrace !== null) { - $config['max_request_grace'] = (int) $maxRequestGrace; - } - if (!empty($config)) { - $server->set($config); - } - - cli_set_process_title( - "Winter swoole -> server" - . ($watcher ? '@watch' : '') - . " [W=" . ($workerNum ?: 'auto') . "]" - . " [MX_R=" . ($maxRequest ?: 'off') . "]" - . " [MX_RG=" . ($maxRequestGrace ?: 'off') . "]" - ); - - $workerHandler = static function (\Swoole\Http\Server $server, int $workerId): void { - cli_set_process_title("Winter swoole -> worker@$workerId"); - }; - $requestHandler = static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { - $router->handle(new SwooleRequest($req), new SwooleResponse($res)); - }; - - if ($watcher) { - $memWatcher = new MemoryWatcher(); - $memWatcher->attach($server, $workerHandler); - $server->on('request', $memWatcher->wrap($requestHandler)); - } else { - $server->on('workerStart', $workerHandler); - $server->on('request', $requestHandler); - } - - $server->start(); - } - - private function isPortInUse(string $host, int $port): bool - { - $sock = @socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - if ($sock === false) { - return false; - } - $inUse = @socket_connect($sock, $host, $port); - socket_close($sock); - return $inUse; - } - - // ── PHP built-in dev server ─────────────────────────────────────────────── - - private function runDev(): void - { - $isManual = isset($this->args['options']['host']) || isset($this->args['options']['port']); - $host = $this->args['options']['host'] ?? self::HOST; - $port = isset($this->args['options']['port']) - ? (int) $this->args['options']['port'] - : self::PORT; - - if ($isManual) { - $connection = @fsockopen($host, $port); - if (is_resource($connection)) { - fclose($connection); - self::printWarning("Address 'http://$host:$port' is already in use."); - return; - } - } else { - $basePort = $port; - for ($i = 0; $i < 10; $i++) { - $port = $basePort + $i; - $connection = @fsockopen($host, $port); - if (is_resource($connection)) { - fclose($connection); - self::printWarning("Port $port is busy, trying next..."); - if ($i === 9) { - self::printWarning("No free port found in range {$basePort}–" . ($basePort + 9) . "."); - return; - } - } else { - break; - } - } - } - - self::printSuccess("Dev server started at http://$host:$port"); - self::printKeyValue('Root', Kernel::$pathPublic, 6, 34, 90); - passthru('php -S ' . escapeshellarg("$host:$port") - . ' -t ' . escapeshellarg(Kernel::$pathPublic)); - } - - // ── Help ────────────────────────────────────────────────────────────────── - public static function help(): void { $cl = 34; self::printTitle("Run Help", $cl); self::printLabel("Usage", $cl); - self::print("call run - start Swoole HTTP server", $cl); - self::print("call run dev - start PHP built-in dev server", $cl); + self::print("call run - run the application (production; DevWatcher off)", $cl); + self::print("call run dev - run the application (development; DevWatcher: memory + hot-reload)", $cl); self::printLabel("Usage", $cl); - self::printLabel("Swoole options", $cl); - self::print("--host= bind host (default: " . self::HOST . ")", $cl); - self::print("--port= bind port (default: " . self::PORT . ")", $cl); - self::print("--workers= number of workers (default: auto)", $cl); - self::print("--tasks= number of task workers (default: off)", $cl); - self::print("--max_request= max requests/worker (default: off)", $cl); - self::print("--max_request_grace= graceful drain count (default: off)", $cl); - self::print("-w / --watcher enable MemoryWatcher (default: off)", $cl); - self::printLabel("Swoole options", $cl); - - self::printLabel("Dev options", $cl); - self::print("--host= bind host (default: " . self::HOST . ")", $cl); - self::print("--port= bind port (default: " . self::PORT . ", auto-scan if omitted)", $cl); - self::printLabel("Dev options", $cl); + self::printDivider($cl); + + self::printLabel("What runs", $cl); + self::print("Everything declared on your WinterApplication via #[Enable*]:", $cl); + self::print(" #[EnableWeb] -> the Swoole HTTP server (main)", $cl); + self::print(" #[EnableProcess()] -> a managed Process, attached via addProcess", $cl); + self::print(" #[EnableDaemon()] -> a supervised Daemon fleet", $cl); + self::print(" #[EnableScheduler] -> the #[Scheduled] scheduler", $cl); + self::print("With no #[EnableWeb] the app runs headless (background only).", $cl); + self::printLabel("What runs", $cl); + + self::printDivider($cl); + + self::printLabel("Options", $cl); + self::print("-w / --watcher force the DevWatcher on (same as `run dev`)", $cl); + self::printLabel("Options", $cl); self::printDivider($cl); self::printLabel("Examples", $cl); self::printInfo("call run"); - self::printInfo("call run --port=8000 --workers=4 -w"); - self::printInfo("call run --max_request=5000 --max_request_grace=500"); self::printInfo("call run dev"); - self::printInfo("call run dev --port=9000"); self::printLabel("Examples", $cl); self::printDivider($cl); diff --git a/console/Command/Schedule.php b/console/Command/Schedule.php new file mode 100644 index 0000000..f4fe991 --- /dev/null +++ b/console/Command/Schedule.php @@ -0,0 +1,190 @@ +args['arguments'][1] ?? '')) { + 'list' => $this->listArg(), + 'start' => $this->startArg(), + 'stop' => $this->stopArg(), + 'status' => $this->statusArg(in_array('v', $this->args['flags'])), + '' => $this->listArg(), + default => self::printWarning("Unknown action (use list|start|stop|status)."), + }; + + self::printTitle("Schedule", self::CL); + } + + /** + * Lists the discovered tasks and their cadence — a static scan, no running + * scheduler required. + */ + private function listArg(): void + { + $tasks = $this->discover(); + + self::printLabel("Scheduled Tasks", self::CL); + if ($tasks === []) { + self::printWarning("No #[Scheduled] methods found."); + self::printInfo("Annotate a public, no-argument method with #[Scheduled]."); + self::printLabel("Scheduled Tasks", self::CL); + return; + } + + self::print(sprintf(" %-46s %s", 'TASK', 'TRIGGER'), 90); + foreach ($tasks as $task) { + self::print(sprintf(" %-46s %s", $task->id(), $task->trigger->describe()), 32); + } + + self::printDivider(); + self::printInfo(count($tasks) . " task(s) defined."); + self::printLabel("Scheduled Tasks", self::CL); + } + + private function startArg(): void + { + $info = Scheduler::status(); + if ($info) { + self::printWarning("Scheduler already running [PID:{$info->pid}] ({$info->getStartedAt()})."); + return; + } + + if (in_array('d', $this->args['flags'])) { + $pid = Scheduler::dispatch(); + $info = null; + for ($i = 0; $i < 20 && $info === null; $i++) { + usleep(50_000); + $info = Scheduler::status(); + } + self::printSuccess("Scheduler dispatched (background)."); + self::printKeyValue("PID", (string) ($info->pid ?? $pid), 12, self::CL, 32); + return; + } + + self::printInfo("Starting scheduler …"); + Scheduler::start(); + self::printSuccess("Scheduler finished."); + } + + private function stopArg(): void + { + $info = Scheduler::status(); + if (!$info) { + self::printWarning("Scheduler is not running."); + return; + } + if (Scheduler::stop()) { + self::printSuccess("Stop signal sent."); + self::printKeyValue("PID", (string) $info->pid, 12, self::CL, 32); + } else { + self::printWarning("Failed to signal scheduler."); + } + } + + private function statusArg(bool $detailed): void + { + $info = Scheduler::status($detailed); + + self::printLabel("Scheduler Status", self::CL); + if (!$info) { + self::printBadge('Scheduler', '○ STOPPED', self::CL, 31); + self::printInfo("The scheduler is not running."); + self::printLabel("Scheduler Status", self::CL); + return; + } + + self::printBadge('Scheduler', 'Scheduler ● ' . $info->state->name, self::CL, 32); + self::printDivider(); + self::printKeyValue("PID", (string) $info->pid, 12, self::CL, 36); + self::printKeyValue("State", $info->state->name, 12, self::CL, 36); + self::printKeyValue( + "Activity", + $info->activity->name, + 12, + self::CL, + $info->activity === Activity::BUSY ? 33 : 90 + ); + self::printKeyValue("Started", $info->getStartedAt(), 12, self::CL, 36); + self::printKeyValue("Uptime", $this->formatDuration(time() - $info->startedAt), 12, self::CL, 36); + self::printKeyValue("Tasks", (string) count($this->discover()), 12, self::CL, 36); + + self::printLabel("Scheduler Status", self::CL); + } + + /** + * @return ScheduledTask[] + */ + private function discover(): array + { + $collector = new ScheduledCollector(); + ClassScanner::scan($collector); + return $collector->getResult(); + } + + /** + * Human-readable duration, e.g. 90061 → "1d 1h". + */ + private function formatDuration(int $seconds): string + { + $seconds = max(0, $seconds); + $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; + + $parts = []; + foreach ($units as $suffix => $size) { + $value = intdiv($seconds, $size); + $seconds %= $size; + if ($value > 0) { + $parts[] = $value . $suffix; + } + } + + return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); + } + + public static function help(): void + { + $cl = self::CL; + self::printTitle("Schedule Help", $cl); + + self::printLabel("Usage", $cl); + self::print("call schedule -[flags]", $cl); + self::printLabel("Usage", $cl); + + self::printLabel("Commands", $cl); + self::printBadge('list', 'list all #[Scheduled] tasks and their cadence (default)', $cl, 36); + self::printBadge('start', 'run the scheduler in the foreground', $cl, 36); + self::printBadge('start -d', 'run the scheduler detached in the background', $cl, 36); + self::printBadge('stop', 'send a graceful stop signal (SIGTERM)', $cl, 36); + self::printBadge('status', 'scheduler run state + task count', $cl, 36); + self::printLabel("Commands", $cl); + + self::printDivider($cl); + self::printInfo("The scheduler fires one process per host (singleton lock)."); + + self::printTitle("Schedule Help", $cl); + } +} diff --git a/console/Command/Script.php b/console/Command/Script.php index c4d31ac..9609a1f 100644 --- a/console/Command/Script.php +++ b/console/Command/Script.php @@ -5,11 +5,11 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\Console\Inc\CmdCustom; -use Flytachi\Winter\K2\Collector\SubclassCollector; -use Flytachi\Winter\K2\Core\ClassScanner; +use Flytachi\Winter\Console\Stereotype\CmdCustom; +use Flytachi\Winter\Kernel\Collector\SubclassCollector; +use Flytachi\Winter\Kernel\Core\ClassScanner; -class Script extends Cmd +final class Script extends Cmd { public static string $title = "run or list custom Cmd scripts"; diff --git a/console/Command/Storage.php b/console/Command/Storage.php index 47c513d..53e810a 100644 --- a/console/Command/Storage.php +++ b/console/Command/Storage.php @@ -5,9 +5,9 @@ namespace Flytachi\Winter\Console\Command; use Flytachi\Winter\Console\Inc\Cmd; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Kernel; -class Storage extends Cmd +final class Storage extends Cmd { public static string $title = "manage storage folders (init, clean)"; private string $templatePath; diff --git a/console/Command/Thread.php b/console/Command/Thread.php deleted file mode 100644 index 1c6a8d3..0000000 --- a/console/Command/Thread.php +++ /dev/null @@ -1,422 +0,0 @@ -args['arguments']) > 1) { - $this->resolution(); - } else { - self::help(); - } - - self::printTitle("Thread", 34); - } - - private function resolution(): void - { - switch ($this->args['arguments'][1] ?? '') { - case 'list': - $this->listArg(); - break; - case 'daemons': - $this->daemonsArg(); - break; - default: - $this->runArg($this->args['arguments'][1]); - break; - } - } - - private function runArg(string $input): void - { - if (!extension_loaded('pcntl')) { - self::printWarning("Extension 'pcntl' is not loaded — async signals unavailable."); - return; - } - - pcntl_async_signals(true); - - $class = str_replace( - '/', - '\\', - implode('/', array_map( - fn($word) => ucfirst($word), - explode('/', str_replace('.', '/', $input)) - )) - ); - $name = basename(str_replace('\\', '/', $class)); - - if (!class_exists($class)) { - self::printWarning("Class '$name' not found."); - self::printInfo("Resolved: $class"); - self::printInfo("Run 'call thread list' to see available threads."); - } elseif (!is_subclass_of($class, Dispatchable::class)) { - self::printWarning("Class '$name' does not implement Dispatchable."); - self::printInfo("Resolved: $class"); - } elseif (is_subclass_of($class, ThreadDaemon::class)) { - $this->daemonAction($class); - } else { - $inBackground = in_array('d', $this->args['flags']); - if ($inBackground) { - $this->threadRunnableToBack($class); - } else { - $this->threadRunnable($class); - } - } - } - - /** - * @param class-string $class - */ - private function daemonAction(string $class): void - { - match (strtolower($this->args['arguments'][2] ?? '')) { - 'start' => $this->daemonStart($class), - 'stop' => $this->daemonStop($class), - 'status' => $this->daemonStatus($class, in_array('v', $this->args['flags'])), - '' => $this->daemonToggle($class), - default => self::printWarning("Unknown daemon action (use start|stop|status)."), - }; - } - - /** - * Default daemon behavior: toggle between start and stop based on status. - * - * @param class-string $class - */ - private function daemonToggle(string $class): void - { - $info = $class::status(); - if ($info) { - self::printInfo("Already running [PID:{$info->status->pid}], stopping..."); - $this->daemonStop($class); - } elseif (in_array('d', $this->args['flags'])) { - $this->daemonStart($class); - } else { - $this->threadRunnable($class); - } - } - - /** - * @param class-string $class - */ - private function daemonStart(string $class): void - { - try { - $pid = $class::dispatch(); - self::printSuccess("Started: $class"); - self::printKeyValue("PID", (string) $pid, 12, 34, 32); - } catch (DaemonException $e) { - self::printWarning($e->getMessage()); - } - } - - /** - * @param class-string $class - */ - private function daemonStop(string $class): void - { - try { - $info = $class::status(); - $class::stop(); - self::printSuccess("Stopped: $class"); - if ($info) { - self::printKeyValue("PID", (string) $info->status->pid, 12, 34, 32); - } - } catch (DaemonException $e) { - self::printWarning($e->getMessage()); - } - } - - /** - * @param class-string $class - */ - private function daemonStatus(string $class, bool $detailed): void - { - $dot = str_replace('\\', '.', $class); - $info = $class::status($detailed); - - self::printLabel("Daemon Status", 34); - - if (!$info) { - self::printBadge($dot, '○ STOPPED', 34, 31); - self::printInfo("The daemon is not running."); - self::printLabel("Daemon Status", 34); - return; - } - - $s = $info->status; - self::printBadge($dot, '● RUNNING', 34, 32); - self::printDivider(); - - self::printKeyValue("PID", (string) $s->pid, 12, 34, 36); - self::printKeyValue("Condition", $s->condition->name, 12, 34, 36); - self::printKeyValue("Started", $s->getStartedAt(), 12, 34, 36); - self::printKeyValue("Uptime", $this->formatDuration(time() - $s->startedAt), 12, 34, 36); - if ($s->streamRps) { - self::printKeyValue("Stream RPS", (string) $s->streamRps, 12, 34, 36); - } - self::printKeyValue("Forks", (string) $this->daemonForkQty($class), 12, 34, 36); - - foreach ($s->info as $key => $value) { - self::printKeyValue( - (string) $key, - is_scalar($value) ? (string) $value : json_encode($value), - 12, - 34, - 90 - ); - } - - if ($detailed) { - self::printDivider(); - self::printLabel("Resources", 34); - if ($info->stats) { - $st = $info->stats; - self::printKeyValue("User", $st->user, 12, 34, 35); - self::printKeyValue("PPID", (string) $st->ppid, 12, 34, 35); - self::printKeyValue("CPU", $st->cpu . ' %', 12, 34, 35); - self::printKeyValue( - "Memory", - $st->mem . ' % (' . round($st->rssMb(), 1) . ' MB)', - 12, - 34, - 35 - ); - self::printKeyValue("Elapsed", $st->etime, 12, 34, 35); - self::printKeyValue("Command", $st->command, 12, 34, 35); - } else { - self::printInfo("Resource stats unavailable (process gone or 'ps' returned nothing)."); - } - - $this->daemonForks($class); - } - - self::printLabel("Daemon Status", 34); - } - - /** - * @param class-string $class - */ - private function daemonForkQty(string $class): int - { - try { - return $class::forkQty(); - } catch (\Throwable) { - return 0; - } - } - - /** - * Render the daemon's child forks with per-fork resource stats. - * - * @param class-string $class - */ - private function daemonForks(string $class): void - { - try { - $forks = $class::forkListInfo(true); - } catch (\Throwable) { - $forks = []; - } - - self::printDivider(); - self::printLabel("Forks (" . count($forks) . ")", 34); - - if ($forks === []) { - self::printInfo("No active forks."); - return; - } - - foreach ($forks as $fork) { - $fs = $fork->status; - $line = sprintf( - "#%-7d %-11s %s", - $fs->pid, - $fs->condition->name, - $this->formatDuration(time() - $fs->startedAt) - ); - if ($fork->stats) { - $line .= sprintf( - " cpu %s%% rss %s MB", - $fork->stats->cpu, - round($fork->stats->rssMb(), 1) - ); - } - self::print($line, 36); - } - } - - /** - * Human-readable duration, e.g. 90061 → "1d 1h". - */ - private function formatDuration(int $seconds): string - { - $seconds = max(0, $seconds); - $units = ['d' => 86400, 'h' => 3600, 'm' => 60, 's' => 1]; - - $parts = []; - foreach ($units as $suffix => $size) { - $value = intdiv($seconds, $size); - $seconds %= $size; - if ($value > 0) { - $parts[] = $value . $suffix; - } - } - - return $parts === [] ? '0s' : implode(' ', array_slice($parts, 0, 2)); - } - - private function listArg(): void - { - $collector = new ImplementorCollector(Dispatchable::class); - ClassScanner::scan($collector); - $threads = $collector->getResult(); - - self::printLabel("Available Threads", 34); - if (empty($threads)) { - self::printWarning("No Dispatchable classes found."); - self::printInfo("Create one that implements Dispatchable."); - } else { - foreach ($threads as $ref) { - $dotName = str_replace('\\', '.', $ref->getName()); - [$type, $badgeColor] = match (true) { - $ref->isSubclassOf(ThreadDaemon::class) => ['Daemon', 35], - $ref->isSubclassOf(ThreadJob::class) => ['Job', 36], - $ref->isSubclassOf(ThreadProcess::class) => ['Process', 36], - default => ['Dispatchable', 36], - }; - self::printBadge($dotName, $type, 34, $badgeColor); - } - } - self::printLabel("Available Threads", 34); - } - - private function daemonsArg(): void - { - $collector = new SubclassCollector(ThreadDaemon::class); - ClassScanner::scan($collector); - $daemons = $collector->getResult(); - - self::printLabel("Available Daemons", 34); - if (empty($daemons)) { - self::printWarning("No Daemon classes found."); - self::printInfo("Create one that extends ThreadDaemon."); - } else { - foreach ($daemons as $ref) { - $this->printDaemonRow($ref->getName()); - } - } - self::printLabel("Available Daemons", 34); - } - - /** - * Render one daemon row: name, live state, fork count and uptime. - * - * @param class-string $class - */ - private function printDaemonRow(string $class): void - { - $dotName = str_replace('\\', '.', $class); - $info = $class::status(); - - echo "\033[34m" . str_pad(" |\t $dotName ", 65, '.') . " "; - if ($info) { - $forks = $this->daemonForkQty($class); - $uptime = $this->formatDuration(time() - $info->status->startedAt); - echo "\033[32m[● RUNNING]\033[36m [forks:{$forks}]\033[90m {$uptime}"; - } else { - echo "\033[31m[○ STOPPED]"; - } - echo "\033[0m\n"; - } - - /** - * @param class-string $class - */ - private function threadRunnable(string $class): void - { - self::printInfo("Starting: $class"); - ($class)::start(); - self::printSuccess("Finished: $class"); - } - - /** - * @param class-string $class - */ - private function threadRunnableToBack(string $class): void - { - $pid = ($class)::dispatch(); - self::printSuccess("Dispatched: $class"); - self::printKeyValue("PID", (string) $pid, 10, 34, 32); - } - - public static function help(): void - { - $cl = 34; - self::printTitle("Thread Help", $cl); - - self::printLabel("Usage", $cl); - self::print("call thread [args] -[flags]", $cl); - self::print("call th [args] -[flags] (alias)", $cl); - self::printLabel("Usage", $cl); - - self::printLabel("Commands", $cl); - self::printBadge('list', 'list all Dispatchable classes', $cl, 36); - self::printBadge('daemons', 'list daemons with live status', $cl, 36); - self::printBadge('', 'run thread in foreground', $cl, 36); - self::printBadge(' -d', 'dispatch to background', $cl, 36); - self::printLabel("Commands", $cl); - - self::printLabel("Daemon Commands", $cl); - self::printBadge('', 'toggle: stop if running, else start (foreground)', $cl, 35); - self::printBadge(' -d', 'toggle: start in background (-d only here)', $cl, 35); - self::printBadge(' start', 'start daemon in background', $cl, 35); - self::printBadge(' stop', 'stop running daemon', $cl, 35); - self::printBadge(' status', 'show daemon status', $cl, 35); - self::printBadge(' status -v', 'detailed: resources + forks', $cl, 35); - self::printLabel("Daemon Commands", $cl); - - self::printLabel("Flags", $cl); - self::printKeyValue("-d", "dispatch task as background process", 10, $cl, 36); - self::printKeyValue("-v", "verbose daemon status (resources + forks)", 10, $cl, 36); - self::printLabel("Flags", $cl); - - self::printDivider($cl); - - self::printLabel("Examples", $cl); - self::printInfo("call thread list"); - self::printInfo("call thread daemons"); - self::printInfo("call thread main.threads.ExampleJob"); - self::printInfo("call thread main.threads.ExampleJob -d"); - self::printInfo("call thread main.threads.ExampleDaemon"); - self::printInfo("call thread main.threads.ExampleDaemon status"); - self::printInfo("call thread main.threads.ExampleDaemon status -v"); - self::printInfo("call thread main.threads.ExampleDaemon stop"); - self::printLabel("Examples", $cl); - - self::printDivider($cl); - self::printInfo("Docs: https://winterframe.net/docs/3.0.0/cmd-thread"); - - self::printTitle("Thread Help", $cl); - } -} diff --git a/console/Core.php b/console/Core.php index 6507a86..ca541c6 100644 --- a/console/Core.php +++ b/console/Core.php @@ -6,12 +6,14 @@ use Flytachi\Winter\Console\Inc\CoreHandle; -class Core extends CoreHandle +final class Core extends CoreHandle { /** Short aliases → Command class name */ protected static array $aliases = [ 'sc' => 'Script', - 'th' => 'Thread', + 'proc' => 'Process', + 'dmn' => 'Daemon', + 'sch' => 'Schedule', ]; public function __construct($args) diff --git a/console/Inc/CmdCustom.php b/console/Stereotype/CmdCustom.php similarity index 88% rename from console/Inc/CmdCustom.php rename to console/Stereotype/CmdCustom.php index 3db9a85..f54f14d 100644 --- a/console/Inc/CmdCustom.php +++ b/console/Stereotype/CmdCustom.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\Console\Inc; +namespace Flytachi\Winter\Console\Stereotype; +use Flytachi\Winter\Console\Inc\Printer; use Flytachi\Winter\DI\Container; abstract class CmdCustom extends Printer implements CmdCustomInterface diff --git a/console/Inc/CmdCustomInterface.php b/console/Stereotype/CmdCustomInterface.php similarity index 69% rename from console/Inc/CmdCustomInterface.php rename to console/Stereotype/CmdCustomInterface.php index 303c717..ffd59fb 100644 --- a/console/Inc/CmdCustomInterface.php +++ b/console/Stereotype/CmdCustomInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\Console\Inc; +namespace Flytachi\Winter\Console\Stereotype; interface CmdCustomInterface { diff --git a/console/Template/Build/phpstormMeta b/console/Template/Build/phpstormMeta index 86cbc28..0327995 100644 --- a/console/Template/Build/phpstormMeta +++ b/console/Template/Build/phpstormMeta @@ -14,25 +14,25 @@ namespace PHPSTORM_META { 'LOG_OUTPUT', 'LOG_FILE', 'LOG_FILE_MAX', - 'LOG_SYSLOG_IDENT', + 'LOG_COLOR', 'LOG_SYS_LEVEL', 'LOG_SYS_FORMAT', 'LOG_SYS_OUTPUT', 'LOG_SYS_FILE', 'LOG_SYS_FILE_MAX', - 'LOG_SYS_SYSLOG_IDENT', + 'LOG_SYS_COLOR', 'LOG_HTTP_LEVEL', 'LOG_HTTP_FORMAT', 'LOG_HTTP_OUTPUT', 'LOG_HTTP_FILE', 'LOG_HTTP_FILE_MAX', - 'LOG_HTTP_SYSLOG_IDENT', + 'LOG_HTTP_COLOR', 'LOG_CLI_LEVEL', 'LOG_CLI_FORMAT', 'LOG_CLI_OUTPUT', 'LOG_CLI_FILE', 'LOG_CLI_FILE_MAX', - 'LOG_CLI_SYSLOG_IDENT' + 'LOG_CLI_COLOR' ); // 2. Говорим, что 0-й аргумент функции env() должен быть из этого набора @@ -43,6 +43,7 @@ namespace PHPSTORM_META { registerArgumentsSet('log_levels', 'info', 'debug', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'); registerArgumentsSet('log_formats', 'line', 'json'); registerArgumentsSet('log_outputs', 'auto', 'stdout', 'stderr', 'syslog', 'file', 'null'); + registerArgumentsSet('log_colors', 'auto', 'always', 'never'); registerArgumentsSet('time_zones', 'UTC', 'Europe/Moscow', 'America/New_York', 'Asia/Tokyo'); // 4. Связываем: если 0-й аргумент равен X, то для 1-го аргумента предлагаем набор Y @@ -54,20 +55,24 @@ namespace PHPSTORM_META { 'LOG_LEVEL' => argumentsSet('log_levels'), 'LOG_FORMAT' => argumentsSet('log_formats'), 'LOG_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_COLOR' => argumentsSet('log_colors'), // Системные логи 'LOG_SYS_LEVEL' => argumentsSet('log_levels'), 'LOG_SYS_FORMAT' => argumentsSet('log_formats'), 'LOG_SYS_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_SYS_COLOR' => argumentsSet('log_colors'), // HTTP 'LOG_HTTP_LEVEL' => argumentsSet('log_levels'), 'LOG_HTTP_FORMAT' => argumentsSet('log_formats'), 'LOG_HTTP_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_HTTP_COLOR' => argumentsSet('log_colors'), // CLI 'LOG_CLI_LEVEL' => argumentsSet('log_levels'), 'LOG_CLI_FORMAT' => argumentsSet('log_formats'), 'LOG_CLI_OUTPUT' => argumentsSet('log_outputs'), + 'LOG_CLI_COLOR' => argumentsSet('log_colors'), }); } diff --git a/console/Template/Docker/Dockerfile b/console/Template/Docker/Dockerfile index 1014545..1f6c80d 100644 --- a/console/Template/Docker/Dockerfile +++ b/console/Template/Docker/Dockerfile @@ -1,24 +1,16 @@ # syntax=docker/dockerfile:1 # ============================================================================= -# Winter — unified image. One Dockerfile for both runtimes. +# Winter — Swoole image. # ----------------------------------------------------------------------------- -# Pick the runtime at build time (do NOT edit this file): -# docker build --build-arg RUNTIME=fpm . # PHP-FPM + Nginx (default) -# docker build --build-arg RUNTIME=swoole . # Swoole HTTP server -# or set `RUNTIME` in docker-compose.yml. -# -# Everything runtime-specific lives in docker// and the two base -# stages below. Extra components (DB drivers, redis, cron, ...) go in -# docker/dependencies.sh — you should not need to touch this file. -# -# Requires BuildKit (Docker 23+, or DOCKER_BUILDKIT=1) — used for `FROM -# base-${RUNTIME}` and `ARG` interpolation in FROM. +# One runtime (Swoole HTTP server). You should not need to edit this file: +# - DB drivers / PHP extensions / cron → docker/dependencies/*.sh +# (delete the ones you don't need; they run in filename order) +# - dev vs prod → DEV in docker-compose.yml # ============================================================================= -ARG RUNTIME=fpm # ───────────────────────────────────────────────────────────────────────────── -# builder (shared) — composer install, no dev deps. vendor is copied into the -# runtime stage; composer itself never ships in the final image. +# builder — composer install, no dev deps. vendor is copied into the runtime +# stage; composer itself never ships in the final image. # ───────────────────────────────────────────────────────────────────────────── FROM alpine:3.23.3 AS builder WORKDIR /var/www/html @@ -33,88 +25,37 @@ RUN curl -sS https://getcomposer.org/installer | php85 -- --install-dir=/usr/loc && rm /usr/local/bin/composer # ───────────────────────────────────────────────────────────────────────────── -# base-fpm — slim Alpine + prebuilt php85 packages + nginx + runit (multi-service) +# final — maintained phpswoole base (swoole + opcache precompiled). # ───────────────────────────────────────────────────────────────────────────── -FROM alpine:3.23.3 AS base-fpm +FROM phpswoole/swoole:php8.5-alpine AS final WORKDIR /var/www/html -ENV APP_GROUP=nginx - -RUN apk add --no-cache php85 \ - php85-dom php85-xml php85-xmlwriter \ - php85-tokenizer php85-common php85-session \ - php85-ctype php85-phar php85-pcntl php85-fileinfo \ - php85-posix php85-mbstring php85-simplexml \ - php85-iconv php85-pdo php85-fpm php85-curl \ - php85-openssl php85-sockets \ - nginx runit procps bash \ - && rm -rf /var/cache/apk/* \ - && (test -f /usr/bin/php || ln -s /usr/bin/php85 /usr/bin/php) \ - && adduser -D -H -s /sbin/nologin winter \ - && adduser winter nginx \ - && mkdir -p /var/run/ && touch /run/php8.5-fpm.pid \ - && echo "variables_order = 'EGPCS'" > /etc/php85/conf.d/99-custom.ini \ - && sed -i '/^\[global\]/a error_log = /proc/self/fd/2' /etc/php85/php-fpm.conf \ - && mkdir -p /etc/service/php-fpm /etc/service/nginx \ - && printf '#!/bin/sh\n[ -e /etc/service/syslog/run ] && while [ ! -S /dev/log ]; do sleep 1; done\nexec php-fpm85 -F\n' > /etc/service/php-fpm/run \ - && printf '#!/bin/sh\n[ -e /etc/service/syslog/run ] && while [ ! -S /dev/log ]; do sleep 1; done\nexec nginx -g "daemon off;"\n' > /etc/service/nginx/run \ - && chmod +x /etc/service/php-fpm/run /etc/service/nginx/run - -# ── syslog (ENABLED by default — comment this whole block to DISABLE) ───────── -# syslogd tails /dev/log and streams it to stdout, so `docker logs` shows nginx + -# php-fpm + app logs together. The php-fpm/nginx run scripts wait for /dev/log -# ONLY while this service exists, so disabling it never hangs startup. -# To disable: comment this block, then switch to the stdout/stderr variants in -# docker/fpm/nginx.conf and docker/fpm/php-fpm.conf (both marked in-file). -RUN mkdir -p /etc/service/syslog \ - && printf '#!/bin/sh\nexec syslogd -n -O /dev/stdout\n' > /etc/service/syslog/run \ - && chmod +x /etc/service/syslog/run \ - && sed -i 's#^error_log = /proc/self/fd/2#error_log = syslog#' /etc/php85/php-fpm.conf -# ───────────────────────────────────────────────────────────────────────────── - -COPY docker/fpm/nginx.conf /etc/nginx/nginx.conf -COPY docker/fpm/php-fpm.conf /etc/php85/php-fpm.d/www.conf - -# Opcache (fpm-flavored: enable_cli=0). Toggle off for dev via DISABLE_OPCACHE=true. -ARG DISABLE_OPCACHE=false -COPY docker/fpm/php-opcache.ini /tmp/php-opcache.ini -RUN if [ "$DISABLE_OPCACHE" = "false" ]; then \ - cp /tmp/php-opcache.ini /etc/php85/conf.d/10-opcache.ini; \ - fi && rm -f /tmp/php-opcache.ini - -# ───────────────────────────────────────────────────────────────────────────── -# base-swoole — maintained phpswoole base (swoole + opcache precompiled) -# ───────────────────────────────────────────────────────────────────────────── -FROM phpswoole/swoole:php8.5-alpine AS base-swoole -WORKDIR /var/www/html -ENV APP_GROUP=winter RUN apk add --no-cache su-exec procps \ && rm -rf /var/cache/apk/* \ && adduser -D -H -s /sbin/nologin winter -# Opcache (swoole-flavored: enable_cli=1). Toggle off for dev via DISABLE_OPCACHE=true. -ARG DISABLE_OPCACHE=false -COPY docker/swoole/php-opcache.ini /tmp/php-opcache.ini -RUN if [ "$DISABLE_OPCACHE" = "false" ]; then \ - cp /tmp/php-opcache.ini /usr/local/etc/php/conf.d/10-opcache.ini; \ - fi && rm -f /tmp/php-opcache.ini - -# ───────────────────────────────────────────────────────────────────────────── -# final (shared tail) — base is selected by RUNTIME. Everything below is -# identical for both runtimes; per-runtime bits come from ENV APP_GROUP (set in -# the base) and docker//entrypoint.sh. -# ───────────────────────────────────────────────────────────────────────────── -FROM base-${RUNTIME} AS final -ARG RUNTIME -ENV RUNTIME=${RUNTIME} -WORKDIR /var/www/html - -# User hook: extra packages / cron / custom build steps. Placed BEFORE the app -# code copy so this (rarely-changing) layer stays cached across code edits. -COPY docker/dependencies.sh /tmp/dependencies.sh -RUN sh /tmp/dependencies.sh \ - && rm -f /tmp/dependencies.sh \ - && rm -rf /var/cache/apk/* /tmp/pear +RUN docker-php-ext-install -j"$(nproc)" pcntl + +# Opcache config staged for the entrypoint to activate at runtime (no rebuild to +# switch): prod → on (tuned, enable_cli=1); dev (DEV=true) → left off so mounted +# code is always live. +COPY docker/php-opcache.ini /opt/winter/php-opcache.ini + +# Memory ceiling per worker — active in both modes, so it is not staged like opcache. +# PHP's 128M default is invisible until a worker dies of it; this puts the dial in +# sight. Read the file before changing it: the box must hold worker_num × this value. +COPY docker/php-memory.ini /usr/local/etc/php/conf.d/20-memory.ini + +# User hook: DB drivers / PHP extensions / cron. Modular scripts in +# docker/dependencies/ — delete what you don't need; they run in filename order +# (numeric prefixes). Placed BEFORE the app code copy so this rarely-changing +# layer stays cached across code edits. +COPY docker/dependencies/ /tmp/dependencies/ +RUN for f in /tmp/dependencies/*.sh; do \ + [ -e "$f" ] || continue; \ + echo "→ deps: $f"; sh "$f"; \ + done \ + && rm -rf /tmp/dependencies /var/cache/apk/* /tmp/pear # vendor from builder (composer not included in the runtime image) COPY --from=builder /var/www/html/vendor ./vendor @@ -122,16 +63,21 @@ COPY --from=builder /var/www/html/vendor ./vendor # Application code COPY . /var/www/html -RUN chown -R winter:${APP_GROUP} /var/www/html \ - && chmod -R 755 /var/www/html/public \ +# chown covers the whole tree; storage additionally needs to be group-writable. +# No separate mode for web assets: Swoole serves them from the worker itself, so +# there is no second process that has to be able to read them. +RUN chown -R winter:winter /var/www/html \ && chmod -R 775 /var/www/html/storage # Warm shell completion (best-effort; never fails the build) RUN php call cfg completion -if || true -# Entrypoint — one line, the actual script is per-runtime -COPY docker/${RUNTIME}/entrypoint.sh /entrypoint.sh +COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 80 +# No EXPOSE on purpose. It publishes nothing — `ports:` in docker-compose.yml does that, +# and the port itself is decided there too. Baking a number in here would only add one +# that can disagree: `docker compose up` without `--build` reuses the existing image, so +# the recorded port would be whatever the last build happened to use. A metadata line +# that quietly lies is worse than no metadata line. ENTRYPOINT ["/entrypoint.sh"] diff --git a/console/Template/Docker/docker-compose.yml b/console/Template/Docker/docker-compose.yml index b9d717b..94b0252 100644 --- a/console/Template/Docker/docker-compose.yml +++ b/console/Template/Docker/docker-compose.yml @@ -1,17 +1,25 @@ +# The port lives here and nowhere else. Change this one number and both the published +# port and the port the application binds follow — they are the same anchor below. +# +# The `${SERVER_PORT:-…}` form keeps an escape hatch: set SERVER_PORT in the environment +# (or in .env) and it wins without this file being touched, which is usually what a +# production host wants. +x-port: &port ${SERVER_PORT:-8000} + services: server: + tty: true + container_name: ${COMPOSE_PROJECT_NAME:-app} network_mode: bridge build: context: . dockerfile: Dockerfile - args: - # Runtime: fpm | swoole. Change this one line to switch — no regenerate. - RUNTIME: fpm - # Dev: opcache off so mounted code changes are picked up live. - DISABLE_OPCACHE: "true" environment: - - COMPOSE_BAKE=true + COMPOSE_BAKE: "true" + SERVER_PORT: *port + DEV: ${DEV:-false} ports: - - "8000:80" + - target: *port + published: *port volumes: - ./:/var/www/html/ diff --git a/console/Template/Docker/docker/dependencies.sh b/console/Template/Docker/docker/dependencies.sh deleted file mode 100644 index 09d5b46..0000000 --- a/console/Template/Docker/docker/dependencies.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/sh -# ============================================================================= -# dependencies.sh — extra components & custom build steps. -# ----------------------------------------------------------------------------- -# Runs as ROOT during image build (before the app code is copied, so this layer -# stays cached across code edits). Everything here is OPT-IN: uncomment what you -# need. An empty script is a valid no-op. -# -# $RUNTIME tells you which base you are on: -# fpm → slim Alpine, classic sync workers. Extensions from apk (php85-); -# plain blocking drivers are fine — there is no event loop. -# swoole → phpswoole base, coroutine workers. The framework runs with -# `Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL ^ PROC)`, so the -# STANDARD pdo/phpredis drivers are transparently made non-blocking by -# Swoole's runtime hooks — you do NOT need special "coroutine" clients. -# The base already bundles: pdo_mysql, mysqlnd, pdo_sqlite, redis, and -# swoole compiled WITH coroutine_pgsql (libpq). See notes per-DB below. -# -# After editing, rebuild: docker compose build (or: docker build ...) -# ============================================================================= -set -e - -# ----------------------------------------------------------------------------- -# 1) PostgreSQL (pdo_pgsql + pgsql) -# swoole: the coroutine PG engine is already compiled into swoole, but the -# PDO pgsql DRIVER is NOT bundled — install it. Once present, SWOOLE_HOOK_PDO_PGSQL -# (active in the framework's hook mask) makes it non-blocking automatically. -# ----------------------------------------------------------------------------- -# if [ "$RUNTIME" = "fpm" ]; then -# apk add --no-cache php85-pgsql php85-pdo_pgsql -# else -# apk add --no-cache libpq \ -# && apk add --no-cache --virtual .pg-deps postgresql-dev \ -# && docker-php-ext-install -j"$(nproc)" pdo_pgsql pgsql \ -# && apk del .pg-deps -# fi - -# ----------------------------------------------------------------------------- -# 2) MySQL / MariaDB (pdo_mysql + mysqli) -# swoole: pdo_mysql + mysqlnd are ALREADY in the base (coroutine-safe via the -# mysqlnd/stream hooks) — nothing to install unless you specifically need mysqli. -# ----------------------------------------------------------------------------- -# if [ "$RUNTIME" = "fpm" ]; then -# apk add --no-cache php85-pdo_mysql php85-mysqli php85-mysqlnd -# else -# docker-php-ext-install -j"$(nproc)" mysqli # pdo_mysql already present -# fi - -# ----------------------------------------------------------------------------- -# 3) Redis -# swoole: phpredis is ALREADY in the base and is coroutinized transparently by -# Swoole's socket hooks (Swoole 6 dropped the old SWOOLE_HOOK_REDIS constant — -# the plain phpredis client just works). Nothing to install. -# ----------------------------------------------------------------------------- -# if [ "$RUNTIME" = "fpm" ]; then -# apk add --no-cache php85-pecl-redis -# fi - -# ----------------------------------------------------------------------------- -# 4) Cron (busybox crond — ALREADY in both bases, no package to install) -# crond runs as root and executes each crontab as the user matching its -# FILENAME, so a `winter` crontab runs jobs as winter — no su-exec/chpst needed. -# The crontab is identical for both runtimes; only HOW crond starts differs: -# fpm → runit service (below, build-time) -# swoole → a `crond` line in docker/swoole/entrypoint.sh (runtime) -# ----------------------------------------------------------------------------- -# mkdir -p /etc/crontabs -# cat > /etc/crontabs/winter <<'CRON' -# 0 1 * * * cd /var/www/html && php call storage clean -c -# 0 1 * * * cd /var/www/html && php call sc io.scripts.orphanFolderGc -# 30 1 * * * cd /var/www/html && php call sc io.scripts.orphanBlobGc -# CRON -# -# if [ "$RUNTIME" = "fpm" ]; then -# # fpm has runit → supervise crond as a service (auto-starts with the rest). -# # -f = foreground (required so runit keeps it supervised). -# mkdir -p /etc/service/cron -# printf '#!/bin/sh\nexec crond -f -l 8\n' > /etc/service/cron/run -# chmod +x /etc/service/cron/run -# fi -# # swoole has NO supervisor → uncomment the `crond` line in -# # docker/swoole/entrypoint.sh (crond backgrounds itself, then the server execs) - -# ----------------------------------------------------------------------------- -# 5) Anything else — timezone, CA certs, CLI tools, PHP ini tweaks, ... -# ----------------------------------------------------------------------------- -# apk add --no-cache tzdata \ -# && cp /usr/share/zoneinfo/Asia/Tashkent /etc/localtime \ -# && echo "Asia/Tashkent" > /etc/timezone diff --git a/console/Template/Docker/docker/dependencies/10-bcmath.sh b/console/Template/Docker/docker/dependencies/10-bcmath.sh new file mode 100644 index 0000000..6a46fa6 --- /dev/null +++ b/console/Template/Docker/docker/dependencies/10-bcmath.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +# bcmath — arbitrary-precision arithmetic (framework Number / money math). +if php -m | grep -qi '^bcmath$'; then + echo "bcmath already present — skip" +else + docker-php-ext-install -j"$(nproc)" bcmath +fi diff --git a/console/Template/Docker/docker/dependencies/20-pgsql.sh b/console/Template/Docker/docker/dependencies/20-pgsql.sh new file mode 100644 index 0000000..02216a8 --- /dev/null +++ b/console/Template/Docker/docker/dependencies/20-pgsql.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# PostgreSQL (pdo_pgsql + pgsql). +# swoole ships the coroutine PG engine, but NOT the PDO pgsql driver — install it. +# Once present, SWOOLE_HOOK_PDO_PGSQL (in the framework's hook mask) makes it +# non-blocking automatically. +if php -m | grep -qi '^pdo_pgsql$'; then + echo "pdo_pgsql already present — skip" +else + apk add --no-cache libpq \ + && apk add --no-cache --virtual .pg-deps postgresql-dev \ + && docker-php-ext-install -j"$(nproc)" pdo_pgsql pgsql \ + && apk del .pg-deps +fi diff --git a/console/Template/Docker/docker/dependencies/30-mysql.sh b/console/Template/Docker/docker/dependencies/30-mysql.sh new file mode 100644 index 0000000..4d1d05c --- /dev/null +++ b/console/Template/Docker/docker/dependencies/30-mysql.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +# MySQL / MariaDB — pdo_mysql is the driver CDO uses (PDO). mysqli is optional; +# uncomment the block below if your code needs it. +if php -m | grep -qi '^pdo_mysql$'; then + echo "pdo_mysql already present — skip" +else + docker-php-ext-install -j"$(nproc)" pdo_mysql +fi + +# if ! php -m | grep -qi '^mysqli$'; then +# docker-php-ext-install -j"$(nproc)" mysqli +# fi diff --git a/console/Template/Docker/docker/dependencies/40-redis.sh b/console/Template/Docker/docker/dependencies/40-redis.sh new file mode 100644 index 0000000..739a9d1 --- /dev/null +++ b/console/Template/Docker/docker/dependencies/40-redis.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# phpredis (\Redis) — the framework's Redis client (winter-cache). Many bases +if php -m | grep -qi '^redis$'; then + echo "redis already present — skip" +else + apk add --no-cache --virtual .redis-deps $PHPIZE_DEPS \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del .redis-deps +fi diff --git a/console/Template/Docker/docker/entrypoint.sh b/console/Template/Docker/docker/entrypoint.sh new file mode 100644 index 0000000..f0ff48a --- /dev/null +++ b/console/Template/Docker/docker/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Swoole entrypoint. su-exec exec-replaces itself, so the Swoole master becomes +# PID 1 and receives SIGTERM directly → graceful shutdown (Swoole reaps its own +# workers, no zombies). App logs go straight to stdout, so `docker logs` shows +# them without any syslog relay. + +# If you set up a crontab in a docker/dependencies/ script, start crond here: +# crond -l 8 + +# The port arrives from compose's `environment:`, holding the same anchor it published +# with `ports:`. Passing it as a flag keeps the two in step and leaves the application +# unaware it is running in a container — the framework has no Docker-specific setting. +PORT="${SERVER_PORT:-8000}" + +# Opcache is toggled here (runtime, as root before su-exec), so switching dev/prod +# needs no rebuild. Idempotent: safe on a fresh or a restarted container. +OPCACHE_CONF=/usr/local/etc/php/conf.d/10-opcache.ini + +if [ "${DEV:-false}" = "true" ]; then + # Development: opcache off (mounted code always live) + DevWatcher hot-reload. + rm -f "$OPCACHE_CONF" + exec su-exec winter php /var/www/html/call run dev --port="$PORT" +else + # Production: tuned opcache on. + cp /opt/winter/php-opcache.ini "$OPCACHE_CONF" + exec su-exec winter php /var/www/html/call run --port="$PORT" +fi diff --git a/console/Template/Docker/docker/fpm/entrypoint.sh b/console/Template/Docker/docker/fpm/entrypoint.sh deleted file mode 100644 index 70a9dc0..0000000 --- a/console/Template/Docker/docker/fpm/entrypoint.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# FPM runtime entrypoint. -# Re-own the volatile store to winter (0700) before services boot, then hand off -# to runit which supervises syslog + php-fpm + nginx. -# Path = /tmp/flytachi.winter.volatile.. -VOL=/tmp/flytachi.winter.volatile.html -mkdir -p "$VOL" -chown -R winter:winter "$VOL" -chmod 0700 "$VOL" -exec runsvdir /etc/service diff --git a/console/Template/Docker/docker/fpm/nginx.conf b/console/Template/Docker/docker/fpm/nginx.conf deleted file mode 100644 index 94ee061..0000000 --- a/console/Template/Docker/docker/fpm/nginx.conf +++ /dev/null @@ -1,104 +0,0 @@ -user nginx; -worker_processes auto; -pid /run/nginx.pid; -# syslog ON (default). If you disabled the syslog service in the Dockerfile, -# comment the next line and uncomment the /dev/stderr one below. -error_log syslog:server=unix:/dev/log,tag=nginx warn; -#error_log /dev/stderr warn; - -events { - worker_connections 1024; - use epoll; - multi_accept on; -} - -http { - server_tokens off; - keepalive_timeout 30s; - client_max_body_size 20m; - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Block known bad bots and scrapers - map $http_user_agent $blocked_agent { - default 0; - ~*LWP::Simple 1; - ~*BBBike 1; - ~*wget 1; - ~*msnbot 1; - ~*scrapbot 1; - } - - # Block spam referers - map $http_referer $blocked_referer { - default 0; - ~*babes 1; - ~*forsale 1; - ~*girl 1; - ~*jewelry 1; - ~*nudit 1; - ~*organic 1; - ~*poker 1; - ~*porn 1; - ~*sex 1; - ~*teen 1; - } - -# # Hotlink protection (uncomment to enable) -# map $http_referer $block_hotlink { -# default 1; # block by default -# "" 0; # allow direct requests (curl, mobile apps) -# ~*localhost 0; -# ~*google\. 0; # Google search preview -# ~*telegram\. 0; # Telegram link preview -# ~*facebook\. 0; # Facebook / WhatsApp OG preview -# ~*twitter\. 0; # Twitter card preview -# } - - log_format main '[nginx] $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent'; - # syslog ON (default). If syslog is disabled, use the /dev/stdout line instead. - access_log syslog:server=unix:/dev/log,tag=nginx main; - #access_log /dev/stdout main; - - server { - listen 80; - server_name localhost; - root /var/www/html/public/; - include mime.types; - - if ($blocked_agent) { return 403; } - if ($blocked_referer) { return 403; } - - location / { - index index.php; - try_files $uri $uri/ /index.php?$args; - } - - location ~ \.php$ { - include fastcgi_params; - - fastcgi_pass unix:/var/run/php-fpm.sock; - fastcgi_index index.php; - fastcgi_buffers 8 16k; - fastcgi_buffer_size 32k; - fastcgi_read_timeout 60s; - - fastcgi_param DOCUMENT_ROOT $realpath_root; - fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; - fastcgi_param HTTP_X_REAL_IP $http_x_real_ip; - fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for; - } - - location ~ /\.ht { - deny all; - } - -# location ~* \.(webp|jpg|jpeg|gif|png|svg|js|css|ico)$ { -# expires 30d; -# add_header Cache-Control "public, no-transform"; -# -# if ($block_hotlink) { return 403; } -# } - } -} \ No newline at end of file diff --git a/console/Template/Docker/docker/fpm/php-fpm.conf b/console/Template/Docker/docker/fpm/php-fpm.conf deleted file mode 100644 index eb4a4c8..0000000 --- a/console/Template/Docker/docker/fpm/php-fpm.conf +++ /dev/null @@ -1,87 +0,0 @@ -[www] - -; Unix user/group of FPM child processes -user = winter -group = winter - -; FastCGI socket (unix socket or ip:port) -listen = /var/run/php-fpm.sock - -; Allow nginx to access the socket -listen.group = nginx - -; Process manager: static | dynamic | ondemand -pm = dynamic - -; Maximum number of child processes (simultaneous requests) -; -; Formula: max_children = available_RAM / avg_PHP_process_memory -; Check avg memory: ps --no-headers -o "rss,cmd" -C php-fpm | awk '{sum+=$1} END {print sum/NR/1024 " MB"}' -; -; Recommended values based on available RAM: -; 512 MB RAM → max_children = 5–10 (small dev container) -; 1 GB RAM → max_children = 10–15 (light API / dev) -; 2 GB RAM → max_children = 20–30 (medium API, CRM) -; 4 GB RAM → max_children = 40–60 (production, ERP) -; 8 GB RAM → max_children = 80–120 (high-load backend) -; -pm.max_children = 25 - -; Child processes created on startup = (min_spare + max_spare) / 2 -pm.start_servers = 8 - -; Min idle processes (~20% of max_children) -pm.min_spare_servers = 5 - -; Max idle processes (~40% of max_children) -pm.max_spare_servers = 10 - -; Requests per child before respawn (0 = unlimited) -; -; Recommended values: -; Development 500 (frequent restarts acceptable) -; Production (stable app) 0 (no limit, maximize performance) -; Production (memory leak risk) 500–1000 -; Apps using Thread/async jobs 200–500 (child processes add memory pressure) -; -pm.max_requests = 500 - -; FPM status page URI (uncomment to enable: GET /status) -;pm.status_path = /status - -; Slow request log (requires slowlog path) -;slowlog = log/$pool.slow.log -;request_slowlog_timeout = 5s - -; Kill request after timeout (must match nginx fastcgi_read_timeout) -; -; Recommended values: -; API / CRM / ERP (standard) 60s -; File uploads / imports 120–300s -; Long background tasks 300s+ -; Development (no timeout) 0 -; -request_terminate_timeout = 60s - -; Capture worker stdout/stderr into main FPM error log -;catch_workers_output = yes - -; Pass all ENV variables from host to PHP workers (getenv(), $_ENV, $_SERVER) -clear_env = no - -; Per-worker PHP ini overrides -;php_flag[display_errors] = off -; syslog ON (default). If you disabled the syslog service in the Dockerfile, -; comment the syslog line and uncomment the stderr one so worker/app errors -; still reach `docker logs`. -php_admin_value[error_log] = syslog -;php_admin_value[error_log] = /proc/self/fd/2 -php_admin_flag[log_errors] = on -; -; Recommended memory_limit based on application type: -; Small API / blog 64M -; CRM / ERP / e-commerce 128M -; File processing / imports 256M -; Data-heavy reports / exports 512M+ -; -;php_admin_value[memory_limit] = 128M \ No newline at end of file diff --git a/console/Template/Docker/docker/fpm/php-opcache.ini b/console/Template/Docker/docker/fpm/php-opcache.ini deleted file mode 100644 index fc87900..0000000 --- a/console/Template/Docker/docker/fpm/php-opcache.ini +++ /dev/null @@ -1,86 +0,0 @@ -; -------------------------- -; Opcache Configuration File -; -------------------------- - -; Enables Opcache to speed up PHP execution -opcache.enable=1 - -; Amount of memory allocated for caching PHP scripts (in megabytes) -; -; Recommended values based on project size: -; Small (landing page, blog) 64M -; Medium (API, CRM, e-commerce) 128M -; Large (high-load backend, WebSocket) 256M+ -; -opcache.memory_consumption=64 - -; Maximum number of files that can be cached -; -; Recommended values based on project size: -; Small (landing page, blog) 4000 -; Medium (API, CRM, e-commerce) 10000 -; Large (high-load backend, WebSocket) 20000+ -; -opcache.max_accelerated_files=10000 - -; Interval (in seconds) between file update checks -; If a file is modified, Opcache will refresh the cache. -opcache.revalidate_freq=10 - -; File change detection -; 0 - Opcache will not check for file changes (manual cache reset required) -; 1 - Opcache checks for file modifications (useful for development) -opcache.validate_timestamps=0 - -; Amount of memory (in megabytes) allocated for caching interned strings -; This helps save memory and speed up execution -; -; Recommended values based on project size: -; Small (landing page, blog) 8M -; Medium (API, CRM, e-commerce) 16M -; Large (high-load backend, WebSocket) 32M+ -; -opcache.interned_strings_buffer=16 - -; Enables optimized PHP shutdown -; 1 - Uses fast memory cleanup, reducing system load -opcache.fast_shutdown=1 - -; -------------------------- -; JIT Configuration -; -------------------------- - -; Enables JIT compilation to improve performance -; JIT compiles PHP code into machine code for faster execution -; JIT Modes: -; 1205 - Function + Side traces (no loop unrolling), stable for most apps -; 1255 - Tracing mode with loop optimizations (faster but may use more memory) -; 0 - Disables JIT -opcache.jit=0 - -; Recommended JIT buffer size based on project scale: -; -; Small (landing page, blog) 32M -; Medium (API, CRM, e-commerce) 64M -; Large (high-load backend, WebSocket, Queues) 128M -; Very large (data processing, High Load) 256M+ -; -; Amount of memory allocated for JIT compilation (in megabytes) -opcache.jit_buffer_size=64M - -; -------------------------- -; CLI Opcache Configuration -; -------------------------- - -; Enables Opcache for CLI mode (useful for long-running PHP processes) -; This is important for Threads, WebSockets, and Daemon Processes -opcache.enable_cli=0 - -; Recommended settings for different environments: -; Development: -; opcache.validate_timestamps=1 -; opcache.revalidate_freq=0 -; -; Production: -; opcache.validate_timestamps=0 -; opcache.revalidate_freq=10 \ No newline at end of file diff --git a/console/Template/Docker/docker/php-memory.ini b/console/Template/Docker/docker/php-memory.ini new file mode 100644 index 0000000..2b3280e --- /dev/null +++ b/console/Template/Docker/docker/php-memory.ini @@ -0,0 +1,33 @@ +; -------------------------- +; Memory +; -------------------------- +; +; This file is always active — dev and prod alike — unlike php-opcache.ini, which the +; entrypoint switches on only outside dev mode. + +; Memory ceiling for ONE worker process. +; +; PHP's own default is 128M, and it is invisible: nothing announces it, so the first +; time most projects learn the number is when a worker dies under load. 256M is set +; here explicitly so the dial is where you can see it. +; +; Two things about this limit are easy to get wrong: +; +; 1. It is per PROCESS, and a Swoole worker serves many requests at once as +; coroutines out of one heap. So it bounds their SUM, not any single request. +; Measured on a small API: roughly 90 KB of heap per in-flight request, i.e. +; ~1400 concurrent requests fit in 128M and ~2800 in 256M. +; +; 2. The box must hold worker_num × this value, plus opcache's shared memory +; (see php-opcache.ini). Four workers at 256M want 1 GiB before a single row of +; data is read. The framework warns at startup when that product exceeds the +; container's memory limit. +; +; Raising it moves the threshold; it does not remove it. What keeps a worker alive +; under a spike is bounding concurrency, not the ceiling. +; +; Do NOT set -1 here. Unlimited does not remove the ceiling, it moves it into the +; kernel: PHP never stops the process, so the OOM killer eventually sends SIGKILL — +; no shutdown functions, no log line, and the whole container when the server is PID 1. +; A real limit at least fails through PHP, and the manager restarts that one worker. +memory_limit = 256M diff --git a/console/Template/Docker/docker/swoole/php-opcache.ini b/console/Template/Docker/docker/php-opcache.ini similarity index 74% rename from console/Template/Docker/docker/swoole/php-opcache.ini rename to console/Template/Docker/docker/php-opcache.ini index 17d48aa..cb52962 100644 --- a/console/Template/Docker/docker/swoole/php-opcache.ini +++ b/console/Template/Docker/docker/php-opcache.ini @@ -50,23 +50,14 @@ opcache.fast_shutdown=1 ; JIT Configuration ; -------------------------- -; Enables JIT compilation to improve performance -; JIT compiles PHP code into machine code for faster execution -; JIT Modes: -; 1205 - Function + Side traces (no loop unrolling), stable for most apps -; 1255 - Tracing mode with loop optimizations (faster but may use more memory) -; 0 - Disables JIT -opcache.jit=1205 - -; Recommended JIT buffer size based on project scale: -; -; Small (landing page, blog) 32M -; Medium (API, CRM, e-commerce) 64M -; Large (high-load backend, WebSocket, Queues) 128M -; Very large (data processing, High Load) 256M+ -; -; Amount of memory allocated for JIT compilation (in megabytes) -opcache.jit_buffer_size=64M +; JIT is DISABLED on purpose. Swoole registers user opcode handlers, which are +; incompatible with opcache JIT — PHP auto-disables JIT and warns at startup +; ("JIT is incompatible with third party extensions ..."). A long-lived Swoole +; process gains little from JIT anyway, so it is turned off explicitly to keep the +; startup clean. (Modes, for reference: 1205 function+side-traces, 1255 tracing, +; 0 off.) +opcache.jit=0 +opcache.jit_buffer_size=0 ; -------------------------- ; CLI Opcache Configuration diff --git a/console/Template/Docker/docker/swoole/entrypoint.sh b/console/Template/Docker/docker/swoole/entrypoint.sh deleted file mode 100644 index 7cadbf8..0000000 --- a/console/Template/Docker/docker/swoole/entrypoint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Swoole runtime entrypoint. -# su-exec exec-replaces itself, so the Swoole master becomes PID 1 and receives -# SIGTERM directly → graceful shutdown. Swoole reaps its own workers (no zombies). - -# ── syslog (ENABLED by default — comment the line to DISABLE) ───────────────── -# Runs busybox syslogd in the background so that, IF you set LOG_OUTPUT=syslog in -# .env, app logs surface in `docker logs` (/dev/log → syslogd → stdout). The -# framework picks the log target itself (default: straight to stdout), so with -# stdout output this daemon just sits idle — harmless. Comment to drop it. -syslogd -O /dev/stdout -# ───────────────────────────────────────────────────────────────────────────── - -# If you set up a crontab in docker/dependencies.sh, start crond here first. -# busybox crond backgrounds itself, so it runs alongside the server below. -# It stays root and executes the `winter` crontab as winter (no su-exec needed). -# crond -l 8 - -exec su-exec winter php /var/www/html/call run --port=80 diff --git a/console/Template/Make/CmdTemplate b/console/Template/Make/CmdTemplate index 5f0bd77..96d4001 100644 --- a/console/Template/Make/CmdTemplate +++ b/console/Template/Make/CmdTemplate @@ -2,7 +2,7 @@ namespace __namespace__; -use Flytachi\Winter\Console\Inc\CmdCustom; +use Flytachi\Winter\Console\Stereotype\CmdCustom; class __className__ extends CmdCustom { diff --git a/console/Template/Make/ControllerTemplate b/console/Template/Make/ControllerTemplate index a5f640e..577266f 100644 --- a/console/Template/Make/ControllerTemplate +++ b/console/Template/Make/ControllerTemplate @@ -2,9 +2,9 @@ namespace __namespace__; -use Flytachi\Winter\K2\Http\Response\ResponseEntity; -use Flytachi\Winter\K2\Route\Annotation\RequestMapping; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; class __className__ extends Controller { diff --git a/console/Template/Make/DaemonTemplate b/console/Template/Make/DaemonTemplate index e7f3918..d683f76 100644 --- a/console/Template/Make/DaemonTemplate +++ b/console/Template/Make/DaemonTemplate @@ -2,23 +2,25 @@ namespace __namespace__; -use Flytachi\Winter\K2\Stereotype\Daemon; +use Flytachi\Winter\Kernel\Process\Stereotype\Daemon; -class __className__ extends Daemon +final class __className__ extends Daemon { - public function resolution(mixed $data = null): void - { - $this->logger?->info('START'); - $this->prepare(); - $pid = $this->forkAnonymous(); - $this->wait($pid, function ($pid, $status) { - $this->logger?->info("PROC finally (status {$status})"); - }); - $this->logger?->info('END'); - } + /** Baseline fleet size — replicas the master keeps alive. */ + protected int $replicas = 3; - public function anonymousResolution(mixed $data = null): void + /** + * The worker body — the daemon is also the worker, so this runs in every + * replica. (Alternatively drop this and set `$workerClass` to a Process class.) + */ + protected function workerRun(): void { - $this->logger?->info("PROC running"); + while ($this->isRunning()) { + $this->markBusy(); + // ... work ... + $this->markIdle(); + + $this->sleep(1.0); + } } } diff --git a/console/Template/Make/DbConfigTemplate b/console/Template/Make/DbConfigTemplate index 0059294..f88967e 100644 --- a/console/Template/Make/DbConfigTemplate +++ b/console/Template/Make/DbConfigTemplate @@ -3,9 +3,9 @@ namespace __namespace__; use Flytachi\Winter\Cdo\Config\DbConfig; -use Flytachi\Winter\K2\Ppa\PpaCallTrait; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolConfigInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolTrait; +use Flytachi\Winter\Kernel\Ppa\PpaCallTrait; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolConfigInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolTrait; class __className__ extends DbConfig implements PpaPoolConfigInterface { diff --git a/console/Template/Make/EntityTemplate b/console/Template/Make/EntityTemplate index 66a6db6..bc41aac 100644 --- a/console/Template/Make/EntityTemplate +++ b/console/Template/Make/EntityTemplate @@ -2,9 +2,9 @@ namespace __namespace__; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; #[Table] class __className__ diff --git a/console/Template/Make/JobTemplate b/console/Template/Make/JobTemplate deleted file mode 100644 index dfac219..0000000 --- a/console/Template/Make/JobTemplate +++ /dev/null @@ -1,13 +0,0 @@ -logger->info('RUN'); - } -} diff --git a/console/Template/Make/MiddlewareTemplate b/console/Template/Make/MiddlewareTemplate index 67421e9..f79f248 100644 --- a/console/Template/Make/MiddlewareTemplate +++ b/console/Template/Make/MiddlewareTemplate @@ -2,9 +2,9 @@ namespace __namespace__; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] class __className__ extends Middleware diff --git a/console/Template/Make/ProcessTemplate b/console/Template/Make/ProcessTemplate index a6c12d9..68c68e2 100644 --- a/console/Template/Make/ProcessTemplate +++ b/console/Template/Make/ProcessTemplate @@ -2,22 +2,22 @@ namespace __namespace__; -use Flytachi\Winter\K2\Stereotype\Process; +use Flytachi\Winter\Kernel\Process\Stereotype\Process; -class __className__ extends Process +final class __className__ extends Process { - public function resolution(mixed $data = null): void + /** + * The process body — loop while the process is running. A stop signal flips + * isRunning() to false; an idle sleep() is interrupted at once. + */ + public function run(): void { - $this->logger->info('START'); - $pid = $this->forkAnonymous(); - $this->wait($pid, function ($pid, $status) { - $this->logger->info("fork finally (status {$status})"); - }); - $this->logger->info('END'); - } + while ($this->isRunning()) { + $this->markBusy(); + // ... work ... + $this->markIdle(); - public function anonymousResolution(mixed $data = null): void - { - $this->logger->info("fork running"); + $this->sleep(1.0); + } } } diff --git a/console/Template/Make/RepositoryTemplate b/console/Template/Make/RepositoryTemplate index 2d16a11..18986e0 100644 --- a/console/Template/Make/RepositoryTemplate +++ b/console/Template/Make/RepositoryTemplate @@ -2,8 +2,10 @@ namespace __namespace__; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\DI\Attribute\Singleton; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; +#[Singleton] class __className__ extends Repository { protected string $dbConfigClassName; diff --git a/console/Template/Make/ResponseTemplate b/console/Template/Make/ResponseTemplate deleted file mode 100644 index 0693177..0000000 --- a/console/Template/Make/ResponseTemplate +++ /dev/null @@ -1,19 +0,0 @@ -content = [ - 'code' => $this->httpCode->value, - 'result' => $this->content, - ...$this->debugger() - ]; - return parent::getBody(); - } -} diff --git a/console/Template/Make/ServiceTemplate b/console/Template/Make/ServiceTemplate index 272a031..0535142 100644 --- a/console/Template/Make/ServiceTemplate +++ b/console/Template/Make/ServiceTemplate @@ -2,8 +2,6 @@ namespace __namespace__; -use Flytachi\Winter\K2\Stereotype\Service; - -class __className__ extends Service +class __className__ { } diff --git a/console/Template/Make/WebSocketTemplate b/console/Template/Make/WebSocketTemplate deleted file mode 100644 index 4a52fb9..0000000 --- a/console/Template/Make/WebSocketTemplate +++ /dev/null @@ -1,22 +0,0 @@ - start [-d] | stop | status + * php call process start [-d] | stop | status */ -class Boot extends BaseBoot +#[EnableWeb] +#[EnableActuator] +// #[EnableAsync] // proxy #[Async] methods +// #[EnableScheduler] // run Main\Schedule\DemoTasks beside the server +// #[EnableProcess(\Main\Process\SendProc::class)] // a worker running beside the server +// #[EnableDaemon(\Main\Process\StableDaemon::class)] // a supervised fleet beside the server +final class Application extends WinterApplication { - /** - * Kernel — paths, .env, logging, timezone. - * - * All parameters are optional; omitted ones are derived from $pathRoot. - * Logging is configured entirely via .env — see LOG_* variables below. - * - * Paths: - * pathRoot Project root (default: cwd) - * pathEnv .env file location (default: $pathRoot/.env) - * pathPublic Web-accessible directory (default: $pathRoot/public) - * pathResource View / template directory (default: $pathRoot/resources) - * pathStorage Writable storage root (default: $pathRoot/storage) - * pathStorageLog Log files directory (default: $pathStorage/logs) - * pathStorageCache Cache files directory (default: $pathStorage/cache) - * pathStorageRunnable Runnable task files (default: $pathStorage/runnable) - * - * .env logging variables: - * LOG_LEVEL=info Minimum severity: DEBUG|INFO|NOTICE|WARNING|ERROR|... - * Empty → logging disabled (NullLogger for all channels) - * LOG_FORMAT=line Output format: line | json - * LOG_OUTPUT=auto Destination: auto | stdout | stderr | syslog | file | null - * auto — Docker/K8s → syslog, Swoole → stdout, FPM/CLI → stderr - * LOG_FILE= Absolute path when LOG_OUTPUT=file - * LOG_FILE_MAX=30 Number of daily rotating files to keep - * LOG_SYSLOG_IDENT=winter Program identity tag in syslog (journalctl -t winter) - * - * Per-channel overrides (LOG_{CHANNEL}_* takes priority over global): - * LOG_HTTP_LEVEL=warning - * LOG_HTTP_OUTPUT=file - * LOG_HTTP_FILE=/var/log/app/http.log - * LOG_CLI_OUTPUT=stderr - * LOG_SYS_OUTPUT=syslog - */ - protected static function configure(): void + public static function main(array $argv): never { - Kernel::init(pathRoot: __DIR__); - } - - /** - * DI — service providers and manual bindings. - * - * Called after the Scanner auto-discovers #[Singleton] / #[Request] / #[Transient]. - * Use this hook to bind interfaces to implementations, register factories, - * or set named scalar values that cannot be expressed via attributes. - * - * Service providers (group related bindings): - * $c->register(AppServiceProvider::class); - * $c->register(DatabaseServiceProvider::class); - * - * Manual bindings: - * $c->singleton(CacheInterface::class, RedisCache::class); - * $c->request(AuthContext::class); - * $c->transient(QueryBuilder::class); - * $c->bind(MailerInterface::class, fn(Container $c) => - * new SmtpMailer(env('MAIL_HOST'), $c->make(LoggerInterface::class)) - * ); - * - * Named scalar values (inject via #[Inject('config.timeout')]): - * $c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); - * $c->set('app.name', env('APP_NAME', 'Winter')); - */ - protected static function providers(Container $c): void - { - // $c->register(AppServiceProvider::class); - } - - /** - * Logging — additional channels beyond the built-in sys / http / cli. - * - * Each channel reads LOG_{NAME}_* env vars with the same fallback chain - * as the built-in channels. Channel name is lowercase by convention. - * - * Kernel::channel('job'); - * Kernel::channel('daemon'); - * - * Usage in application code: - * LoggerFactory::getLogger(MyJob::class, 'job')->info('started'); - * LoggerFactory::channel('daemon')->warning('slow tick'); - * - * .env for custom channels: - * LOG_JOB_LEVEL=debug - * LOG_JOB_OUTPUT=file - * LOG_JOB_FILE=/var/log/app/job.log - * LOG_JOB_FILE_MAX=7 - */ - protected static function channels(): void - { - // Kernel::channel('job'); - } - - /** - * CORS — global Cross-Origin policy. - * - * Applied to every response (including 404 / 500) before route dispatch. - * Per-route overrides are available via #[CrossOrigin] on controller class or method. - * - * Cors::configure( - * origins: ['https://app.example.com'], - * allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'], - * exposeHeaders: ['X-Request-Id'], - * credentials: true, - * maxAge: 3600, - * ); - * - * Empty origins array → wildcard '*' (any origin allowed). - */ - protected static function httpCors(): void - { - // Cors::configure(); - } - - /** - * Health / Actuator — diagnostic endpoints under /actuator. - * - * Endpoints (GET): - * /actuator — full aggregated report - * /actuator/health — up | degraded | down - * /actuator/info — PHP version, SAPI, framework meta - * /actuator/metrics — CPU, memory, disk, opcache, uptime - * /actuator/env — custom env values - * /actuator/loggers — active channels and levels - * /actuator/mappings — registered route table - * - * Default (built-in indicator, open access): - * Health::configure(); - * - * Custom indicator + middleware guard: - * Health::configure( - * indicator: App\Health\AppHealthIndicator::class, - * middleware: App\Http\Middleware\InternalOnlyMiddleware::class, - * ); - */ - protected static function health(): void - { - // Health::configure(); - } - - /** - * Plugins — route-prefixed sub-applications. - * - * Each plugin's src/ directory is scanned for controllers automatically. - * No extra wiring required — routes are discovered on scan. - * - * Plugin::registry('acme/auth-plugin', '/auth'); - * Plugin::registry('acme/billing-plugin', '/billing'); - * - * Parameters: - * package Composer package name (e.g. 'acme/billing') - * prefix URL prefix (e.g. '/billing') - * required Throw if not installed (default: true) - */ - protected static function plugins(): void - { - // Plugin::registry('', ''); - } - - /** - * Swoole server settings passed to \Swoole\Http\Server::set(). - * - * Override to tune concurrency, request limits, and other Swoole options. - * Return an empty array to use Swoole defaults. - * - * return [ - * 'worker_num' => swoole_cpu_num() * 2, - * 'max_request' => 5000, - * 'max_request_grace' => 500, - * ]; - */ - public static function swooleConfig(): array - { - return []; + parent::run($argv); } } diff --git a/dev/call b/dev/call index 1b87abd..cdb96d6 100755 --- a/dev/call +++ b/dev/call @@ -8,7 +8,7 @@ If the requirement is not met the script exits with a human-readable message instead of a cryptic parse error. */ -if (PHP_VERSION_ID >= 80300) { +if (PHP_VERSION_ID >= 80400) { /* Working directory @@ -22,14 +22,15 @@ if (PHP_VERSION_ID >= 80300) { /* Bootstrap --------- - Initialises the framework (autoload, Kernel::init, optional CORS / Health / - Plugin config). See bootstrap.php for full parameter reference. + Loads the autoloader and declares the application class. Everything the app + contains is declared there with #[Enable*] attributes; the rest of the + configuration lives in scanned classes. See bootstrap.php. */ require './bootstrap.php'; - Boot::cli($argv); + Application::main($argv); } else { - echo "\033[33m"." Please use PHP version 8.3 or higher.\n"; + echo "\033[33m"." Please use PHP version 8.4 or higher.\n"; echo "\033[34m"." Current PHP version => " . PHP_VERSION . "\n"; } diff --git a/dev/composer.json b/dev/composer.json index 4d6b4cb..a32ca99 100644 --- a/dev/composer.json +++ b/dev/composer.json @@ -1,6 +1,6 @@ { - "name": "project/winter-k2", - "description": "My Project winter-k2", + "name": "project/winter", + "description": "My Project winter", "type": "project", "license": "MIT", "scripts": { @@ -13,7 +13,7 @@ }, "require": { "php": ">=8.3", - "flytachi/winter-kernel": "dev-k2" + "flytachi/winter-kernel": "dev-alpha" }, "require-dev": { "phpunit/phpunit": "@stable", diff --git a/dev/main/AuthMiddleware.php b/dev/main/AuthMiddleware.php index af50d26..c0c6302 100644 --- a/dev/main/AuthMiddleware.php +++ b/dev/main/AuthMiddleware.php @@ -2,10 +2,10 @@ namespace Main; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] class AuthMiddleware extends Middleware diff --git a/dev/main/ListOrder.php b/dev/main/ListOrder.php index 29f403b..e251b94 100644 --- a/dev/main/ListOrder.php +++ b/dev/main/ListOrder.php @@ -2,8 +2,8 @@ namespace Main; -use Flytachi\Winter\K2\Http\Request\Validation\ListOf; -use Flytachi\Winter\K2\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; readonly class ListOrder { diff --git a/dev/main/MainController.php b/dev/main/MainController.php index 673298e..44bb553 100644 --- a/dev/main/MainController.php +++ b/dev/main/MainController.php @@ -3,15 +3,18 @@ namespace Main; use Flytachi\Winter\DI\Attribute\Inject; -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Response\ResponseEntity; -use Flytachi\Winter\K2\Route\Annotation\PostMapping; -use Flytachi\Winter\K2\Route\Annotation\RequestMapping; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Concurrent\Executors; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Route\Annotation\GetMapping; +use Flytachi\Winter\Kernel\Route\Annotation\PostMapping; +use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; +use Flytachi\Winter\Logger\Log; use Main\Services\SendInterface; use Main\Services\SmsSendService; @@ -21,6 +24,25 @@ class MainController extends Controller #[Inject(SmsSendService::class)] private SendInterface $service; + #[GetMapping] + public function test(): mixed + { + Executors::common()->execute(function () { + sleep(1); + Log::info("agu agu"); + $this->service->send(); + }); + + $t= Executors::common()->submit(function () { + sleep(2); + Log::info("submit"); + return $this->service->list(); + }); + dd( + $t->get() + ); + } + #[AuthMiddleware] #[PostMapping('test')] public function hello( diff --git a/dev/main/Order.php b/dev/main/Order.php index 09c56a9..ae67bb6 100644 --- a/dev/main/Order.php +++ b/dev/main/Order.php @@ -2,9 +2,9 @@ namespace Main; -use Flytachi\Winter\K2\Http\Request\K1ValidationTrait; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Localization\Locale; readonly class Order { diff --git a/dev/main/Process/AutoscaleDaemon.php b/dev/main/Process/AutoscaleDaemon.php new file mode 100644 index 0000000..98dc51e --- /dev/null +++ b/dev/main/Process/AutoscaleDaemon.php @@ -0,0 +1,58 @@ +bootAt === 0.0) { + $this->bootAt = microtime(true); + } + $elapsed = microtime(true) - $this->bootAt; + + if ($elapsed < 3.0) { + return 1; + } + if ($elapsed < 8.0) { + return 5; + } + return 2; + } + + protected function onScale(int $from, int $to): void + { + $this->logger->info("AutoscaleDaemon scaled {$from} → {$to}"); + } + + protected function workerRun(): void + { + while ($this->isRunning()) { + $this->sleep(0.5); + } + } +} diff --git a/dev/main/Process/ConsumerDemo.php b/dev/main/Process/ConsumerDemo.php new file mode 100644 index 0000000..edaac74 --- /dev/null +++ b/dev/main/Process/ConsumerDemo.php @@ -0,0 +1,41 @@ +logger->info('Consumer started'); + $n = 0; + + try { + while ($this->isRunning()) { + $this->sleep(0.5); // IDLE wait (interruptible) + + $n++; + $this->markBusy(); + $this->logger->info("unit #{$n}: BUSY start"); + $this->sleep(3.0); // long processing — must finish on stop + $this->logger->info("unit #{$n}: BUSY done"); + $this->markIdle(); + } + } catch (InterruptedException) { + $this->logger->info('Woken from IDLE wait by stop'); + } + + $this->logger->info('Consumer stopped'); + } +} diff --git a/dev/main/Process/CrashDaemon.php b/dev/main/Process/CrashDaemon.php new file mode 100644 index 0000000..7abff94 --- /dev/null +++ b/dev/main/Process/CrashDaemon.php @@ -0,0 +1,32 @@ +logger->info('CrashDaemon worker START pid=' . $this->pid); + $this->sleep(0.6); + $this->logger->warning('CrashDaemon worker about to crash pid=' . $this->pid); + throw new \RuntimeException('boom'); + } +} diff --git a/dev/main/Process/DemoProcess.php b/dev/main/Process/DemoProcess.php new file mode 100644 index 0000000..22bb427 --- /dev/null +++ b/dev/main/Process/DemoProcess.php @@ -0,0 +1,34 @@ +logger->info('DemoProcess START (concurrency=' . $this->concurrency . ')'); + + for ($i = 1; $i <= 6; $i++) { + $this->spawn(fn() => $this->task($i)); + } + + $this->logger->info('DemoProcess loop done, draining...'); + } + + private function task(int $n): void + { + $this->logger->info(" task #$n start (inFlight)"); + $this->sleep(0.3); + $this->logger->info(" task #$n done"); + } +} diff --git a/dev/main/Process/FleetDaemon.php b/dev/main/Process/FleetDaemon.php new file mode 100644 index 0000000..d624c7a --- /dev/null +++ b/dev/main/Process/FleetDaemon.php @@ -0,0 +1,30 @@ +logger->info('FleetDaemon worker#' . ($slot + 1) . " start pid={$pid}"); + } + + protected function onWorkerExit(int $slot, int $pid, bool $crashed): void + { + $this->logger->info( + 'FleetDaemon worker#' . ($slot + 1) . " exit pid={$pid} crashed=" . ($crashed ? '1' : '0') + ); + } +} diff --git a/dev/main/Process/HungDaemon.php b/dev/main/Process/HungDaemon.php new file mode 100644 index 0000000..c6f30dc --- /dev/null +++ b/dev/main/Process/HungDaemon.php @@ -0,0 +1,35 @@ +logger->info('HungDaemon worker START pid=' . $this->pid); + $this->sleep(1.0); + $this->logger->warning('HungDaemon worker WEDGING now pid=' . $this->pid); + while (true) { + // Deadlock simulation: blocks the reactor, so no heartbeat lands. + } + } +} diff --git a/dev/main/Process/LongDemo.php b/dev/main/Process/LongDemo.php new file mode 100644 index 0000000..82802ed --- /dev/null +++ b/dev/main/Process/LongDemo.php @@ -0,0 +1,43 @@ +logger->info('LongDemo START pid=' . $this->pid); + + $tick = 0; + while ($this->isRunning()) { + $this->logger->info('LongDemo tick ' . (++$tick)); + $this->sleep(1.0); + $this->spawn(function () { + $this->logger->notice('spawner'); + $i = 10; + while ($this->isRunning()) { + $i--; + if ($i == 0) { + break; + } + $this->sleep(1); + } + }); + } + + $this->logger->info('LongDemo graceful exit after ' . $tick . ' ticks'); + } + + protected function onShutdown(): void + { + $this->logger->alert('LongDemo shutdown'); + } +} diff --git a/dev/main/Process/NeverDaemon.php b/dev/main/Process/NeverDaemon.php new file mode 100644 index 0000000..1dc7395 --- /dev/null +++ b/dev/main/Process/NeverDaemon.php @@ -0,0 +1,30 @@ +logger->info('NeverDaemon worker START pid=' . $this->pid); + $this->sleep(0.5); + throw new \RuntimeException('boom (never restarts)'); + } +} diff --git a/dev/main/Process/SendProc.php b/dev/main/Process/SendProc.php new file mode 100644 index 0000000..e35499a --- /dev/null +++ b/dev/main/Process/SendProc.php @@ -0,0 +1,33 @@ +info('SendProc afterFork pid=' . getmypid()); + } + + public function run(): void + { + $this->logger->info('SendProc worker run pid=' . $this->pid); + while ($this->isRunning()) { + $this->markBusy(); + $this->sleep(0.4); + $this->markIdle(); + $this->sleep(0.4); + } + $this->logger->info('SendProc worker exit pid=' . $this->pid); + } +} diff --git a/dev/main/Process/SignalDemo.php b/dev/main/Process/SignalDemo.php new file mode 100644 index 0000000..c66e834 --- /dev/null +++ b/dev/main/Process/SignalDemo.php @@ -0,0 +1,75 @@ +logger->info('Process started'); + + try { + while ($this->isRunning()) { + $this->logger->debug('Working'); + $this->sleep(30); + } + } catch (InterruptedException) { + // Stop arrived mid-sleep — handle partial work (requeue, roll back) here. + $this->logger->info('Interrupted during sleep, shutting down'); + } finally { + $this->logger->debug('Releasing local resources'); + } + + $this->logger->info('Process stopped'); + } + + // --- stop signals: shutdown is guaranteed; the hook is only your reaction --- + + protected function onTerminate(): void + { + $this->logger->info('SIGTERM received, shutting down'); + } + + protected function onInterrupt(): void + { + $this->logger->info('SIGINT received, shutting down'); + } + + // --- control signals: the process keeps running --- + + protected function onReload(): void + { + $this->logger->notice('SIGHUP received, reloading configuration'); + } + + protected function onUser1(): void + { + $this->logger->notice('SIGUSR1 received, reopening log files'); + } + + protected function onUser2(): void + { + $this->verbosity = $this->verbosity === 'info' ? 'debug' : 'info'; + $this->logger->notice("SIGUSR2 received, verbosity set to {$this->verbosity}"); + } + + // --- guaranteed teardown on every exit path (graceful, forced, fatal) --- + + protected function onShutdown(): void + { + $this->logger->info('Shutdown hook: final teardown'); + } +} diff --git a/dev/main/Process/StableDaemon.php b/dev/main/Process/StableDaemon.php new file mode 100644 index 0000000..500bc3c --- /dev/null +++ b/dev/main/Process/StableDaemon.php @@ -0,0 +1,28 @@ +logger->info('StableDaemon worker START pid=' . $this->pid); + $tick = 0; + while ($this->isRunning()) { + $this->logger->info('StableDaemon worker ' . $this->pid . ' tick ' . (++$tick)); + $this->sleep(1.0); + } + $this->logger->info('StableDaemon worker ' . $this->pid . ' graceful exit'); + } +} diff --git a/dev/main/S1Job.php b/dev/main/S1Job.php deleted file mode 100644 index 8f00fbd..0000000 --- a/dev/main/S1Job.php +++ /dev/null @@ -1,24 +0,0 @@ -logger->info('RUN'); - $this->service->send(); - for ($i = 1; $i <= 10; $i++) { - sleep(1); - $this->logger->info('Iteration ' . $i); - } - $this->logger->info('END'); - } -} diff --git a/dev/main/Schedule/DemoTasks.php b/dev/main/Schedule/DemoTasks.php new file mode 100644 index 0000000..62da2dc --- /dev/null +++ b/dev/main/Schedule/DemoTasks.php @@ -0,0 +1,56 @@ +info('DemoTasks::heartbeat (fixedRate 2s)'); + } + + /** + * fixedDelay with an initial delay: first run after 3s, then 5s after each + * run finishes. The 1s of work here shows the delay is measured from the end. + */ + #[Scheduled(fixedDelay: 5.0, initialDelay: 3.0)] + public function report(): void + { + $log = LoggerFactory::getLogger(self::class); + $log->info('DemoTasks::report START (fixedDelay 5s, initialDelay 3s)'); + sleep(1); + $log->info('DemoTasks::report DONE'); + } + + /** + * cron: every night at 02:00, clock-aligned. + */ + #[Scheduled(cron: '0 2 * * *')] + public function nightly(): void + { + LoggerFactory::getLogger(self::class)->info('DemoTasks::nightly (cron 0 2 * * *)'); + } + + /** + * cron: every morning at 08:00 on weekdays. + */ + #[Scheduled(cron: '0 8 * * 1-5')] + public function morning(): void + { + LoggerFactory::getLogger(self::class)->info('DemoTasks::morning (cron 0 8 * * 1-5)'); + } +} diff --git a/dev/main/Services/FakeSendService.php b/dev/main/Services/FakeSendService.php index cd4b255..84c549c 100644 --- a/dev/main/Services/FakeSendService.php +++ b/dev/main/Services/FakeSendService.php @@ -2,10 +2,9 @@ namespace Main\Services; -use Flytachi\Winter\K2\Stereotype\Service; use Flytachi\Winter\Logger\Log; -class FakeSendService extends Service implements SendInterface +class FakeSendService implements SendInterface { public function list(): array { diff --git a/dev/main/Services/SmsSendService.php b/dev/main/Services/SmsSendService.php index 7b54a7d..401da2e 100644 --- a/dev/main/Services/SmsSendService.php +++ b/dev/main/Services/SmsSendService.php @@ -2,10 +2,9 @@ namespace Main\Services; -use Flytachi\Winter\K2\Stereotype\Service; use Flytachi\Winter\Logger\Log; -class SmsSendService extends Service implements SendInterface +class SmsSendService implements SendInterface { public function list(): array { diff --git a/dev/main/Te1.php b/dev/main/Te1.php new file mode 100644 index 0000000..a73a1a6 --- /dev/null +++ b/dev/main/Te1.php @@ -0,0 +1,11 @@ +staticPath('resources/static'); + } +} diff --git a/dev/public/.htaccess b/dev/public/.htaccess deleted file mode 100644 index 81e5b77..0000000 --- a/dev/public/.htaccess +++ /dev/null @@ -1,7 +0,0 @@ -Options +FollowSymLinks -MultiViews - -RewriteEngine On -RewriteCond %{REQUEST_FILENAME} !-f -RewriteCond %{REQUEST_FILENAME} !-d -RewriteRule .* index.php [L] -RewriteRule .* - [e=HTTP_AUTHORIZATION:%{HTTP:Authorization}] \ No newline at end of file diff --git a/dev/public/index.php b/dev/public/index.php deleted file mode 100644 index 822e5d9..0000000 --- a/dev/public/index.php +++ /dev/null @@ -1,49 +0,0 @@ -send() - l. Error handling ExceptionWrapper maps Throwable → HTTP response - - Route cache: - DEBUG=false — loads from storage/cache/mapping.php on warm boots; - scans and writes cache on first boot after deployment. - DEBUG=true — always rescans (dev mode, no stale routes). - - To inject per-request context fields into every log line: - $ctx = LoggerFactory::contextStorage(); - $ctx->set('request_id', uniqid('', true)); - $ctx->set('user_id', $authenticatedUserId); -*/ -Boot::web(); diff --git a/dev/public/static/winter/debug.css b/dev/public/static/winter/debug.css deleted file mode 100644 index 81f0072..0000000 --- a/dev/public/static/winter/debug.css +++ /dev/null @@ -1,107 +0,0 @@ -#winter_debug-btn { - font-size: 15px; - cursor: pointer; - background-color: #111; - color: red; - padding: 3px 9px; - border: none; - border-radius: 10%; - position: fixed; - bottom: 5px; - right: 5px; - z-index: 9999999; -} -#winter_debug-btn:hover { - background-color: red; - color: white; -} - -#winter_debug-bar { - display: none; - background-color: #111; - position: fixed; - bottom: 0; - right: 0; - left: 0; - height: 47%; - transition: 0.5s; - padding-top: 5px; - border-style: solid; - border-color: #c3fc04; - border-width: medium; - overflow-x: hidden; - z-index: 9999999; -} -#winter_debug-bar_body-indicator { - padding-top: 5px; - font-family: Georgia, serif; - font-size: 14px; - align-content: center; - align-items: center; - text-align: center; - color: #b2d7f6; -} - -#winter_debug-bar_body-accordion-container { - margin: 10px 0; -} -#winter_debug-bar_body-accordion-container .winter_debug-accordion-body { - width: calc(100% - 40px); - margin: 0 auto; - height: 0; - background-color: black; - line-height: 18px; - padding: 0 30px; - box-sizing: border-box; - transition: color 0.5s, padding 0.5s; - overflow: hidden; - font-family: Verdana, sans-serif; - font-size: 14px; - box-shadow: 0 4px 8px rgba(0,0,0,0.2), 0 10px 16px rgba(0,0,0,0.2); -} -#winter_debug-bar_body-accordion-container .winter_debug-accordion-body pre { - color: lime !important; - margin: 0 0 10px; - white-space: pre-wrap; - word-wrap: break-word; -} -#winter_debug-bar_body-accordion-container label { - cursor: pointer; - background-color: #082236; - display: block; - padding: 10px 20px; - width: 100%; - color: #BFE2FF; - font-weight: 300; - box-sizing: border-box; - z-index: 100; - font-family: Verdana, sans-serif; - font-size: 16px; - margin: 0 0 5px; - transition: color .35s; -} -#winter_debug-bar_body-accordion-container label:hover { - color: #FFF; -} -#winter_debug-bar_body-accordion-container input{ - display: none; -} -#winter_debug-bar_body-accordion-container label:before { - content: '\276F'; - float: right; -} -#winter_debug-bar_body-accordion-container input:checked + label { - background-color: #082236; - color: red; - box-shadow: 0 8px 26px rgba(0,0,0,0.4), 0 28px 30px rgba(0,0,0,0.3); -} -#winter_debug-bar_body-accordion-container input:checked + label:before { - transition: transform .35s; - transform: rotate(90deg); -} -#winter_debug-bar_body-accordion-container input:checked + label + .winter_debug-accordion-body { - height: auto; - margin-top: -5px; - color: lime !important; - padding: 20px 30px 10px; -} \ No newline at end of file diff --git a/dev/public/static/winter/debug.js b/dev/public/static/winter/debug.js deleted file mode 100644 index f40e028..0000000 --- a/dev/public/static/winter/debug.js +++ /dev/null @@ -1,22 +0,0 @@ -function WinterDebugBar() -{ - let bar = document.getElementById("winter_debug-bar"); - let btn = document.getElementById("winter_debug-btn"); - if (bar) { - if (bar.style.display !== "block") { - console.log('open debug panel'); - bar.style.display = "block"; - btn.style.bottom = "49%"; - btn.style.color = "white"; - btn.style.backgroundColor = "red"; - } else { - console.log('close debug panel'); - bar.style.display = "none"; - btn.style.bottom = "5px"; - btn.style.color = "red"; - btn.style.backgroundColor = "#111"; - } - } else { - alert("Debug блок не найден"); - } -} \ No newline at end of file diff --git a/dev/public/static/winter/logo.svg b/dev/resources/static/winter/logo.svg similarity index 100% rename from dev/public/static/winter/logo.svg rename to dev/resources/static/winter/logo.svg diff --git a/dev/wDevRunner b/dev/wDevRunner index c2d5f2e..69fd59b 100755 --- a/dev/wDevRunner +++ b/dev/wDevRunner @@ -3,18 +3,44 @@ declare(strict_types=1); -if (PHP_VERSION_ID < 80300) { - fwrite(STDERR, "Please use PHP version 8.3 or higher.\n"); +/* + wDevRunner — the playground's thread runner. + -------------------------------------------- + Same job as the kernel's `vendor/bin/wKernelRunner`: the child side of + Process::dispatch(). It exists separately here only because `dev/` is not a real + composer installation of the kernel (vendor/ holds a symlink to the repository), + so `.env` points WINTER_THREAD_RUNNER at this file instead. + + Detaching cannot be a fork, so the launcher spawns a fresh PHP process running + this script; the application is booted again from scratch before the staged + payload runs. +*/ + +use Flytachi\Winter\Kernel\WinterApplication; + +if (PHP_VERSION_ID < 80400) { + fwrite(STDERR, "Please use PHP version 8.4 or higher.\n"); exit(1); } -$fileAutoloader = __DIR__ . '/bootstrap.php'; +$bootstrap = __DIR__ . '/bootstrap.php'; -if (!file_exists($fileAutoloader)) { - fwrite(STDERR, "Error: bootstrap.php not found at {$fileAutoloader}\n"); +if (!file_exists($bootstrap)) { + fwrite(STDERR, "Error: bootstrap.php not found at {$bootstrap}\n"); exit(1); } -require_once $fileAutoloader; +require_once $bootstrap; + +set_time_limit(0); +ob_implicit_flush(); +ignore_user_abort(true); + +try { + $appClass = WinterApplication::discoverAppClass(); +} catch (Throwable $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + exit(1); +} -Boot::executor($argv); +$appClass::executor($argv); diff --git a/docs/architecture/01-routing.md b/docs/architecture/01-routing.md index d63a137..2321188 100644 --- a/docs/architecture/01-routing.md +++ b/docs/architecture/01-routing.md @@ -1,6 +1,6 @@ # Routing -Winter K2 uses a Spring Boot-style dual-mode router that works identically in Swoole coroutine mode and PHP-FPM. +Winter uses a Spring Boot-style dual-mode router that works identically in Swoole coroutine mode and PHP-FPM. --- @@ -9,8 +9,8 @@ Winter K2 uses a Spring Boot-style dual-mode router that works identically in Sw Define routes via PHP attributes on controller classes and methods. ```php -use Flytachi\Winter\K2\Route\Annotation\{RequestMapping, GetMapping, PostMapping, PutMapping, PatchMapping, DeleteMapping}; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Route\Annotation\{RequestMapping, GetMapping, PostMapping, PutMapping, PatchMapping, DeleteMapping}; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; #[RequestMapping('users')] // class-level prefix → /users class UserController extends Controller @@ -146,13 +146,18 @@ All errors — including middleware abort and validation failures — are caught ## Static file serving -Required for Swoole (unlike FPM+nginx, Swoole does not serve static files natively): +Not the router's job. Swoole serves files itself, in C, before PHP is reached — it +streams them, honours `Range`, and cannot be walked out of the directory with `..`. +Declare the directory and it never touches the dispatch pipeline: ```php -$router->static(Kernel::$pathPublic); +$server->staticPath('resources/static'); // resources/static/app.css → /app.css ``` -On a `GET` request, the router checks whether the URI maps to an existing file under `$publicDir`. If found, the file is served directly with auto-detected MIME type and a 24-hour `Cache-Control: public, max-age=86400`. No route matching occurs. +Static serving is opt-in: say nothing and no file is ever served, which is what an +API-only service wants. Because those responses never reach PHP, middleware, CORS and +request logging do not apply to them. See +[`../configuration/08-runtime.md`](../configuration/08-runtime.md). --- @@ -163,7 +168,7 @@ On a `GET` request, the router checks whether the URI maps to an existing file u Call once in bootstrap **before** `Router::resolve()`: ```php -use Flytachi\Winter\K2\Http\Cors; +use Flytachi\Winter\Kernel\Http\Cors; Cors::configure( origins: ['https://app.example.com'], @@ -190,7 +195,7 @@ Global CORS headers are written **before** route dispatch — they appear on 404 Placed on a controller class or method, `#[CrossOrigin]` **overrides** (does not merge with) the global config: ```php -use Flytachi\Winter\K2\Route\Annotation\CrossOrigin; +use Flytachi\Winter\Kernel\Route\Annotation\CrossOrigin; // Entire controller #[CrossOrigin(origins: ['https://admin.example.com'], credentials: true)] diff --git a/docs/architecture/02-middleware.md b/docs/architecture/02-middleware.md index 2504fbe..749320f 100644 --- a/docs/architecture/02-middleware.md +++ b/docs/architecture/02-middleware.md @@ -6,13 +6,13 @@ Middleware intercepts requests before and after the controller method runs. ## Creating a middleware -Extend `Flytachi\Winter\K2\Stereotype\Middleware` and override `before()`, `after()`, or both. +Extend `Flytachi\Winter\Kernel\Http\Stereotype\Middleware` and override `before()`, `after()`, or both. Both methods have default no-op implementations — override only what you need. ```php -use Flytachi\Winter\K2\Stereotype\Middleware; -use Flytachi\Winter\K2\Http\Contracts\{HttpRequest, HttpResponse}; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\{HttpRequest, HttpResponse}; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; class AuthMiddleware extends Middleware { @@ -123,7 +123,7 @@ class TimingMiddleware extends Middleware Shorthand for aborting a request with a specific HTTP status from inside middleware: ```php -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; use Flytachi\Winter\Base\HttpCode; throw new MiddlewareException('Token expired'); // 401 Unauthorized @@ -143,16 +143,35 @@ throw (new MiddlewareException('Rate limited', HttpCode::TOO_MANY_REQUESTS)) ### `ClientTimezoneMiddleware` -Applies the client's IANA timezone to `date_default_timezone_set()` for the duration of the request. The value is read from `HttpRequest::getClientTimezone()`, which parses the `Timezone` or `X-Timezone` header and validates it against `timezone_identifiers_list()`. When no valid timezone is supplied, `before()` falls back to `env('TIME_ZONE', 'UTC')`; `after()` restores the same canonical default. +Applies the client's IANA timezone for the duration of the request. The value is read from `HttpRequest::getClientTimezone()`, which parses the `Timezone` or `X-Timezone` header and validates it against `timezone_identifiers_list()`. When no valid timezone is supplied, `before()` falls back to `env('TIME_ZONE', 'UTC')`. ```php -use Flytachi\Winter\K2\Http\Middleware\ClientTimezoneMiddleware; +use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware; #[ClientTimezoneMiddleware] class ReportController extends Controller { ... } ``` -**Swoole caveat.** `after()` is not invoked when the handler throws — the `catch (\Throwable)` in `Router::dispatch` sits outside the after-loop. In a long-running worker, an unhandled exception leaves the global TZ at the client's value until the next request that passes through this middleware overwrites it. Apply uniformly across routes, or skip the middleware and read `$request->getClientTimezone()` explicitly inside handlers. +It stores the timezone in two places, and the difference decides which of your code is safe: + +| | Where | Safe under concurrency | +|---|---|---| +| `Timezone::current()` | coroutine-local (`RequestLocal`) | **yes** | +| `date_default_timezone_set()` | PHP engine global | only while concurrent requests share one zone | + +`Timezone` is the source of truth. Everything the framework does on the request's behalf reads it — including the timezone of the database session, so a pooled connection never carries the previous user's zone into your query. + +The engine global is set as a convenience for code that has not been adapted, and it is genuinely shared: a Swoole worker runs many requests as coroutines in one process, so a request that sets its zone and then waits on I/O can resume to find another request's value in place. Measured, not reasoned — a request from `Asia/Tashkent` yielded, a request from `Europe/London` set its own, and the first read `Europe/London` on resume. No library can fix that; it is where PHP keeps its default. + +So pass the zone explicitly wherever the answer must belong to the requesting user: + +```php +use Flytachi\Winter\Kernel\Localization\Timezone; + +$when = new \DateTimeImmutable('now', new \DateTimeZone(Timezone::current())); +``` + +**Swoole caveat.** `after()` is not invoked when the handler throws — the `catch (\Throwable)` in `Router::dispatch` sits outside the after-loop. The coroutine-local value dies with the coroutine either way, but the engine global keeps the client's value until the next request through this middleware resets it — one more reason not to lean on it. See [Client timezone detection](../configuration/01-kernel.md#client-timezone-detection) for the request-side contract and the no-mutation usage pattern. diff --git a/docs/architecture/03-response.md b/docs/architecture/03-response.md index 0fde628..8b2dc0c 100644 --- a/docs/architecture/03-response.md +++ b/docs/architecture/03-response.md @@ -9,7 +9,7 @@ Router serializes it to the underlying `HttpResponse` — controllers never call `$response->end()` themselves. ```php -use Flytachi\Winter\K2\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; // Static factory shortcuts return ResponseEntity::ok($data); // 200 @@ -68,9 +68,9 @@ Prefer `ResponseEntity::noContent()` for an explicit 204. `Sendable` is the common contract for all response objects. Implement it for fully custom responses: ```php -use Flytachi\Winter\K2\Http\Response\Sendable; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Response\Sendable; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; class CsvResponse implements Sendable { @@ -101,7 +101,7 @@ File and download responses. All formats set `Content-Encoding: identity` and `C ### Factory methods ```php -use Flytachi\Winter\K2\Http\Response\ResponseFile; +use Flytachi\Winter\Kernel\Http\Response\ResponseFile; // Raw bytes — any MIME type ResponseFile::binary($data, 'report.bin'); @@ -152,7 +152,7 @@ memory-light `fpassthru` stream on FPM). Use it for large files (video, audio, archives, database dumps) where `ResponseFile::file()` would waste worker memory. ```php -use Flytachi\Winter\K2\Http\Response\ResponseStreamFile; +use Flytachi\Winter\Kernel\Http\Response\ResponseStreamFile; return ResponseStreamFile::open('/var/media/video.mp4'); // inline return ResponseStreamFile::open('/var/export/report.pdf')->attachment(); // download @@ -244,7 +244,7 @@ ResponseView::setBasePath(__DIR__ . '/resources/views'); ### Factory methods ```php -use Flytachi\Winter\K2\Http\Response\ResponseView; +use Flytachi\Winter\Kernel\Http\Response\ResponseView; // Render a single template return ResponseView::view('user/profile', ['user' => $user]); diff --git a/docs/architecture/04-request/00-overview.md b/docs/architecture/04-request/00-overview.md index 4593415..7d946d9 100644 --- a/docs/architecture/04-request/00-overview.md +++ b/docs/architecture/04-request/00-overview.md @@ -1,6 +1,6 @@ # Request Parameter Binding — Overview -Winter K2 resolves controller method parameters automatically at request time. +Winter resolves controller method parameters automatically at request time. You declare what you need via PHP type hints and attributes — the framework reads the request, validates the value, casts it to the declared type, and injects it into your method. No manual `$_GET`, `$_POST`, or `json_decode` in controller code. diff --git a/docs/architecture/04-request/01-path-variable.md b/docs/architecture/04-request/01-path-variable.md index b154c99..db2d359 100644 --- a/docs/architecture/04-request/01-path-variable.md +++ b/docs/architecture/04-request/01-path-variable.md @@ -5,7 +5,7 @@ placeholder embedded inside the route URL pattern — it captures a dynamic port the request path and delivers it, cast to the declared PHP type, as a method argument. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; ``` --- diff --git a/docs/architecture/04-request/02-request-param.md b/docs/architecture/04-request/02-request-param.md index e485303..05b7ab3 100644 --- a/docs/architecture/04-request/02-request-param.md +++ b/docs/architecture/04-request/02-request-param.md @@ -5,7 +5,7 @@ key-value pair that follows the `?` separator in a URL — it is part of the req not part of the path that the router uses to select the route. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; ``` --- diff --git a/docs/architecture/04-request/03-request-body.md b/docs/architecture/04-request/03-request-body.md index ee7b58b..d712d98 100644 --- a/docs/architecture/04-request/03-request-body.md +++ b/docs/architecture/04-request/03-request-body.md @@ -4,7 +4,7 @@ Binds the raw request body to a controller method parameter. The format is auto-detected from the `Content-Type` header unless the type is `string`. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; ``` --- @@ -154,11 +154,11 @@ declared on DTO constructor parameters after hydration. Failed constraints throw `ValidationException (422)`. ```php -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\Max; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\Max; class CreateOrderDto { diff --git a/docs/architecture/04-request/04-request-header.md b/docs/architecture/04-request/04-request-header.md index 2cee855..9273fe5 100644 --- a/docs/architecture/04-request/04-request-header.md +++ b/docs/architecture/04-request/04-request-header.md @@ -5,7 +5,7 @@ are case-insensitive by spec, so `Authorization`, `authorization`, and `AUTHORIZ refer to the same header — the framework handles this transparently. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestHeader; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestHeader; ``` --- diff --git a/docs/architecture/04-request/05-request-file.md b/docs/architecture/04-request/05-request-file.md index 887433c..a959ff6 100644 --- a/docs/architecture/04-request/05-request-file.md +++ b/docs/architecture/04-request/05-request-file.md @@ -5,7 +5,7 @@ controller pre-validated and ready to use — transfer errors, size limits, and type restrictions are all enforced by `ParameterResolver` before the method is called. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestFile; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestFile; ``` --- diff --git a/docs/architecture/04-request/06-request-query.md b/docs/architecture/04-request/06-request-query.md index 87fd2ff..152759e 100644 --- a/docs/architecture/04-request/06-request-query.md +++ b/docs/architecture/04-request/06-request-query.md @@ -4,7 +4,7 @@ Binds the entire query string as a typed DTO or raw array. Use when multiple query parameters form a logical group (filters, pagination, search). ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestQuery; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestQuery; ``` --- diff --git a/docs/architecture/04-request/07-request-json-form-xml.md b/docs/architecture/04-request/07-request-json-form-xml.md index 0da5819..c944b97 100644 --- a/docs/architecture/04-request/07-request-json-form-xml.md +++ b/docs/architecture/04-request/07-request-json-form-xml.md @@ -4,9 +4,9 @@ Force a specific body format regardless of the `Content-Type` header. Use when you need strict format enforcement instead of auto-detection. ```php -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; ``` --- diff --git a/docs/architecture/04-request/08-validation.md b/docs/architecture/04-request/08-validation.md index ae0476f..cd0f158 100644 --- a/docs/architecture/04-request/08-validation.md +++ b/docs/architecture/04-request/08-validation.md @@ -1,11 +1,11 @@ # Request Validation — `#[Valid]` + Constraints -Winter K2 provides an attribute-based validation system for request DTOs. +Winter provides an attribute-based validation system for request DTOs. Constraints are PHP attributes placed on DTO constructor parameters. Validation is triggered by adding `#[Valid]` to the controller method parameter. ```php -use Flytachi\Winter\K2\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; ``` --- @@ -39,7 +39,7 @@ use Flytachi\Winter\K2\Http\Request\Validation\Valid; ## Usage ```php -use Flytachi\Winter\K2\Http\Request\Validation\{Valid, Required, NotBlank, Min, Max, Email}; +use Flytachi\Winter\Kernel\Http\Request\Validation\{Valid, Required, NotBlank, Min, Max, Email}; class CreateUserDto { @@ -339,7 +339,9 @@ Return `null` to pass, return a string to fail with that message. ## Legacy: `K1ValidationTrait` -`Flytachi\Winter\K2\Http\Request\K1ValidationTrait` is the older, string-rule API used by `RequestObject` (now `#[\Deprecated]`). It is kept for backwards compatibility — **prefer the attribute-based system above for any new code**. +`Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait` is the older, string-rule API. It is kept for backwards compatibility — **prefer the attribute-based system above for any new code**. + +Use it on any DTO of your own; there is no base class to extend. Key differences from the attribute system: @@ -376,10 +378,12 @@ Key differences from the attribute system: ### Usage ```php -use Flytachi\Winter\K2\Http\Request\RequestObject; +use Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait; -final class CreateUserRequest extends RequestObject +final class CreateUserRequest { + use K1ValidationTrait; + public function __construct( public readonly ?string $name = null, public readonly ?int $age = null, @@ -413,4 +417,4 @@ Rules apply **per existing element**, and failures report the resolved path element-level checks run — validate the parent itself with a separate `$this->validate('staffs', ['array'])` when its presence is required. -> **Migration tip:** when you move a `RequestObject` to a plain readonly DTO with `#[Constraint]` attributes, you also gain (a) all-errors-at-once reporting, (b) per-rule `message:` overrides, and (c) richer i18n placeholders (`:max`, `:min`, …). +> **Migration tip:** when you move a `K1ValidationTrait` DTO to `#[Constraint]` attributes, you also gain (a) all-errors-at-once reporting, (b) per-rule `message:` overrides, and (c) richer i18n placeholders (`:max`, `:min`, …). diff --git a/docs/architecture/05-localization.md b/docs/architecture/05-localization.md index 3937d89..35e6491 100644 --- a/docs/architecture/05-localization.md +++ b/docs/architecture/05-localization.md @@ -11,7 +11,7 @@ Coroutine-safe: each Swoole coroutine carries its own locale state. In FPM mode **1. Bootstrap (once, in your Boot class):** ```php -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\Locale; Locale::setBasePath(__DIR__ . '/lang'); Locale::setDefault('en'); diff --git a/docs/architecture/06-exception.md b/docs/architecture/06-exception.md index cc765b4..4f4c197 100644 --- a/docs/architecture/06-exception.md +++ b/docs/architecture/06-exception.md @@ -29,7 +29,7 @@ Without any custom handler, every exception is handled by `ExceptionResponseBase Throw from anywhere — controllers, services, middleware. The Router catches it and sends an HTTP response. ```php -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; use Flytachi\Winter\Base\HttpCode; throw new ResponseException('User not found', HttpCode::NOT_FOUND); @@ -45,7 +45,7 @@ Default code: **400 Bad Request**. Logged at `warning` level. ### `ClientError` — business / domain errors caused by the caller ```php -use Flytachi\Winter\K2\Exception\ClientError; +use Flytachi\Winter\Kernel\Exception\ClientError; throw new ClientError('Email already taken'); ClientError::throw('Email already taken', HttpCode::UNPROCESSABLE_ENTITY); @@ -56,7 +56,7 @@ Default code: **409 Conflict**. Logged at `warning` level. ### `ServerError` — unexpected infrastructure or application failures ```php -use Flytachi\Winter\K2\Exception\ServerError; +use Flytachi\Winter\Kernel\Exception\ServerError; throw new ServerError('Payment gateway timeout'); ServerError::throw('Database connection failed'); @@ -76,7 +76,7 @@ Maps the HTTP code to a log level automatically: | Other | `notice` | ```php -use Flytachi\Winter\K2\Exception\Error; +use Flytachi\Winter\Kernel\Exception\Error; throw new Error('Not implemented', HttpCode::NOT_IMPLEMENTED); Error::throw('Method not allowed', HttpCode::METHOD_NOT_ALLOWED); @@ -89,7 +89,7 @@ Default code: **520** (Unknown Error). Useful when callers do not know whether a Reserved for bugs in the kernel itself (misconfiguration, impossible state): ```php -use Flytachi\Winter\K2\Exception\KernelError; +use Flytachi\Winter\Kernel\Exception\KernelError; throw new KernelError('Router not initialized before handle()'); ``` @@ -109,7 +109,7 @@ See the [Middleware docs](02-middleware.md) for details. Default code: **401**. - Implements `ExceptionLogLevel` → calls `$e->getLogLevel()` — the exception declares its own level - Anything else → `error` (including plain `\RuntimeException`, `\LogicException`, etc.) -All K2 exceptions (`ResponseException`, `ClientError`, `ServerError`, `Error`, `KernelError`, `MiddlewareException`) implement `ExceptionLogLevel`. +All kernel exceptions (`ResponseException`, `ClientError`, `ServerError`, `Error`, `KernelError`, `MiddlewareException`) implement `ExceptionLogLevel`. --- @@ -121,7 +121,7 @@ Create a class that: 3. Carries the `#[AdviceException]` attribute ```php -use Flytachi\Winter\K2\Http\Response\{AdviceException, ExceptionResponseBase}; +use Flytachi\Winter\Kernel\Http\Response\{AdviceException, ExceptionResponseBase}; #[AdviceException(MyDomainException::class)] class MyDomainExceptionHandler extends ExceptionResponseBase diff --git a/docs/concurrent/00-overview.md b/docs/concurrent/00-overview.md new file mode 100644 index 0000000..50f09f0 --- /dev/null +++ b/docs/concurrent/00-overview.md @@ -0,0 +1,153 @@ +# Winter Concurrent — Overview + +Some work does not belong in the response. Pushing an event to Mixpanel, +sending a welcome e-mail, calling an SMS gateway — the user does not need to +wait 300 ms for any of it, and the request should return as soon as the useful +work is done. + +The **Concurrent** unit is how the kernel runs that work: submit a task, get a +handle back, keep serving the request. + +```php +Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); + +return ResponseEntity::ok($user); // client is not waiting for Mixpanel +``` + +The API is modelled on `java.util.concurrent` — `Future`, `ExecutorService`, +`Executors`, `CompletableFuture` mean here what they mean there. On top of it +sits `#[Async]`, the equivalent of Spring's `@Async`, which moves the decision +from the call site into the service declaration. + +--- + +## Why an abstraction at all + +PHP has no threads. Historically the only way to run something in the +background was to spawn a whole new process: fork or `proc_open`, boot the +framework again, open a fresh database connection, and only then do the work. +That costs about **112 ms and tens of megabytes for a task whose body is +empty** — usually far more than the work itself. + +Under Swoole the answer is different: a coroutine costs roughly **12 KB** and a +few microseconds, and a task blocked on a network call simply yields so the +worker keeps serving other requests. Under PHP-FPM there are still no +coroutines, but a task can at least be deferred until *after* the response has +been flushed. + +Two runtimes, two mechanisms, one contract. Application code says *what* should +happen in the background; the kernel decides *how*, based on where it is +running. + +--- + +## How the pieces fit together + +``` +Executors ← entry point, picks the backend for the runtime + │ + ├── ExecutorService ← the contract: submit / execute / invokeAll + │ ↑ + │ ├── CoroutineExecutorService (Swoole) go() + Channel + │ ├── DeferredExecutorService (FPM/CLI) lazy + fastcgi_finish_request + │ └── FixedExecutorService (pool) N-slot semaphore over the above + │ + └── Future ← handle on a result + ↑ + CompletableFuture + +#[Async] ← declaration-site sugar, generates a proxy that + calls the executor for you +``` + +`Future` and `CompletableFuture` know nothing about Swoole. The executor hands +the future a waiting strategy when it creates it, which is why the same object +works unchanged in both runtimes. + +--- + +## The two runtimes + +| | Swoole | PHP-FPM / CLI | +|---|---|---| +| Backend | `CoroutineExecutorService` | `DeferredExecutorService` | +| A task is | a coroutine | a deferred callback | +| Concurrency | real — tasks interleave on I/O | none — tasks run one after another | +| Waiting on a `Future` | suspends the calling coroutine | runs the task right there | +| Fire-and-forget | runs alongside the request | runs after the response is flushed | +| Cost per task | ~12 KB | ~0 | + +The contract is identical in both: **the result is always correct, and +fire-and-forget never makes the client wait.** What differs is parallelism, +which FPM cannot provide at all. See [01-executors.md](01-executors.md) for the +exact FPM semantics. + +--- + +## Two ways to use it + +Concurrent offers a primitive and, on top of it, an attribute. They are not +alternatives to choose between once — they answer different questions. + +**`Executors` — asynchrony is a decision of the caller.** + +```php +Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); +``` + +Visible at the call site, works everywhere: with `new`, with `final` classes, +with private methods, with plain closures. This is the right default for +one-off background work. + +**`#[Async]` — asynchrony is a property of the service.** + +```php +#[Async] +public function track(int $userId, string $event): void { … } +``` + +Declared once, and every one of the twenty call sites gets it without knowing. +Use it when "this service always works in the background" is part of its +contract. It requires the object to come from the DI container — see +[03-async.md](03-async.md) for the full rule set. + +| Situation | Reach for | +|---|---| +| One-off background work in a controller | `Executors::common()` | +| A service that is asynchronous by nature | `#[Async]` | +| Static method, `final` class, manual `new` | `Executors::common()` | +| Fan-out over several external calls | `invokeAll()` | +| Cap the parallelism of a workstream | `Executors::newFixedExecutor(n)` — [05-pools.md](05-pools.md) | + +--- + +## What it costs + +Measured on PHP 8.5.8, a single background task with an empty body, start to +result: + +| | process per task (the old way) | coroutine | +|---|---|---| +| Full cycle | 112 ms | **1.0 ms** | +| Memory | 3.5 MB (minimal app; a real one is far larger) | **12.3 KB** | + +500 concurrent coroutines occupy about 6 MB in one worker. + +--- + +## Pages + +| # | File | Contents | +|---|------|----------| +| 00 | this page | What the unit is, the two runtimes, when to use what | +| 01 | [01-executors.md](01-executors.md) | `Executors`, `ExecutorService`, backend behaviour | +| 02 | [02-future.md](02-future.md) | `Future`, `CompletableFuture`, results and failures | +| 03 | [03-async.md](03-async.md) | `#[Async]` — contract, proxying, pitfalls | +| 04 | [04-build.md](04-build.md) | Caches, `call di build`, deployment | +| 05 | [05-pools.md](05-pools.md) | Fixed-size pools — sizing, reject policies, gauges | + +## See also + +- [`configuration/07-di.md`](../configuration/07-di.md) — the container the proxies are registered in +- [`console/10-di.md`](../console/10-di.md) — the `call di` command +- [`process/00-overview.md`](../process/00-overview.md) — managed workers and supervised fleets that own their own process diff --git a/docs/concurrent/01-executors.md b/docs/concurrent/01-executors.md new file mode 100644 index 0000000..34e67c1 --- /dev/null +++ b/docs/concurrent/01-executors.md @@ -0,0 +1,234 @@ +# Executors + +An **executor** is the thing that actually runs a background task. Application +code never says "spawn a coroutine" or "defer until the response is flushed" — +it hands the task to an `ExecutorService` and the executor decides, based on the +runtime it is living in. + +That indirection is the whole point: the same three lines of application code +behave correctly under Swoole and under PHP-FPM, and there is no +`if (Runtime::isSwoole())` anywhere in your project. + +```php +use Flytachi\Winter\Kernel\Concurrent\Executors; + +Executors::common()->execute(fn() => $mixpanel->track($userId, 'signup')); +``` + +--- + +## `Executors` + +```php +use Flytachi\Winter\Kernel\Concurrent\Executors; +``` + +### `common(): ExecutorService` + +Returns the shared executor matching the current runtime — the coroutine +executor inside a Swoole coroutine, the deferred one everywhere else. + +The decision is made **on every call**, never cached, because one process can +legitimately be in both states: a Swoole worker booting outside a coroutine and +then serving requests inside one. + +This is what application code should use. + +### `newCoroutineExecutor(): ExecutorService` + +A fresh coroutine-backed executor. Useful when you want an isolated +`shutdown()` / `awaitTermination()` scope — for example a batch job that must +drain its own tasks without waiting for everything else in the worker. + +Requires an active coroutine at submit time; otherwise every call throws +`RejectedExecutionException`. + +### `newDeferredExecutor(): ExecutorService` + +A fresh deferred executor. Chiefly useful in tests, and when you deliberately +want synchronous behaviour inside a Swoole process. + +### `newFixedExecutor(int $concurrency, int $queue = 0, RejectPolicy $onReject = RejectPolicy::ABORT): BoundedExecutorService` + +A fresh **fixed-size pool**: at most `$concurrency` tasks run at once, the rest +wait for a slot. `Executors.newFixedThreadPool(n)` for coroutines. Register it in +the container to give an `#[Async('id')]` method a dedicated, capped pool. + +```php +$pool = Executors::newFixedExecutor(5); // cap at 5 concurrent +$pool->submit(fn() => $gateway->send($msg)); +``` + +The cap is enforced under Swoole; without coroutines the pool runs tasks +sequentially and the size is a no-op. Full treatment — the reject policy, the +occupancy gauges, the per-process caveat — is in [05-pools.md](05-pools.md). + +### `shutdownCommon(?float $timeout = null): bool` + +Stops the shared executors accepting new work and waits for what is already +running. Returns `false` if the timeout elapsed with tasks still in flight. + +Intended for graceful shutdown — worker recycling under Swoole, or an explicit +flush at the end of a console command: + +```php +Executors::common()->execute(fn() => $reporter->send($stats)); + +Executors::shutdownCommon(timeout: 5.0); // let it finish before we exit +``` + +--- + +## `ExecutorService` + +The contract every backend implements. + +### `submit(callable $task, mixed ...$args): Future` + +Runs the task and returns a handle on its result. + +```php +$future = Executors::common()->submit($api->fetch(...), $id); +$value = $future->get(); +``` + +Extra arguments are passed to the task, which pairs nicely with PHP's +first-class callable syntax (`$api->fetch(...)`) — no closure needed. + +The task is **guaranteed to run** even if the returned future is never awaited. + +### `execute(callable $task, mixed ...$args): void` + +Fire-and-forget: no handle, no result. + +Because nobody can observe a failure through a future, **a throwable is logged** +instead of being silently dropped. `submit()` does not log — its owner is +expected to see the failure through `get()`. + +### `invokeAll(iterable $tasks, ?float $timeout = null): array` + +Submits every task and blocks until all of them have settled. Returns the +futures in the order the tasks were given; inspect each one individually. + +```php +$results = Executors::common()->invokeAll([ + fn() => $billing->fetch($id), + fn() => $crm->fetch($id), + fn() => $support->fetch($id), +]); + +foreach ($results as $future) { + if ($future->isCompletedExceptionally()) { + $logger->warning($future->failure()->getMessage()); + continue; + } + $data[] = $future->get(); +} +``` + +Under Swoole the three calls run **concurrently**: total time is the slowest +one, not the sum. Under FPM they run in sequence — the results are identical, +the wall clock is not. + +`$timeout` bounds the whole batch. A task that outlives it keeps running; only +the waiting stops. + +### `shutdown(): void` / `isShutdown(): bool` + +Stops accepting new tasks. Already submitted ones still run to completion. +Submitting afterwards throws `RejectedExecutionException`. + +### `awaitTermination(?float $timeout = null): bool` + +Blocks until every submitted task has settled. Returns `false` on timeout. + +> Under Swoole this only works from **inside a coroutine**. Outside one the +> scheduler is not running, so busy-waiting could never let the pending tasks +> advance; the method reports the current state instead of hanging. + +--- + +## Backends + +### `CoroutineExecutorService` — Swoole + +Each task becomes a coroutine in the current worker. A task blocked on I/O +yields, and the worker keeps serving other work — this is the only backend that +offers real concurrency. + +Waiting on a future suspends the calling coroutine through a `Channel` rather +than blocking the process, and any number of coroutines may wait on the same +future. + +Two consequences worth knowing: + +**A task gets its own coroutine context.** That means its own connection +borrowed from the PPA pool, returned automatically when the task ends. It also +means request-scoped state — headers, locale, repository query state — is +**not** inherited. Everything a task needs must arrive through its arguments. + +**A task that never suspends finishes eagerly.** Swoole enters the new coroutine +immediately, so a purely computational body completes before `submit()` even +returns. The result is still correct; only the "runs later" intuition does not +hold. + +`runningCount(): int` reports how many tasks have not settled yet. + +### `DeferredExecutorService` — PHP-FPM, CLI, built-in server + +There is no concurrency to be had in a synchronous SAPI, so the contract is +preserved by moving work in *time* rather than running it in parallel: + +- **`submit()`** runs the task lazily, at the moment its future is awaited. A + future that is never awaited still runs — during the drain below. +- **`execute()`** always defers. The queue is drained after + `fastcgi_finish_request()` has flushed the response, so the client never + waits. +- **`invokeAll()`** runs everything immediately, in order. + +Deferred tasks execute **sequentially in the same worker**: four tasks of 200 ms +occupy the worker for 800 ms after the response was sent. That is the deliberate +trade — no process spawning, no closure serialization, no extra database +connection. It also means the worker is unavailable for new requests during that +time, which is the real cost to watch under load. + +Two limits keep counting during the drain: `max_execution_time` and FPM's +`request_terminate_timeout`. A long deferred task can still be killed. + +`pendingCount(): int` reports the queue depth; `drain(): void` runs it early. + +> `fastcgi_finish_request()` exists only under FPM (LiteSpeed's equivalent is +> used when present). Under CLI and the built-in dev server the response is +> simply sent when the script ends. + +### `FixedExecutorService` — a bounded pool + +A decorator over the two backends above: under Swoole it gates each task with a +`Channel` semaphore of N tokens (so no more than N coroutines run the body at +once); without coroutines it delegates to the deferred backend, where the bound +is moot. It adds occupancy gauges (`activeCount()`, `queuedCount()`, +`remainingCapacity()`) and a reject policy for a bounded queue. Created via +`newFixedExecutor()` — see [05-pools.md](05-pools.md). + +--- + +## Errors + +| Thrown by | When | +|---|---| +| `RejectedExecutionException` | executor is shut down, or a coroutine executor is used outside a coroutine | +| `ExecutionException` | the task threw — original available via `getPrevious()` | +| `TimeoutException` | the wait elapsed; the task itself keeps running | +| `CancellationException` | the task was cancelled before producing a result | + +The first is raised at submit time, the rest by `Future::get()` — see +[02-future.md](02-future.md). + +--- + +## See also + +- [02-future.md](02-future.md) — the handle `submit()` returns +- [03-async.md](03-async.md) — declaring asynchrony on the method instead +- [05-pools.md](05-pools.md) — fixed-size pools, reject policies, occupancy gauges +- [`ppa/`](../ppa/00-overview.md) — connection pool a task borrows from diff --git a/docs/concurrent/02-future.md b/docs/concurrent/02-future.md new file mode 100644 index 0000000..24a96ff --- /dev/null +++ b/docs/concurrent/02-future.md @@ -0,0 +1,208 @@ +# Future and CompletableFuture + +A background task produces a value at some point after the call that started it +has already returned. A **`Future`** is the handle on that value: you hold it, +and when you finally need the result you ask for it. + +```php +$future = Executors::common()->submit($api->fetch(...), $id); + +// …other work, which is the entire point… + +$value = $future->get(); +``` + +`Future` is the read side, `CompletableFuture` the implementation that also +exposes the write side. Application code normally only sees the interface; +`CompletableFuture` becomes relevant when writing an `#[Async]` method body or +composing results by hand. + +Both are runtime-agnostic. The executor that created a future hands it a waiting +strategy, so `get()` suspends a coroutine under Swoole and runs the task inline +under FPM — without the future knowing which happened. + +--- + +## `Future` + +```php +use Flytachi\Winter\Kernel\Concurrent\Future; +``` + +### `get(?float $timeout = null): mixed` + +Waits for the task and returns its value. + +```php +$value = $future->get(); // wait as long as it takes +$value = $future->get(2.5); // …but no longer than 2.5 s +``` + +| Outcome | Result | +|---|---| +| Task returned | the value | +| Task threw | throws `ExecutionException`, original in `getPrevious()` | +| Task was cancelled | throws `CancellationException` | +| Timeout elapsed | throws `TimeoutException` | + +**A timeout bounds the wait, not the task.** The work keeps going, and a later +`get()` on the same future still returns the result: + +```php +try { + $value = $future->get(0.05); +} catch (TimeoutException) { + $logger->info('slow, moving on'); +} + +$value = $future->get(); // still works, still correct +``` + +### `isDone(): bool` + +True once the task has completed, failed or been cancelled — the three are not +distinguished here. Use `isCompletedExceptionally()` or `isCancelled()` when you +need to tell them apart. + +### `isCancelled(): bool` + +True if the task was cancelled before it produced a result. + +### `cancel(bool $mayInterruptIfRunning = false): bool` + +Attempts to cancel; returns `true` only if this call is what cancelled it. With +`$mayInterruptIfRunning` the backend also tries to interrupt work already in +progress — under Swoole that means cancelling the coroutine. + +--- + +## `CompletableFuture` + +```php +use Flytachi\Winter\Kernel\Concurrent\CompletableFuture; +``` + +Everything `Future` has, plus the ability to settle it yourself. + +### Factories + +```php +CompletableFuture::completedFuture($value); // already successful +CompletableFuture::failedFuture($throwable); // already failed +``` + +`completedFuture()` is what the body of a `Future`-returning `#[Async]` method +returns — see [03-async.md](03-async.md). + +```php +CompletableFuture::supplyAsync(fn() => $api->fetch($id)); // submit() +CompletableFuture::runAsync(fn() => $mailer->send($to)); // result discarded +``` + +Both accept an optional executor and default to `Executors::common()`. + +### `allOf(Future ...$futures): Future` + +Completes when every given future has completed. The value is always `null`; +you read the individual futures for results. + +```php +$a = Executors::common()->submit(fn() => $billing->fetch($id)); +$b = Executors::common()->submit(fn() => $crm->fetch($id)); + +CompletableFuture::allOf($a, $b)->get(); + +$data = ['billing' => $a->get(), 'crm' => $b->get()]; +``` + +If any input fails or is cancelled, the returned future fails with that same +throwable. + +> For a batch you are building anyway, `ExecutorService::invokeAll()` is more +> direct — it submits and waits in one call. `allOf()` is for futures you +> already hold, possibly from different sources. + +### Settling manually + +```php +$future = new CompletableFuture(); + +$future->complete($value); // → true, or false if already settled +$future->completeExceptionally($throwable); // → same +``` + +Both return `false` when the future was already settled, which makes them safe +to call from a race without checking first. + +### `whenComplete(\Closure $action): self` + +Registers a callback fired once the future settles, receiving `(mixed $value, +?Throwable $error)` — exactly one of them is non-null. If the future has already +settled, the callback runs immediately. + +```php +$future->whenComplete(function (mixed $value, ?\Throwable $e) use ($logger): void { + $e === null + ? $logger->info('done') + : $logger->error($e->getMessage()); +}); +``` + +> Unlike Java this returns the **same** instance rather than a new stage. The +> composition pipeline (`thenApply`, `thenCompose`, …) is deliberately not +> implemented — it buys little in a language without a fluent async ecosystem, +> and it would double the surface to maintain. + +### Inspecting the outcome + +```php +$future->isCompletedExceptionally(); // failed? +$future->failure(); // the throwable, or null +$future->join(); // get() with no timeout +``` + +`failure()` is the way to look at an error **without** it being thrown at you — +useful when processing a batch where some failures are expected: + +```php +foreach ($results as $future) { + if ($error = $future->failure()) { + $logger->warning('partial failure: ' . $error->getMessage()); + continue; + } + $ok[] = $future->get(); +} +``` + +--- + +## Errors in background work + +Where a failure surfaces depends on how the task was started: + +| Started with | A throwable | +|---|---| +| `submit()` | is stored on the future, re-thrown wrapped in `ExecutionException` by `get()` | +| `execute()` | is **logged** — nobody holds a handle, so silence would lose it | +| `invokeAll()` | is stored on the individual future; the batch itself does not throw | + +`ExecutionException` always wraps the original: + +```php +try { + $future->get(); +} catch (ExecutionException $e) { + $original = $e->getPrevious(); // the real exception from the task body +} +``` + +A task that is never awaited and never logged would be a black hole, so the +deferred backend logs failures of futures it had to run during the drain — that +is, ones nobody ever asked for. + +--- + +## See also + +- [01-executors.md](01-executors.md) — where futures come from +- [03-async.md](03-async.md) — `#[Async]` methods return a `Future` diff --git a/docs/concurrent/03-async.md b/docs/concurrent/03-async.md new file mode 100644 index 0000000..891779d --- /dev/null +++ b/docs/concurrent/03-async.md @@ -0,0 +1,276 @@ +# `#[Async]` + +With `Executors` the decision to go asynchronous is made at the **call site** — +every place that calls the service has to remember to wrap it. When a service is +asynchronous *by nature*, that repetition is noise, and one forgotten wrap is a +silent regression. + +`#[Async]` moves the decision into the declaration: + +```php +#[Singleton] +class NotificationService +{ + #[Async] + public function track(int $userId, string $event): void + { + $this->mixpanel->push($userId, $event); + } +} +``` + +```php +class AuthController extends Controller +{ + #[Autowired] + protected NotificationService $notifications; + + #[PostMapping('/register')] + public function register(): ResponseEntity + { + $user = $this->users->create(…); + $this->notifications->track($user->id, 'signup'); // returns immediately + + return ResponseEntity::ok($user); + } +} +``` + +The controller knows nothing. Nothing is registered by hand. This is Spring's +`@Async`, and — as there — it comes with rules, because PHP has no way to make a +method asynchronous without generating code around it. + +--- + +## How it works + +At scan time the kernel finds every class with `#[Async]` methods and generates +a **subclass** that overrides them: + +```php +final class NotificationService__Async extends NotificationService implements ProxyInterface +{ + public static function proxyTarget(): string + { + return NotificationService::class; + } + + public function track(int $userId, string $event): void + { + AsyncSupport::execute(Executors::common(), fn() => parent::track($userId, $event)); + } +} +``` + +The container binding is then swapped — `NotificationService` resolves to the +generated class — while the DI lifetime is preserved: a `#[Singleton]` service +stays a singleton, only its concrete class changes. + +Generated files live in volatile storage and are produced on first boot or by +`call di build`; see [04-build.md](04-build.md). + +Because the proxy **extends** the original, `instanceof NotificationService` +still holds and every type hint keeps working. + +--- + +## The contract + +A method carrying `#[Async]` must satisfy five rules. All of them are checked +when proxies are generated — a violation fails the build, never a request. + +### 1. The class is not `final`, the method is not `final` or `static` + +The proxy is a subclass. A `final` class cannot be extended, a `final` method +cannot be overridden, and a `static` call names the class at the call site so no +subclass ever participates: + +```php +NotificationService::track($id, 'signup'); // resolved by name → original class +``` + +`protected` methods **are** allowed — PHP dispatches them virtually, so an +internal asynchronous helper works and a self-call reaches the override. + +`private` methods are not: PHP resolves them statically inside their own class, +so no subclass can intercept. Make the method `protected`. + +### 2. The return type is `Future` or `void` + +An overriding method must be compatible with the declared type, so the proxy can +only return what the signature already promises. This is the same rule Spring +has, for the same reason. + +### 3. A `Future`-returning body returns a completed future + +```php +#[Async] +public function send(int $userId): Future +{ + $result = $this->mailer->send($userId); + + return CompletableFuture::completedFuture($result); +} +``` + +The proxy runs this body in the background and immediately returns a *pending* +future; when the body finishes, the value inside its completed future is +unwrapped into the outer one. The caller sees a plain `Future` carrying +`$result`. + +### 4. No by-reference parameters + +An asynchronous call returns before the body runs, so writes to a `&$param` +could never be observed. Return the value instead. + +### 5. The object comes from the DI container + +This is the rule with teeth — see below. + +--- + +## The `new` trap + +`#[Async]` is metadata; on its own it does nothing. Only the container knows +about the substitution, and `new` goes straight past it: + +```php +// ❌ runs synchronously — the request waits +$service = new NotificationService(); +$service->track($user->id, 'signup'); + +// ✅ container hands out the proxy +#[Autowired] +protected NotificationService $notifications; +``` + +Measured on the same class and the same call: + +``` +from the container: returned in 0.4 ms, result after 102 ms +via new: returned in 101 ms ← blocked +``` + +Nothing distinguishes the two in code — same declared type, `instanceof Future` +true in both, `get()` returns the same value. The only tell is that the `new` +version's future is already `isDone()`, which nobody checks. + +`void` methods are worse still: with no return value there is nothing to +inspect at all. + +Two mitigations: + +- **`call di build` warns about it.** The build scans your sources for `new X()` + where `X` has `#[Async]` methods and reports file and line. It is textual and + therefore not exhaustive — see [04-build.md](04-build.md). +- **Prefer returning `Future` over `void`**, even when the result is not needed. + `$future = $svc->track(...)` at least reads as asynchronous. + +--- + +## Self-invocation works + +In Spring, one method of a bean calling another annotated method of the same +bean bypasses the proxy — the famous `@Async` gotcha. The cause is that Spring's +proxy and the target bean are two different objects, and `this` inside the body +is the target. + +Here there is only one object: the container creates the proxy itself, so `this` +in an inherited method **is** the proxy, and PHP dispatches virtually. + +```php +class NotificationService +{ + #[Async] + public function track(int $userId, string $event): Future { … } + + public function trackBatch(array $userIds): void + { + foreach ($userIds as $id) { + $this->track($id, 'batch'); // goes through the override + } + } +} +``` + +There is no recursion risk: the override calls `parent::track()`, which is +non-virtual by definition. + +--- + +## What a task does and does not inherit + +An `#[Async]` method runs in a fresh execution context. Under Swoole that is +literally a new coroutine context. + +**Inherited:** nothing beyond its arguments and the object's own state. + +**Not inherited:** request headers, locale, repository query state, logging +correlation fields. A repository configured in the caller will arrive empty — +build it inside the method. + +Pass everything the task needs explicitly: + +```php +#[Async] +public function sendWelcome(int $userId, string $locale): Future // ← locale is an argument +``` + +The one thing that *is* handled for you is the database connection: the task +borrows its own from the PPA pool and returns it automatically when it ends. + +--- + +## Choosing an executor + +```php +#[Async(executor: 'reports')] +public function build(int $month): Future { … } +``` + +The argument is a container id resolved at call time. Omitted, the method uses +`Executors::common()`. + +To back a method with a **bounded pool** — cap its parallelism, or share one +budget across several methods — register an executor under that id as a singleton +and name it: + +```php +// a #[Configuration] class the scan finds +$c->singleton('reports', fn() => Executors::newFixedExecutor(3)); // at most 3 at once + +// the method +#[Async(executor: 'reports')] +public function build(int $month): Future { … } +``` + +Now every `build()` call runs on the `reports` pool, three concurrently, the rest +queued. Any `ExecutorService` registered under the id works; a fixed pool is the +usual choice. See [05-pools.md](05-pools.md) for pool sizing, reject policies and +the per-process caveat. + +--- + +## When not to use it + +`#[Async]` needs the container. Where there is no container there is no +substitution, and the primitive is the honest answer: + +| Situation | Use | +|---|---| +| `final` class or `final` method | `Executors::common()` | +| `static` method | `Executors::common()` inside the method body | +| object built with `new` | `Executors::common()` | +| `private` method | make it `protected`, or call the executor directly | +| one-off background work | `Executors::common()` | + +Nothing is lost by doing so — `#[Async]` is sugar over exactly that call. + +--- + +## See also + +- [01-executors.md](01-executors.md) — the primitive underneath +- [02-future.md](02-future.md) — `CompletableFuture::completedFuture()` +- [04-build.md](04-build.md) — generation, caches, the bypass warning +- [`configuration/07-di.md`](../configuration/07-di.md) — container lifetimes the proxy preserves diff --git a/docs/concurrent/04-build.md b/docs/concurrent/04-build.md new file mode 100644 index 0000000..f32f97f --- /dev/null +++ b/docs/concurrent/04-build.md @@ -0,0 +1,181 @@ +# Build and caches + +`#[Async]` needs generated code — a proxy class per annotated service. This page +covers where that code comes from, where it lives, and what the build step is +actually for. + +Nothing here applies if you only use `Executors` directly: the primitive +generates nothing and caches nothing. + +--- + +## What gets produced + +Two artefacts, both derived from the same project scan and both living in +volatile storage next to the DI cache: + +| Path | Contents | +|---|---| +| `/async.php` | list of classes that carry `#[Async]` | +| `/async/*.php` | the generated proxy classes | + +`` is `Kernel::$pathStorageVolatile`. By default that is +`sys_get_temp_dir() . '/flytachi.winter.volatile.'`; with +`Kernel::init(isTmpVolatile: false)` it becomes `storage/volatile`. The proxies +follow that setting automatically — they are the same kind of artefact as +`di.php`, so they obey the same durability policy. + +Both are **regenerable**. If the directory is wiped, the next boot rebuilds what +it needs; writes are atomic, so several Swoole workers rebuilding at once cannot +corrupt a file. + +--- + +## When generation happens + +``` +DEBUG=true → no caches at all; a proxy is regenerated whenever its source file is newer +DEBUG=false → generated on first boot if missing, then only checked for existence +``` + +Editing a service and reloading is enough in development. In production nothing +is regenerated after the first boot unless the files are gone. + +The class list is cached separately for a reason: **finding** `#[Async]` methods +means reflecting every method of every class in the project, which costs about +three times as much as the whole DI collector and would be paid on every boot — +under FPM, on every request. With the list cached, later boots do a plain array +lookup. + +| Per boot, 300-class project | | +|---|---| +| DI collector alone | 0.26 ms | +| + async, list cached | 0.30 ms | +| + async, discovering | 0.59 ms | + +Memory overhead of the layer at boot: about 2 KB. + +--- + +## `call di build` + +One scan produces everything the container needs: + +``` +$ php call di build + + | di cache ........................ [BUILT (247 classes)] + | async proxies ................... [BUILT (6 classes, 14 methods)] + | async bypass .................... [NONE] +``` + +The DI class list and the proxies come from the same filesystem walk on purpose +— two separate commands would leave a window where one is stale. + +**The build is primarily a contract check.** With volatile storage in `/tmp` the +generated files do not survive into a container image anyway, so the value is +not a warmer start: it is that an invalid `#[Async]` method fails here rather +than on the first request in production. + +``` + | di cache ........................ [BUILT (248 classes)] + | async proxies ................... [FAILED] + | [!] App\Services\BrokenService: the class is final and cannot be extended. + Drop "final" from the class, or move the #[Async] method to a non-final service. +``` + +**A failed build exits with code 1** — the only command in the console that +returns a non-zero status, added precisely so CI notices. Note that the DI cache +still reports `BUILT`: the class list is written before collectors run, and the +output says so honestly rather than pretending the whole step failed. + +--- + +## `call di clean` + +Removes `di.php`, `async.php` and every generated proxy: + +``` + | di cache ........................ [CLEANED] + | async proxies ................... [CLEANED (6 files)] +``` + +Worth running after deleting or renaming a service — a stale proxy for a class +that no longer exists is harmless but confusing. `build` clears them itself +before regenerating. + +--- + +## `call di async` + +Lists every `#[Async]` method in the project and whether its proxy exists: + +``` +$ php call di async + + | [ Async methods (2 classes) ] + | App\Services\NotificationService ......... [BUILT] + | track() → void + | send() → Flytachi\Winter\Kernel\Concurrent\Future + | App\Services\ReportService ............... [PENDING] + | build() → Flytachi\Winter\Kernel\Concurrent\Future +``` + +`PENDING` means the proxy has not been generated yet; it will be on first use. +Accepts an optional case-insensitive FQCN filter: `call di async Notification`. + +--- + +## The bypass warning + +Every build scans your sources for services built with `new` instead of resolved +from the container — the trap described in [03-async.md](03-async.md#the-new-trap): + +``` + | async bypass .................... [2 FOUND] + | [!] main/Http/AuthController.php:16 — new App\Services\NotificationService() + bypasses the proxy and runs synchronously; inject it instead + | [!] main/Http/OrderController.php:13 — new App\Services\ReportService() + bypasses the proxy and runs synchronously; inject it instead +``` + +It resolves names through each file's `namespace` and `use` block, so imported, +aliased, grouped and fully qualified forms are all recognised. + +**It is a warning, never a build failure**, because the scan reads source text +and cannot be exhaustive: + +- dynamic construction — `new $class`, factories, names from configuration — is + invisible; +- `vendor/` and test directories are skipped, since constructing a service + directly is usually what a test wants; +- `new self()` / `new static()` inside the service are not bypasses. + +A clean report is therefore not proof of correctness — but what it does find is +almost always a real mistake. Output is capped at 20 entries, with the remainder +summarised. + +--- + +## Deployment + +```bash +php call mapping build # route cache +php call di build # DI cache + async proxies + contract check +``` + +Run both in CI so a broken `#[Async]` contract fails the pipeline. If your +volatile storage is project-local (`isTmpVolatile: false`) the artefacts also +ship with the image and the first request skips generation entirely. + +Nothing breaks if you skip the build: the first boot generates what it needs, +provided the runtime user can write to volatile storage — the same requirement +`di.php` and `mapping.php` already have. + +--- + +## See also + +- [03-async.md](03-async.md) — the contract this step verifies +- [`console/10-di.md`](../console/10-di.md) — the rest of the `call di` command +- [`configuration/01-kernel.md`](../configuration/01-kernel.md) — `isTmpVolatile` and storage paths diff --git a/docs/concurrent/05-pools.md b/docs/concurrent/05-pools.md new file mode 100644 index 0000000..6e8f0df --- /dev/null +++ b/docs/concurrent/05-pools.md @@ -0,0 +1,219 @@ +# Fixed-size pools + +`Executors::common()` never says no. Every `execute()` starts a coroutine at +once, so a burst of a thousand tasks becomes a thousand coroutines fighting over +the same database pool and the same CPU. Usually that is fine — coroutines are +cheap and I/O-bound work interleaves. Sometimes it is not: an external API that +allows five connections, a report that each eats 200 MB, a mailer you must not +hammer. + +A **fixed-size pool** puts a ceiling on it: at most *N* tasks run at once, the +rest wait for a slot. It is `Executors.newFixedThreadPool(n)` from Java, adapted +to coroutines. + +```php +use Flytachi\Winter\Kernel\Concurrent\Executors; + +$pool = Executors::newFixedExecutor(5); // at most 5 running at a time + +for ($i = 0; $i < 100; $i++) { + $pool->submit(fn() => $gateway->send($messages[$i])); // 5 in flight, 95 waiting +} +``` + +The 100 submits return immediately; only five bodies run concurrently, the other +ninety-five park until a slot frees. Nothing is lost, nothing blocks the caller. + +--- + +## Creating one + +```php +Executors::newFixedExecutor( + int $concurrency, // max tasks running at once (>= 1) + int $queue = 0, // waiting slots; 0 = unbounded + RejectPolicy $onReject = RejectPolicy::ABORT, +): BoundedExecutorService +``` + +- **`concurrency`** — the ceiling. Five means five bodies run at the same time. +- **`queue`** — how many tasks may *wait* for a slot. `0` (the default) is an + unbounded wait queue: submitting always succeeds, matching Java's + `newFixedThreadPool`. A positive value caps the backlog and turns on the reject + policy. +- **`onReject`** — what happens to a task that arrives when the pool is full and + the queue is at capacity. Only reached when `queue > 0`. See + [Overflow](#overflow). + +`newFixedExecutor(5)` is the common case: cap the parallelism, let everything +else queue. + +--- + +## Giving a service its own pool + +A pool is most useful behind [`#[Async]`](03-async.md): register the pool once, +name it on the method, and every call to that method runs on it — bounded, +without a line of executor code at the call site. + +**Register it as a singleton** in your Boot's `providers()`: + +```php +use Flytachi\Winter\Kernel\Concurrent\Executors; + +protected static function providers(Container $c): void +{ + $c->singleton('gatewayPool', fn() => Executors::newFixedExecutor(5)); +} +``` + +**Name it on the method:** + +```php +class SmsService +{ + #[Async(executor: 'gatewayPool')] + public function send(string $to, string $body): void + { + $this->gateway->send($to, $body); + } +} +``` + +**Call it normally** — the calls funnel through `gatewayPool`, five at a time: + +```php +foreach ($recipients as $to) { + $this->sms->send($to, $body); // returns immediately, bounded to 5 in flight +} +``` + +> **It must be a `singleton`.** The cap lives in the pool object's state, so every +> `#[Async('gatewayPool')]` call has to resolve the *same* instance. A `bind()` or +> transient registration hands out a fresh pool each call, and the ceiling never +> holds. + +--- + +## The bound is a Swoole property + +A pool caps *concurrency*, and concurrency only exists under Swoole. The backend +switches on the runtime, per call: + +| Runtime | What a pool does | +|---|---| +| **Swoole** (coroutine) | Real ceiling: N coroutines run, the rest park on a `Channel` semaphore. | +| **PHP-FPM** | Delegates to the deferred backend: tasks run **sequentially after the response is flushed**. There is no parallelism to bound, so `concurrency` is a no-op. | +| **Plain CLI** | Deferred / sequential likewise. | + +This mirrors `Executors::common()` — see [01-executors.md](01-executors.md#backends). +The pool never breaks in a non-Swoole runtime; it degrades to running tasks one +at a time, which is all a synchronous SAPI can do. Design for Swoole; treat the +FPM/CLI behaviour as a correct-but-serial fallback. + +--- + +## Overflow + +With the default unbounded queue (`queue = 0`) a pool never rejects — a slow +drain just grows the backlog of parked coroutines, which costs memory. When you +need a hard limit, set `queue` and pick a `RejectPolicy` for the moment both the +slots and the queue are full: + +```php +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; + +Executors::newFixedExecutor( + concurrency: 5, + queue: 50, + onReject: RejectPolicy::ABORT, +); +``` + +| Policy | On overflow | The caller sees | +|---|---|---| +| `ABORT` (default) | Throw `RejectedExecutionException` at submit | An exception — fail fast, shed load loudly | +| `CALLER_RUNS` | Run the task inline, right here | Back-pressure: the submitter does the work and slows down | +| `DISCARD` | Drop the task | A **cancelled** future (`isCancelled()` true); for `execute()`, nothing | + +Which one you feel depends on how you called it: + +**Fire-and-forget (`void` / `execute`)** — you hold no handle, so the *pool* +reacts: `ABORT` surfaces the exception to whoever triggered the call (a scheduler +tick, a request), `CALLER_RUNS` makes that caller do the work, `DISCARD` drops it +silently. + +**With a result (`: Future` / `submit`)** — you hold the handle and decide: + +```php +$future = $this->reports->build($month); // #[Async('reportPool')] : Future +try { + $report = $future->get(timeout: 30.0); +} catch (RejectedExecutionException $e) { + // pool saturated → degrade (503, retry later, …) +} +``` + +Rule of thumb: unbounded queue for work that must not be lost; bounded + `ABORT` +or `DISCARD` for work where "too much at once" should shed rather than pile up; +`CALLER_RUNS` when you want the producer to naturally slow to the pool's pace. + +--- + +## Reading the gauges + +`newFixedExecutor` returns a `BoundedExecutorService`, which reports its live +occupancy — the equivalent of `ThreadPoolExecutor.getActiveCount()` / +`getQueue().size()`: + +```php +$pool->concurrency(); // the ceiling N +$pool->activeCount(); // running right now (0..N) +$pool->queuedCount(); // accepted, waiting for a slot +$pool->remainingCapacity(); // room before the reject policy applies (PHP_INT_MAX if unbounded) +``` + +Surface them where the owning process is already observed — a `status` command, +an actuator endpoint: *"gateway: 5/5 busy, 12 queued."* The gauges are live only +under Swoole; in a sequential runtime they stay at rest. + +> These are counters, not a task registry. "How many are waiting" is cheap; "which +> tasks are waiting" is not tracked — if you need a named, inspectable, durable +> queue, that is a job-queue built on [Process/Daemon](../process/00-overview.md), +> not this in-memory pool. + +--- + +## Two things to keep in mind + +**The pool is per-process.** It lives in the memory of whichever process holds +it. Under a Swoole HTTP server every worker has its own instance, so a pool of 5 +registered as a singleton caps at 5 *per worker* — with 4 workers, up to 20 run +across the server. The scheduler process has its own, separate. A single ceiling +shared across processes or hosts is a different tool (an external broker), not an +in-memory pool. + +**A pool is not durable.** The queue is RAM. If the process dies, the parked +tasks die with it. That is the trade for being instant and free — no Redis, no +serialization, no network. When you need tasks to survive a restart, reach for a +persistent queue, again on Process/Daemon. + +--- + +## When to use what + +| Need | Reach for | +|---|---| +| One-off background work, no limit | `Executors::common()` | +| Cap the parallelism of a workstream | `Executors::newFixedExecutor(n)` | +| A service always async, on its own bounded pool | `#[Async('id')]` + a registered `newFixedExecutor` | +| One global cap across processes / hosts, durable | a job queue on [Process/Daemon](../process/00-overview.md) | + +--- + +## See also + +- [01-executors.md](01-executors.md) — `Executors`, the primitive backends +- [03-async.md](03-async.md#choosing-an-executor) — `#[Async(executor: 'id')]` +- [02-future.md](02-future.md) — the handle `submit()` returns +- [`schedule/01-usage.md`](../schedule/01-usage.md#bounding-concurrency-with-a-named-pool) — sharing a pool between the scheduler and the API diff --git a/docs/configuration/01-kernel.md b/docs/configuration/01-kernel.md index f3bda79..2f70cc0 100644 --- a/docs/configuration/01-kernel.md +++ b/docs/configuration/01-kernel.md @@ -1,48 +1,49 @@ # Kernel & Bootstrap -The kernel resolves application paths, loads `.env`, configures logging and the thread runner, and exposes a small `Boot` class that you extend to wire up everything else. +The kernel resolves application paths, loads `.env`, and configures logging and the thread runner. It runs once, at the very start of the boot, before anything is scanned. This page describes the configuration knobs at the bottom of the stack. Logging, CORS, health, and plugins are documented separately — links at the end. --- -## The Boot class +## When it runs -Every application has one `Boot` class that extends `Flytachi\Winter\K2\BaseBoot`. It overrides only the hooks it needs and is called from one of four entry points (`web()`, `swoole()`, `cli()`, `executor()`). +`Kernel::init()` is the first thing the boot does, because it decides where the scan +will look. Everything after it — DI, configurers, routes — depends on the paths it sets. -```php -// bootstrap.php -use Flytachi\Winter\K2\BaseBoot; -use Flytachi\Winter\K2\Kernel; +You normally never call it: `WinterApplication::configure()` does, deriving the project +root from the application class's own file. -class Boot extends BaseBoot +```php +// bootstrap.php — no Kernel::init() in sight +#[EnableWeb] +final class Application extends WinterApplication { - protected static function configure(): void - { - Kernel::init(pathRoot: __DIR__); - } + public static function main(array $argv): never { parent::run($argv); } } ``` +Override `configure()` only for a non-standard layout — for instance to keep runtime +files outside the project: + ```php -// public/index.php -require __DIR__ . '/../bootstrap.php'; -Boot::web(); -``` +use Flytachi\Winter\Kernel\App\ApplicationArguments; +use Flytachi\Winter\Kernel\Kernel; -The full hook list: +#[EnableWeb] +final class Application extends WinterApplication +{ + protected static function configure(ApplicationArguments $args): void + { + Kernel::init(pathRoot: __DIR__, pathStorage: '/var/lib/myapp'); + } -| Hook | When called | Purpose | -|---|---|---| -| `configure()` | first, before everything else | Call `Kernel::init(...)`, set paths and timezone. | -| `providers(Container $c)` | after DI scan | Manual DI bindings (factories, named scalars, service providers). | -| `channels()` | after `configure()` | Register custom log channels via `Kernel::channel('name')`. | -| `httpCors()` | before request dispatch | Call `Cors::configure(...)`. | -| `health()` | before request dispatch | Call `Health::configure(...)`. | -| `plugins()` | before route scan | Call `Plugin::registry(...)` for each plugin. | -| `swooleConfig()` | only in `swoole()` mode | Return options for `Swoole\Http\Server::set()`. | + public static function main(array $argv): never { parent::run($argv); } +} +``` -All hooks except `swooleConfig()` are `protected` — override only what you need; defaults are no-ops or sane defaults. +Because `configure()` decides where the scan looks, it cannot itself be a discovered +class — it is the one thing that stays a method on the application. --- @@ -52,22 +53,22 @@ All hooks except `swooleConfig()` are `protected` — override only what you nee Kernel::init( pathRoot: __DIR__, // project root pathEnv: __DIR__ . '/.env', - pathPublic: __DIR__ . '/public', pathResource: __DIR__ . '/resources', pathStorage: __DIR__ . '/storage', pathStorageLog: __DIR__ . '/storage/logs', pathStorageCache: __DIR__ . '/storage/cache', pathStorageRunnable: __DIR__ . '/storage/runnable', - isTmpVolatile: false, // see "Volatile storage" below + isTmpVolatile: true, // the default; see "Volatile storage" below ); ``` -Every parameter is **optional**. When `pathRoot` is omitted, it is derived from the calling location (`dirname(__DIR__, 5)`). All other paths are derived from `pathRoot` if not given: +Every parameter is **optional**. When `pathRoot` is omitted, it is derived from the calling location. All other paths are derived from `pathRoot` if not given. + +There is no `pathPublic`: a document root is an FPM concept, and the Swoole server decides for itself what it serves (see [`../starter/00-quickstart.md`](../starter/00-quickstart.md)). | Param | Default | |---|---| | `pathEnv` | `$pathRoot . '/.env'` | -| `pathPublic` | `$pathRoot . '/public'` | | `pathResource` | `$pathRoot . '/resources'` | | `pathStorage` | `$pathRoot . '/storage'` | | `pathStorageLog` | `$pathStorage . '/logs'` | @@ -79,7 +80,6 @@ After `init()`, all of these are available as public static properties on `Kerne ```php Kernel::$pathRoot Kernel::$pathEnv -Kernel::$pathPublic Kernel::$pathResource Kernel::$pathStorage Kernel::$pathStorageLog @@ -101,7 +101,9 @@ Kernel::$pathStorageVolatile Use `true` for ephemeral containers (Docker, Kubernetes) where `/tmp` is fast and disposable. Use `false` for long-lived deployments where you want the route cache to persist with the rest of your storage. -`K2\Kernel::init()` passes `isTmpVolatile: false` by default; `KernelConfig::init()` defaults to `true` (the `K2\Kernel` wrapper flips it). Pass it explicitly if you want the other behaviour. +Both `Kernel::init()` and `KernelConfig::init()` default to `true` — the temp directory. Pass `false` explicitly if you want volatile artefacts to live inside your storage tree. + +Note one consequence of `false`: volatile storage then sits **inside the project root**, where class discovery runs. The scan excludes it (see `ClassScanner::scanner()`), because that directory holds generated code — the DI cache and the `#[Async]` proxies — and scanning what a previous scan produced is self-referential. The directory is auto-created (`mkdir 0777 recursive`) on first call. @@ -146,12 +148,15 @@ The constant `WINTER_STARTUP_TIME` is defined here if not already set elsewhere. ### Thread runner discovery -`bindThread()` looks for the executor binary in this order: +Detaching a process spawns a fresh PHP process running the **thread runner**, which +boots the application again and runs the staged payload. `Kernel::init()` resolves it: + +1. `WINTER_THREAD_RUNNER` env var — an absolute path, used when the file exists; +2. otherwise `/vendor/bin/wKernelRunner`, the binary this package ships. -1. `WINTER_THREAD_RUNNER` env var (absolute path) -2. `/vendor/bin/wKernelExecutor` -3. `/vendor/bin/wExecutor` -4. If none exists, the runner is left unbound — `Thread::dispatch()` will fail at runtime. +The runner resolves the project root from its own location, so it must be **copied** +into place rather than symlinked — PHP resolves `__DIR__` through symlinks and the path +would point at the package instead of the project. When `ext-shmop` is loaded, payload mode is set to `PAYLOAD_SHM` automatically (avoids fd conflicts in Swoole). @@ -169,15 +174,25 @@ $now = new DateTime('now', $tz); For applications that want the timezone applied globally for the duration of the request, attach `ClientTimezoneMiddleware` to a controller or method: ```php -use Flytachi\Winter\K2\Http\Middleware\ClientTimezoneMiddleware; +use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware; #[ClientTimezoneMiddleware] class ReportController extends Controller { ... } ``` -The middleware calls `date_default_timezone_set()` in `before()` with the client value (or `env('TIME_ZONE', 'UTC')` as fallback) and restores the canonical default in `after()`. +The middleware stores the client value (or `env('TIME_ZONE', 'UTC')`) in `Timezone`, and additionally sets PHP's own default so unadapted code keeps working: + +```php +use Flytachi\Winter\Kernel\Localization\Timezone; + +Timezone::current(); // 'Asia/Tashkent' — this request's zone, coroutine-local +``` + +**`Timezone::current()` is the safe read; `date()` is not.** PHP keeps its default timezone in an engine global, and a Swoole worker runs many requests as coroutines in one process — so a request that sets its zone and then waits on I/O can resume to find a concurrent request's value in place. That is measured behaviour, not a theoretical risk, and no library can change it. `Timezone` lives in `RequestLocal` instead, so concurrent requests cannot see each other's. -**Swoole caveat.** `after()` does not run when the handler throws — `Router::dispatch` catches `Throwable` outside the after-loop. In a long-running worker, an unhandled exception leaves the global TZ at the client's value until the next request that passes through the middleware overwrites it. Apply the middleware uniformly across routes, or skip it and call `getClientTimezone()` explicitly inside handlers. +The framework reads `Timezone` for everything it does on the request's behalf, including the timezone of the database session — a pooled connection is handed from one user to the next, and its session zone is corrected on every query rather than left as the previous user found it. + +**Swoole caveat.** `after()` does not run when the handler throws — `Router::dispatch` catches `Throwable` outside the after-loop. The coroutine-local value disappears with the coroutine regardless, but the engine global keeps the client's value until the next request through the middleware resets it. --- @@ -222,7 +237,7 @@ runs as the first step of the request pipeline and snapshots the origin alongsid headers, so these getters need no `HttpRequest` argument: ```php -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Header; Header::getBaseUrl(); // "https://example.com:8443" Header::getScheme(); // "https" @@ -242,67 +257,45 @@ process-wide static under FPM. ## Entry points -A complete request lifecycle from each entry point. Detailed pipelines live in [`../architecture/01-routing.md`](../architecture/01-routing.md#request-handling-pipeline). - -### `Boot::web()` — FPM / nginx - -```php -// public/index.php -require __DIR__ . '/../bootstrap.php'; -Boot::web(); -``` - -Reads the HTTP request from PHP superglobals, dispatches via `Router::resolve(Kernel::$pathRoot)` (cache-first), serves static files in `Kernel::$pathPublic`, then `exit(0)`. Default log channel is `http`. - -### `Boot::swoole(host, port)` — Swoole HTTP server +There is one: `Application::main($argv)`, reached from `call`. What happens next depends +on the verb, not on a different entry file. ```php -// server.php -require __DIR__ . '/bootstrap.php'; -Boot::swoole(host: '0.0.0.0', port: 9501); +#!/usr/bin/env php + swoole_cpu_num() * 2, - 'max_request' => 5000, - 'max_request_grace' => 500, - 'enable_coroutine' => true, - ]; -} -``` - -Default log channel is `http`. - -### `Boot::cli($argv)` — Console - -```php -// call -require __DIR__ . '/bootstrap.php'; -Boot::cli($argv); -``` +| Invocation | What runs | +|---|---| +| `php call run` | boots, then serves the components in the manifest (`serve()`) | +| `php call run dev` | the same with the file watcher | +| `php call ` | boots, then hands the verb to the console | +| `php call` | boots, prints the command list | -Runs the console application (`Flytachi\Winter\Console\Core`). Default log channel is `cli`. +Two more entries exist but are not called by hand: -### `Boot::executor($argv)` — Thread / job runner +- **`WinterApplication::executor($argv)`** — the child side of `Process::dispatch()`. + Detaching cannot be a fork (the parent may be a Swoole worker whose reactor must not + be duplicated), so the launcher spawns a fresh PHP process running + `vendor/bin/wKernelRunner`, which boots the application again and runs the staged + payload. Its CLI flags are the thread runner's own: -Invoked by the `wKernelExecutor` binary (you do not call this directly). Reads a serialised `Runnable` from stdin or shared memory, runs it, exits with the appropriate status code. Default log channel is `cli`. + | Flag | Purpose | + |---|---| + | `--namespace=App` | process title namespace prefix | + | `--name=MyJob` | process title name (default: class short name) | + | `--tag=worker` | process title tag | + | `--shmkey=1234` | read the payload from a SHM segment instead of stdin | + | `--detach` | daemonise (fork + `setsid`) before running | + | `--debug` | full error reporting in the child | -CLI flags accepted by the binary: +- **`WinterApplication::discoverAppClass()`** — how that runner finds your application + class after requiring `bootstrap.php`, without knowing its name. -| Flag | Purpose | -|---|---| -| `--namespace=App` | Process title namespace prefix | -| `--name=MyJob` | Override process title name (default: class short name) | -| `--tag=worker` | Process title tag (default: `runnable`) | -| `--shmkey=1234` | Read payload from SHM segment instead of stdin | -| `--debug` | Enable full error reporting in the child | -| `--arg-key=value` | Pass `['key' => 'value']` into `Runnable::run()` | -| `--arg-flag` | Pass `['flag' => true]` into `Runnable::run()` | +FPM has no entry here; it is moving to a separate `winter-fpm` project. --- @@ -318,20 +311,23 @@ CLI flags accepted by the binary: ## Boot order -`BaseBoot` runs the hooks in a fixed order — knowing this matters when you cross-reference services: +The boot runs in a fixed order — knowing it matters when you cross-reference services: ``` -1. configure() ← Kernel::init() + .env + paths -2. DI Scanner pass ← discovers #[Singleton] / #[Request] / #[Transient] -3. providers(Container) ← manual DI bindings -4. channels() ← Kernel::channel('job') etc. -5. httpCors() ← Cors::configure() -6. health() ← Health::configure() -7. plugins() ← Plugin::registry() -8. ← web() / swoole() / cli() / executor() +1. configure() ← Kernel::init(): paths, .env, logging +2. Container::init() ← the shared container +3. one Scanner pass ← DI classes, #[Configuration]/#[Bean], + WebConfigurer, LoggingConfigurer, HealthContributor +4. apply logging ← discovered LoggingConfigurer classes +5. apply CORS ← discovered WebConfigurer classes +6. apply actuator ← #[EnableActuator] + discovered contributors +7. apply imports ← #[Import] plugin packages +8. dispatch ← serve() or the console ``` -`BaseBoot::getBootClass()` returns the concrete `Boot` class name set during step 1. +Everything from step 3 onward is discovered, not registered: adding a configurer is +adding a class. `WinterApplication::getAppClass()` returns the concrete application +class name, set at the start of step 1. --- diff --git a/docs/configuration/02-logging.md b/docs/configuration/02-logging.md index c145816..bc8c649 100644 --- a/docs/configuration/02-logging.md +++ b/docs/configuration/02-logging.md @@ -11,27 +11,27 @@ over [Monolog](https://github.com/Seldaek/monolog) designed for multi-runtime PH ``` Kernel::init() └── LoggerManager ← built once, holds all channel configs - ├── channel 'sys' ← system / kernel-level events - ├── channel 'http' ← HTTP request lifecycle - └── channel 'cli' ← console commands and jobs + ├── channel 'http' ← HTTP request lifecycle (coroutine-isolated) + └── channel 'sys' ← everything else: kernel, CLI, background components Entry point (index.php / call / run) - └── LoggerFactory::setDefaultChannel('http' | 'cli') + └── LoggerFactory::setDefaultChannel('http' | 'sys') Application code └── LoggerFactory::getLogger(MyClass::class) └── Logger (wraps Monolog, merges class FQCN into every record) ``` -**Key principle — entry-point driven.** The kernel registers all channels and sets `sys` as -the default. Entry points switch to the channel that matches the runtime: +**Key principle — entry-point driven.** The kernel registers both channels and sets `sys` as +the default. Only the HTTP request path switches to `http`; everything else stays on `sys`: | Entry point | Channel | Context storage | |-------------|---------|-----------------| -| `public/index.php` | `http` | `ProcessContext` (per FPM worker) | -| `call run` (Swoole) | `http` | `CoroutineContext` (per coroutine) | -| `call` (CLI) | `cli` | `ProcessContext` (per process) | -| `wKernelExecutor` (threads/jobs) | `cli` | `ProcessContext` | +| `public/index.php` (FPM) | `http` | `ProcessContext` (per FPM worker) | +| `call run` — request workers | `http` | `CoroutineContext` (per coroutine) | +| `call run` — master + components (Process/Daemon/Scheduler) | `sys` | `ProcessContext` (per process) | +| `call` (CLI commands) | `sys` | `ProcessContext` (per process) | +| `wKernelRunner` (detached processes) | `sys` | `ProcessContext` | --- @@ -73,7 +73,6 @@ These apply to every channel unless a per-channel override is set. | `LOG_OUTPUT` | `auto` | Destination (see table below). | | `LOG_FILE` | *(auto)* | Absolute file path when `LOG_OUTPUT=file`. | | `LOG_FILE_MAX` | `30` | Number of daily rotating files to keep. | -| `LOG_SYSLOG_IDENT` | `winter` | Program identity tag in syslog. | #### `LOG_LEVEL` values @@ -85,7 +84,7 @@ Case-insensitive. Records below the configured level are discarded before reachi | Value | Handler | When to use | |-------|---------|-------------| -| `auto` | *detected* | Docker/K8s → `syslog`; Swoole → `stdout`; FPM/CLI → `stderr` | +| `auto` | `php://stdout` | Default — always `stdout`; the orchestrator / supervisor / terminal captures it | | `stdout` | `php://stdout` | Swoole workers, CLI tools with piped output | | `stderr` | `php://stderr` | FPM — immune to broken-pipe on client disconnect | | `syslog` | system syslog | Docker, Kubernetes (journald, `/var/log/syslog`) | @@ -123,13 +122,8 @@ LOG_HTTP_OUTPUT=file LOG_HTTP_FILE=/var/log/app/http.log LOG_HTTP_FILE_MAX=14 -# sys channel — always goes to syslog with a custom ident +# sys channel — always goes to syslog LOG_SYS_OUTPUT=syslog -LOG_SYS_SYSLOG_IDENT=myapp-sys - -# cli channel — debug level, stderr -LOG_CLI_LEVEL=debug -LOG_CLI_OUTPUT=stderr # custom 'job' channel (registered via Kernel::channel('job')) LOG_JOB_LEVEL=debug @@ -142,7 +136,7 @@ LOG_JOB_FILE_MAX=7 ## Custom channels -Built-in channels (`sys`, `http`, `cli`) are registered automatically by `Kernel::init()`. +Built-in channels (`http`, `sys`) are registered automatically by `Kernel::init()`. Add extra channels in `bootstrap.php` using `Kernel::channel()`: ```php @@ -191,7 +185,7 @@ Raw `channel()` calls omit it. [2024-01-01 12:00:00] [DEBUG] -http- [4821] (UserService): db query {"class":"App\\Service\\UserService","request_id":"abc-123"} [2024-01-01 12:00:00] [WARN ] -http- [4821] (PaymentService): retry {"class":"App\\PaymentService","attempt":3} [2024-01-01 12:00:00] [ERROR] -http- [4821] (OrderController): checkout failed {"class":"App\\OrderController","order_id":99} -[2024-01-01 12:00:00] [DEBUG] -cli- [5012] (MyJob): step done {"class":"App\\Job\\MyJob","job_id":"xyz"} +[2024-01-01 12:00:00] [DEBUG] -sys- [5012] (MyJob): step done {"class":"App\\Job\\MyJob","job_id":"xyz"} [2024-01-01 12:00:00] [INFO ] -sys- [4821]: kernel booted ``` @@ -229,13 +223,13 @@ class OrderController extends Controller } ``` -**How it works.** `BaseBoot` registers a [contextual binding](https://github.com/flytachi/winter-di) +**How it works.** The boot registers a [contextual binding](https://github.com/flytachi/winter-di) for `Psr\Log\LoggerInterface` by default — the container resolves the injected logger to `LoggerFactory::getLogger()`. So the logger is automatically named after the class it lives in, with the same per-class `(ClassName)` output you'd get from the factory — but injected by type. -**Override** the default in `Boot::providers()` — e.g. pin a channel or swap the +**Override** the default in a `#[Configuration]` class — e.g. pin a channel or swap the factory entirely (re-registering wins, since `contextual()` is last-write for the key): ```php @@ -295,7 +289,7 @@ Equivalent to `LoggerFactory::logger()->{level}(...)` — always writes to the c ```php LoggerFactory::channel('http')->warning('rate limit hit'); -LoggerFactory::channel('cli')->debug('job started'); +LoggerFactory::channel('sys')->debug('job started'); ``` No `(ClassName)` in output. Throws `InvalidArgumentException` if the channel is not registered. @@ -411,7 +405,6 @@ LOG_LEVEL=info # DEBUG | INFO | NOTICE | WARNING | ERROR | CRITICAL | LOG_FORMAT=line # line | json LOG_OUTPUT=auto # auto | stdout | stderr | syslog | file | null LOG_FILE_MAX=30 # rotating daily files to keep (output=file only) -LOG_SYSLOG_IDENT=winter # syslog program tag # Per-channel overrides (LOG_{CHANNEL}_* — channel uppercased) # LOG_HTTP_LEVEL=warning @@ -419,10 +412,6 @@ LOG_SYSLOG_IDENT=winter # syslog program tag # LOG_HTTP_FILE=/var/log/app/http.log # LOG_SYS_OUTPUT=syslog -# LOG_SYS_SYSLOG_IDENT=myapp - -# LOG_CLI_OUTPUT=stderr -# LOG_CLI_LEVEL=debug # Custom channels (registered via Kernel::channel('job') in bootstrap.php) # LOG_JOB_LEVEL=debug diff --git a/docs/configuration/03-cors.md b/docs/configuration/03-cors.md index 24157b0..229fd32 100644 --- a/docs/configuration/03-cors.md +++ b/docs/configuration/03-cors.md @@ -1,8 +1,8 @@ # CORS -Winter K2 has two layers of CORS configuration, both modelled after Spring's `@CrossOrigin`: +Winter has two layers of CORS configuration, both modelled after Spring's `@CrossOrigin`: -1. **Global** — `Cors::configure(...)` in your `Boot::httpCors()` hook. Applied to every response, including 404, 405, and error responses. +1. **Global** — a `WebConfigurer` the scan finds. Applied to every response, including 404, 405, and error responses. 2. **Per-route** — `#[CrossOrigin(...)]` attribute on a controller class or method. **Overrides** the global config (does not merge with it) for that specific route. The OPTIONS preflight is handled automatically by the Router before middleware or the controller runs. @@ -11,38 +11,42 @@ The OPTIONS preflight is handled automatically by the Router before middleware o ## Global CORS -Configure once during boot. The simplest example: +Global CORS is declared by any class extending `WebConfigurerAdapter` — there is no hook +to override on the application class, the scan finds the configurer wherever it lives: ```php -use Flytachi\Winter\K2\Http\Cors; +use Flytachi\Winter\Kernel\App\Config\CorsRegistry; +use Flytachi\Winter\Kernel\App\Config\WebConfigurerAdapter; -class Boot extends BaseBoot +final class WebConfig extends WebConfigurerAdapter { - protected static function httpCors(): void + public function configureCors(CorsRegistry $cors): void { - Cors::configure( - origins: ['https://app.example.com'], - allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'], - exposeHeaders: ['X-Request-Id'], - credentials: true, - maxAge: 3600, - ); + $cors->allowedOrigins('https://app.example.com') + ->allowedHeaders('Content-Type', 'Authorization', 'X-Request-Id') + ->exposeHeaders('X-Request-Id') + ->allowCredentials() + ->maxAge(3600); } } ``` -### `Cors::configure()` parameters +Each method takes a variadic list rather than an array, and returns the registry so the +calls chain. Touch nothing and no global CORS is applied. -| Parameter | Type | Default | Description | -|---|---|---|---| -| `origins` | `string[]` | `[]` | Allowed origins. Empty → `Access-Control-Allow-Origin: *`. Multiple origins → reflects the matching request `Origin` and adds `Vary: Origin`. | -| `allowHeaders` | `string[]` | `[]` | Request headers the browser may send. Empty → reflects `Access-Control-Request-Headers`. | -| `exposeHeaders` | `string[]` | `[]` | Response headers exposed to JavaScript. | -| `credentials` | `bool` | `false` | Sends `Access-Control-Allow-Credentials: true`. **Requires explicit `origins`** — incompatible with the wildcard `*`. | -| `maxAge` | `int` | `0` | Preflight cache TTL (`Access-Control-Max-Age`). `0` = header not sent. | -| `vary` | `string[]` | `[]` | Extra `Vary` header values appended to the response. | +### The settings -When this hook is not overridden, no global CORS is sent — every response is same-origin only. +| Method | Default | Description | +|---|---|---| +| `allowedOrigins(...)` | none | Allowed origins. None → `Access-Control-Allow-Origin: *`. Several → reflects the matching request `Origin` and adds `Vary: Origin`. | +| `allowedHeaders(...)` | none | Request headers the browser may send. None → reflects `Access-Control-Request-Headers`. | +| `exposeHeaders(...)` | none | Response headers exposed to JavaScript. | +| `allowCredentials(bool)` | `false` | Sends `Access-Control-Allow-Credentials: true`. **Requires explicit origins** — incompatible with the wildcard `*`. | +| `maxAge(int)` | `0` | Preflight cache TTL (`Access-Control-Max-Age`). `0` = header not sent. | +| `vary(...)` | none | Extra `Vary` header values appended to the response. | + +When no configurer touches the registry, no global CORS is sent — every response is +same-origin only. Source: `src/Http/Cors.php`. @@ -80,9 +84,9 @@ Application code never sees an `OPTIONS` request that has a registered handler `#[CrossOrigin]` overrides the global config for the routes it covers. Method-level wins over class-level; class-level wins over global. ```php -use Flytachi\Winter\K2\Route\Annotation\CrossOrigin; -use Flytachi\Winter\K2\Route\Annotation\{RequestMapping, GetMapping}; -use Flytachi\Winter\K2\Stereotype\Controller; +use Flytachi\Winter\Kernel\Route\Annotation\CrossOrigin; +use Flytachi\Winter\Kernel\Route\Annotation\{RequestMapping, GetMapping}; +use Flytachi\Winter\Kernel\Http\Stereotype\Controller; #[RequestMapping('admin')] #[CrossOrigin( diff --git a/docs/configuration/04-health.md b/docs/configuration/04-health.md index 6713d1b..f97e982 100644 --- a/docs/configuration/04-health.md +++ b/docs/configuration/04-health.md @@ -1,6 +1,6 @@ # Health / Actuator -Read-only diagnostic endpoints under `/actuator/*` — modelled after Spring Boot Actuator. Disabled by default; opt in from your `Boot::health()` hook. +Read-only diagnostic endpoints under `/actuator/*` — modelled after Spring Boot Actuator. Disabled by default; opt in with `#[EnableActuator]` on the application class. The endpoints are useful for: @@ -15,15 +15,21 @@ The endpoints are useful for: Override `health()` in your `Boot` class and call `Health::configure()`: ```php -use Flytachi\Winter\K2\Http\Health\Health; +use Flytachi\Winter\Kernel\App\Attribute\EnableActuator; +use Flytachi\Winter\Kernel\WinterApplication; -class Boot extends BaseBoot -{ - protected static function health(): void - { - Health::configure(); // default built-in indicator, open access - } -} +#[EnableWeb] +#[EnableActuator] // default built-in indicator, open access +final class Application extends WinterApplication { /* ... */ } +``` + +The attribute accepts a custom indicator and a guard middleware: + +```php +#[EnableActuator( + indicator: App\Health\AppHealthIndicator::class, + middleware: App\Http\Middleware\InternalOnlyMiddleware::class, +)] ``` To restrict access or replace the indicator, pass arguments: @@ -87,7 +93,7 @@ Source: `src/Route/Router.php` (`registerHealth()`). { "status": "degraded", "components": { - "db": {"status": "up", "details": {...}}, + "db": {"status": "up", "details": {...}}, // per datasource, pool nested inside "cache": {"status": "up", "details": {...}}, "disk": {"status": "degraded", "details": {"usage_percent": 85.4, "warning": "Disk usage above 80%"}}, "memory": {"status": "up", "details": {...}}, @@ -98,6 +104,27 @@ Source: `src/Route/Router.php` (`registerHealth()`). Aggregation rule: any `down` → overall `down`; otherwise any `degraded` → overall `degraded`; otherwise `up`. +### Response code + +The overall status is also the response code, so a probe that reads the code rather than the body reaches the same verdict: + +| Overall status | Code | +|---|---| +| `up` | `200` | +| `degraded` | `200` | +| `down` | `503 Service Unavailable` | + +`degraded` stays `200` on purpose: it means working worse, not not working, and a readiness probe that pulled the instance out of rotation over it would turn a partial outage into a full one. The body is the same in either case — the code carries no information the report does not. + +Only `/actuator/health` is affected. The other endpoints answer `200`, including one whose payload happens to contain a `status` key of its own. + +This makes the endpoint usable directly as a container health check or a k8s probe: + +```yaml +readinessProbe: + httpGet: { path: /actuator/health, port: 8000 } +``` + ### Disk / memory thresholds Built into `HealthIndicator`: @@ -121,6 +148,28 @@ Optional — only reports `up` and an empty `details` map unless one of these pa `HealthIndicator` scans `Kernel::$pathRoot` for implementations and calls `pingDetail()` on each. A latency ≥ **500 ms** flips the component to `degraded`. Connection failure → `down`. +The `db` component carries **one entry per datasource**, holding both its reachability +and how loaded its connection pool is: + +```json +"db": { + "status": "up", + "details": { + "App\\Config\\AppDb": { + "status": "up", "driver": "pgsql", "latency": 1.2, "error": null, + "pool": {"total": 5, "idle": 3, "active": 2, "maximum": 10} + } + } +} +``` + +The two answer different questions — the ping says whether the database is reachable, the +pool says whether this worker has connections left — so they live together rather than in +separate components. A saturated pool (`active >= maximum`) degrades its datasource. +`pool` is `null` when the worker holds no pool for that config, and the numbers are **per +worker**: a health request reports the worker that served it. For the fleet-wide view use +[`call db pool`](../console/06-db.md). + --- ## Authoring a custom indicator @@ -130,7 +179,7 @@ Subclass `HealthIndicator` to add custom checks without losing the built-in disk ```php namespace App\Health; -use Flytachi\Winter\K2\Http\Health\HealthIndicator; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicator; final class AppHealthIndicator extends HealthIndicator { @@ -187,9 +236,9 @@ Production deployments usually want `/actuator/*` reachable only from inside the ```php namespace App\Http\Middleware; -use Flytachi\Winter\K2\Stereotype\Middleware; -use Flytachi\Winter\K2\Http\Contracts\{HttpRequest, HttpResponse}; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareException; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Contracts\{HttpRequest, HttpResponse}; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException; use Flytachi\Winter\Base\HttpCode; final class InternalOnlyMiddleware extends Middleware @@ -245,9 +294,8 @@ See [`../architecture/02-middleware.md`](../architecture/02-middleware.md) for m ```json { - "sys": {"level": "INFO", "output": "syslog", "format": "line"}, "http": {"level": "WARN", "output": "file", "format": "json", "file": {"path": "storage/logs/http.log", "max_files": 14}}, - "cli": {"level": "DEBUG", "output": "stderr", "format": "line"} + "sys": {"level": "INFO", "output": "syslog", "format": "line"} } ``` diff --git a/docs/configuration/05-plugins.md b/docs/configuration/05-plugins.md index 9c0492f..e32ff04 100644 --- a/docs/configuration/05-plugins.md +++ b/docs/configuration/05-plugins.md @@ -1,9 +1,9 @@ # Plugins -Plugins are regular Composer packages that contribute controllers, exception handlers, and DI services under a URL prefix. Each plugin registration is one line in your `Boot::plugins()` hook: +Plugins are regular Composer packages that contribute controllers, exception handlers, and DI services under a URL prefix. Each plugin is one attribute on the application class: ```php -Plugin::registry('acme/billing-plugin', '/billing'); +#[Import('acme/billing-plugin', '/billing')] ``` After that, every `#[Controller]` discovered under `vendor/acme/billing-plugin/src/` is mounted under `/billing/...`. No extra wiring needed. @@ -15,18 +15,18 @@ After that, every `#[Controller]` discovered under `vendor/acme/billing-plugin/s Override `plugins()` in your `Boot` class: ```php -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\Kernel\App\Attribute\Import; +use Flytachi\Winter\Kernel\WinterApplication; -class Boot extends BaseBoot -{ - protected static function plugins(): void - { - Plugin::registry('acme/auth-plugin', '/auth'); - Plugin::registry('acme/billing-plugin', '/billing'); - } -} +#[EnableWeb] +#[Import('acme/auth-plugin', '/auth')] +#[Import('acme/billing-plugin', '/billing')] +final class Application extends WinterApplication { /* ... */ } ``` +A package that must be present but is not installed fails the boot; pass +`required: false` to make it optional. + ### `Plugin::registry()` parameters ```php @@ -57,11 +57,11 @@ Source: `src/Plugin.php`. Plugin registration runs **before** the route scan. During `Router::fromScan()` (or the first request after a clean cache): ``` -1. Boot::plugins() runs ← Plugin::registry() entries collected +1. #[Import] attributes applied ← plugin packages registered 2. Router::fromScan(pathRoot) ← scans your application src/ 3. For each registered plugin: scan vendor//src/ ← MappingCollector with prefix -4. Health::configure() endpoints ← only the app-level ones +4. #[EnableActuator] endpoints ← only the app-level ones 5. Cache compiled routes ← if DEBUG=false ``` diff --git a/docs/configuration/06-db.md b/docs/configuration/06-db.md index 1c28a6b..7b20eb6 100644 --- a/docs/configuration/06-db.md +++ b/docs/configuration/06-db.md @@ -61,9 +61,9 @@ call db sql -e # preview extension statements only `db sql` and `db migrate` are opt-in at the **DbConfig** level. A `DbConfig` class participates in migrations only if it carries `#[Migratable]`. This is intentional — `DbConfig` classes that point at legacy databases, read replicas, or vendor schemas are excluded from migration tooling by default. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; #[Migratable] // default priority: Normal #[Extension('uuid-ossp')] diff --git a/docs/configuration/07-di.md b/docs/configuration/07-di.md index 08bb4a9..c5fd921 100644 --- a/docs/configuration/07-di.md +++ b/docs/configuration/07-di.md @@ -13,7 +13,7 @@ then hands the container to your `providers()` hook for manual wiring. ## What boot does for you -`BaseBoot::boot()` performs the DI setup automatically, before any of the +The application boot performs the DI setup automatically, before any of the HTTP/CLI entry points run: ``` @@ -70,9 +70,10 @@ discovered bindings. use Flytachi\Winter\DI\Container; use Psr\Log\LoggerInterface; -class Boot extends BaseBoot +#[Configuration] +final class AppConfig { - protected static function providers(Container $c): void + public function register(Container $c): void { // Service providers $c->register(AppServiceProvider::class); @@ -162,14 +163,98 @@ you rarely call it directly, but it's worth knowing where injection happens: | Site | How dependencies are supplied | |------|-------------------------------| -| Controllers | Resolved through the container before the route method runs (constructor + `#[Autowired]`). | +| Controllers | Resolved through the container before the route method runs. Dependencies arrive as `#[Autowired]` properties — `Http\Stereotype\Controller` declares a `final` constructor, so constructor injection is not available (declaring one is a fatal error). | | Middleware | Same container; `#[Autowired]` fields populated. | -| Threads / Jobs / Daemons | `Container::make(static::class)` builds a **fresh** DI instance inside the child process. | +| Processes / Daemons | `Container::make(static::class)` builds a **fresh** DI instance inside the child process. | | Console commands | Resolved via the container when dispatched. | Because the same container backs all of these, a `#[Singleton]` is shared within a process but **not** across forked child processes — each thread -rebuilds its own graph. See [`../threads/01-job.md`](../threads/01-job.md). +rebuilds its own graph. See [`../process/00-overview.md`](../process/00-overview.md). + +--- + +## Choosing a scope + +One rule decides every case: + +> **A class may hold a reference to a shorter-lived object only if it does not outlive it.** + +Injected properties are resolved **once, when the holder is built**. A `#[Singleton]` is +built once per worker, so whatever it captured at that moment is what it keeps — for +every later request. + +| scope | one instance per | safe to hold | +|---|---|---| +| `#[Transient]` (default) | resolution | anything | +| `#[Request]` | request (coroutine) | `#[Request]`, `#[Singleton]` | +| `#[Singleton]` | **worker process**, not application | `#[Singleton]` only | + +Note the middle column for `#[Singleton]`: with four Swoole workers you have four +instances, not one. A counter or cache in a singleton field will disagree with itself +depending on which worker answered. + +### What makes a class safe to share + +A `#[Singleton]` is exactly as safe as its least careful field. Fields holding request +data — the current user, an accumulated result, a scratch value — break under +concurrency, because a Swoole worker serves requests in interleaved coroutines and they +all see the same object. + +Repositories are safe by construction: their query state (`sqlParts`, alias, entity class) +lives in the coroutine context rather than in the object, so concurrent requests never +share a builder. That is why `call make -r` marks them `#[Singleton]` out of the box. + +### The boot refuses a singleton that holds a request bean + +```php +#[Request] +class AuthContext { /* ... */ } + +#[Singleton] +class OrderService +{ + #[Autowired] private AuthContext $context; // ← the application will not start +} +``` + +The first request's `AuthContext` would be frozen into `OrderService` for the worker's +lifetime, and every later request would keep seeing it — silently, with no error and +nothing in the log. With an authentication context in that position, every user after the +first is served under the first user's identity. + +The check is **transitive**, because a singleton freezes its whole dependency subtree: +`#[Singleton] → plain service → #[Request]` leaks exactly as badly. The error names the +full path, since the mistake usually hides in the middle — each link looks correct alone. + +Fixes, in order of preference: + +- drop `#[Singleton]` from the holder, so it is built per request (the default); +- resolve the request-scoped bean where it is used, not as a property; +- make the dependency stateless and give it `#[Singleton]` too. + +The canonical way to carry request data is the reverse of holding it: a `#[Request]` bean +written by middleware and read by whatever needs it, where **every reader is transient**. + +```php +#[Request] +class AuthContext { /* ... */ } + +class StaffMiddleware extends Middleware // transient — outlives nothing +{ + #[Autowired] public AuthContext $context; + + public function before(HttpRequest $request, HttpResponse $response): void + { + $this->context->init(/* ... */); // ← written once per request + } +} + +class CategoryController extends Controller // transient +{ + #[Autowired] private AuthContext $context; // ← same instance, same request +} +``` --- @@ -184,7 +269,7 @@ services in a plugin land in the shared container automatically. See ## Source -- `src/BaseBoot.php` — `boot()` (Scanner + contextual logger), `providers()` hook +- `src/WinterApplication.php` — `bootstrap()` (Scanner + contextual logger) and the `#[Enable*]` manifest - `Flytachi\Winter\DI\Container` — `init()`, `register()`, `singleton()`, `bind()`, `set()`, `contextual()`, `make()` - `Flytachi\Winter\DI\Scanner` + `Collector\DICollector` — auto-discovery diff --git a/docs/configuration/08-runtime.md b/docs/configuration/08-runtime.md index ddd57c2..34ecb0d 100644 --- a/docs/configuration/08-runtime.md +++ b/docs/configuration/08-runtime.md @@ -1,36 +1,38 @@ -# Runtime — FPM & Swoole +# Runtime -The same application runs under two HTTP runtimes without touching a line of -controller or middleware code: +The kernel runs the same application code two ways, and the difference that reaches your +code is not the transport — it is **how long a process lives**. -| Runtime | Entry point | Process model | -|---------|-------------|---------------| -| PHP-FPM / Apache (CGI) | `Boot::web()` | One request per process, no shared state | -| Swoole HTTP server | `Boot::swoole()` | Long-lived workers, routes & state stay in memory | +| Runtime | Started by | Process model | +|---|---|---| +| Swoole HTTP server | `php call run` | long-lived workers; routes, singletons and connection pools stay in memory | +| Plain process | `php call `, `call process` / `call daemon`, the scheduler | one process per invocation, or a long-lived worker without a reactor | -You pick the runtime by which entry point your bootstrap file calls — the -kernel handles the rest. +FPM is not served by the kernel. That is a deliberate boundary: a document root, +`public/index.php` and per-request teardown belong to the FPM model, and they are moving +to a separate `winter-fpm` project. The adapters below still exist and are tested, so +that project has a foundation to build on. --- ## Why your code doesn't change -Every K2 internal — `Router`, `ParameterResolver`, middleware, controllers — -depends only on two interfaces, never on a concrete transport: +Every internal — `Router`, `ParameterResolver`, middleware, controllers — depends on two +interfaces, never on a concrete transport: ```php -namespace Flytachi\Winter\K2\Http\Contracts; +namespace Flytachi\Winter\Kernel\Http\Contracts; interface HttpRequest { /* getMethod(), getUri(), getHeader(), getRawBody(), … */ } interface HttpResponse { /* status(), header(), end(), sendfile() */ } ``` -Each runtime ships a thin adapter pair: +Each transport ships a thin adapter pair: -| Contract | FPM adapter | Swoole adapter | -|----------|-------------|----------------| -| `HttpRequest` | `FpmRequest` — reads `$_SERVER` / `$_GET` / `$_POST` / `php://input` | `SwooleRequest` — wraps `Swoole\Http\Request` | -| `HttpResponse` | `FpmResponse` — `http_response_code()` / `header()` / `echo` | `SwooleResponse` — thin proxy over `Swoole\Http\Response` | +| Contract | Swoole adapter | FPM adapter | +|---|---|---| +| `HttpRequest` | `SwooleRequest` — wraps `Swoole\Http\Request` | `FpmRequest` — reads `$_SERVER` / `$_GET` / `$_POST` / `php://input` | +| `HttpResponse` | `SwooleResponse` — proxy over `Swoole\Http\Response` | `FpmResponse` — `http_response_code()` / `header()` / `echo` | `Router::handle()` takes the interfaces: @@ -38,158 +40,408 @@ Each runtime ships a thin adapter pair: public function handle(HttpRequest $request, HttpResponse $response): void ``` -So the dispatch pipeline, route table, validation, and response serialization -are identical in both modes. Only the boundary objects differ. +So the dispatch pipeline, route table, validation and response serialization are +identical whichever transport sits on the boundary. Only the boundary objects differ. --- -## FPM — `Boot::web()` +## Serving -```php -// public/index.php -require __DIR__ . '/../bootstrap.php'; -Boot::web(); +```bash +php call run # bind and serve +php call run dev # the same, restarting on file changes +php call run --host=127.0.0.1 --port=9501 ``` -One request lives in one process and then dies — no state survives between -requests. The pipeline: +What happens on `run`: ``` -Boot::web() - ├── boot() ← configure + DI scan (once per request) - ├── Router::resolve(pathRoot) ← cached route table when DEBUG=false - ├── $router->static(pathPublic) ← serve existing public files directly - └── $router->handle(new FpmRequest(), new FpmResponse()) +Application::main($argv) + ├── boot ← Kernel::init, scan, DI, configurers + ├── Router::fromScan(Kernel::$pathRoot) + ├── Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL) + ├── new Swoole\Http\Server(host, port) + ├── $server->set($settings->toArray()) ← from .env + WebConfigurer + └── on('request', fn($req, $res) => $router->handle( + new SwooleRequest($req), new SwooleResponse($res))) ``` -**Route cache.** `Router::resolve()` avoids re-scanning on every request: +`SWOOLE_HOOK_ALL` makes PDO, cURL, file I/O and `sleep()` coroutine-aware, so blocking +calls yield instead of stalling the worker. Coroutine isolation keeps per-request state +separate even though the worker is shared. -| `DEBUG` | Behavior | -|---------|----------| -| `false` | Loads `storage/volatile/mapping.php` if present; otherwise scans **once** and writes the cache for subsequent requests (first boot after deploy). | -| `true` | Always rescans; cache is never read or written (dev mode). | +Server tuning is a `WebConfigurer` the scan finds, not a hook on the application class: -If the cache write fails, the request still serves — the kernel logs a -warning and runs uncached. +```php +final class WebConfig extends WebConfigurerAdapter +{ + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + $server->workers(4) + ->maxRequest(5000) + ->maxRequestGrace(500) + ->set('ssl_cert_file', '/etc/ssl/app.pem'); // any raw Swoole option + } +} +``` -**Static files.** `static(pathPublic)` short-circuits GET requests whose URI -maps to an existing file in `Kernel::$pathPublic`, skipping route dispatch. -Behind nginx this is usually a no-op because nginx serves the file first. +The `.env` shorthands seed the same settings before the configurer runs: ---- +| Variable | Method | Default | +|---|---|---| +| `SERVER_WORKERS` | `workers()` | 1 — see below | +| `SERVER_TASKS` | `taskWorkers()` | Swoole's | +| `SERVER_MAX_REQUEST` | `maxRequest()` | from the profile | +| `SERVER_MAX_REQUEST_GRACE` | `maxRequestGrace()` | from the profile | +| `SERVER_PROFILE` | `profile()` | `balance` | +| `SERVER_MAX_REQUEST_SIZE` | `maxRequestSize()` | `8388608` (8 MB) | +| `SERVER_MAX_CONCURRENCY` | `maxConcurrency()` | from the profile | +| `SERVER_IDLE_TIMEOUT` | `idleConnectionTimeout()` | off | +| `SERVER_MAX_CONNECTIONS` | `maxConnections()` | from the profile | +| `SERVER_MEMORY_LIMIT` | `memoryLimit()` | untouched — PHP's own 128M | +| `SERVER_MEMORY_TRIM` | `memoryTrimThreshold()` | from the profile | +| `SERVER_REQUEST_TIMEOUT` | `requestTimeout()` | `30` seconds | + +### Limits, and the profile behind them + +Most of what bounds a request — how many run at once, how many clients may be connected, +how large a request may be, when a worker is replaced — follows from a single profile: + +```php +$server->profile(Profile::Performance); +``` + +| Profile | One request may use | Suits | +|---|---:|---| +| `Stable` | 512 KB | reports, exports, a monolith with wide joins | +| **`Balance`** (default) | 128 KB | ordinary CRUD | +| `Performance` | 64 KB | a thin API, a proxy, an integration bridge | +| `Stress` | — | benchmarks only | -## Swoole — `Boot::swoole()` +The axis is the **shape of a request, not caution**: `Performance` gives a service with +small requests more concurrency, not less. Everything else is derived from measured +constants — 78 KB per in-flight request, 68 KB per open connection, 180 B leaked by a +request that arms a timer — against the heap left after the application's own baseline, +which is measured at startup rather than assumed. Worker replacement is the one derived +setting that does *not* vary with the profile: what it guards against is the same for all +of them, and what it costs — the requests the worker was serving — is too. + +Swoole **queues** what exceeds the concurrency cap rather than refusing it, so overload +becomes latency, not errors — measured, twenty concurrent 0.3-second requests against a +cap of 2 all succeeded, taking 3.3 seconds instead of 0.3. Time spent queueing counts +against the request deadline, since a request's coroutine is not created until the worker +lets it through. + +**None of it is a rate limit.** It has no idea who is calling, so it cannot give one +partner 50 requests a second and another 20, and it delays rather than rejects where a +quota has to answer `429`. + +Every setting, its measurement, and how to override it: +[`09-web-server.md`](09-web-server.md). + +### Memory per worker ```php -// server.php -require __DIR__ . '/bootstrap.php'; -Boot::swoole(); // defaults: 0.0.0.0:9501 -// or: Boot::swoole('0.0.0.0', 8080); +$server->workers(4)->memoryLimit('256M'); ``` -Requires `ext-swoole`. The route scan runs **once at startup** and stays in -memory for the whole server lifetime — every request reuses the same -`Router` instance: +```dotenv +SERVER_MEMORY_LIMIT=256M +``` + +Say nothing and **the framework does not touch the setting at all** — PHP's own value +stands (128M compiled in, unless a `php.ini` raises it). Configure it and the limit is +applied on worker start. + +`memoryLimit()` is a PHP ini, not a Swoole option, so it never reaches +`Swoole\Server::set()`. It lives on `ServerSettings` because it is one half of an +arithmetic whose other half — `worker_num` — is already there: + +> The box must hold **`worker_num × memory_limit`**, plus opcache's shared memory. + +Four workers at 256M want 1 GiB before a row of data is read. The framework says this +out loud at startup and **warns** when the product exceeds the container's memory +limit (read from cgroup). It warns rather than refuses: every worker peaking together +is a worst case, and over-committing memory is a legitimate choice. + +Two properties of this limit are easy to get wrong: + +- **It is per process, and a Swoole worker runs many requests at once out of one + heap.** So it bounds their *sum*, not any single request. Measured on a small API: + roughly 90 KB of heap per in-flight request — about 1 400 concurrent requests fit in + 128M. When the sum is reached the worker dies and takes every request it was holding + (measured: 1 193 coroutines discarded at once). +- **Raising it moves the threshold, it does not remove it.** At high enough concurrency + any ceiling is reached. What keeps a worker alive under a spike is bounding + concurrency, not the ceiling. + +**Do not set `-1`.** Unlimited does not remove the ceiling — it moves it into the +kernel. PHP never stops the process, so the OOM killer eventually sends `SIGKILL`: no +shutdown functions, no log entry, and the whole container when the server is PID 1. A +real limit at least fails through PHP, and the manager restarts that one worker. The +framework warns at startup when it finds `-1`. + +### Workers are replaced, and have to be + +A worker does not live forever: after a number of requests derived from the profile — +157 903 under `Balance` at 256M — it is replaced by a fresh one. This is a default, not a +precaution. + +**Swoole leaks 56 bytes every time a coroutine suspends and resumes.** Measured to the +byte across five runs of ten thousand, and it survives both `gc_collect_cycles()` and +`gc_mem_caches()`. Neither a coroutine on its own nor a timer on its own leaks — only the +pair. An HTTP request always suspends: on the database, on an upstream call, on writing +the response. Through the full pipeline it measures nearer 170 bytes a request. + +| Load | Leaked | A 256M worker lasts | +|---:|---:|---:| +| 500 req/s | 96 MB/h | 2.7 hours | +| 2 000 req/s | 385 MB/h | 42 minutes | +| 5 000 req/s | 961 MB/h | 16 minutes | + +So the choice is not whether to recycle but how often. 100 000 keeps the leak near 17 MB +— comfortably inside any sane limit — while being rare enough that the cost of +replacement disappears. Replacement is not free: the new worker starts with an empty +connection pool and has to fill it, about 30 ms, which spread over 100 000 requests is +0.0003 ms each. + +`SERVER_MAX_REQUEST_GRACE` (default `10000`) is what keeps workers from recycling +together. Swoole **adds** a random amount up to the grace, so the real limit is +`max_request + rand(0, grace)` — verified: at `max_request = 20, grace = 15` worker +instances served 30, 27, 34 and 26 requests, while at `grace = 0` every one served exactly +20. Without it, workers counting to the same number under even traffic go cold almost +together and the survivors take the load. + +> **What a replacement resets.** Everything living in that worker's memory: `#[Singleton]` +> instances, in-process caches, its share of the connection pool. This was always possible +> — a worker could die at any moment — but it now happens on a schedule. State that must +> outlive a request belongs in a database or a cache, not in a singleton's property. + +### Giving memory back + +PHP frees memory to **its own allocator**, not to the kernel. Whether the kernel ever +sees it again depends on how it was taken: one large block is mapped directly and +unmapped on release, but many small objects live in the allocator's chunks, and those +are kept for reuse. So a worker that once built a large result goes on holding that +memory for the rest of its life — measured on a live worker, `top` reading 114 MiB +against 6 MiB actually in use. + +On a host running several containers that is first-come-first-served: one spike and the +memory is spoken for. + +After each request the framework checks how much the allocator is holding idle and hands +it back when that passes a threshold — **32M by default**: +```php +$server->memoryLimit('256M')->memoryTrimThreshold('64M'); ``` -Boot::swoole(host, port) - ├── boot() ← configure + DI scan (once, at startup) - ├── Router::fromScan(pathRoot) ← scan once, keep routes in memory - ├── $router->static(pathPublic) - ├── Runtime::enableCoroutine(SWOOLE_HOOK_ALL) ← all blocking I/O made coroutine-friendly - ├── Runtime::boot(RuntimeMode::Swoole) - ├── new Swoole\Http\Server(host, port) - ├── $server->set(static::swooleConfig()) - ├── MemoryWatcher attached ← per-worker memory baseline reporting - └── on('request', fn($req, $res) => $router->handle( - new SwooleRequest($req), new SwooleResponse($res))) + +```dotenv +SERVER_MEMORY_TRIM=64M +``` + +`0` never hands anything back. + +Measured on 600 000 small objects: + +| | in use | taken from the OS | idle reserve | +|---|---:|---:|---:| +| request working | 282 MB | 284 MB | 1.7 MB | +| request finished | 10 MB | 268 MB | **258 MB** | +| after the release | 10 MB | 12 MB | 2 MB | + +The decision is made on that **reserve** — what the allocator took minus what is in use — +rather than on the process peak, for two reasons. A worker in the middle of a large +request has a high peak and almost no reserve, so sustained load never trips it; and the +reserve falls again once released, so one release is enough. `memory_get_peak_usage()` +never decreases, and would trigger on every request for the rest of the worker's life. + +Cost, measured: **80 ms** when there is 128 MB to give back, **5 µs** when there is +nothing — which is what an ordinary request pays — and about 10 % on the next large +allocation, which has to take fresh chunks. The release runs after the response is sent, +so no client waits for it. + +`gc_collect_cycles()` does none of this: measured, it collects nothing here and frees +nothing. What is being held are not cycles. + +`WorkerMemory::idleReserve()` returns the same number, if you want to see where a +worker's resident size went. + +### Request timeout + +A request that never finishes holds more than itself: its pooled database connection is +borrowed for the whole request, and the pool is shared by every request in the worker. +So requests have a deadline — **30 seconds by default**. + +```php +$server->requestTimeout(60); // globally +$server->requestTimeout(0); // no deadline at all ``` -`SWOOLE_HOOK_ALL` makes PDO, cURL, file, and `sleep()` coroutine-aware, so -blocking I/O yields instead of stalling the worker. Coroutine isolation keeps -per-request state separate even though workers are shared. +```dotenv +SERVER_REQUEST_TIMEOUT=60 +``` -**Server tuning** — override `swooleConfig()` in your `Boot` class; the array -is passed straight to `Swoole\Http\Server::set()`: +Individual routes override it with `#[Timeout]`, on the controller or on the method — +the method wins: ```php -protected static function swooleConfig(): array +use Flytachi\Winter\Kernel\Route\Annotation\Timeout; + +#[RequestMapping('reports')] +#[Timeout(120)] // everything here gets two minutes +class ReportController extends Controller { - return [ - 'worker_num' => swoole_cpu_num() * 2, - 'max_request' => 5000, - 'enable_coroutine' => true, - ]; + #[GetMapping('export')] + #[Timeout(600)] // …but the export gets ten + public function export(): ResponseEntity { ... } + + #[GetMapping('stream')] + #[Timeout(0)] // …and this one is never timed out + public function stream(): ResponseEntity { ... } } ``` -Return `[]` for Swoole's built-in defaults. A `max_request` ceiling is a -cheap safety net — workers recycle after N requests, bounding any slow leak. +The attribute is read once, during the scan, and stored in the compiled route table — +nothing is reflected per request. + +When the deadline passes the request's coroutine is cancelled: `finally` and `defer` run +(so transactions close and pooled connections go back), and the client receives +**504 Gateway Timeout**. + +**Swoole has no request timeout of its own.** Verified against the extension: none of its +67 server options concerns execution time, `max_request_execution_time` appears nowhere in +the binary, and setting it changes nothing — a handler sleeping three seconds under a +one-second "limit" still answers 200 after three, in both server modes. What it does +produce is `unsupported option` on every start. + +#### What it can and cannot interrupt + +| The handler | What happens at the deadline | +|---|---| +| waits on I/O and does not swallow errors | interrupted, `finally`/`defer` run, client gets 504 | +| waits on I/O but catches `Throwable` | cannot complete any further I/O; its result is discarded and the client still gets 504 | +| burns CPU without yielding | **not interrupted** — it runs to completion | -**MemoryWatcher** records each worker's memory baseline at `workerStart` and -reports per-request growth — useful for spotting leaks introduced by shared -state. +The last row is a property of a single-threaded event loop, not a gap in the +implementation: while a handler loops without touching I/O nothing else in the worker +runs, the watchdog included. Measured — a sweep scheduled for 0.10 s woke at 1.91 s, +behind the 1.8 s loop it was waiting on. + +PHP-FPM has the mirror-image limitation: its `max_execution_time` kills a runaway loop +but not a hung query. Neither model covers both. + +The second row exists because cancellation is **not sticky**: after application code +catches the `CanceledException` the coroutine is fully functional again — a following +`sleep(2)` really sleeps two seconds. So an overdue request is cancelled on every sweep, +and the framework answers 504 rather than sending a report built from queries that never +ran. + +### One worker by default + +Say nothing and the server runs **a single worker process** — the framework sets no +`worker_num`, and Swoole's own default in `SWOOLE_BASE` mode is one. Concurrency still +works: requests are coroutines inside that process, and a blocking call yields rather +than stalling the others. + +What one worker does not give you is more than one CPU core. Measured on a 12-core box, +a single worker saturates one core at roughly 2 400 req/s against a database and 8 500 +req/s without one. `->workers(n)` is what spreads the load; until then, extra +cores sit idle. Set `n` from the cores the **container** was given — `swoole_cpu_num()` +reports the host's, and answers 12 under `--cpus=1` on a 12-core machine. + +The setting also changes what a pool size means. `maximumPoolSize` is **per worker**, so +one worker makes it the whole server's connection budget, while `workers(12)` multiplies +it by twelve — and a database with `max_connections = 100` will refuse the difference. +Size it as `worker_num × maximumPoolSize` ≤ what the server allows. --- -## The one thing to watch: shared state +## Set `opcache.enable_cli=1` -This is the only behavioral difference that reaches your code. +A Swoole server runs under the **CLI SAPI**, and `opcache.enable_cli` is `0` by default. +Leave it off and the server gets no opcache at all — every worker keeps its own copy of +every compiled class instead of sharing one in opcache's shared memory. -Under **FPM**, every request starts from a clean process, so a `#[Singleton]` -effectively lives for one request. Under **Swoole**, a worker is long-lived — -a `#[Singleton]` is created once and **reused across many requests** in that -worker. That is a feature (no per-request rebuild cost), but it means: +Measured on a synthetic 400-class application (560 lines per class), loading all of them: -- Do **not** store per-request data (the current user, a request ID, a - fetched entity) on a singleton — it will bleed into the next request. -- Keep singletons stateless, or scope per-request data with `#[Request]` - (see the `flytachi/winter-di` docs and [`07-di.md`](07-di.md)). -- Avoid module-global mutable statics for request data — same reason. +| | memory held by the process | +|---|---:| +| `opcache.enable_cli=0` | 52.3 MiB | +| `opcache.enable_cli=1` | **7.7 MiB** | -Code written this way runs identically under both runtimes. Code that leans -on "the process dies after each request" works under FPM and breaks under -Swoole. +Seven times less, per worker, for one ini line. Time is roughly unchanged — opcache saves +the parse, not the class linking, which every process still does for itself. + +```ini +opcache.enable=1 +opcache.enable_cli=1 ; required: Swoole is a CLI process +opcache.validate_timestamps=0 ; production — no stat() per file per request +opcache.jit=0 ; Swoole registers opcode handlers; JIT is auto-disabled anyway +``` + +The shipped Docker template (`call docker`) already sets these; the values above matter +when you build your own image or run outside a container. --- -## Non-HTTP entry points +## The one thing to watch: shared state + +A Swoole worker is long-lived, so a `#[Singleton]` is created once and **reused across +many requests** in that worker. That is a feature — no per-request rebuild cost — but it +means: -The same `Boot` also drives the CLI and the thread executor — these are not -HTTP runtimes but share `boot()`: +- Do **not** store per-request data (the current user, a request id, a fetched entity) + on a singleton; it bleeds into the next request. +- Keep singletons stateless, or scope per-request data with `#[Request]` (see + [`07-di.md`](07-di.md)). +- Avoid module-global mutable statics for request data, for the same reason. -| Entry point | Used by | Purpose | -|-------------|---------|---------| -| `Boot::cli($argv)` | `call` binary | Console commands | -| `Boot::executor($argv)` | `wKernelExecutor` | Runs a `Runnable` in a forked child (threads/jobs) | +Code that leans on "the process dies after each request" is exactly the code that breaks +here. -See [`../console/00-overview.md`](../console/00-overview.md) and -[`../threads/00-overview.md`](../threads/00-overview.md). +### Timers keep a worker alive + +Anything that arms a `Swoole\Timer` inside a worker must release it when the worker +exits, or the reactor never drains and Swoole force-kills the worker on shutdown +(`Worker_reactor_try_to_exit(): worker exit timeout`). The kernel releases its own in the +`workerExit` handler; do the same for yours. --- -## Choosing a runtime +## Non-HTTP execution + +The same application boots for everything else, through the same entry: + +| Invocation | Purpose | +|---|---| +| `php call ` | console commands | +| `php call process\|daemon start [-d]` | managed workers and supervised fleets | +| `php call schedule start [-d]` | the scheduler | -| Prefer… | When | -|---------|------| -| FPM | Standard hosting, nginx/Apache in front, no persistent connections, simplest ops. | -| Swoole | High throughput / low latency, WebSockets, persistent pools, in-memory caches — and your code is stateless across requests. | +A detached start (`-d`) does not fork the current process: the launcher spawns a fresh +PHP process running `vendor/bin/wKernelRunner`, which boots the application again and +runs the staged payload. See [`../process/03-control.md`](../process/03-control.md). -Both read the same `.env`, the same `Boot` hooks, and the same route table. -Switching is changing which entry point the runtime file calls. +Without a reactor, coroutine-only machinery degrades on purpose — `Process::spawn()` +forks instead of starting coroutines, and the connection pool falls back to a single +self-maintaining connection. --- ## Source -- `src/BaseBoot.php` — `web()`, `swoole()`, `swooleConfig()` entry points +- `src/WinterApplication.php` — the boot, `serve()`, and the `workerStart` / `workerExit` handlers +- `src/App/Config/ServerSettings.php` — the Swoole options builder - `src/Http/Contracts/HttpRequest.php`, `HttpResponse.php` — the transport-agnostic contracts -- `src/Http/Adapter/FpmRequest.php`, `FpmResponse.php`, `SwooleRequest.php`, `SwooleResponse.php` -- `src/Route/Router.php` — `resolve()` (FPM, cached), `fromScan()` (Swoole, in-memory), `handle()` -- `src/Route/MemoryWatcher.php` — Swoole per-worker memory reporting +- `src/Http/Adapter/SwooleRequest.php`, `SwooleResponse.php`, `FpmRequest.php`, `FpmResponse.php` +- `src/Route/Router.php` — `resolve()` (cached), `fromScan()` (live), `handle()` +- `src/Route/DevWatcher.php` — the `run dev` file watcher ## See also -- [`01-kernel.md`](01-kernel.md) — the `Boot` class and entry points +- [`09-web-server.md`](09-web-server.md) — `WebConfig`, and every server setting in one reference +- [`01-kernel.md`](01-kernel.md) — paths, `.env`, and the boot order - [`07-di.md`](07-di.md) — singleton lifetime and the shared-state caveat - [`../architecture/01-routing.md`](../architecture/01-routing.md) — the dispatch pipeline behind `handle()` +- [`../process/00-overview.md`](../process/00-overview.md) — processes and daemons diff --git a/docs/configuration/09-web-server.md b/docs/configuration/09-web-server.md new file mode 100644 index 0000000..9f2d933 --- /dev/null +++ b/docs/configuration/09-web-server.md @@ -0,0 +1,522 @@ +# Web server configuration + +Everything the HTTP server is told — where it binds, how many workers it runs, what a +request is allowed to cost — is set in one place: a class implementing `WebConfigurer`. + +This page is the reference for that class and for every setting on it. For what the +runtime *is* — worker lifetime, shared state, why your code does not change between +transports — see [`08-runtime.md`](08-runtime.md). + +--- + +## The class + +```php +namespace Main\Config; + +use Flytachi\Winter\Kernel\App\ApplicationArguments; +use Flytachi\Winter\Kernel\App\Config\ServerSettings; +use Flytachi\Winter\Kernel\App\Config\WebConfigurerAdapter; + +final class WebConfig extends WebConfigurerAdapter +{ + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + $server->port($args->int('port', 8000)) + ->workers(4) + ->memoryLimit('256M'); + } +} +``` + +There is no registration step. The class is found by the boot scan, like every other +`#[Configuration]` or `HealthContributor` — adding configuration is adding a class. + +`WebConfigurerAdapter` supplies empty defaults for both methods of the contract, so you +override only the one you need. Implement `WebConfigurer` directly when you want both: + +| Method | Concern | When it runs | +|---|---|---| +| `configureServer()` | bind address and server tuning | in the master, **before workers fork** | +| `configureCors()` | the global CORS policy | per request — see [`03-cors.md`](03-cors.md) | + +`configureServer()` receives the parsed CLI arguments so *you* decide where the bind +address comes from — a `--port` flag, a flag of your own, `.env`, or a literal. The +handle arrives pre-seeded with the framework default (`--host` / `--port`, falling back +to `0.0.0.0:8000`), so leaving it untouched keeps that. + +Several configurers may exist; each is invoked in turn on the same object. The last +write wins, which is worth knowing if two of them touch the same setting. + +### Where a value comes from + +``` +framework defaults → .env (SERVER_*) → configureServer() → Swoole +``` + +Each stage overrides the one before it, so a value set in code cannot be overridden by +the environment. If you want a setting to stay operator-tunable, read it yourself: + +```php +$server->workers((int) env('APP_WORKERS', 1)); +``` + +Only environment variables that are actually set contribute anything — an unset one +leaves the framework default (or Swoole's) in place. + +--- + +## Profiles + +Five of the settings below are not numbers you have to choose. They follow from one +question — **how much memory does a single request use?** — and a profile is how you +answer it. + +```php +$server->profile(Profile::Performance); +``` + +| Profile | One request may use | Suits | +|---|---:|---| +| `Stable` | 512 KB | reports, exports, a monolith with wide joins | +| **`Balance`** (default) | 128 KB | ordinary CRUD | +| `Performance` | 64 KB | a thin API, a proxy, an integration bridge | +| `Stress` | — | benchmarks only: every cap and every periodic task off | + +**The axis is the shape of a request, not caution.** `Performance` is not "faster but +riskier" — it is for services whose requests are *small*, and it gives such a service +**more** concurrency than `Stable` would, not less. The question is measurable: + +```php +$before = memory_get_usage(); +// ... your handler ... +$after = memory_get_usage(); // under 64 KB → Performance; over 200 KB → Stable +``` + +### What a profile decides + +Everything else follows arithmetically, from measurements rather than preference: + +| Setting | Derivation | +|---|---| +| `maxConcurrency` | available heap ÷ (78 KB + the profile's number + 68 KB) | +| `maxConnections` | twice that — the working ones and one idle client apiece, capped by `ulimit -n` | +| `memoryTrimThreshold` | `memory_limit` ÷ 16 · 8 · 4 | +| `maxRequest` | `memory_limit` × 20% ÷ 180 B — **the same for every profile** | +| `maxRequestGrace` | a tenth of `maxRequest` | + +Worker replacement is the one setting that does **not** vary with the profile, and that is +deliberate. What it guards against is a fixed number of bytes per request, which knows +nothing about how large a request is; what it costs is the requests the worker was +serving, which it kills. Tying it to the profile made the most cautious one replace four +times as often as the least, and drop clients accordingly — measured on a live stand, +6 805 connections lost against 48. Making it uniform took a Stable run from 751 failures +to 95, and a Balance run from 15 to none. + +The leak it guards against is also **conditional**, which is worth knowing before raising +it further: 4.6 million ordinary requests grew the heap by nothing at all, and neither did +`defer`, a channel round-trip, or a pooled borrow that waits. Only a timer that fires — +`Coroutine::sleep()` and what builds on it — leaks, at about 180 bytes a request. + +The three constants are measured, not assumed: **78 KB** is what an in-flight request +holds before allocating anything of its own (600 requests suspended in a trivial handler +cost 78.2 KB each), **68 KB** is an idle connection (measured linearly at 401, 801 and +1 201), and **180 B** is what a request that arms a timer leaks. + +"Available heap" is the limit less what the application already holds at boot — and that +baseline is **measured**, not guessed, so an application with a hundred routes and three +connection pools gets a correspondingly smaller ceiling without being asked. + +At 256M, with a booted application carrying ~16M: + +| Profile | In flight | Connections | Trim at | Recycle at | +|---|---:|---:|---:|---:| +| Stable | 373 | 746 | 16M | 298 261 | +| Balance | 896 | 1 792 | 32M | 298 261 | +| Performance | 1 170 | 2 340 | 64M | 298 261 | + +Double the memory limit and every number roughly doubles. The profile stays the same +decision, so it does not have to be revisited when the container is resized. + +### Overriding + +A profile supplies **defaults**, resolved when each value is read. Anything set +explicitly wins, whatever order the calls are made in: + +```php +$server->profile(Profile::Performance) + ->maxConnections(10_000) // browsers hold connections open between requests + ->requestTimeout(120); // never part of a profile — see below +``` + +An operator can switch without touching code: + +```dotenv +SERVER_PROFILE=stress +``` + +### `Stress` + +Not "Performance, more so". Throughput plateaus long before the memory ceiling — measured +on a live application, 2 250 req/s at 500 concurrent, with more concurrency adding nothing +— so removing the caps does not raise it. What it removes is **periodic interference that +distorts a measurement**: handing memory back pauses for tens of milliseconds and shows in +p99, replacing a worker empties its connection pool mid-run, and the request watchdog runs +a timer of its own. + +A run under it can exhaust the worker's memory, and over a long one Swoole's per-request +leak accumulates with no replacement to clear it. Both are fine for a bounded benchmark +and for nothing else. The banner says so on startup. + +### What a profile does not decide + +`requestTimeout()`, `maxRequestSize()`, `staticPath()`, `workers()` and the bind address. +Those are properties of the application or the deployment, not of how memory is spent — a +report endpoint needs its ten minutes under every profile. + +`workers()` in particular is left alone because there is nothing to derive it from: +`swoole_cpu_num()` reports the **host's** cores, not the container's. Measured — under +`--cpus=1` it still answers 12 on a 12-core machine, so deriving from it would start twelve +workers on one core. + +--- + +## Reference + +Everything below is a method on `ServerSettings`. Defaults are what you get when nothing +is said. + +| Method | Controls | Default | Environment | +|---|---|---|---| +| `profile()` | the memory stance, and the five settings below it | `Balance` | `SERVER_PROFILE` | +| `host()` | bind address | `0.0.0.0` | `--host` | +| `port()` | bind port | `8000` | `--port` | +| `workers()` | worker processes | `1` | `SERVER_WORKERS` | +| `taskWorkers()` | task worker processes | none | `SERVER_TASKS` | +| `maxRequest()` | requests before a worker is replaced | from profile | `SERVER_MAX_REQUEST` | +| `maxRequestGrace()` | random spread on that number | from profile | `SERVER_MAX_REQUEST_GRACE` | +| `maxRequestSize()` | largest accepted request | `8 MB` | `SERVER_MAX_REQUEST_SIZE` | +| `maxConcurrency()` | requests in flight per worker | from profile | `SERVER_MAX_CONCURRENCY` | +| `requestTimeout()` | seconds a request may run | `30` | `SERVER_REQUEST_TIMEOUT` | +| `maxConnections()` | simultaneous TCP connections | from profile | `SERVER_MAX_CONNECTIONS` | +| `idleConnectionTimeout()` | seconds before a quiet connection is closed | off | `SERVER_IDLE_TIMEOUT` | +| `memoryLimit()` | PHP heap per worker | PHP's own (128M) | `SERVER_MEMORY_LIMIT` | +| `memoryTrimThreshold()` | idle memory before it is returned to the OS | from profile | `SERVER_MEMORY_TRIM` | +| `staticPath()` | directory served as static files | none | — | +| `set()` | any raw Swoole option | — | — | + +--- + +### Bind address + +```php +$server->host('127.0.0.1')->port(8080); +``` + +`host()` and `port()` decide where the server listens. Binding to `127.0.0.1` makes the +server unreachable from outside the machine, which is what you want when a reverse proxy +sits in front of it on the same host; `0.0.0.0` accepts from anywhere, which is what a +container needs. + +Read them back with `getHost()` and `getPort()` — the banner does. + +--- + +### Processes + +```php +$server->workers(4)->taskWorkers(2); +``` + +**`workers()`** — how many worker processes serve requests. One by default, and nothing +derives it, deliberately: a worker handles many requests at once through coroutines, so one worker is +already concurrent, and the first thing extra workers cost is memory (each has its own +heap, its own connection pool, its own singletons). Add them when a profile says the +worker is CPU-bound, not on principle. + +Scaling is close to linear when the bottleneck is in PHP — measured on an endpoint with +no database, 8 551 → 16 265 rps going from one worker to two. It is not linear when the +bottleneck is elsewhere: the same jump on a database-backed endpoint gave 2 049 → 2 331 +rps, because PostgreSQL was already using eleven of the machine's twelve cores. + +**`taskWorkers()`** — processes for Swoole's task queue. Unset by default. The kernel +does not use them; they are here for applications that call `$server->task()` directly. + +--- + +### Worker replacement + +```php +$server->maxRequest(100_000)->maxRequestGrace(10_000); +``` + +**`maxRequest()`** — requests a worker serves before it is replaced by a fresh one. Not +a precaution but a necessity: Swoole leaks **56 bytes every time a coroutine suspends and +resumes**, measured to the byte across five runs of ten thousand, and surviving both +`gc_collect_cycles()` and `gc_mem_caches()`. An HTTP request always suspends — on the +database, on an upstream call, on writing the response — so every request leaves at least +that behind, and through the full pipeline it measures nearer 170 bytes. At 2 000 req/s +that is roughly 385 MB an hour. + +100 000 keeps the leak near 17 MB while making replacement rare enough to be free: the +new worker starts with an empty connection pool and has to fill it, about 30 ms, which +spread over 100 000 requests is 0.0003 ms each. + +**`maxRequestGrace()`** — Swoole **adds** a random amount up to this to `max_request`, so +the real limit is `max_request + rand(0, grace)`. Verified: at `max_request = 20, +grace = 15` worker instances served 30, 27, 34 and 26 requests, while at `grace = 0` +every one served exactly 20. + +Without it, workers counting to the same number under even traffic recycle almost +together — several connection pools go cold at once and the survivors take the load. + +Replacement also closes that worker's connections, which is worth knowing: it is what +reclaims connections abandoned by clients that vanished without closing. + +--- + +### Request size + +```php +$server->maxRequestSize(32 * 1024 * 1024); +``` + +The largest request accepted, **headers included**. Swoole's own limit is 2 MB — measured, +2 000 KB passes and 2 048 KB comes back `413` — which is tight for anything accepting a +document or an image, and invisible when it bites: the client gets a bare status with +nothing explaining it. The default of 8 MB matches PHP's `post_max_size`. + +The limit covers the whole packet, not the body alone: at the default a body of +8 388 400 bytes passes and 8 388 608 does not, the difference being the request's own +headers. Leave room for them when sizing an upload endpoint. + +Raise it knowing the cost: the request sits in the worker's heap for its whole life, and +that heap is shared by every request in flight. It does **not** cost anything per +connection — measured, per-connection memory is identical at 2 MB, 8 MB and 32 MB. + +--- + +### Concurrency + +```php +$server->maxConcurrency(10_000); +``` + +How many requests one worker processes at the same time. This is the setting that +protects the worker: `memoryLimit()` decides when it dies, this decides whether it gets +there. + +Swoole **queues** what exceeds the cap rather than refusing it — measured, twenty +concurrent 0.3-second requests against a cap of 2 all succeeded, taking 3.3 seconds in +total instead of 0.3. Overload becomes latency, not errors. + +The default comes from the profile — see [Profiles](#profiles) above. +`getMaxConcurrency()` returns what will be applied. + +Whether raising it (or the memory limit behind it) buys anything depends on whether it was +what bound. `worker_concurrency` in `Swoole\Server::stats()` answers that: sitting at the +ceiling means requests are queueing, well below it means the bottleneck is elsewhere. + +Time spent queueing counts against the request deadline: a request's coroutine is not +created until the worker lets it through, so without that a client that waited three +seconds would then be granted a fresh thirty. + +**It is not a rate limit.** It does not know who is calling, so it cannot give one partner +50 requests a second and another 20, and it delays rather than rejects where a quota has +to answer `429`. + +--- + +### Request timeout + +```php +$server->requestTimeout(60); // globally +$server->requestTimeout(0); // no deadline at all +``` + +Seconds a request may run before the server stops waiting for it. Thirty by default, +rather than PHP-FPM's sixty, because under Swoole a stuck request holds more than itself: +a pooled connection is borrowed for the whole request, and the pool is shared by every +request in the worker. + +Swoole has no request timeout of its own — verified against the extension, and +`max_request_execution_time` is rejected as an unsupported option. The deadline is +enforced by the framework's own watchdog, which cancels the request's coroutine: `finally` +and `defer` run, so transactions close and pooled connections return, and the client +receives `504`. + +Individual routes override it with `#[Timeout]`: + +```php +#[RequestMapping('reports')] +#[Timeout(600)] // ten minutes for everything in this controller +final class ReportController extends Controller +{ + #[GetMapping('quick')] + #[Timeout(5)] // …except this one + public function quick(): array { /* … */ } +} +``` + +It interrupts a request that **waits**. A request burning CPU is not interrupted, because +the event loop is single-threaded — nothing else in the worker runs while it holds the +CPU, the watchdog included. + +--- + +### Connections + +```php +$server->maxConnections(2000)->idleConnectionTimeout(600); +``` + +**`maxConnections()`** — the largest number of simultaneous TCP connections, itself +clamped by the process's file-descriptor limit. Left at Swoole's 100 000. + +Connections are not requests, though it is easy to read them that way: a keep-alive +connection serves many requests one after another, and an idle one serves none. Measured +— `max_connection = 2` did not slow six concurrent requests at all, while +`worker_max_concurrency = 2` doubled their total time by queueing them. + +They are not free, however. A held keep-alive connection costs about **68 KB of PHP heap** +— measured linearly at 401, 801 and 1 201 connections — so it counts against +`memoryLimit()`, and a worker with the default 128M dies at roughly 1 900 held +connections. The memory returns when they close, and connections spread across workers +(two workers holding 1 200 between them used ~43 MB each rather than 84 MB on one). + +**`idleConnectionTimeout()`** — closes a connection that has gone quiet. Off by default, +and understand it before turning it on: Swoole measures the time since the client last +*sent* something, and a client waiting for a slow response sends nothing, so **a request +still being worked on looks exactly like an abandoned connection**. Measured — with the +timeout at 2 seconds a 5-second request was cut at 2.07 s and the client got no response; +at 8 seconds the same request returned normally at 5.00 s. Neither wrote anything to the +log. + +So it is a ceiling on request duration as much as on idleness, and it would silently +overrule `#[Timeout]`. Set it above the longest request the application permits. What it +buys is reclaiming connections from clients that vanished without closing — and worker +replacement already does that, which is why it is not on by default. + +(`heartbeat_check_interval` is not required for it to work, a common claim and verified +false here.) + +--- + +### Memory + +```php +$server->memoryLimit('256M')->memoryTrimThreshold('64M'); +``` + +**`memoryLimit()`** — the PHP heap ceiling for each worker. Untouched by default, so PHP's +own value stands (128M compiled in, unless a php.ini raises it). + +It is **per worker and shared by every coroutine in it** — a Swoole worker serves many +requests out of one heap, so this bounds their sum, not any single request. Raising it +moves the threshold; it does not remove it. What stops a worker dying is bounding +concurrency. + +At boot the framework says the arithmetic out loud — `worker_num × memoryLimit` plus +opcache's shared memory against the container's limit — and warns when the fleet will not +fit. It warns rather than refuses: every worker peaking at once is rare, and +over-committing is a legitimate choice. + +`-1` is accepted but warned about: without a limit PHP never stops, so a runaway request +grows until the kernel's OOM killer sends `SIGKILL` — no shutdown functions, no log line, +and the whole container when the server is PID 1. A real limit at least fails through PHP, +which the manager recovers from. + +**`memoryTrimThreshold()`** — how much idle memory a worker may hold before handing it +back to the operating system, checked after each request. `0` never hands anything back. + +PHP releases memory to its own allocator, not to the kernel: many small objects live in +chunks that are kept for reuse, so a worker that once built a large result goes on holding +that memory for the rest of its life. Measured — one request that built 600 000 objects +left 258 MB reserved and unused. + +The threshold is compared against the **reserve** (what the allocator took from the kernel +minus what is in use), so a busy worker is never trimmed and one trim is enough. It costs +about 80 ms when there is a lot to return and 5 µs when there is nothing, which is what an +ordinary request pays. + +--- + +### Static files + +```php +$server->staticPath('resources/static'); // resources/static/app.css → /app.css +``` + +Serves files from a directory using Swoole's own handler, which answers in C before PHP is +involved: it streams the file rather than reading it into the worker, honours `Range`, and +cannot be walked out of the directory with `..`. + +Opt-in — say nothing and no file is ever served, which is what an API-only service wants. +Relative paths resolve against the project root, and a missing directory throws +`ApplicationConfigException` rather than becoming silent 404s at runtime. + +Three consequences: + +- **The directory is the URL root.** Swoole appends the whole request path to it, so the + layout on disk mirrors the layout in URLs. Point it at a directory holding assets and + nothing else — everything under it becomes downloadable. +- **Static responses never reach PHP**, so middleware, CORS and request logging do not + apply to them. +- **One directory only.** `document_root` is a single value; a plugin's assets have to be + collected into the one root rather than mounted from a second. + +--- + +### Raw options + +```php +$server->set('ssl_cert_file', '/etc/ssl/app.pem') + ->set('static_handler_locations', ['/assets']); +``` + +`set()` writes any Swoole option directly, for the ones the framework does not wrap. It is +the escape hatch, not a fallback — a wrapped setting should be set through its method, so +the reasoning above stays attached to it. + +An option Swoole does not recognise is **not** an error that stops the server: it prints +`unsupported option` as a warning and carries on. Watch for that line when a setting from +a blog post appears to do nothing — `keepalive_timeout` and `request_timeout`, for +instance, do not exist in Swoole 6.2. + +--- + +## Reading the result + +| | | +|---|---| +| `getHost()`, `getPort()` | the bind address | +| `getProfile()` | the profile in force, configured or default | +| `getMaxConcurrency()` | the ceiling that will apply, derived or configured | +| `getMaxConnections()` | connections allowed at once | +| `getMaxRequest()`, `getMaxRequestGrace()` | when a worker is replaced; `0` = never | +| `getMemoryLimit()` | the configured limit, or `null` when the ini is left alone | +| `getRequestTimeout()` | the deadline in seconds; `0.0` when disabled | +| `getMemoryTrimThreshold()` | the threshold, as written | +| `toArray()` | the Swoole options only | + +`toArray()` is what reaches `Swoole\Http\Server::set()`. Three of the settings above are +deliberately absent from it — `memoryLimit`, `requestTimeout` and `memoryTrimThreshold` +are a PHP ini value and two framework mechanisms, and Swoole would answer each with +`unsupported option` on every start. + +--- + +## Source + +- `src/App/Config/WebConfigurer.php`, `WebConfigurerAdapter.php` — the contract +- `src/App/Config/ServerSettings.php` — every setting on this page +- `src/App/Config/Profile.php` — the four profiles and the arithmetic behind them +- `src/App/Config/WorkerMemory.php` — the boot check and the idle trim +- `src/Route/RequestWatchdog.php` — the request deadline +- `src/WinterApplication.php` — `serve()`, and the `workerStart` / `workerExit` handlers + +## See also + +- [`08-runtime.md`](08-runtime.md) — what the runtime is, and the caveats that come with a long-lived worker +- [`03-cors.md`](03-cors.md) — the other half of `WebConfigurer` +- [`01-kernel.md`](01-kernel.md) — paths, `.env`, and the boot order diff --git a/docs/console/00-overview.md b/docs/console/00-overview.md index 1487f6f..dc421e3 100644 --- a/docs/console/00-overview.md +++ b/docs/console/00-overview.md @@ -5,8 +5,8 @@ single binary — `call` — that dispatches to a small set of built-in commands (scaffolding, config, runtime, mapping, DB, threads…) and to any custom command the project or its plugins register. -The entry point on disk is `wKernelExecutor`, which boots the kernel and -hands `$argv` to `Boot::executor()`. In a typical project the user-facing +The entry point on disk is `call`, which requires `bootstrap.php` and hands `$argv` +to `Application::main()`; any verb other than `run` goes to the console. In a typical project the user-facing binary is `call` (a project script, alias, or symlink to the executor); the rest of this section uses `call` as the canonical invocation. @@ -28,12 +28,15 @@ three buckets: | `options` | `--key` or `--key=v`| `--port=8000`, `--mvc` | The first positional argument is the command name; if omitted, `Help` runs. -Two short aliases are wired in `console/Core.php`: +Short aliases are wired in `console/Core.php`: -| Alias | Resolves to | -|-------|-------------| -| `sc` | `Script` | -| `th` | `Thread` | +| Alias | Resolves to | +|--------|-------------| +| `sc` | `Script` | +| `th` | `Thread` | +| `proc` | `Process` | +| `dmn` | `Daemon` | +| `sch` | `Schedule` | A command name is mapped to `Flytachi\Winter\Console\Command\` via `ucwords()`, and `::script($parsed)` is called on it. @@ -176,12 +179,17 @@ it directly. See [11-complete.md](11-complete.md). | [03](03-cfg.md) | `cfg` | Manage configuration, `.env`, key, Docker, completion | | [04](04-run.md) | `run` | Start the HTTP server (Swoole / dev) | | [05](05-script.md) | `script` | Run custom `Cmd` / `CmdCustom` scripts (alias `sc`) | -| [06](06-db.md) | `db` | Database ping / migrate / SQL preview | +| [06](06-db.md) | `db` | Database ping / migrate / SQL preview / pool stats | | [07](07-mapping.md) | `mapping` | Build / clean / show route cache | | [08](08-storage.md) | `storage` | Initialize and clean `storage/` folders | -| [09](09-thread.md) | `thread` | Run `Dispatchable` tasks (alias `th`) | | [10](10-di.md) | `di` | Build / clean / show DI scanner cache | | [11](11-complete.md) | `complete`| Shell-completion endpoint (internal) | +| [12](12-schedule.md) | `schedule`| Run the scheduler; list `#[Scheduled]` tasks (alias `sch`) | + +The **`process`** and **`daemon`** commands (aliases `proc` / `dmn`) manage +long-lived worker processes and supervised fleets; they are documented with the +runtime itself in [`process/03-control.md`](../process/03-control.md) and +[`process/daemon/03-control.md`](../process/daemon/03-control.md). --- diff --git a/docs/console/04-run.md b/docs/console/04-run.md index 94d845d..5fbfbfe 100644 --- a/docs/console/04-run.md +++ b/docs/console/04-run.md @@ -38,9 +38,9 @@ a warning and exits. | `--max_request_grace=` | off | `max_request_grace` | | `-w` / `--watcher` | off | Enable `MemoryWatcher` to recycle workers on RSS pressure | -CLI options **override** the base config returned by -`Boot::swooleConfig()` (`Flytachi\Winter\K2\BaseBoot`). Anything not -overridden falls back to the Boot class's value or Swoole defaults. +CLI options are read by `ServerSettings::fromEnv()` together with the `SERVER_*` +environment variables; a discovered `WebConfigurer::configureServer()` then tunes the +result. Anything left alone falls back to Swoole's own defaults. ### Runtime behavior @@ -53,7 +53,8 @@ On start, `run`: 5. Swaps the log context to `CoroutineContext` so per-request fields (request_id, user_id, …) are isolated per coroutine. 6. Sets the `http` log channel as default. -7. Maps `Router::static(Kernel::$pathPublic)` for static assets. +7. Applies the Swoole options built from `.env`, CLI flags and the `WebConfigurer` + (static files among them, when `staticPath()` was declared). 8. Sets a descriptive `cli_set_process_title()` for `ps` visibility. 9. With `-w`: wraps the request handler in `MemoryWatcher` to track per-request memory growth. @@ -73,7 +74,7 @@ call run --host=127.0.0.1 --port=9501 ## `call run dev` — PHP built-in dev server A `passthru` wrapper around `php -S`. No Swoole, no workers, no -coroutines — just one process serving from `Kernel::$pathPublic`. +coroutines — just one process serving the declared static directory. ### Options @@ -121,5 +122,6 @@ Use this only for local development — there is no concurrency. - [`../architecture/01-routing.md`](../architecture/01-routing.md) — how routes are scanned - [07-mapping.md](07-mapping.md) — `Router` cache management -- [`../configuration/01-kernel.md`](../configuration/01-kernel.md) — `Boot::swooleConfig()` +- [`../configuration/09-web-server.md`](../configuration/09-web-server.md) — every server setting +- [`../configuration/08-runtime.md`](../configuration/08-runtime.md) — the runtime and its caveats - [`../configuration/02-logging.md`](../configuration/02-logging.md) — per-coroutine log context diff --git a/docs/console/06-db.md b/docs/console/06-db.md index 46c95a5..dd61903 100644 --- a/docs/console/06-db.md +++ b/docs/console/06-db.md @@ -20,6 +20,7 @@ call db [-flags] [--plugin= | --plugins] | `ping` | Connect to every configured DB, report driver/DSN/latency | | `migrate` | Run DDL against connected databases | | `sql` | Render DDL to stdout (no execution) | +| `pool` | Show connection-pool utilisation of the running server | `migrate` and `sql` are filtered by **kind flags** and **plugin scope**. @@ -115,6 +116,38 @@ with no `#[Table]` entities is also skipped. --- +## `call db pool` + +Shows how loaded the connection pools are, aggregated over the workers of a **running** +server: + +```bash +call db pool +``` + +``` +App\Config\AppDb + active 12 · idle 3 · total 15 · maximum 20 · workers 2 + saturated 1 of 2 workers [SATURATED] +per worker + worker#0 App\Config\AppDb active=2 idle=3 total=5 max=10 age=0s + worker#1 App\Config\AppDb active=10 idle=0 total=10 max=10 age=3s +``` + +A pool lives inside the server's memory and the CLI is a separate process, so this reads +what each worker publishes to the shared store — not the pool itself. Two consequences: + +- numbers are as fresh as the last publish (the `age` column), controlled by + `PPA_POOL_TELEMETRY` (seconds, default `5`, `0` disables); +- with no server running, the command says so instead of printing an empty table. + +**Saturation is counted per worker**, never derived from the totals: a borrow queues on +its own worker's pool, so one blocked worker matters even while the fleet looks roomy. + +See [`../ppa/17-pool.md`](../ppa/17-pool.md). + +--- + ## `call db sql` Same scanning + DDL generation as `migrate`, but writes statements to diff --git a/docs/console/07-mapping.md b/docs/console/07-mapping.md index 3ed1058..94b13fa 100644 --- a/docs/console/07-mapping.md +++ b/docs/console/07-mapping.md @@ -3,7 +3,7 @@ Manages the on-disk route cache. Routing is normally scanned from controllers at boot via `Router::fromScan(Kernel::$pathRoot)`; on every boot. For production you compile that scan into a single PHP file with -`mapping build`, and `BaseBoot` loads it with `Router::fromCache()` +`mapping build`, and the boot loads it with `Router::fromCache()` instead. --- @@ -45,8 +45,8 @@ call mapping build Run this in your image build / deploy step so production cold starts don't pay for the controller scan. -`Router::fromCache()` is what `BaseBoot::boot()` prefers when the cache -file exists; `fromScan()` is the live fallback. +`Router::fromCache()` is what the boot prefers when the cache file exists; +`fromScan()` is the live fallback. --- diff --git a/docs/console/09-thread.md b/docs/console/09-thread.md deleted file mode 100644 index bfdefb3..0000000 --- a/docs/console/09-thread.md +++ /dev/null @@ -1,253 +0,0 @@ -# `call thread` (`call th`) — run Dispatchable tasks & manage daemons - -Runs a `Dispatchable` class — a queue job, long-lived process, websocket -handler, or daemon — in the foreground (blocking) or detached as a -background process. Daemons get a full lifecycle: start, stop, status and -a live overview. - -Alias: **`th`**. - ---- - -## Synopsis - -``` -call thread list -call thread daemons -call thread [-d] -call thread [start | stop | status [-v]] [-d] -``` - -| Command | Purpose | -|---------------------------------|----------------------------------------------------------------| -| `list` | List every non-abstract `Dispatchable` class, tagged by kind | -| `daemons` | List daemons with live state, fork count and uptime | -| `` | Foreground — `Class::start()` (blocking) | -| ` -d` | Background — `Class::dispatch()` (returns child PID) | -| `` | Toggle: stop if running, else start in foreground | -| ` -d` | Toggle: stop if running, else start in background | -| ` start` | Start the daemon in background (`::dispatch()`) | -| ` stop` | Stop the running daemon (`::stop()`) | -| ` status` | Show daemon state, PID, uptime, fork count | -| ` status -v` | Detailed: process resources + child fork list | - -There is no `run` sub-command — the class is passed directly. The runner -requires the `pcntl` extension for async signal handling; without it, -running a thread exits with a warning. - -Class resolution is dot-notation: `ucfirst` each segment, `.` → `/` → `\` -(e.g. `main.threads.ExampleJob` → `Main\Threads\ExampleJob`). A missing -class or one that doesn't implement `Dispatchable` produces a clear -warning instead of a stack trace. - ---- - -## `call thread list` - -Lists every non-abstract class implementing `Dispatchable`, discovered -across the project and registered plugins. The badge tells you which kind -it is: - -```bash -call thread list -``` - -``` - | [============ Thread ============] - | [ Available Threads ] - | Main.TestDaemon ............... [Daemon] - | Main.S1Job ..................... [Job] - | Main.TestJob ................... [Job] - | Main.TestProcess ............... [Process] - | [ Available Threads ] -``` - -`Daemon` is highlighted in a distinct colour; `Job` / `Process` and any -other `Dispatchable` (`Dispatchable` badge) share the default colour. - ---- - -## `call thread daemons` - -A focused view of `ThreadDaemon` subclasses with their **live** state: - -```bash -call thread daemons -``` - -``` - | [ Available Daemons ] - | Main.TestDaemon ............... [● RUNNING] [forks:2] 2m 14s - | Main.Cleanup .................. [○ STOPPED] - | [ Available Daemons ] -``` - -For a running daemon the row shows the active fork count and uptime; -stopped daemons show just the state. - ---- - -## Running a thread (Job / Process) - -Resolves the class, confirms it implements `Dispatchable`, then: - -| Mode | Call | Behavior | -|-------------------|----------------------|---------------------------------------------------| -| Foreground | `$class::start()` | Blocks until the task finishes — output & signals on the current TTY | -| Background (`-d`) | `$class::dispatch()` | Forks a detached child; prints the new PID | - -```bash -call thread main.threads.ExampleJob # foreground -call thread main.threads.ExampleJob -d # detach, prints PID -``` - -``` - | [✓] Dispatched: Main\Threads\ExampleJob - | PID 48213 -``` - ---- - -## Managing a daemon - -When the resolved class is a `ThreadDaemon`, `thread` switches to -lifecycle mode. - -### Toggle (no sub-command) - -`call thread ` is a smart toggle: - -- **running** → stops it (prints the PID it stopped); -- **stopped** → starts it in the **foreground** (blocking), or in the - **background** with `-d`. - -```bash -call thread main.threads.Cleanup # stopped → foreground start; running → stop -call thread main.threads.Cleanup -d # stopped → background start; running → stop -``` - -`-d` is only read in this toggle (no-command) mode — start/stop/status -ignore it. - -### `start` / `stop` - -```bash -call thread main.threads.Cleanup start # always background dispatch -call thread main.threads.Cleanup stop -``` - -``` - | [✓] Started: Main\Threads\Cleanup - | PID 48213 -``` - -``` - | [✓] Stopped: Main\Threads\Cleanup - | PID 48213 -``` - -Starting an already-running daemon, or stopping one that isn't running, -prints a clear warning instead of an error. - -### `status` and `status -v` - -`status` is the lightweight view; add `-v` for resource stats and the -child-fork list. - -```bash -call thread main.threads.Cleanup status -call thread main.threads.Cleanup status -v -``` - -``` - | [ Daemon Status ] - | Main.Threads.Cleanup ............... [● RUNNING] - | - - - - - - - - - - - - - - - - - - - - - | PID 48213 - | Condition ACTIVE - | Started 2026-06-29 09:16:18 +00:00 - | Uptime 2m 14s - | Stream RPS 4 - | Forks 2 - | [ Daemon Status ] -``` - -With `-v`, two more sections are appended: - -``` - | - - - - - - - - - - - - - - - - - - - - - | [ Resources ] - | User www-data - | PPID 1 - | CPU 0.1 % - | Memory 0.4 % (24.5 MB) - | Elapsed 02:14 - | Command php .../wKernelExecutor ... - | - - - - - - - - - - - - - - - - - - - - - | [ Forks (2) ] - | #48261 ACTIVE 2m 10s cpu 0.1% rss 8.2 MB - | #48262 WAITING 2m 09s cpu 0.0% rss 7.8 MB - | [ Daemon Status ] -``` - -A stopped daemon prints `[○ STOPPED]` and a one-line note. - ---- - -## Foreground vs background - -| Use foreground when… | Use `-d` / background when… | -|-------------------------------------------------|---------------------------------------------| -| You want output in your terminal | You want to detach and walk away | -| It's a one-off job | Starting a long-running daemon / worker | -| You're inside a systemd/Docker entrypoint | You need the PID to track or signal later | - -For production daemons, prefer a real supervisor (systemd, supervisord, -Docker `restart: always`) pointing at the daemon in **foreground** — let -the supervisor own the process lifecycle. The `start`/`stop`/`-d` actions -are for ad-hoc management from a shell. - ---- - -## Examples - -```bash -call thread list -call thread daemons -call thread main.threads.ExampleJob -call thread main.threads.ExampleJob -d -call thread main.threads.Cleanup # toggle (foreground) -call thread main.threads.Cleanup -d # toggle (background) -call thread main.threads.Cleanup start -call thread main.threads.Cleanup status -call thread main.threads.Cleanup status -v -call thread main.threads.Cleanup stop -``` - ---- - -## Notes - -- `pcntl_async_signals(true)` is enabled before running a thread so Ctrl-C - and `kill` behave predictably in foreground mode. -- `list`, `daemons`, and the `Complete` suggestion engine share the same - unified class discovery (`ClassScanner` + collectors) — newly added - classes appear immediately after autoload regenerates. -- Tab-completion is context-aware: after a daemon class it suggests - `start` / `stop` / `status` / `-d`; after `status` it suggests `-v`; - after a Job/Process it suggests `-d`. - ---- - -## Source - -- `console/Command/Thread.php` -- Discovery: `src/Core/ClassScanner.php`, `src/Collector/{ImplementorCollector,SubclassCollector}.php` -- Contract: `src/Process/Core/Dispatchable.php` -- Stereotypes that implement it: `src/Stereotype/{Job,Daemon,Process,WebSocket}.php` -- Engine: `src/Process/Thread{Job,Daemon,Process}.php` - -## See also - -- [02-make.md](02-make.md) — scaffold a `Job` / `Daemon` / `Process` / `WebSocket` -- [05-script.md](05-script.md) — the analogous `Cmd`/`CmdCustom` runner diff --git a/docs/console/10-di.md b/docs/console/10-di.md index c3500ca..904e5c6 100644 --- a/docs/console/10-di.md +++ b/docs/console/10-di.md @@ -35,8 +35,8 @@ Forces a fresh scan: 1. Unlinks the existing cache file (and invalidates opcache for it). 2. Runs `Scanner::run(rootDir: Kernel::$pathRoot, cache: $cachePath)` - with `new DICollector(Container::init())` — the same call - `BaseBoot::boot()` makes during normal startup. + with `new DICollector(Container::init())` — the same call the application + boot (`WinterApplication::bootstrap()`) makes during normal startup. 3. Invalidates opcache again so subsequent loads see the new file. 4. Reports the discovered class count. diff --git a/docs/console/11-complete.md b/docs/console/11-complete.md index e61242f..1b0a243 100644 --- a/docs/console/11-complete.md +++ b/docs/console/11-complete.md @@ -161,4 +161,4 @@ Each prints the candidate list to stdout, one per line. - [03-cfg.md](03-cfg.md#cfg-completion--shell-tab-completion) — installing completion - [01-help.md](01-help.md) — `call help` discovery - [05-script.md](05-script.md) — `call sc list` (same discovery as `sc` completion) -- [09-thread.md](09-thread.md) — `call thread list` / `daemons` (same discovery as `thread` completion) +- [../process/03-control.md](../process/03-control.md) — `call process` / `call daemon`, which the completion discovers the same way diff --git a/docs/console/12-schedule.md b/docs/console/12-schedule.md new file mode 100644 index 0000000..1b4ae74 --- /dev/null +++ b/docs/console/12-schedule.md @@ -0,0 +1,124 @@ +# `call schedule` — run the scheduler and list `#[Scheduled]` tasks + +Drives the **Scheduler** — the single process that fires every method annotated +with `#[Scheduled]` on its trigger. Unlike `call process` / `call daemon` there is +no class to name: the scheduler is one fixed runtime, and its "schedule" is the set +of annotated methods it discovers across the project. + +Alias: **`sch`**. + +--- + +## Synopsis + +``` +call schedule list # list every #[Scheduled] task and its cadence (default) +call schedule start # run the scheduler in the foreground +call schedule start -d # run it detached in the background +call schedule status # run state + task count +call schedule status -v # also the master's resource usage +call schedule stop # graceful stop (SIGTERM) +``` + +Running `call schedule` with no action is the same as `call schedule list`. + +--- + +## Actions + +### `list` + +Runs the discovery scan (`ClassScanner` + `ScheduledCollector`) and prints every +task and its trigger. It is a **static scan** — nothing has to be running, and it +is the fastest way to confirm an annotation was picked up (or why it was rejected: +a misconfigured `#[Scheduled]` fails the scan with a `ScheduleConfigException`). + +``` + | [ Scheduled Tasks ] + TASK TRIGGER + App\ReportService::nightly cron 0 2 * * * + App\PollService::poll fixedRate 30s + | [i] 2 task(s) defined. +``` + +### `start` + +Launches the scheduler. Foreground by default (blocks the terminal; `Ctrl-C` +stops it). With `-d` it is dispatched **detached** into the background and the +command returns after briefly polling for the started PID. + +| Flag | Effect | +|------|--------| +| `-d` | Start detached in the background instead of the foreground | + +A second `start` while one is already running is refused — the scheduler holds a +per-class singleton lock (a crash-safe `flock`), so there is never more than one +per host. + +### `stop` + +Sends a graceful `SIGTERM` to the running scheduler. In-flight task runs drain, +then the process exits and removes its status record. Prints a warning if nothing +is running. + +### `status` + +Reports the scheduler's run state, PID, activity, uptime and the number of +discovered tasks. `-v` additionally attaches the master process's live resource +usage (CPU / memory via `ps`). + +| Flag | Effect | +|------|--------| +| `-v` | Attach the master's resource usage | + +``` + | [ Scheduler Status ] + PID 48213 + State RUNNING + Activity IDLE + Started 2026-07-26 02:00:00 +00:00 + Uptime 6h 12m + Tasks 2 +``` + +When nothing is running, `status` prints `○ STOPPED`. + +--- + +## Runtime notes + +- **One per host.** The singleton lock means `start` / `dispatch` refuse a second + instance; a task is never fired twice by two schedulers on the same box. +- **`list` needs no running process** — it boots the container and scans, exactly + as the scheduler does on start, so it surfaces configuration errors early. +- **Under FPM there is no long-lived scheduler.** Like `call daemon`, this is a + CLI-driven long-running process; in production run it under a supervisor + (systemd / supervisor / a Kubernetes deployment) with `start` in the foreground, + or `start -d` for a quick detached run. + +--- + +## Examples + +```bash +call schedule list # what got discovered? +call sch list # same, via the alias +call schedule start # run in the foreground (Ctrl-C to stop) +call schedule start -d # run detached; note the PID it prints +call schedule status -v # is it alive, and how much is it using? +call schedule stop # ask it to stop gracefully +``` + +--- + +## Source + +- `console/Command/Schedule.php` — the command +- `src/Schedule/Scheduler.php` — the runtime it starts +- `src/Schedule/ScheduledCollector.php` — the discovery `list` runs + +## See also + +- [`../schedule/00-overview.md`](../schedule/00-overview.md) — what the Scheduler is and how triggers work +- [`../schedule/01-usage.md`](../schedule/01-usage.md) — writing `#[Scheduled]` methods, recipes, production +- [`../process/03-control.md`](../process/03-control.md) — the `start` / `stop` / `status` model the scheduler inherits from Process diff --git a/docs/file/00-overview.md b/docs/file/00-overview.md index 036b63a..3cc2066 100644 --- a/docs/file/00-overview.md +++ b/docs/file/00-overview.md @@ -4,7 +4,7 @@ Three static helper classes for moving plain arrays in and out of the most common flat-file formats. No instances, no state — call the static methods directly. On any I/O or parse problem they throw `FileException`. -Namespace: `Flytachi\Winter\K2\File`. +Namespace: `Flytachi\Winter\Kernel\File`. | Class | Reads | Writes | Extra | |--------|---------------------|---------------------|-------| @@ -35,7 +35,7 @@ public static function write(string $path, array $data, ?array $head = null): vo associative rows keyed by that header: ```php -use Flytachi\Winter\K2\File\CSV; +use Flytachi\Winter\Kernel\File\CSV; // users.csv: // id,name @@ -81,7 +81,7 @@ public static function write(string $path, array $data): void ``` ```php -use Flytachi\Winter\K2\File\JSON; +use Flytachi\Winter\Kernel\File\JSON; $config = JSON::read('config.json'); // decoded as an associative array JSON::write('config.json', $config); // written with JSON_PRETTY_PRINT @@ -104,7 +104,7 @@ public static function isAvailable(): bool ``` ```php -use Flytachi\Winter\K2\File\XML; +use Flytachi\Winter\Kernel\File\XML; $tree = XML::read('feed.xml'); // element tree → nested array XML::write('feed.xml', $tree, 'feed'); // array → @@ -135,11 +135,11 @@ if (XML::isAvailable()) { ## Errors -Every failure path raises `Flytachi\Winter\K2\File\FileException`: +Every failure path raises `Flytachi\Winter\Kernel\File\FileException`: ```php -use Flytachi\Winter\K2\File\CSV; -use Flytachi\Winter\K2\File\FileException; +use Flytachi\Winter\Kernel\File\CSV; +use Flytachi\Winter\Kernel\File\FileException; try { $rows = CSV::read('missing.csv'); diff --git a/docs/operation/00-overview.md b/docs/operation/00-overview.md deleted file mode 100644 index 88060b5..0000000 --- a/docs/operation/00-overview.md +++ /dev/null @@ -1,190 +0,0 @@ -# Operation — async results via `Future` - -`Operation::async()` wraps any callable, runs it in a **child process** -(a `Thread`), and hands back a `Future`. From there you either **await** -the result or **drop** the handle for fire-and-forget execution. - -Namespace: `Flytachi\Winter\K2\Unit\Operation`. The result is marshaled -back to the parent through a volatile store (`Kernel::volatile('operations')`), -so this is real OS-level process isolation, not coroutines or threads-in-memory. - -```php -use Flytachi\Winter\K2\Unit\Operation\Operation; - -// Await the result -$value = Operation::async(fn() => heavyJob())->return(); - -// Fire-and-forget — don't keep the Future -Operation::async(fn() => sendWelcomeEmail($userId)); -``` - ---- - -## The three classes - -| Class | Role | -|------------|----------------------------------------------------------------------| -| `Operation`| Static entry point. `async(callable): Future` dispatches the task. | -| `Future` | Handle to the running task. Await with `return()` / `get()`. | -| `OpResult` | Immutable outcome — either a return value or a caught `Throwable`. | - -`OperationRunnable` is the internal `Runnable` that wraps your callable; -application code never touches it directly. - ---- - -## `Operation` - -```php -public static function async(callable $callback): Future -public static function store(): FileStorage -``` - -`$callback` is any PHP callable — closure, named function, or invokable -object. The child process is spawned immediately (inside `Future`'s -constructor), so the work starts the moment `async()` returns. - -`store()` exposes the shared `FileStorage` used to pass results between -parent and child. You rarely need it directly. - ---- - -## `Future` - -```php -public function return(bool $isThrow = true): mixed // raw value -public function get(): OpResult // full outcome -public function join(): void // wait, no result -``` - -| Method | Blocks? | Returns | Notes | -|-------------------|---------|------------------------|-------| -| `return()` | yes | the callback's value | Re-throws the child's exception when `$isThrow = true` (default). | -| `return(false)` | yes | the callback's value | Swallows the exception; returns `null` if the task threw. | -| `get()` | yes | `OpResult` | Inspect success/failure yourself via `getThrowable()`. | -| `join()` | yes | `void` | Waits for the process to finish without reading a result. | - -`get()` waits for the child, then reads the stored result with up to 3 -retries (1 ms apart). If nothing is found it throws -`Error('Operation result not found')`. - ---- - -## `OpResult` - -```php -public function getResult(): mixed // the returned value (null on failure) -public function getThrowable(): ?Throwable // the caught exception, or null -``` - -```php -$opResult = $future->get(); - -if ($opResult->getThrowable() !== null) { - log_error($opResult->getThrowable()->getMessage()); -} else { - $value = $opResult->getResult(); -} -``` - ---- - -## Await vs fire-and-forget - -The mode is decided by **whether you keep the `Future`**, not by a flag. - -```php -// AWAIT — block until the background task returns -$report = Operation::async(fn() => buildMonthlyReport($month))->return(); - -// FIRE-AND-FORGET — let the Future go out of scope -public function store(Request $request): Response -{ - Operation::async(fn() => sendReceipt($request->user)); - return new Response('queued'); // Future destructed here → result discarded -} -``` - -When a `Future` is destroyed without `get()` / `return()`, its destructor -deletes the "pending" marker in the store. The child sees the marker is -gone and **does not write its result back** — no orphaned data accumulates. - ---- - -## How the result crosses the process boundary - -``` -Operation::async($cb) - ↓ -new OperationRunnable($cb) ← id = "op_" + 16 random chars - ↓ -new Future(id, Thread) - ├── store->write(id, 'pending') ← parent marks the slot - └── Thread::start() ← fork - ↓ (inside the child) - $result = $cb() ← YOUR CODE, isolated process - ↓ - if store->read(id) === 'pending' - store->write(id, OpResult) ← only if parent still waiting - ↓ (back in the parent) -$future->get() → store->read(id) → store->del(id) -``` - ---- - -## Caveats — it's a separate process - -- The callback runs in a **forked child**. Mutations to objects, static - state, or in-memory caches inside the callback are **not** visible to - the parent — only the returned value comes back. -- The return value must survive storage in the volatile store. Return - plain serializable data; don't return live resources (open DB handles, - sockets, stream resources). -- Closures capture by value at fork time. A captured DB connection may not - be usable in the child — open what you need **inside** the callback. -- `return()` / `get()` are **blocking**. For genuinely parallel work, - dispatch several operations first, then await them: - -```php -$a = Operation::async(fn() => fetchA()); -$b = Operation::async(fn() => fetchB()); - -// both already running in parallel -[$ra, $rb] = [$a->return(), $b->return()]; -``` - ---- - -## Error handling - -If the callback throws, the exception is caught in the child and carried -back inside the `OpResult`: - -- `return()` (default) re-throws it in the parent — handle it with a - normal `try/catch` around the await. -- `return(false)` returns `null` instead of throwing. -- `get()` never throws the child's exception — inspect `getThrowable()`. - -```php -try { - $value = Operation::async(fn() => riskyJob())->return(); -} catch (\Throwable $e) { - // the exception thrown inside the child surfaces here -} -``` - ---- - -## Source - -- `src/Unit/Operation/Operation.php` — `async()`, `store()` -- `src/Unit/Operation/Future.php` — `return()`, `get()`, `join()`, destructor -- `src/Unit/Operation/OpResult.php` — `getResult()`, `getThrowable()` -- `src/Unit/Operation/OperationRunnable.php` — internal `Runnable` wrapper - -## See also - -- [`../threads/00-overview.md`](../threads/00-overview.md) — the `Thread` / - `Dispatch` model `Operation` is built on -- [`../threads/01-job.md`](../threads/01-job.md) — stereotype-based - background tasks when you want a named, DI-managed class instead of a closure diff --git a/docs/pagination/00-overview.md b/docs/pagination/00-overview.md index 2025b71..94b8cf6 100644 --- a/docs/pagination/00-overview.md +++ b/docs/pagination/00-overview.md @@ -64,7 +64,7 @@ Wrapper (final, static) — page-centric, for numbere ## Quick start ```php -use Flytachi\Winter\K2\Unit\Pagination\Paginator; +use Flytachi\Winter\Kernel\Unit\Pagination\Paginator; // 1. Repository-backed offset pagination (most common) $result = Paginator::repo( diff --git a/docs/pagination/04-result-types.md b/docs/pagination/04-result-types.md index f98cf1b..111daae 100644 --- a/docs/pagination/04-result-types.md +++ b/docs/pagination/04-result-types.md @@ -150,7 +150,7 @@ See [03-cursor.md](03-cursor.md). ## Example — full round trip ```php -use Flytachi\Winter\K2\Unit\Pagination\Paginator; +use Flytachi\Winter\Kernel\Unit\Pagination\Paginator; // Controller public function index(Request $req): JsonResponse @@ -205,7 +205,7 @@ Generic over ``: */ ``` -Returned by {@see \Flytachi\Winter\K2\Unit\Wrapper::paginator()}. Pairs with +Returned by {@see \Flytachi\Winter\Kernel\Unit\Wrapper::paginator()}. Pairs with {@see WrapMeta}. See [05-wrapper.md](05-wrapper.md) for the full Wrapper contract. diff --git a/docs/ppa/00-overview.md b/docs/ppa/00-overview.md index be49e68..a4c9c0e 100644 --- a/docs/ppa/00-overview.md +++ b/docs/ppa/00-overview.md @@ -16,7 +16,7 @@ RepositoryCore (abstract) ├── implements RepositoryMappingInterface — originTable, mapIdentifierColumnName └── extends Stereotype (PSR-3 logger) - ├── + RepositoryCrudTrait → insert, insertGroup, update, delete, upsert, upsertGroup + ├── + RepositoryCrudTrait → insert, insertBatch, update, delete, upsert, upsertBatch └── + RepositoryViewTrait → find, findAll, findColumn, count, exists, rawFetch findById, findBy, findAllBy, *OrThrow @@ -109,7 +109,7 @@ $repo->update(['status' => 'inactive'], Qb::lt('last_login', '2024-01-01')); |---|------|----------| | 12 | [12-view-fetch.md](12-view-fetch.md) | `find`, `findAll`, `findColumn`, `count`, `exists`, `rawFetch` | | 13 | [13-static-finders.md](13-static-finders.md) | `findById`, `findBy`, `findAllBy`, `findByIdOrThrow`, `findByOrThrow` | -| 14 | [14-crud.md](14-crud.md) | `insert`, `insertGroup`, `update`, `delete`, `upsert`, `upsertGroup` | +| 14 | [14-crud.md](14-crud.md) | `insert`, `insertBatch`, `update`, `delete`, `upsert`, `upsertBatch` | ### Utilities diff --git a/docs/ppa/01-stereotypes.md b/docs/ppa/01-stereotypes.md index 1ef644e..d74aff8 100644 --- a/docs/ppa/01-stereotypes.md +++ b/docs/ppa/01-stereotypes.md @@ -19,7 +19,7 @@ Each one wires together `RepositoryCore` with the appropriate traits. ## Repository — Full Access ```php -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; class UserRepository extends Repository { @@ -56,7 +56,7 @@ $repo->update(['status' => 'inactive'], Qb::lt('last_login', '2024-01-01')); ## RepositoryView — Read-Only ```php -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; class ReportRepository extends RepositoryView { @@ -73,7 +73,7 @@ Ideal for database views or projections where writes are not allowed. ## RepositoryCrud — Write-Only ```php -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryCrud; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryCrud; class EventLogRepository extends RepositoryCrud { @@ -95,7 +95,7 @@ not require subclassing — use it for one-off queries that don't belong to a dedicated repository. ```php -use Flytachi\Winter\K2\Ppa\Stereotype\CteRepo; +use Flytachi\Winter\Kernel\Ppa\Stereotype\CteRepo; // Ad-hoc query against any table: $repo = new CteRepo(DbConfig::class); diff --git a/docs/ppa/02-configuration.md b/docs/ppa/02-configuration.md index 3de1cc3..c78c947 100644 --- a/docs/ppa/02-configuration.md +++ b/docs/ppa/02-configuration.md @@ -111,7 +111,7 @@ property. Return a `[propertyName => 'sql_expression']` array from `selection()` Properties absent from the map are selected by plain name (with alias prefix if set). ```php -use Flytachi\Winter\K2\Ppa\Entity\EntityInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityInterface; class UserEntity implements EntityInterface { @@ -190,8 +190,8 @@ every repository pointing at it) is **silently skipped** by `db sql` and `db migrate` — even if entities have `#[Table]`. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; #[Migratable] // priority: Normal final class AppDbConfig extends PgDbConfig { /* … */ } @@ -206,7 +206,7 @@ final class AuthDbConfig extends PgDbConfig { /* … */ } ignored on mysql/mariadb at the SQL-generation step. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; #[Migratable] #[Extension('uuid-ossp')] diff --git a/docs/ppa/13-static-finders.md b/docs/ppa/13-static-finders.md index 90ee028..cddadd4 100644 --- a/docs/ppa/13-static-finders.md +++ b/docs/ppa/13-static-finders.md @@ -143,11 +143,11 @@ $user = UserRepository::findByOrThrow( ## EntityException `findByIdOrThrow()` and `findByOrThrow()` throw -`Flytachi\Winter\K2\Ppa\Entity\EntityException` on miss. +`Flytachi\Winter\Kernel\Ppa\Entity\EntityException` on miss. It extends the Winter framework exception and is logged at `WARNING` level. ```php -use Flytachi\Winter\K2\Ppa\Entity\EntityException; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityException; try { $user = UserRepository::findByIdOrThrow($id); diff --git a/docs/ppa/14-crud.md b/docs/ppa/14-crud.md index dc06d52..e36c9bd 100644 --- a/docs/ppa/14-crud.md +++ b/docs/ppa/14-crud.md @@ -56,28 +56,74 @@ $id = $repo->insert($user); --- -## insertGroup() +## insertBatch() ```php -public function insertGroup(array|object ...$entities): void +public function insertBatch(iterable|object ...$entities): void ``` -Batch-inserts multiple records efficiently. Rows are chunked to avoid -exceeding database placeholder limits. +Inserts many records, sent to the database in batches so no single statement +exceeds the driver's placeholder limit. ```php $repo = new UserRepository(); -$repo->insertGroup( +$repo->insertBatch( ['name' => 'Alice', 'email' => 'a@example.com', 'status' => 'active'], ['name' => 'Bob', 'email' => 'b@example.com', 'status' => 'trial'], ['name' => 'Carol', 'email' => 'c@example.com', 'status' => 'active'], ); // Or spread an array: -$repo->insertGroup(...$usersArray); +$repo->insertBatch(...$usersArray); ``` +### One rule: an array is a row, a stream is many + +Every argument is read the same way — **an array is one row, anything traversable +is a stream of rows** — so the forms combine freely: + +```php +$repo->insertBatch($user1, $user2); // entities +$repo->insertBatch(['name' => 'John']); // an array is one row +$repo->insertBatch(...$entities); // an unpacked array +$repo->insertBatch($generator); // a stream +$repo->insertBatch($fromCsv, $extraRow); // mixed, one call +``` + +Nothing is ambiguous: an array is a valid entity here, and a `Traversable` never is. + +### Streaming: why the shape matters + +Rows reach the driver lazily and each batch is flushed as it fills, so **peak +memory follows the batch size, not the size of the job**: + +```php +// 500 000 rows, ~4 MiB peak — the collection never exists at once +$repo->insertBatch((function () { + for ($i = 1; $i <= 500_000; $i++) { + $row = new Bench(); + $row->field = "Record #{$i}"; + $row->created_at = TimeTool::now()->format('Y-m-d H:i:s'); + yield $row; + } +})()); +``` + +Building the collection first costs whatever that collection costs. Measured on +500 000 entities: **440 MiB** as an array against **4 MiB** streamed — and most of +that 440 was not the entities but their conversion to rows, which used to happen +for all of them before the first statement was sent. + +That measurement is also how a worker dies: PHP's default `memory_limit` is 128 MiB, +per worker, and the array form crosses it around 150 000 rows. + +### Failure + +Batches are sent as they fill, so a failure part-way leaves the earlier batches +committed. Wrap the call in a transaction when the whole job must be +all-or-nothing. + --- ## update() @@ -174,22 +220,24 @@ $repo->upsert( --- -## upsertGroup() +## upsertBatch() ```php -public function upsertGroup( - array $entities, - array $conflictColumns, - ?array $updateColumns = null +public function upsertBatch( + iterable $entities, + array $conflictColumns, + ?array $updateColumns = null ): void ``` -Batch version of `upsert()`. Rows are chunked to avoid database limits. +Batch version of `upsert()`. `$entities` is any `iterable` — an array, a generator, +any `Traversable` — and a generator keeps peak memory at one batch whatever the +total, exactly as in [`insertBatch()`](#insertbatch). ```php $repo = new ProductRepository(); -$repo->upsertGroup( +$repo->upsertBatch( $stockItems, ['sku'], [ @@ -200,6 +248,17 @@ $repo->upsertGroup( ); ``` +`$updateColumns` maps **column => expression**, not a list of column names: + +```php +['price' => ':new'] // ✅ replace price with the incoming value +['price', 'stock'] // ❌ RepositoryException — this is Laravel's shape +[] or null // ✅ ignore conflicts (DO NOTHING / INSERT IGNORE) +``` + +The list form is refused with a message naming the column and showing the corrected +call, rather than reaching the database and coming back as `no such column: 0`. + --- ## Exception handling diff --git a/docs/ppa/16-advanced-examples.md b/docs/ppa/16-advanced-examples.md index c8db48c..1ac82c2 100644 --- a/docs/ppa/16-advanced-examples.md +++ b/docs/ppa/16-advanced-examples.md @@ -204,7 +204,7 @@ try { // Sync stock levels — accumulate quantity, always overwrite cost. $repo = new ProductRepository(); -$repo->upsertGroup( +$repo->upsertBatch( $incomingStock, // array of ['sku', 'quantity', 'cost'] ['sku'], [ @@ -220,7 +220,7 @@ $repo->upsertGroup( ## 9. Ad-hoc query with CteRepo ```php -use Flytachi\Winter\K2\Ppa\Stereotype\CteRepo; +use Flytachi\Winter\Kernel\Ppa\Stereotype\CteRepo; // Raw aggregation across multiple tables — no dedicated repository needed. $stats = (new CteRepo(AppDbConfig::class)) diff --git a/docs/ppa/17-pool.md b/docs/ppa/17-pool.md index 0a1a179..dfe5dea 100644 --- a/docs/ppa/17-pool.md +++ b/docs/ppa/17-pool.md @@ -1,37 +1,56 @@ # Pool — Connection Pool -`PpaConnectionPool` is the unified connection manager for both **FPM** and **Swoole** runtimes. -It replaces CDO's `ConnectionPool` with a driver-agnostic, pool-aware alternative. +`PpaConnectionPool` is the connection manager for both runtimes. Under Swoole it is a +pool of live connections shared by coroutines; on a plain process it is a single +connection kept healthy for the life of the process. + +The reason it exists is not reuse but **resilience**. Under FPM a database outage healed +itself for free: the process died and the next one reconnected. A long-lived worker keeps +its connections in memory, and a plain channel-based pool hands the same dead sockets out +forever once the database has gone away and come back. This pool is modelled on HikariCP +and actively keeps its connections usable. --- ## How it works | Runtime | Behaviour | -|---------|-----------| -| **FPM** | One `CDO` per config class per process. Reused for the entire request. | -| **Swoole** | One `Swoole\ConnectionPool` (backed by `Swoole\Coroutine\Channel`) per config class. Connections are borrowed on first `db()` call inside a coroutine and automatically returned via `defer` when the coroutine ends. | +|---|---| +| **Swoole** | One pool per config class over a `Coroutine\Channel`. A connection is borrowed on the first `db()` call inside a coroutine, cached for that coroutine, and returned automatically by a `defer` when it ends. | +| **Plain process** (console, `call process`, FPM) | One self-maintaining connection per config class, kept for the life of the process. | + +Both apply the same lifecycle rules on every borrow: + +- **Idle-gated validation.** A connection idle longer than `aliveBypassWindow` (500 ms) + is probed before it is handed out; a dead one is retired and replaced. A connection in + active use skips the probe entirely, so healthy traffic pays nothing. +- **maxLifetime rotation.** A connection older than `maxLifetime` (30 min, jittered) is + replaced before the server can drop it. +- **connectionTimeout.** A borrow waits at most `poolWaitTimeout` for a free connection, + then fails fast with `PpaPoolException`. -Broken connections in Swoole mode: pass `null` to `Swoole\ConnectionPool::put()` — the pool discards and recreates the slot automatically. +> A `SELECT 1` on *every* borrow is deliberately **not** what happens — that costs a +> round trip per query and churns healthy connections. Only connections that actually sat +> idle are probed. --- ## Making a config pool-aware -By default every config class gets **1 connection** (safe, consistent with FPM). -To increase the pool size implement `PpaPoolConfigInterface` via `PpaPoolTrait`: +By default a config gets a small pool with the lifecycle rules above. To tune it, +implement `PpaPoolConfigInterface` via `PpaPoolTrait`: ```php use Flytachi\Winter\Cdo\Config\PgDbConfig; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolConfigInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolTrait; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolConfigInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaPoolTrait; class AppDb extends PgDbConfig implements PpaPoolConfigInterface { use PpaPoolTrait; - public int $poolMaxConnections = 10; // max simultaneous CDO connections - public float $poolWaitTimeout = 5.0; // seconds to wait before PpaPoolException + public int $poolMaxConnections = 10; // upper bound + public float $poolWaitTimeout = 5.0; // seconds before PpaPoolException public function setUp(): void { @@ -44,69 +63,141 @@ class AppDb extends PgDbConfig implements PpaPoolConfigInterface } ``` +The trait supplies defaults for every knob, so a config declares only what it changes and +never breaks when a new one is added. + +| Property | Default | Meaning | +|---|---|---| +| `$poolMaxConnections` | `5` | upper bound on connections per config | +| `$poolWaitTimeout` | `3.0` | seconds to wait for a free connection | +| `$keepaliveTime` | `0` (off) | background probe of connections idle at least this long | +| `$idleTimeout` | `0` (never) | close connections idle at least this long, down to `$minimumIdle` | +| `$minimumIdle` | `0` (lazy) | warm connections to keep open | + +The last three drive a background housekeeper and are **Swoole-only** — a plain process +has no timer to run them on. Leave them at zero and no timer is ever armed. + +Sizing matters against the server: `worker_num × poolMaxConnections × instances` must +stay under the database's `max_connections`. + --- -## API +## Failure handling -### `PpaConnectionPool::db(string $configClass): CDO` +A query can fail for two very different reasons, and the pool separates them: + +- **The connection died** — SQLSTATE class `08`, PostgreSQL `57P01/02/03`, MySQL driver + codes 2006/2013/2055. The connection is evicted instead of returned, so the next + borrow — including the next query in the same request — gets a fresh one. +- **The query was rejected** — a constraint violation (`23xxx`), a syntax error + (`42xxx`), a deadlock. The server is healthy; the connection is left alone. + +PostgreSQL needs care here: PDO does not report a lost connection as `08006`. With the +socket gone there is no result to take a SQLSTATE from, so it arrives as `HY000` with +libpq's generic code `7` — the same code an ordinary syntax error carries. When the +driver's verdict is that inconclusive, the pool probes the connection and decides from +the answer. + +**The failed statement is never retried.** The pool cannot know what ran: the break may +have happened after the server applied the write, so a replay could duplicate it, and +replaying one statement of an interrupted transaction is meaningless. One request fails; +the connection is thrown away. -Returns an active `CDO` for the given config class. -- FPM: process-level singleton. -- Swoole: borrows from the pool on first call per coroutine, auto-releases on coroutine end. +Repositories report failures automatically. Code that uses `db()` directly can do the +same: ```php -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +try { + $cdo->query($sql); +} catch (Throwable $e) { + PpaConnectionPool::reportFailure(AppDb::class, $e); // evicts only on a real loss + throw $e; +} +``` + +--- + +## Observability -$cdo = PpaConnectionPool::db(AppDb::class); -$rows = $cdo->query("SELECT * FROM users")->fetchAll(); +Each worker holds its own pool, so numbers are **per worker** — a saturated worker is a +real stall even when the fleet total looks roomy. + +```bash +php call db pool +``` + +reads what the workers publish and prints the fleet: + +``` +App\Config\AppDb + active 12 · idle 3 · total 15 · maximum 20 · workers 2 + saturated 1 of 2 workers [SATURATED] +per worker + worker#0 App\Config\AppDb active=2 idle=3 total=5 max=10 age=0s + worker#1 App\Config\AppDb active=10 idle=0 total=10 max=10 age=3s ``` +The CLI is a separate process and cannot read a running server's memory, so each worker +publishes its stats to the shared store on a timer — `PPA_POOL_TELEMETRY` (seconds, +default `5`, `0` disables). Records carry a TTL of three intervals, so a worker that +stops simply expires. + +The publisher starts on the worker's **first pool**, not at worker start. An application +with no datasource runs no timer, writes no record and creates no store directory — it +pays nothing for a subsystem it never uses. + +The same numbers appear in the `db` component of `/actuator/health`, nested under the +datasource they belong to, where a saturated pool marks it `degraded`. + +--- + +## API + +### `PpaConnectionPool::db(string $configClass): CDO` + +Returns a live connection. Inside a coroutine it borrows one and registers the automatic +return; elsewhere it hands back the process-wide connection. Throws `PpaPoolException` +when no connection can be obtained in time. + ### `PpaConnectionPool::getConfigDb(string $configClass): DbConfigInterface` -Returns the initialised (after `setUp()`) config instance. -Cached after first access — `setUp()` is called only once per config class. +The initialised config instance (`setUp()` already called), cached per class. ### `PpaConnectionPool::showDbConfigs(): DbConfigInterface[]` -Returns all registered config instances. Used internally by Health checks. +Every registered config — for diagnostics. ---- +### `PpaConnectionPool::stats(): array` -## `PpaPoolConfigInterface` +Live utilisation of each pool in **this** process: `total`, `idle`, `active`, `maximum` +keyed by config class. -```php -interface PpaPoolConfigInterface -{ - public function getPoolMaxConnections(): int; // default via trait: 5 - public function getPoolWaitTimeout(): float; // default via trait: 3.0 -} -``` +### `PpaConnectionPool::reportFailure(string $configClass, Throwable $e): bool` + +Classifies a failure and evicts the connection when it is genuinely lost. Returns whether +it evicted. -### `PpaPoolTrait` +### `PpaConnectionPool::reset()` / `::shutdown()` -Drop-in implementation of `PpaPoolConfigInterface`. -Does **not** declare properties — define `$poolMaxConnections` and `$poolWaitTimeout` -directly on your class to override the defaults. +Two opposite things, and the difference matters: -| Property | Type | Default | -|----------|------|---------| -| `$poolMaxConnections` | `int` | `5` | -| `$poolWaitTimeout` | `float` | `3.0` | +- **`reset()`** — for a **forked child**: forget inherited connections **without closing + them**, since the sockets still belong to the parent. Registered as the fork-safety + reset, so a daemon worker reconnects on its own. +- **`shutdown()`** — for a process that genuinely owns its connections: close them + properly and release the housekeeping timers. The kernel calls it on worker exit. --- ## Exceptions -`PpaPoolException` is thrown when: -- Pool is exhausted and `$poolWaitTimeout` is exceeded. -- Connection factory throws during slot creation. +`PpaPoolException` — no connection could be obtained: the pool was exhausted within +`poolWaitTimeout`, or opening one failed. -```php -use Flytachi\Winter\K2\Ppa\Pool\PpaPoolException; +--- -try { - $cdo = PpaConnectionPool::db(AppDb::class); -} catch (PpaPoolException $e) { - // log and return 503 -} -``` +## See also + +- [`02-configuration.md`](02-configuration.md) — declaring a database config +- [`../configuration/04-health.md`](../configuration/04-health.md) — the actuator report +- [`../console/06-db.md`](../console/06-db.md) — `call db` diff --git a/docs/ppa/18-migration.md b/docs/ppa/18-migration.md index 84a729d..120deaa 100644 --- a/docs/ppa/18-migration.md +++ b/docs/ppa/18-migration.md @@ -61,7 +61,7 @@ migration scan silently. under `Kernel::$pathRoot` (or under a registered plugin's root). The scanner only walks paths it's been pointed at. 2. **The entity class must carry `#[Table]`** — the marker attribute - from `Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table`. Without + from `Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table`. Without it `PPAMapping::scanDeclarationFilter()` short-circuits and the repo is ignored. 3. **The DbConfig class must carry `#[Migratable]`** — without it @@ -69,9 +69,9 @@ migration scan silently. filters the item out before any SQL is emitted. ```php -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; // (1) discoverable — lives under your app's pathRoot @@ -95,7 +95,7 @@ final class UserRepository extends Repository ## `#[Migratable]` — opting a DbConfig into migration -`Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable` +`Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable` | Parameter | Type | Default | Effect | |---|---|---|---| @@ -132,7 +132,7 @@ Within the same priority, items run in the order returned by ## `#[Extension]` — PostgreSQL extension declarations -`Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension` +`Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension` PostgreSQL-only. Stack the attribute multiple times on the same config: diff --git a/docs/process/00-overview.md b/docs/process/00-overview.md new file mode 100644 index 0000000..46306ff --- /dev/null +++ b/docs/process/00-overview.md @@ -0,0 +1,184 @@ +# Winter Process — Overview + +Most of what a web framework does is shaped like a request: something comes in, +you compute an answer, you send it back, and the worker is free again in +milliseconds. A large class of real work does not fit that shape at all. + +An outbound-email worker has to sit on a queue for days, sending as jobs arrive. +A media service has to turn uploaded videos into thumbnails without blocking the +upload. A billing integration has to hold a persistent connection to a payment +gateway open around the clock. A reporting job has to walk millions of rows at +3 a.m. and assemble a file. None of these is a request. Each one has to be +*started*, *observed while it runs*, and *stopped cleanly* — and each has to keep +running long after the shell that launched it has closed. + +**Process** is the unit for that work. You write the logic as an ordinary class; +the framework wraps it in a managed lifecycle — start, stop and status from the +CLI and the web — and supplies the machinery underneath: a runtime that is either +Swoole coroutines or forked processes, a bounded concurrency model, cooperative +cancellation so a running worker can be interrupted safely, and cleanup that is +guaranteed to run however the process ends. + +Here is a complete outbound-email worker. It is the example this documentation +returns to, because a queue consumer exercises nearly everything a process does: + +```php +final class EmailDispatchWorker extends Process +{ + #[Autowired] private MailQueue $queue; + #[Autowired] private Mailer $mailer; + + public function run(): void + { + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); // next pending email, or null + if ($job === null) { + continue; // queue empty — poll again + } + + try { + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + } catch (DeliveryException $e) { + $this->queue->release($job->id); // return it for a retry + $this->logger->warning("delivery failed for {$job->to}: {$e->getMessage()}"); + } + } + } +} +``` + +Nothing in that class knows whether it is running on top of a coroutine scheduler +or a forking process. It reserves a job, sends it, acknowledges it, and loops. +Operating it is three commands: + +``` +call process main.EmailDispatchWorker start -d # launch, detached from the shell +call process main.EmailDispatchWorker status -v # is it alive? busy? how much memory? +call process main.EmailDispatchWorker stop # stop it gracefully +``` + +--- + +## The central idea: you own the body, the framework owns everything else + +The method you write, `run()`, is plain application code. It never opens an event +loop, never forks, never installs a signal handler, never writes a PID file. It +reads as if it were the only thing running. + +Everything the body *needs* from its environment arrives through a small set of +protected primitives — `isRunning()` to drive its loop, `sleep()` for +interruptible pauses, `spawn()` to run work concurrently. Everything the body +should *react to* — a stop request, a reload signal, a fatal exit — arrives +through overridable hooks such as `onTerminate()`, `onReload()` and +`onShutdown()`. The two sets are small enough to hold in your head, and they are +the entire contract. + +The payoff is that the same class is correct on two very different runtimes. When +Swoole is present the body runs inside a coroutine scheduler and `spawn()` yields +lightweight coroutines that interleave on I/O. When it is not, the body runs in a +plain process and `spawn()` forks children. The framework chooses; the body does +not change. This is the design goal stated once and enforced everywhere: +**one body, two runtimes, identical observable behaviour.** + +--- + +## A process is whatever its body is + +A process is not a specific structure like "a worker pool." The body dictates the +shape, and three shapes cover almost everything. + +**A one-shot task** runs to completion and exits. There is no loop; `run()` does +its work and returns. + +```php +final class ExpiredSessionCleanup extends Process +{ + #[Autowired] private SessionRepository $sessions; + + public function run(): void + { + $removed = $this->sessions->deleteOlderThan(days: 30); + $this->logger->info("purged {$removed} expired sessions"); + // run() returns → the process is finished and exits + } +} +``` + +**A long-lived consumer** loops until it is told to stop. This is the +`EmailDispatchWorker` above, and the most common shape by far. + +**A concurrent worker** is a consumer whose loop hands each unit to `spawn()`, so +the units run in parallel instead of one at a time — the right shape when the work +is I/O-bound and the units are independent. That is the subject of +[02-concurrency.md](02-concurrency.md). + +Concurrency is opt-in. A process that never calls `spawn()` is an ordinary +sequential program, and that is a perfectly good thing to be. + +**Triggered by a clock, not a loop?** When the question is *when* to run rather +than *how* to loop — every night at 02:00, every five minutes — you usually do +not write a Process at all: annotate a plain method with `#[Scheduled]` and the +**Scheduler** runs it on time. The Scheduler is itself a Process, built on +everything this page describes; see [`schedule/`](../schedule/00-overview.md). + +--- + +## The two runtimes + +The framework selects the backend from the loaded extension: if the Swoole +extension is available the body runs on coroutines, otherwise on forks. The +selection is invisible to your code, but the mechanics differ enough that it is +worth seeing side by side. + +``` + run() (your body) + │ + ┌───────────────┴────────────────┐ + Swoole extension present no Swoole (PHP-FPM / CLI) + │ │ + Swoole\Coroutine\run() a plain OS process + spawn() → a coroutine spawn() → pcntl_fork() + sleep() → yields, non-blocking sleep() → a real pause + tasks share the body's memory each task is an isolated child +``` + +| | Swoole | PHP-FPM / CLI | +|---|---|---| +| The body runs inside | `Swoole\Coroutine\run()` | the process itself | +| `spawn()` produces | a coroutine (~12 KB, µs to start) | a forked child process (MBs, ms to start) | +| `sleep()` | yields — other coroutines keep running | pauses this process | +| Concurrent tasks share memory | yes — they observe `isRunning()` directly | no — a child has its own copy of everything | +| Parallelism | many coroutines overlapping on I/O, one core | one running task per forked child | + +Everything a process *is* — its lifecycle, the way it stops, the concurrency cap +and back-pressure — is identical on both backends. What differs is the physics +underneath: coroutines are cheap and share memory, forks are heavier and +isolated. The following pages describe each mechanism once and then, where the +difference is observable, spell out how the FPM backend is adapted to match. + +**One boundary is worth stating up front.** Coroutines give you *concurrency* — +many tasks making progress by overlapping their I/O — but not *parallelism* +across CPU cores, because a coroutine scheduler runs on a single thread. When you +need genuine parallelism, fault isolation, or workers that scale up and down +independently, you run several separate *processes* under a supervisor. That +supervisor is a `Daemon` (a supervised Process), and `spawn()` is the concurrency +layer *inside* a single worker — the two compose rather than compete. + +--- + +## Pages + +| # | File | Contents | +|---|------|----------| +| 00 | this page | What a process is, the two runtimes, the shapes it takes | +| 01 | [01-lifecycle.md](01-lifecycle.md) | Writing the body, cooperative stop, signals, guaranteed cleanup | +| 02 | [02-concurrency.md](02-concurrency.md) | `spawn`, the concurrency cap, the semaphore and back-pressure | +| 03 | [03-control.md](03-control.md) | Starting, stopping and inspecting a process; the CLI | + +## See also + +- [`concurrent/00-overview.md`](../concurrent/00-overview.md) — `Executors` and `Future`, the primitive `spawn()` is built on +- [`schedule/00-overview.md`](../schedule/00-overview.md) — the Scheduler, a Process that runs `#[Scheduled]` methods on a clock +- [`process/daemon/00-overview.md`](daemon/00-overview.md) — a supervised fleet of identical processes +- [`ppa/00-overview.md`](../ppa/00-overview.md) — the database connection pool a process shares across its coroutines diff --git a/docs/process/01-lifecycle.md b/docs/process/01-lifecycle.md new file mode 100644 index 0000000..acdca5c --- /dev/null +++ b/docs/process/01-lifecycle.md @@ -0,0 +1,364 @@ +# Lifecycle, stop and signals + +The hardest part of a long-running process is not starting it — it is stopping it +correctly. A deploy, a scale-down, a `systemctl restart`, an operator pressing +Ctrl-C: all of them ask the process to end, and the process has to end *well*. It +must not be cut off in the middle of sending an email or halfway through a +database transaction. It must release the connections and locks it holds. And it +has to be stoppable even at the moment it is blocked waiting on a socket that may +stay quiet for minutes. + +Getting this right is a solved problem, and the solution is the same one Java, +Go, and Kubernetes all arrived at independently: **cooperative cancellation with a +forced deadline behind it.** The process is *asked* to stop; the body itself +decides the safe point at which to actually do so; and if the body never reaches +such a point, a deadline forces the process down anyway. Winter's Process +implements exactly this model, and this page is the small surface you use to +participate in it. + +--- + +## `isRunning()` — the cooperative signal + +The backbone of the whole mechanism is a single boolean the body polls. It is +`true` while the process should keep working and becomes `false` the moment a stop +is requested — whether that request came from `SIGTERM`, from the CLI `stop` +command, or from the body calling `requestStop()` on itself. + +A loop driven by `isRunning()` therefore stops **between** units of work, never in +the middle of one: + +```php +final class EmailDispatchWorker extends Process +{ + #[Autowired] private MailQueue $queue; + #[Autowired] private Mailer $mailer; + + public function run(): void + { + while ($this->isRunning()) { // re-checked before every job + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + // if a stop was requested during send(), the loop exits cleanly here, + // *after* this email is fully sent and acknowledged + } + } +} +``` + +This is the direct analogue of Java's `while (!Thread.currentThread().isInterrupted())`: +a flag you check at the points where stopping is safe. You are never obliged to +call anything to make a process stop. If `run()` returns on its own — a one-shot +task, or a loop that reached its natural end — the process is finished. If a stop +is requested from outside, the framework flips the flag for you; your only +responsibility is to test it. + +--- + +## Interruptible waits — `sleep()` and `InterruptedException` + +A polled flag has a blind spot. In the worker above, `reserve(timeout: 1.0)` +returns at least once a second, so the loop re-checks `isRunning()` promptly. But +consider a process that legitimately blocks for a long time — waiting thirty +seconds between health checks, or parked on a socket read. A flag alone would let +it sit out the entire wait before noticing the stop. + +To close that gap, the framework's own blocking primitive is **interruptible**. +When a stop arrives while the body is parked in `sleep()`, the wait is abandoned +immediately and throws `InterruptedException` — it does not run out the clock: + +```php +final class HealthReporter extends Process +{ + #[Autowired] private HealthCheck $check; + #[Autowired] private StatusdClient $statusd; + + public function run(): void + { + while ($this->isRunning()) { + $this->statusd->report($this->check->run()); + $this->sleep(30); // a 30-second wait, but interruptible + } + } +} +``` + +If this process is stopped nineteen seconds into a `sleep(30)`, it does not wait +the remaining eleven — the `sleep()` throws at once and the body unwinds. This is +precisely how Java behaves: `Thread.sleep()`, `Object.wait()`, +`BlockingQueue.take()` throw `InterruptedException` when the thread is interrupted +mid-block, so a blocked thread wakes instantly rather than after its timeout. + +Because it is an exception, you decide how much ceremony to give it. The common +case is none — let it propagate: + +```php +public function run(): void +{ + while ($this->isRunning()) { + $this->statusd->report($this->check->run()); + $this->sleep(30); // may throw; nothing to clean up, so we let it + } + // an interrupt during sleep() simply unwinds out of run() — a graceful stop +} +``` + +The other case is when you have work in flight that must be settled before you +stop — a reserved job that should go back on the queue rather than be lost: + +```php +public function run(): void +{ + $job = null; + try { + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 30.0); // interruptible block + if ($job === null) { + continue; + } + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + $job = null; + } + } catch (InterruptedException) { + // stopped while parked in reserve() — no email was half-sent, nothing to undo + } finally { + if ($job !== null) { + $this->queue->release($job->id); // hand a reserved-but-unsent job back + } + } +} +``` + +Two details distinguish this from Java and remove its sharpest edge. First, +`finally` blocks always run on the way out, so cleanup is guaranteed even when the +interrupt propagates. Second, catching `InterruptedException` does **not** clear +any flag — `isRunning()` is a separate, sticky signal — so there is no equivalent +of Java's notorious "swallowed interrupt" bug, where catching the exception hides +the fact that a stop was ever requested. + +> Interruptibility is real coroutine cancellation under Swoole. A native PHP +> `sleep()`, or a blocking call in a client that is not coroutine-aware, cannot be +> interrupted this way — it will run to completion regardless. Use `$this->sleep()` +> and coroutine-aware clients for any wait that should be interruptible. Under +> FPM, `$this->sleep()` is implemented as a series of short slices, so an +> interrupt is noticed within one slice rather than instantly, but the effect is +> the same. + +--- + +## The stop sequence, end to end + +With those two primitives in place, a `stop` (which sends `SIGTERM`) drives the +following sequence. Read it as the definition of "stopping well": + +``` +1. isRunning() flips to false the body stops accepting new work +2. onTerminate() runs your reaction — log it, flush a metric +3. the body reaches a safe point: + · parked in an interruptible wait → the wait throws, the body unwinds now + · in the middle of a unit of work → the unit finishes; it is never cut off +4. in-flight spawn()ed tasks drain the process waits for its own children +5. the body returns → onShutdown() runs guaranteed teardown, on every path + └─ if draining outlasts $grace → the process is forced down (see below) +``` + +Steps 3 and 4 are what make a stop *graceful* rather than merely fast. A unit of +work already in progress is allowed to complete — the framework distinguishes a +process that is *waiting* for work (safe to interrupt at once) from one that is +*doing* work (finished first). That distinction is the process's **activity**, +`IDLE` versus `BUSY`, and how it is tracked — automatically for `spawn()`, or +explicitly with `markBusy()` — is covered in [02-concurrency.md](02-concurrency.md). + +Underpinning step 4 is a firm guarantee: **a process always waits for the tasks it +spawned to finish before it exits.** It never abandons its children. This is +structured concurrency, and it is why a graceful stop drains outstanding work +instead of dropping it. + +--- + +## `grace` — the drain deadline + +Draining is bounded by `grace`: it is *how long the process will wait for +in-flight work to finish before it stops waiting and forces itself down.* It is a +drain deadline — deliberately **not** a blunt "kill this process after N seconds" +timer, which would cut off legitimate long-running work. + +```php +final class VideoTranscodeWorker extends Process +{ + // a single transcode can take minutes; do not let a deploy kill one mid-flight + protected float $grace = 0.0; // wait for the current job to finish, however long +} +``` + +| `$grace` | Meaning | +|---|---| +| `0.0` (default) | wait for in-flight work to drain **for as long as it takes** — never force by a timer | +| `> 0` | wait up to *N* seconds for the drain, then force the process down | + +The choice is a policy decision about your work. If dropping an in-progress unit +is worse than a slow shutdown — a video transcode, a large export — keep `grace` +at `0` and let an external `SIGKILL` (or, for queue work, the broker's redelivery) +be the ultimate stop. If you need a hard ceiling — a container orchestrator that +will `SIGKILL` after thirty seconds anyway — set `grace` to just under that so the +process controls its own forced exit. A **repeated** `stop` on a bare process is +ignored — the first request already began the graceful stop, and a second SIGTERM +changes nothing; the only forced exits are its own `grace` timer (when `grace > 0`) +or an external `kill -9`. (The "second signal forces now" behaviour belongs to a +[Daemon](daemon/03-control.md#stopping-the-fleet), whose supervisor collapses the +drain deadline on a repeat signal.) + +--- + +## Signals + +A process traps the conventional daemon signal set and routes each signal to an +overridable hook. It is important to understand the division of responsibility: +for the stop signals, **the framework guarantees the stop** — the hook is your +chance to *react*, not a veto. You cannot accidentally ignore a `SIGTERM` by +forgetting to act on it; the process will stop regardless. + +| Signal | Hook | Default behaviour | +|---|---|---| +| `SIGTERM` | `onTerminate()` | graceful stop — this is what `stop` and orchestrators send | +| `SIGINT` | `onInterrupt()` | graceful stop — an interactive Ctrl-C | +| `SIGHUP` | `onReload()` | **reload configuration — the process keeps running** | +| `SIGUSR1` | `onUser1()` | a user-defined action — no-op by default | +| `SIGUSR2` | `onUser2()` | a user-defined action — no-op by default | +| `SIGPIPE` | — | ignored, so a write to a closed socket never kills the process | + +`SIGHUP` follows the long-standing daemon convention that a hang-up means *reload*, +not *stop* — the same signal nginx and Apache use to re-read their configuration +without dropping a connection. It is the one place to make a running process pick +up new settings: + +```php +final class ApiPoller extends Process +{ + private RateLimit $limit; + + #[Autowired] private ConfigStore $config; + #[Autowired] private UpstreamApi $api; + + public function run(): void + { + $this->limit = $this->config->rateLimit(); // initial config + while ($this->isRunning()) { + $this->api->poll(); + $this->sleep($this->limit->intervalSeconds()); + } + } + + protected function onReload(): void + { + // operator ran `kill -HUP` after changing the config file; + // pick up the new rate without a restart, keep running + $this->limit = $this->config->rateLimit(); + $this->logger->notice("reloaded: interval now {$this->limit->intervalSeconds()}s"); + } +} +``` + +`SIGUSR1` and `SIGUSR2` are the two signals POSIX leaves for the application to +define — the standard way to trigger a one-off action on a running process without +restarting it (nginx, for example, uses them to reopen log files and to begin a +binary upgrade). A common pair is "show me what you are doing" and "change how +loudly you report it," neither of which should stop the worker: + +```php +private bool $verbose = false; + +protected function onUser1(): void +{ + // operator wants a live snapshot of internal counters, without stopping the worker + $this->logger->info('metrics', $this->metrics->snapshot()); +} + +protected function onUser2(): void +{ + // flip verbose logging on a running process to debug an incident, then flip it back + $this->verbose = !$this->verbose; + $this->logger->notice($this->verbose ? 'verbose logging enabled' : 'verbose logging disabled'); +} +``` + +--- + +## `onShutdown()` — cleanup that always runs + +A `finally` block in `run()` covers the graceful path, but not a forced exit past +the `grace` deadline, and not a fatal error. For teardown that must happen on +**every** exit path — releasing a distributed lock, deregistering from a service +registry, flushing a buffer — override `onShutdown()`. The framework calls it +exactly once, whether the process ended gracefully, was forced down, or crashed: + +```php +final class LeaderElectedWorker extends Process +{ + #[Autowired] private DistributedLock $lock; + + public function run(): void + { + $this->lock->acquire('report-generator'); // only one instance may run + while ($this->isRunning()) { + $this->generateNextReport(); + } + } + + protected function onShutdown(): void + { + // must run even on a forced or fatal exit, or the lock would be held + // until its TTL expires and no other instance could take over + $this->lock->release('report-generator'); + } +} +``` + +The two levels are deliberate and complementary. The `finally` inside `run()` is +your local cleanup for the cooperative path — closing what this particular loop +opened. `onShutdown()` is the guaranteed hook for critical, externally-visible +teardown that has to run no matter how the process dies. + +--- + +## `requestStop()` — stopping from the inside + +Everything so far is about a stop arriving from *outside*. Sometimes the body +itself decides it is done — it consumed a poison-pill message, hit a +non-recoverable error, or finished the batch it was created for. `requestStop()` +is how it stops itself: it is the cooperative equivalent of the process sending +itself a `SIGTERM`. `isRunning()` flips to false everywhere — in the top-level +loop, in any nested loop, and in any `spawn()`ed task that checks it: + +```php +public function run(): void +{ + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + if ($job->isShutdownSignal()) { + $this->logger->notice('received shutdown job, draining and exiting'); + $this->requestStop(); // stop the whole process, not just this loop + break; + } + $this->process($job); + } +} +``` + +For a plain top-level loop, `break` or `return` alone is enough to end the body. +Reach for `requestStop()` when the intent to stop has to be visible beyond the +current stack — to a nested loop, or to concurrent tasks that are checking +`isRunning()` to wind themselves down. + +--- + +Next: [02-concurrency.md](02-concurrency.md) — running many units at once, capping +how many, and the semaphore that turns the cap into back-pressure. diff --git a/docs/process/02-concurrency.md b/docs/process/02-concurrency.md new file mode 100644 index 0000000..eefe670 --- /dev/null +++ b/docs/process/02-concurrency.md @@ -0,0 +1,339 @@ +# Concurrency, the semaphore and back-pressure + +The `EmailDispatchWorker` from the previous pages has a ceiling that has nothing +to do with the framework: it sends one email at a time. Each `send()` spends most +of its time waiting on the SMTP server — connecting, handshaking, waiting for a +`250 OK` — and while it waits, the worker does nothing else. If the queue holds +ten thousand emails and each send takes 200 ms of mostly-idle waiting, the worker +needs over half an hour, almost all of it spent blocked on the network. + +The fix is to have several sends in flight at once, overlapping their waits. That +is what `spawn()` is for. And the moment you have several things in flight, you +need a way to say *how many* — otherwise a fast producer will launch work faster +than it completes and exhaust the machine. That limit, and the well-behaved way +it is enforced, is the substance of this page. + +--- + +## `spawn()` — run a unit concurrently + +`spawn()` hands a task to the runtime to run alongside the body, and returns a +`Future` — a handle you can use to wait for its result or discover its failure, +though for fire-and-forget work you usually ignore it. + +Here is the email worker rewritten to send concurrently. The body's job is no +longer to *send* — it is to *dispatch*: pull the next email and hand it off, as +fast as the concurrency limit allows. + +```php +final class EmailDispatchWorker extends Process +{ + protected int $concurrency = 25; // up to 25 sends in flight at once + + #[Autowired] private MailQueue $queue; + #[Autowired] private Mailer $mailer; + + public function run(): void + { + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + + // hand this email to the runtime and immediately go get the next one + $this->spawn(function () use ($job): void { + try { + $this->mailer->send($job->to, $job->subject, $job->body); + $this->queue->markSent($job->id); + } catch (DeliveryException) { + $this->queue->release($job->id); + } + }); + } + } +} +``` + +Under Swoole each `spawn()` becomes a coroutine. When `send()` blocks on the +network the coroutine yields, the scheduler runs another coroutine, and the body +keeps reserving jobs — so up to twenty-five sends make progress at the same time +on a single thread. The half hour becomes roughly a minute and a half. + +`spawn()` is built directly on the Concurrent unit +([`concurrent/00-overview.md`](../concurrent/00-overview.md)) — a spawned task is +an `Executors::common()->submit()` under the hood, which means it runs in its own +coroutine context and borrows its own connection from the shared PPA pool, so the +twenty-five concurrent sends do not fight over one database handle. + +One guarantee underlies all of this and is worth stating plainly: **a process +always waits for every task it spawned to finish before it exits.** A parent never +outlives its children. This is structured concurrency, and it is the reason a +graceful stop drains the sends that are already in flight instead of dropping +them on the floor. + +--- + +## `$concurrency` — the bound, and why it is not optional + +The `protected int $concurrency` field is the maximum number of spawned tasks +allowed to run at once. + +```php +protected int $concurrency = 25; +``` + +| Value | Behaviour | +|---|---| +| `0` (default) | **unlimited** — every `spawn()` starts immediately | +| `N` | at most `N` tasks run concurrently; further `spawn()`s wait for a slot | + +It is tempting to read `0` as "as fast as possible" and reach for it, but it is a +trap, and it is worth understanding exactly why. With no limit, the dispatch loop +reserves a job and spawns a send, reserves another and spawns another, and never +pauses — because nothing makes it pause. If the SMTP server slows down, sends +start taking longer, but the loop keeps launching new ones at full speed. The +number of in-flight coroutines climbs without bound; each one holds a job, a +buffer, a connection. Memory grows until the worker is killed by the OOM killer, +and the slow SMTP server — the actual bottleneck — is now also drowning in +connections. An unbounded producer does not go faster; it goes down. + +Setting `$concurrency` to a real number prevents this, but the interesting part is +*how* it prevents it. + +--- + +## The mechanism: a counting semaphore + +The limit is implemented as a **counting semaphore** — a pool of `N` permits. To +start a task, `spawn()` must first take a permit; when the task finishes, its +permit goes back into the pool. While all `N` permits are checked out, the next +`spawn()` cannot get one, so it **waits until a running task returns its permit.** + +``` +$concurrency = N creates a permit pool of size N + +spawn(): + take a permit ← if none are available, WAIT here + start the task + ── task finishes ──► return the permit (which wakes one waiting spawn) +``` + +Under Swoole the permit pool is a `Swoole\Coroutine\Channel(N)` pre-loaded with +`N` tokens. Taking a permit is a `pop()`; when the channel is empty the calling +coroutine **suspends** — it does not spin, it does not burn CPU, and critically it +does not block the worker thread, so all the other coroutines keep running while +this one is parked. When a finishing task pushes its token back, the scheduler +resumes exactly one waiting `spawn()`. That suspension is the whole trick. + +This is not a Winter invention; it is the canonical way to bound concurrent work, +and you will recognise it from other ecosystems: + +- **Java** — the pattern in Goetz's *Java Concurrency in Practice* is literally a + `Semaphore` wrapped around a thread pool; `acquire()` blocks the submitter when + the pool is saturated. +- **Go** — the idiomatic bounded worker pool is a buffered channel used as a + semaphore: `sem := make(chan struct{}, N)`, and sending to a full channel blocks + the goroutine. +- **Kotlin / Reactive Streams** — a coroutine `Semaphore`, or first-class + back-pressure where the consumer signals the producer to slow down. + +The principle they share is the one that matters here: **when the workers are +saturated, slow down the producer.** Blocking the thing that submits work is not a +deficiency to be engineered away — it is the correct behaviour, and the unbounded +alternative is the classic mistake it exists to prevent. + +--- + +## Back-pressure, in practice + +The practical consequence of the semaphore is precise and worth internalising: +**`spawn()` is a point at which the body can pause.** When every permit is out, the +body stops *at the `spawn()` call* and does not proceed until a slot frees. Any +code that follows `spawn()` in the loop waits along with it. + +You can see this most clearly by pushing the limit to its extreme, `concurrency = +1`, which serialises the tasks completely — task #2 cannot even begin until task +#1 has finished. Instrumenting a body that logs on either side of `spawn()` makes +the pause visible: + +```php +protected int $concurrency = 1; + +public function run(): void +{ + while ($this->isRunning()) { + $job = $this->queue->reserve(timeout: 1.0); + if ($job === null) { + continue; + } + $this->logger->debug("about to dispatch #{$job->id}"); + $this->spawn(fn() => $this->handle($job)); // waits here while a task holds the permit + $this->logger->debug("dispatched #{$job->id}"); + } +} +``` + +``` +about to dispatch #41 12:00:00.000 +dispatched #41 12:00:00.001 ← permit was free, taken instantly +about to dispatch #42 12:00:00.100 +dispatched #42 12:00:03.100 ← +3s: waited for #41's task to return the permit +``` + +Nothing is lost in this waiting — every `spawn()` you call still runs; the body is +simply *paced* to the rate at which tasks complete, which is exactly what stops it +from overwhelming the workers or the memory. Where you place a log line relative to +`spawn()` determines whether it fires on every iteration (put it before) or only +once a permit is actually acquired (put it after). + +> Back-pressure paces the *producer*; it is not a durable queue. In-flight tasks +> live in memory, so a `SIGKILL`, a crash, or a `grace` timeout drops whatever was +> running. When the work itself must survive a crash — a payment, a queued message +> — durability has to come from the source: reserve-and-acknowledge against the +> broker or database, so an un-acknowledged job is redelivered. `spawn()` gives you +> concurrency and back-pressure, not persistence; the two are separate concerns and +> should stay that way. + +--- + +## Activity — `IDLE` and `BUSY` + +Separately from its lifecycle state, a process reports whether it is *doing work +right now*. This is its **activity**, and it takes one of two values, `BUSY` or +`IDLE`. Activity drives three things: the drain-to-idle behaviour of a graceful +stop (a `BUSY` process is never interrupted mid-work), the live status shown by +the CLI and web, and — for a supervised `Daemon` — the decision of which workers +are safe to stop when scaling down, since you only ever want to remove an idle one. + +A process is `BUSY` when **either** an inline unit is marked in progress **or** any +`spawn()`ed task is still running, and `IDLE` otherwise. The common case needs no +work from you at all: because `spawn()` tracks its own in-flight count, a worker +that dispatches through `spawn()` reports `BUSY`/`IDLE` automatically. + +You only mark activity by hand when you process a unit **inline** — directly in +the body, without `spawn()` — because then there is no in-flight count for the +framework to observe: + +```php +final class ReportBuilder extends Process +{ + #[Autowired] private ReportQueue $queue; + + public function run(): void + { + while ($this->isRunning()) { + $request = $this->queue->reserve(timeout: 1.0); + if ($request === null) { + continue; // IDLE — parked waiting for a request + } + + $this->markBusy(); // a report build starts — protect it from mid-work interruption + $this->buildReport($request); // minutes of CPU-bound work, run inline + $this->markIdle(); // done — safe to interrupt or scale down again + } + } +} +``` + +`markBusy()` and `markIdle()` are in-memory flags — they cost nothing and are +safe to call on every iteration. They combine with the spawn count by OR, so a +process that both marks itself busy and has spawns in flight stays `BUSY` until +both are clear; it can never report a false `IDLE` while work is genuinely +outstanding. The value is persisted to the status record on a roughly one-second +heartbeat, and only when it actually changes, so a worker flipping between busy +and idle on every message never touches the disk for it. + +### `markBusy()` also opens a new request scope + +A `#[Request]` binding means *one instance per unit of work*. Over HTTP the framework +knows what a unit is — a request is a coroutine, and the scope dies with it. **A worker +has no such boundary**: its whole body runs inside one coroutine, so a request-scoped +bean resolved there would live for the entire run and hand each job the previous job's +state: + +``` +итерация 1: объект #61, at entry saw "unset" +итерация 2: объект #61, at entry saw "job-1" ← the last job's data +итерация 3: объект #61, at entry saw "job-2" +``` + +Only the body knows where a unit ends, and `markBusy()` already says so. So it doubles as +the scope boundary: request-scoped bindings resolved after it are new. + +```php +while ($this->isRunning()) { + $job = $this->queue->pop(timeout: 1.0); + if ($job === null) { continue; } + + $this->markBusy(); // ← unit starts; request scope reset + $ctx = Container::getInstance()->make(JobContext::class); // fresh every job + // ... work ... + $this->markIdle(); +} +``` + +Two things this does **not** do. Singletons are untouched — a pool, a warm cache or a +counter is not scoped to a unit and keeps both identity and state. And a body that never +calls `markBusy()` has declared no units, so nothing is reset; a process that simply runs +behaves as it always did. + +--- + +## How the FPM backend reproduces this + +When Swoole is absent there are no coroutines, so the backend runs each `spawn()` +as a **forked child process**. Every behaviour above is preserved — the cap, the +back-pressure, the serialisation at `concurrency = 1` — but the machinery is +different, and one difference is observable and worth knowing. + +| | Swoole | PHP-FPM / CLI | +|---|---|---| +| A `spawn()` becomes | a coroutine | a `pcntl_fork()` child process | +| The permit pool is | a `Coroutine\Channel(N)`; taking a permit **suspends** the coroutine | a count of live children; a full pool **blocks** the parent on `pcntl_wait()` | +| Waiting for a permit | non-blocking — other coroutines keep running | a genuine block — the parent has nothing else to do meanwhile | +| The task's memory | shared with the body | an isolated copy taken at fork time | +| The `Future` result | the task's real return value | a settled placeholder — a child cannot return a value across the process boundary | + +The back-pressure has the same shape on both. At `concurrency = 1` under FPM, the +parent reaches the second `spawn()`, sees the one permit is taken, and blocks in +`pcntl_wait()` until the running child exits — then forks the next. The tasks run +strictly one after another, and a trace shows the identical pause you saw under +Swoole. + +The one behaviour that genuinely differs comes from process isolation, and you +should know it before you rely on cooperative cancellation. A Swoole `spawn()` is +a coroutine sharing the body's memory, so it observes `isRunning()` flip to false +the instant a stop is requested and can break out of its own loop early. An FPM +`spawn()` is a separate process holding its own copy of that flag as it stood at +fork time: a child that was already running when the stop arrived does **not** see +the later `requestStop()`, and runs its task to completion. The parent waits for +it in `pcntl_wait()` and then exits. This is ordinary, correct fork semantics, but +it means that under FPM an already-running spawned task cannot be cancelled +mid-flight the way a coroutine can — it finishes what it started. + +--- + +## Where `spawn()` fits, and where it does not + +``` +Daemon ──► N separate Process workers isolation · independent scaling · CPU parallelism + │ + └──► spawn() inside each I/O concurrency within one worker +``` + +`spawn()` gives you concurrency *inside a single process*: many units overlapping +their I/O on one runtime. It does not give you fault isolation — an uncaught fatal +error in one coroutine takes down every coroutine in that process — nor +parallelism across CPU cores, because the coroutine scheduler is single-threaded. +When you need those, you run several separate *processes*, each isolated and +individually restartable, under a supervisor. That supervisor is a `Daemon`, and +the two layers compose cleanly: a supervised worker still uses `spawn()` internally +to overlap its own I/O. Choosing between them is a question of what you are +protecting against — the machine running out of memory (bound with `$concurrency`) +versus one bad job taking down the whole fleet (isolate with separate processes). + +--- + +Next: [03-control.md](03-control.md) — launching, stopping and inspecting a process +from the CLI and the web. diff --git a/docs/process/03-control.md b/docs/process/03-control.md new file mode 100644 index 0000000..c21a49a --- /dev/null +++ b/docs/process/03-control.md @@ -0,0 +1,244 @@ +# Control — start, stop, status + +A process exists to be operated. Someone — a deploy script, an operator, a +supervisor, an admin dashboard — needs to launch it into the background, ask +whether it is alive and what it is doing, and stop it cleanly when the time comes. +That operational surface is four static methods on every process. The +`call process` command is a thin, ergonomic wrapper over those four; the web layer +is whatever you build on the same four. Nothing else is privileged — the CLI has +no special access the rest of your application lacks. + +--- + +## The four methods + +```php +EmailDispatchWorker::start(); // run in this process, in the foreground +$pid = EmailDispatchWorker::dispatch(); // launch detached in the background, return its PID +$status = EmailDispatchWorker::status(true); // its status (with live resource usage), or null +EmailDispatchWorker::stop(); // ask it to stop gracefully +``` + +**`start()`** runs the body in the current process and blocks the caller until it +returns. While it runs it registers itself in the store, so another terminal can +still find it with `status()` and `stop()`. This is what you use in development and +for one-shot tasks you want to watch. + +**`dispatch()`** launches the process detached — double-forked and `setsid`-ed so +it has no controlling terminal — and returns immediately with its PID. A detached +process survives the shell that started it and is what you use for a real service. + +**`status()`** reads the process's record from the store: its PID, lifecycle +state, activity, uptime and concurrency. Pass `true` to also attach a live +`ResourceUsage` snapshot. It returns `null` when the process is not running, and it +is safe to call from anywhere — a controller, a health check, a scheduled audit. + +**`stop()`** sends `SIGTERM`, beginning the graceful stop sequence from +[01-lifecycle.md](01-lifecycle.md). `SIGTERM` is the deliberate choice here: it is +the universal "terminate, and you may clean up" signal that `kill`, systemd, Docker +and Kubernetes all send by default. `SIGINT` is left to mean an interactive Ctrl-C. + +Because these are ordinary static methods, the web layer is not a separate feature +— it is a few lines that call them. A minimal operations endpoint looks like this: + +```php +class WorkersController extends Controller +{ + #[GetMapping('/ops/workers/email')] + public function emailStatus(): ResponseEntity + { + $status = EmailDispatchWorker::status(usage: true); + + if ($status === null) { + return ResponseEntity::ok(['running' => false]); + } + + return ResponseEntity::ok([ + 'running' => true, + 'pid' => $status->pid, + 'activity' => $status->activity->name, // BUSY | IDLE + 'uptime' => time() - $status->startedAt, + 'memory_mb' => round($status->usage?->rssMb() ?? 0, 1), + ]); + } + + #[PostMapping('/ops/workers/email/stop')] + public function stopEmail(): ResponseEntity + { + return ResponseEntity::ok(['stopped' => EmailDispatchWorker::stop()]); + } +} +``` + +--- + +## One instance per class + +A process is a **singleton per class**: one class means one running instance. The +whole control model assumes it — the status record is keyed by the class name, and +`status()` and `stop()` address a process *by its class*, which only has an +unambiguous answer when there is exactly one. Starting a second instance of the +same class would have both write to the same record, so `status()` would see one +while the other ran orphaned and unreachable. + +`start()` and `dispatch()` therefore refuse a second instance and throw +`ProcessAlreadyRunningException`: + +```php +try { + EmailDispatchWorker::dispatch(); +} catch (ProcessAlreadyRunningException $e) { + // one is already running — $e carries its PID and start time +} +``` + +The guard has two layers, so it holds even under a race. `start()` / `dispatch()` +first check the store and refuse immediately if a live instance is recorded. The +launched worker then takes an exclusive `flock` keyed by its class before it does +anything; if two launches slip past the first check at the same instant, only one +wins the lock and the other exits. Because a `flock` is released by the operating +system the moment the process dies, it is never left stale — a crashed or +`kill -9`-ed process frees it instantly, and the next launch succeeds with no +manual cleanup, which a PID file could not guarantee. + +To run **several workers of the same logic**, this is not the mechanism — launching +the same class repeatedly is precisely what is forbidden. Use a `Daemon`, which +supervises a configurable number of identical worker processes, or define distinct +classes for distinct roles. Multiplicity lives in the supervisor; a bare process +stays a single, unambiguous, named instance. + +--- + +## The command line: `call process` + +`call process` (aliased to `call proc`) drives the four methods and renders live +state, with tab-completion wired for command names, discovered process classes, +and flags. + +``` +call process list # every process, with live state +call process main.EmailDispatchWorker # start in the foreground (blocks the shell) +call process main.EmailDispatchWorker start -d # start detached in the background +call process main.EmailDispatchWorker status # the status card +call process main.EmailDispatchWorker status -v # + live resource usage (CPU/memory) +call process main.EmailDispatchWorker stop # graceful stop +``` + +`list` gives you the fleet at a glance. Each row shows a `[P]` process or `[D]` +daemon tag, its running/stopped state, its live `[BUSY]`/`[idle]` activity, and its +uptime, followed by a one-line summary: + +``` + [P] Main.EmailDispatchWorker ......... [● RUNNING] [BUSY] 4h 12m + [P] Main.ExpiredSessionCleanup ....... [○ STOPPED] + [D] Main.SmsGatewayBridge ............ [● RUNNING] [idle] [w:4] 2d 1h + 3 defined, 2 running. +``` + +`status -v` prints the full card, including CPU and resident memory read live from +`ps` at the moment you ask: + +``` + Main.EmailDispatchWorker [Process ● RUNNING] + ───────────────────────────────────────────── + PID 58753 + State RUNNING + Activity BUSY + Started 2026-07-23 07:41:02 +00:00 + Uptime 4h 12m + Concurrency 25 + ─── Resources ─── + CPU 3.1 % + Memory 0.4 % (48.2 MB) +``` + +--- + +## The status record + +While a process runs it owns one small record in the runnable store, keyed by its +class name, holding the PID, lifecycle state, activity, start time and concurrency. +This record is the single source of truth that `status()`, the CLI and any web +endpoint all read; because it is a plain file store, they read it with no locking +or coordination between them. + +Two properties keep the record both trustworthy and cheap. + +It is **self-healing**. Every `status()` call verifies that the recorded PID still +exists before trusting the record; if the process died without cleaning up — a +forced exit, a crash — the stale record is dropped on that read and the process +correctly reports as stopped. You never see a ghost. + +Its writes are **throttled**. The frequently-changing part of the record is the +activity, and a busy worker can flip between `BUSY` and `IDLE` many times a second. +Writing the file on each flip would be pointless disk churn, so activity is +persisted on a roughly one-second heartbeat and only when it has actually changed. +The in-memory value the process acts on is always current; the persisted value the +outside world reads lags by at most about a second, which is exactly the precision +a human or a scale-down decision needs. Live resource usage is never stored at all +— it is read from `ps` on demand. + +--- + +## Resource usage + +Passing `usage: true` to `status()` (or `-v` on the CLI) attaches a `ResourceUsage` +snapshot: PID, parent PID, user, CPU percentage, memory percentage, resident set +size and elapsed time, all read from the operating system's `ps`. + +```php +$usage = ResourceUsage::ofPid($pid); +$megabytes = $usage?->rssMb(); // resident memory, or null if the process is gone +``` + +It is designed for **occasional, on-demand** observation — a status card, a +dashboard poll, an alert threshold — and never for a hot path, because each read +forks the `ps` binary. Note the direction: this is a process being *observed from +the outside*, not a process measuring itself. It answers "how much memory is that +worker using right now?" from another process, which is exactly what an operator or +a monitor asks. + +--- + +## Foreground versus detached + +The difference between `start()` and `dispatch()` (or `start` and `start -d` on the +CLI) is where the process lives and how long it survives. + +| | Foreground — `start` | Detached — `start -d` / `dispatch()` | +|---|---|---| +| Blocks the caller | yes, until the body returns | no — returns a PID immediately | +| Survives the launching shell | no — dies with the terminal | yes — re-parented to init | +| stdout and logs | the terminal | `/dev/null` by default; logging goes to files | +| Typical use | development, one-shot tasks you watch | real long-running services | + +A detached launch re-executes through the framework's runner, which boots the +application in the child process so the detached worker has the very same +container, configuration and logger as any other entry point — it is not a +stripped-down environment. One consequence of the double-fork is worth knowing: the +PID the launcher first hands back is an intermediate one, and the real worker +registers its own, final PID in the store as it starts. The CLI resolves this for +you by reading the store, so the PID it prints and the one `status`/`stop` act on +are always the real process; you never target the wrong thing. + +--- + +## Stopping cleanly + +`stop()` sends `SIGTERM`, and from there the process runs the sequence detailed in +[01-lifecycle.md](01-lifecycle.md): it stops taking new work, lets the current unit +finish, drains its in-flight `spawn()`ed tasks, runs `onShutdown()`, and exits. How +long that takes is bounded by the worker's `$grace` — `0` means it waits for the +drain for as long as the drain needs. A second `stop` on a bare process is ignored; +if you cannot wait, an external `kill -9` forces it down at once, and a `grace > 0` +gives it its own hard ceiling. + +Operating a *fleet* of workers — starting and stopping individual instances by +their activity so that a `BUSY` one is never the one you remove, and scaling the +count up and down with load — is a level above a single process. That is the job of +a `Daemon`, a supervisor that manages a set of Process workers, and it is +documented separately. + +--- + +Back to [00-overview.md](00-overview.md). diff --git a/docs/process/daemon/00-overview.md b/docs/process/daemon/00-overview.md new file mode 100644 index 0000000..13291b4 --- /dev/null +++ b/docs/process/daemon/00-overview.md @@ -0,0 +1,113 @@ +# Winter Daemon — Overview + +A [Process](../00-overview.md) is **one** managed worker: one body, one PID, alive +until you stop it. That is enough for a single consumer or a single scheduled job. +It is not enough when one worker cannot keep up, when a crash must not take the +work down with it, or when the load rises and falls through the day and you want +the number of workers to follow it. + +**Daemon** is the answer to all three. A daemon is the same `Process` body, but run +as a **fleet of identical worker replicas** kept alive by a **master supervisor**. +The master does no work itself — it forks workers, watches them, restarts the ones +that die, and grows or shrinks the fleet to match demand. The mental model is +exactly `nginx` or `php-fpm`: a `master process` over a pool of `worker process`es; +or a Kubernetes Deployment whose controller keeps a set of pods at the size you +declared. + +``` +winter-daemon: EmailDispatch master ← supervisor (does NOT run the body) +├── winter-daemon: EmailDispatch worker#1 ← a Process worker +├── winter-daemon: EmailDispatch worker#2 +└── winter-daemon: EmailDispatch worker#3 +``` + +You write config plus a body; the daemon supplies everything hard about running a +fleet: the two-tier process tree, forking and reaping, restart with back-off, a +graceful stop that drains the whole fleet, autoscaling with damping, and a +crash-safe singleton lock. As with `Process`, the same code runs whether the +workers are Swoole coroutine processes or plain forks. + +## Two ways to give the daemon its body + +The manager and the unit of work are separate concerns (like an executor and its +task), so the worker body is supplied in one of two ways. The daemon picks the +first that applies: + +**1. Inline — `workerRun()`.** Define this method and the daemon *is* the worker; +its body runs in every replica. Simplest, one file: + +```php +final class EmailDispatch extends Daemon +{ + #[Autowired] private MailQueue $queue; + + protected int $replicas = 3; + + protected function workerRun(): void + { + while ($this->isRunning()) { + $job = $this->queue->pop(timeout: 1.0); + if ($job === null) { + continue; + } + $this->markBusy(); + $this->send($job); + $this->markIdle(); + } + } +} +``` + +**2. External — `$workerClass`.** Point at a standalone [`Process`](../00-overview.md) +class. The worker is then reusable on its own (run it solo with `SendProcess::start()`) +*and* supervisable under the daemon: + +```php +final class EmailDispatch extends Daemon +{ + protected int $replicas = 3; + protected ?string $workerClass = SendProcess::class; +} +``` + +If neither is provided the master still starts, but each worker fails on fork with +a `DaemonConfigException` (there is no body to run). The priority is fixed: +**`workerRun()` first, `$workerClass` second.** See +[Workers](01-workers.md) for how a worker inherits DI across the fork and the +`afterFork()` seam that keeps its connections safe. + +## What the master does for you + +Everything below the body is the supervisor's job — you never write it: + +| Concern | Handled by the master | +|---|---| +| Keep N workers alive | forks replicas, reaps exits | +| A worker crashed | restart per [`RestartPolicy`](02-autoscaling.md#restartpolicy) with exponential back-off, into the same slot | +| Load changed | drive the fleet to [`desiredReplicas()`](02-autoscaling.md), damped by [`ScalingPolicy`](02-autoscaling.md#scalingpolicy) | +| Stop requested | drain the whole fleet gracefully, then exit ([Control](03-control.md#stopping-the-fleet)) | +| One instance per class | crash-safe `flock` singleton | +| Observe the fleet | per-worker status (`call daemon … status`) | + +## When a daemon, when a bare process + +- **Bare [`Process`](../00-overview.md)** — a single coordinator, a leader-elected + singleton, one long loop. Not a fleet. +- **`Daemon`** — several identical workers that must stay alive, scale, or survive + crashes independently. A queue consumer pool, an SMPP bind pool, a fan-out of + pollers. + +## Where to go next + +- **[Workers](01-workers.md)** — the two body forms, DI and fork-safety + (`afterFork()` / `ForkReset`), `spawn()` inside a worker. +- **[Autoscaling & restart](02-autoscaling.md)** — `desiredReplicas()`, the + `ScalingPolicy` damping model, `RestartPolicy`, and the master hooks. +- **[Control](03-control.md)** — `start` / `dispatch` / `status` / `stop`, the CLI, + the stop sequence, and the per-worker fleet view. + +## See also + +- [`process/00-overview.md`](../00-overview.md) — the bare Process a Daemon supervises and extends +- [`concurrent/05-pools.md`](../../concurrent/05-pools.md) — an in-process bounded pool; a Daemon is the tool when a workstream needs its *own processes* instead +- [`schedule/00-overview.md`](../../schedule/00-overview.md) — the Scheduler, for time-triggered rather than always-on work diff --git a/docs/process/daemon/01-workers.md b/docs/process/daemon/01-workers.md new file mode 100644 index 0000000..7e54b85 --- /dev/null +++ b/docs/process/daemon/01-workers.md @@ -0,0 +1,107 @@ +# Daemon Workers + +A daemon worker is an ordinary [`Process`](../00-overview.md) body — it has the +same primitives (`isRunning()`, `sleep()`, `spawn()`, `markBusy()` / `markIdle()`) +and the same signal hooks. This page covers what is specific to running that body +*under a supervisor*: how the body is chosen, how it is created, what it inherits +across the fork, and the one seam you need when it holds a connection. + +## Two body forms, fixed priority + +The daemon resolves its worker body in this order: + +1. **`workerRun(): void`** — if you define it, the daemon itself is the worker and + this method is the body. Its primitives come from `Process` by inheritance. +2. **`$workerClass`** — otherwise, if set, the daemon supervises instances of that + standalone `Process` class. +3. Neither → **`DaemonConfigException`** at start. + +Use `workerRun()` for a self-contained daemon (one file); use `$workerClass` when +the worker is worth having on its own — it stays independently testable and +runnable (`SendProcess::start()`), and the same class can be supervised by more +than one daemon. + +Both forms produce the same process title, `winter-daemon: worker#{n}`, +where `n` is one-based (`worker#1` is slot #0) and stable across restarts — the +per-worker fleet table shows the underlying zero-based `SLOT`. + +## How a worker is created: fork, not Thread + +The supervisor is a plain `pcntl` loop with **no event loop running**, so it forks +each worker with `pcntl_fork()`. Forking before any reactor starts is safe — the +child then boots its own clean Swoole coroutine runtime (or a plain fork runtime +without Swoole). Fork is the right tool here, not the Thread +launcher, because supervision needs the direct parent↔child relationship: exact +`waitpid` exit codes, reaping, and per-slot signalling. (Thread's detached launch +re-parents to init and *loses* that relationship; it is used one level up, to send +the **whole daemon** to the background — see [Control](03-control.md#background-dispatch).) + +## Dependency injection across the fork + +A worker inherits the master's memory copy-on-write, so it already has the same DI +container, the same singletons, the same configuration — `#[Autowired]` is resolved +and `Container::make()` works. For `$workerClass`, the worker is resolved with +`Container::make()` *in the child* (after the fork); for `workerRun()`, the daemon +instance itself carries the body. + +### The one caveat: fork-unsafe resources + +A fork copies file descriptors. A DB connection, a pool, a socket held in the +container becomes a **shared descriptor** across the master and every worker — +using it from more than one corrupts the protocol. The rule is simple: + +> **Open connections in the child, after the fork.** + +Two mechanisms make that automatic: + +- **`ForkReset`** — framework packages register a reset at bootstrap (a pool + registers a *reconnect*). The runtime runs every registered reset in the child + before the body. You write nothing; workers get fresh connections. + + ```php + // inside a package's bootstrap + ForkReset::register(fn() => Db::pool()->reconnect()); + ``` + +- **`afterFork()`** — override it to reset your *own* resources the framework does + not know about. Call the parent first: + + ```php + protected function afterFork(): void + { + parent::afterFork(); // runs the registered ForkReset handlers + $this->grpc = null; // drop the inherited channel; reopen lazily in the body + } + ``` + +A reset must **reconnect in place** (close the old fd, open a new one on the same +object) rather than replace the object — otherwise an already-injected reference +keeps pointing at the stale instance. A lazily-opened resource needs no handler at +all: there is nothing to reset until the child first uses it. + +`afterFork()` runs only in a forked worker; a bare foreground `Process` is never +forked and never runs it. + +## `spawn()` inside a worker + +A worker can still use `spawn()` for I/O concurrency *within itself*, exactly as a +bare process does — bounded by `$concurrency`. The mechanism follows the runtime: + +| Runtime | `spawn()` in a worker | +|---|---| +| **Swoole** | real coroutines sharing the worker's memory (and its freshly-reset pool) | +| **FPM / no Swoole** | a `pcntl_fork` per task — isolated child processes, fire-and-forget | + +So a Swoole worker fans out coroutines that share its connections; a fork-runtime +worker fans out isolated child processes (its own grandchildren, which it reaps). +See [Process — Concurrency](../02-concurrency.md) for the full model. + +## Worker state and scale-down + +A worker reports its activity — `IDLE` or `BUSY` — through `markBusy()` / +`markIdle()` (inline units) and in-flight `spawn()` count. The supervisor reads +that heartbeat to build the [fleet view](03-control.md#the-per-worker-view) and, +crucially, to **retire IDLE workers first** when scaling down — so a scale-down +never interrupts work in progress. A BUSY worker chosen for retirement still +drains gracefully; the ordering only decides who goes first. You do not manage any +of this; you only mark your units of work. diff --git a/docs/process/daemon/02-autoscaling.md b/docs/process/daemon/02-autoscaling.md new file mode 100644 index 0000000..09b3998 --- /dev/null +++ b/docs/process/daemon/02-autoscaling.md @@ -0,0 +1,148 @@ +# Autoscaling & Restart + +The master's job is to keep the fleet at the right size and to heal it. Two +policies shape that — one for *how big* the fleet should be, one for *what to do +when a worker dies* — plus a set of hooks for reacting to fleet events. All are +optional: the defaults run a fixed fleet that restarts on failure. + +## Fixed size — `$replicas` + +The simplest daemon just declares a size: + +```php +final class Emails extends Daemon +{ + protected int $replicas = 3; // keep three workers alive +} +``` + +The supervisor forks three workers, restarts any that crash, and keeps the count +at three. Nothing else is needed. + +## Dynamic size — `desiredReplicas()` + +Override `desiredReplicas()` to make the fleet follow load. It returns *how many +workers should run right now*; the supervisor drives the fleet toward it. This is +a declarative target (like a Kubernetes replica count), not an imperative command — +you say the number, the controller reconciles reality to it. + +```php +protected function desiredReplicas(): int +{ + // one worker per 100 queued messages, capped at 16 + return min(16, max(1, intdiv($this->queue->depth(), 100))); +} +``` + +`desiredReplicas()` runs on the master about once per `scaleInterval`. It is a +plain method — read a queue depth, a metric, a config value. The default returns +`$replicas`. + +## Stability over speed — `ScalingPolicy` + +`desiredReplicas()` is a **signal, not a command**. If it flickers — a naive or +noisy implementation — reacting to every reading would thrash the fleet. The +supervisor damps it, and the damping is asymmetric, the way mature autoscalers do +it (Kubernetes HPA, cloud auto-scaling groups): **scale up quickly, scale down only +when low demand is sustained.** + +Three knobs, on three different axes, tuned by a `ScalingPolicy`: + +| Knob | Default | Axis — what it controls | +|---|---|---| +| `scaleDownStabilization` | `60.0` s | *whether* to shrink — low demand must hold this long (shrink only to the high-water demand over the window) | +| `scaleStep` | `0` (∞) | *how many* workers change per action — a gentle ramp, not a cliff | +| `cooldown` | `3.0` s | *how often* an action may happen | +| `scaleUpDelay` | `0.0` s | a rise must be sustained this long before scaling up (0 = at once) | +| `scaleInterval` | `1.0` s | how often `desiredReplicas()` / `tick()` are polled | + +**Why `scaleDownStabilization` matters.** Say the fleet is at 10 and demand dips to +4 for a moment, then jumps back to 9. Without the window you would kill 6 workers, +then scramble to spawn them again — a saw-tooth. With it, scale-down uses the +**maximum** demand over the window, so a transient dip sheds nothing; the fleet +shrinks only after low demand has held for the whole window. + +**Why `scaleStep` matters.** Going from 10 to 2 all at once drops capacity off a +cliff (and going 2 → 40 forks a thundering herd). `scaleStep: 2` moves at most two +workers per action, ramping gradually in either direction. + +The defaults are tuned for a daemon — most never touch them. To tune, override +`scaling()`; the object is immutable and non-`final`, so you can also define reusable +named profiles: + +```php +// one-off +protected function scaling(): ScalingPolicy +{ + return new ScalingPolicy(scaleDownStabilization: 120.0, scaleStep: 2); +} + +// reusable profile +final class ConservativeScaling extends ScalingPolicy +{ + public function __construct() + { + parent::__construct(scaleDownStabilization: 300.0, cooldown: 30.0, scaleStep: 1); + } +} +``` + +Scale-down is always **graceful**: chosen workers are sent SIGTERM and drain to +exit (IDLE ones first, so work in progress is never cut off). See +[Control — Stopping the fleet](03-control.md#stopping-the-fleet) for the drain +deadline that bounds it. + +## Restart — `RestartPolicy` + +When a worker dies *unexpectedly* (not a scale-down, not a stop), the restart +policy decides what happens. It groups three knobs: + +```php +protected function restart(): RestartPolicy +{ + return new RestartPolicy( + mode: RestartMode::ALWAYS, // ALWAYS | ON_FAILURE | NEVER + maxRestarts: 0, // give up after N restarts across the fleet (0 = unlimited) + backoff: 1.0, // base seconds, exponential + ); +} +``` + +- **`RestartMode`** — `ALWAYS` (keep the worker alive on any exit), `ON_FAILURE` + (restart only on a crash / non-zero exit; a clean exit is final — the default), + `NEVER`. +- **`maxRestarts`** — a ceiling on total restarts across the fleet; exceeding it + puts the daemon in the `FAILED` state and stops it. +- **`backoff`** — exponential back-off between restarts (`base × 2^(n-1)`, capped), + so a crash-looping worker does not spin. + +A restart re-forks into the **same slot**, so `worker#{n}` stays stable across a +worker's lifetime. Critically, an intentionally retired worker (scale-down or stop) +is **never** restarted — the supervisor tracks each slot's intent, so the restart +policy and the autoscaler never fight over it. + +## Master hooks + +Optional callbacks on the master for reacting to fleet events and for imperative +periodic work. All run on the supervisor, not in a worker: + +| Hook | Fires | +|---|---| +| `tick()` | about once per `scaleInterval` — poll metrics, drive custom logic | +| `onWorkerStart(int $slot, int $pid)` | a worker was forked | +| `onWorkerExit(int $slot, int $pid, bool $crashed)` | a worker exited (`$crashed` = abnormal exit of a live worker) | +| `onScale(int $from, int $to)` | the fleet size changed | +| `onReload()` | SIGHUP reached the master | + +`tick()` and `desiredReplicas()` are the two periodic seams, and they differ by +intent: `desiredReplicas()` is **declarative** (return a number, the supervisor +reconciles), `tick()` is **imperative** (do whatever — poll an API, refresh a +config). `onScale()` is the *notification* that a change happened; it is not where +the decision is made. + +```php +protected function onScale(int $from, int $to): void +{ + $this->logger->info("scaled {$from} → {$to}"); +} +``` diff --git a/docs/process/daemon/03-control.md b/docs/process/daemon/03-control.md new file mode 100644 index 0000000..4c3c2cd --- /dev/null +++ b/docs/process/daemon/03-control.md @@ -0,0 +1,128 @@ +# Controlling a Daemon + +A daemon is started, observed and stopped exactly like a [`Process`](../03-control.md) — +the same four verbs — but everything reports the *fleet*: the master plus its +workers. This page covers the control surface, the CLI, the stop sequence, and the +per-worker view. + +## The control surface + +```php +Emails::start(); // supervise in the foreground (blocks) +$pid = Emails::dispatch(); // supervise detached in the background → master PID +$info = Emails::status(); // ?DaemonStatus (null when not running) +Emails::stop(); // graceful stop — drains the whole fleet +``` + +`start()` and `dispatch()` are refused if the daemon is already running: a daemon +is a **singleton per class**, guarded by a crash-safe `flock` the master holds for +its lifetime. `status()` returns a `DaemonStatus` — a `ProcessStatus` plus the +fleet: `restarts` and a `workers[]` snapshot. It is a pure read that never mutates +the record, and it works across users (liveness via `posix_getpgid`). + +## The CLI — `call daemon` + +Daemons have their own command, separate from `call process` (bare processes): + +``` +call daemon list # every daemon, with live state + worker count +call daemon main.daemon.Emails # supervise in the foreground +call daemon main.daemon.Emails start -d # supervise detached in the background +call daemon main.daemon.Emails status # status + the per-worker fleet table +call daemon main.daemon.Emails status -v # also the master's resource usage +call daemon main.daemon.Emails stop # graceful stop +call dmn main.daemon.Emails stop # 'dmn' is the alias +``` + +`call daemon list` shows only daemons; bare processes live under `call process`. + +### The per-worker view + +`status` renders the fleet the supervisor sees — one row per slot, so you can tell +at a glance why the fleet is the size it is: + +``` +[ Workers (3) ] + SLOT PID STATE ACT UPTIME RESTARTS + #0 41201 running busy 12m03s 0 + #1 41202 running idle 12m03s 0 + #2 41230 retiring busy 45s 1 +``` + +`STATE` is the slot's authoritative lifecycle (`starting` / `running` / `retiring` +/ `killing` / `restarting` / `retired`); `ACT` is the worker's own `IDLE` / `BUSY` +heartbeat, about a second behind. A `retiring` row explains why the live process +count can exceed the active fleet — that worker is draining on its way out; a +`retired` row is a death the restart policy declined to replace (a `NEVER` worker, +or a clean exit). + +## Background dispatch + +`dispatch()` (CLI `-d`) sends the **whole daemon** to the background via the Thread +launcher: the master detaches, re-parents to init, and keeps running after the +shell closes. The call returns the master PID, and — because the detached +double-fork reports an intermediate PID — the CLI briefly polls the store for the +real master PID before printing it. Once the master is up, it forks its own workers +with `pcntl` (see [Workers](01-workers.md#how-a-worker-is-created-fork-not-thread)). + +``` +CLI: Emails::dispatch() ──Thread──► master (PPID=1) in the background + │ pcntl_fork ×N + ├──► worker#1 + ├──► worker#2 + └──► worker#3 +``` + +## Stopping the fleet + +`stop()` sends SIGTERM to the master, and the master runs a single, ordered stop +sequence — a full stop is simply "retire the whole fleet at once", reusing the same +graceful drain the autoscaler uses: + +1. **Freeze the autoscaler first.** The daemon enters `STOPPING`; reconcile no + longer spawns or restarts anything. This is what stops the restart policy from + resurrecting workers as they exit. +2. **Drain every worker in parallel.** SIGTERM goes to all workers at once; each + drains to idle on its own body (finishing its current unit, refusing new work), + then exits. +3. **Force stragglers.** A worker that outlives its drain deadline is SIGKILLed. + The deadline is the daemon's `$grace` (`0` = wait forever). A **second** stop + signal collapses every deadline to *now* — the operator's "stop now", like a + second `Ctrl+C`. +4. **Tear down.** Once the fleet is empty the master runs `onShutdown()`, removes + its store record, releases the `flock`, and exits `TERMINATED`. + +No worker is orphaned (the master waits for the fleet) and none is restarted (the +autoscaler is frozen). Set `$grace > 0` for a bounded shutdown; leave it `0` to +wait indefinitely for a clean drain. + +## Process titles + +Every process in the tree shares the `winter-daemon:` prefix, so the whole family +is visible — and killable — together: + +``` +winter-daemon: Emails master +winter-daemon: Emails worker#1 +winter-daemon: Emails worker#2 +``` + +```bash +pkill -f 'winter-daemon: Emails' # the entire fleet +``` + +The name is `$processTitle` if set, otherwise the daemon's short class name. + +## Lifecycle states + +The master's `DaemonStatus.state` is one of: + +| State | Meaning | +|---|---| +| `RUNNING` | supervising the fleet | +| `STOPPING` | draining the fleet on a stop request | +| `TERMINATED` | the fleet drained and the master exited | +| `FAILED` | `maxRestarts` was exceeded; the daemon gave up and stopped | + +See [Autoscaling & restart](02-autoscaling.md) for what drives `FAILED`, and +[Workers](01-workers.md) for the body and its fork-safety. diff --git a/docs/schedule/00-overview.md b/docs/schedule/00-overview.md new file mode 100644 index 0000000..347d484 --- /dev/null +++ b/docs/schedule/00-overview.md @@ -0,0 +1,111 @@ +# Winter Schedule — Overview + +Some work is neither a request nor a long-lived consumer: it is a small job that +has to run *again and again on a clock*. Flush a cache every thirty seconds. +Poll a payment gateway for settled transactions every two minutes. Roll yesterday's +metrics into a report five seconds after the last roll finished. There is nothing +to consume from a queue and nothing to keep a connection open for — there is only +a method and a cadence. + +**Schedule** is the layer for that. You annotate an ordinary method with +`#[Scheduled]`, name a cadence, and a single system process — the **Scheduler** — +discovers every such method across the project and fires it on time. It is the +declarative model Spring popularised as `@Scheduled`, expressed with a PHP +attribute and built directly on the [Process](../process/00-overview.md) layer. + +```php +final class ReportService +{ + #[Autowired] private MetricStore $metrics; + + // 5 seconds after each run finishes, run again + #[Scheduled(fixedDelay: 5.0)] + public function roll(): void + { + // ... aggregate and persist ... + } + + // start every 2 seconds, first run 10 seconds after boot + #[Scheduled(fixedRate: 2.0, initialDelay: 10.0)] + public function poll(): void + { + // ... check the gateway ... + } +} +``` + +You never wire the two together. The method carries the *policy* (when to run); +the Scheduler supplies the *mechanism* (discovery, timing, dispatch, isolation). + +## What the Scheduler gives you + +- **Discovery.** On boot it scans the project (and plugins) for every + `#[Scheduled]` method — the same scan the router uses for its mappings. +- **Timing in seconds.** Cadences are floats, in seconds, like the rest of the + kernel (`sleep()`, `grace`) — `fixedDelay: 5.0`, not milliseconds. +- **A concurrency pool.** Each due task is dispatched with the Process + `spawn()` primitive — a coroutine under Swoole — so a slow task never delays + another's clock. A task **never overlaps itself**: an in-flight run holds the + next fire until it finishes. +- **Failure isolation.** A task body is the developer's code and may be wrong — + the Scheduler is built so one bad task cannot take down the schedule. A task + that **throws** is caught and logged; the next fire still happens on time. A + **slow** task runs in its own `spawn`, so it never delays another task's clock, + and it **never overlaps itself** — a run that outlasts its period simply holds + its own next fire, it does not pile up. See + [Usage — robustness](01-usage.md#robustness-against-a-buggy-task-body) for the + one caveat (a CPU-bound task that never yields under the Swoole runtime). +- **One per host.** The Scheduler inherits the Process singleton lock (a + crash-safe `flock`), so a second start refuses — a task is never fired twice by + two schedulers on the same host. +- **Live reload.** `SIGHUP` re-scans the annotated methods without a restart. + +## Triggers + +A `#[Scheduled]` method declares **exactly one** trigger: + +| Trigger | Meaning | +|---|---| +| `fixedDelay: N` | Wait N seconds between the **end** of one run and the **start** of the next. The gap is immune to a run's own duration. | +| `fixedRate: N` | Start every N seconds, measured from the previous **start**. If a run outlasts the period, the next fires once as soon as it frees — missed ticks are dropped, never replayed as a burst. | +| `cron: '…'` | A clock-aligned five-field cron expression (or a macro), for time-of-day work: `0 2 * * *` every night at 02:00, `0 8 * * 1-5` at 08:00 on weekdays, `* * * * *` every minute on the minute, `0 * * * *` hourly on the hour. See [Usage](01-usage.md#cron--clock-aligned). | +| `initialDelay: N` | Seconds to wait before the very first run — with a period trigger only (a cron is already clock-aligned). | + +The period triggers (`fixedDelay` / `fixedRate`) answer "how long between runs"; +`cron` answers "at which wall-clock times". A single project freely mixes both — +a `fixedRate: 5.0` health poll and a `cron: '0 3 * * *'` nightly cleanup live side +by side, each on its own schedule. + +Anything invalid — no trigger, more than one, a non-positive period, a malformed +cron, `initialDelay` with cron, a static method, a method that needs arguments, or +a non-instantiable class — is rejected at discovery with a `ScheduleConfigException`, +so a misconfiguration fails loudly at start rather than silently never firing. + +## Running it + +The Scheduler is one fixed runtime, not a class you write, so it is driven by its +own command rather than by naming a class: + +``` +call schedule list # every #[Scheduled] task and its cadence (no run needed) +call schedule start # run in the foreground +call schedule start -d # run detached in the background +call schedule status # run state + task count +call schedule stop # graceful SIGTERM +``` + +See [Usage](01-usage.md) for the attribute in depth, the execution model, and the +fork-safety note under the non-Swoole runtime. + +## Pages + +| # | File | Contents | +|---|------|----------| +| 00 | this page | What the layer is, triggers, when it runs | +| 01 | [01-usage.md](01-usage.md) | Quick start, recipes, triggers, execution, running in production | + +## See also + +- [`concurrent/05-pools.md`](../concurrent/05-pools.md) — bound a scheduled task to a named pool it shares with the API +- [`concurrent/03-async.md`](../concurrent/03-async.md) — `#[Async]` on a scheduled method +- [`process/00-overview.md`](../process/00-overview.md) — the Process layer the Scheduler runs on diff --git a/docs/schedule/01-usage.md b/docs/schedule/01-usage.md new file mode 100644 index 0000000..9d53245 --- /dev/null +++ b/docs/schedule/01-usage.md @@ -0,0 +1,341 @@ +# Winter Schedule — Usage + +## Quick start + +Three steps, no wiring. + +**1. Annotate a method** on any class the container can build: + +```php +use Flytachi\Winter\Kernel\Schedule\Scheduled; +use Psr\Log\LoggerInterface; + +class ReportService +{ + #[Autowired] private LoggerInterface $logger; + + #[Scheduled(cron: '0 2 * * *')] // every night at 02:00 + public function nightly(): void + { + $this->logger->info('rolling yesterday’s metrics'); + // ... work ... + } +} +``` + +**2. See what was found** (a static scan — nothing has to be running): + +``` +$ call schedule list + TASK TRIGGER + App\ReportService::nightly cron 0 2 * * * +``` + +**3. Run the scheduler:** + +``` +call schedule start # foreground (Ctrl-C to stop) +call schedule start -d # background; stop with: call schedule stop +``` + +That is the whole loop. Everything below is detail on the triggers, the execution +model, and running it in production. + +## The attribute + +`#[Scheduled]` marks a method to be run on a cadence. It is repeatable, so one +method may carry several triggers. + +```php +use Flytachi\Winter\Kernel\Schedule\Scheduled; + +#[Scheduled(fixedDelay: 5.0)] +public function flush(): void { /* ... */ } +``` + +The method must be: + +- **public**, **non-static**, and take **no required arguments** — the Scheduler + resolves the declaring class from the container on each fire and calls the + method with no arguments; +- declared on an **instantiable** class (an interface or an abstract class cannot + be resolved). + +Its class needs no marker or base type: any DI-resolvable class works, and its +`#[Autowired]` dependencies are injected as usual, because the instance comes from +the container. + +## Choosing a trigger + +Declare **exactly one** of `fixedDelay`, `fixedRate`, or `cron`. `initialDelay` is +independent and may accompany either period. + +### fixedDelay — steady gap between runs + +```php +#[Scheduled(fixedDelay: 5.0)] +public function roll(): void { /* ... */ } +``` + +The next start is measured from the moment the previous run **finished**. A run +that takes eight seconds is still followed by a full five-second gap. Two runs of +the same task therefore can never overlap. Use it when what matters is breathing +room between runs — most maintenance jobs. + +### fixedRate — steady cadence + +```php +#[Scheduled(fixedRate: 2.0)] +public function poll(): void { /* ... */ } +``` + +The next start is measured from the previous **start**, so the cadence is +independent of how long a run takes. If a run outlasts the period, the Scheduler +holds the next fire until the run finishes (a task never overlaps itself) and then +fires it once immediately — the missed ticks are dropped, not replayed as a burst. +Use it when you want a run "about every N seconds" regardless of duration. + +### cron — clock-aligned + +```php +#[Scheduled(cron: '0 2 * * *')] +public function nightlyCleanup(): void { /* ... */ } +``` + +Use cron when a run must land at specific wall-clock times rather than at an +interval. The expression has five fields — `minute hour day-of-month month +day-of-week` — each accepting a star, a number, an `a-b` range, a `/step`, and +comma lists. Day-of-week is `0-6` with Sunday `0` (or `7`); when day-of-month and +day-of-week are both restricted the day matches their **union** (the standard +cron rule). Common macros are accepted: `@yearly`, `@monthly`, `@weekly`, +`@daily` (= `@midnight`), `@hourly`. + +| You want | Expression | +|---|---| +| Every night at 02:00 | `0 2 * * *` | +| Every morning at 08:00 on weekdays | `0 8 * * 1-5` | +| Every minute, on the minute | `* * * * *` | +| Every hour, on the hour | `0 * * * *` | +| Every 15 minutes | `*/15 * * * *` | +| Twice a day, 06:30 and 18:30 | `30 6,18 * * *` | +| First of every month, midnight | `@monthly` | + +Cron fires on the local timezone and is clock-aligned, so it ignores +`initialDelay` (setting both is a configuration error). A malformed expression is +rejected at discovery, not silently ignored. + +### initialDelay — delay the first run + +```php +#[Scheduled(fixedRate: 2.0, initialDelay: 10.0)] +public function warmThenPoll(): void { /* ... */ } +``` + +Seconds to wait after boot before the first run — handy for letting dependencies +warm up, or staggering tasks that would otherwise all fire at boot. + +## Recipes + +Copy-paste starting points for the common cadences. + +```php +// every 30 seconds, measured from the start of each run +#[Scheduled(fixedRate: 30.0)] +public function poll(): void { /* ... */ } + +// 5 seconds after each run finishes (gap immune to run duration) +#[Scheduled(fixedDelay: 5.0)] +public function drainQueue(): void { /* ... */ } + +// warm up, then poll every 10s starting 60s after boot +#[Scheduled(fixedRate: 10.0, initialDelay: 60.0)] +public function refreshCache(): void { /* ... */ } + +// every night at 02:00 +#[Scheduled(cron: '0 2 * * *')] +public function nightlyCleanup(): void { /* ... */ } + +// 08:00 on weekdays +#[Scheduled(cron: '0 8 * * 1-5')] +public function weekdayReport(): void { /* ... */ } + +// top of every hour +#[Scheduled(cron: '0 * * * *')] +public function hourlyRollup(): void { /* ... */ } + +// every 15 minutes, on the quarter +#[Scheduled(cron: '*/15 * * * *')] +public function everyQuarterHour(): void { /* ... */ } + +// two triggers on one method — both fire it +#[Scheduled(cron: '0 9 * * *')] // 09:00 daily +#[Scheduled(cron: '0 17 * * *')] // and 17:00 daily +public function twiceADay(): void { /* ... */ } +``` + +## The execution model + +The Scheduler loops: on each pass it fires every task that is due and not already +in flight, then sleeps until the next task is due (bounded, so it stays responsive +to a stop). A fired task is dispatched with the Process `spawn()` primitive: + +- **Under Swoole**, `spawn()` is a real coroutine — tasks run concurrently in one + process, sharing memory, and a slow task never delays another's clock. +- **Without Swoole**, `spawn()` forks a child per run. + +Concurrency across *different* tasks is bounded by the Scheduler's `spawn` pool; +the same task never runs concurrently with itself, matching a single-threaded +scheduler's default behaviour. + +A task that throws is caught and logged; the schedule is unaffected and the next +fire happens on time. + +## Robustness against a buggy task body + +A task body is application code and will sometimes be wrong. The Scheduler is +designed so a single bad task cannot derail the schedule or the other tasks: + +- **A task that throws** is caught and logged, and the next fire happens on time. + The exception never reaches the loop. +- **A slow task** runs in its own `spawn`, so it does not delay when any other + task fires. Its own next fire is held until it finishes (a task never overlaps + itself), so a run that drifts over its period does not stack up a backlog. +- **A task stuck in a fire-and-forget fork** (the non-Swoole runtime) is isolated + in its own child process; the Scheduler is unaffected. + +The one case the runtime cannot defend against is inherent to cooperative +coroutines: under Swoole, a task that runs a **long CPU-bound loop and never +yields** (no I/O, no `sleep`) holds the single reactor thread, freezing the +Scheduler loop and every other task until it returns. This is not specific to +scheduling — it is how coroutines work. If a task must do heavy, non-yielding CPU +work, either break it into chunks that yield (`\Swoole\Coroutine::sleep(0)` / +periodic I/O), or run the Scheduler under the fork runtime so each run is a +separate process. Blocking *I/O* is not a problem: Swoole's runtime hooks turn it +into a yield. + +## Bounding concurrency with a named pool + +A scheduled method may also be an `#[Async]` method — the two compose. When the +scheduler fires it, the `#[Async]` proxy routes the body onto its executor, exactly +as it would for an API call. Point both at the same **named pool** and scheduled +runs and API-triggered runs share one bounded set of workers. + +Register the pool once (a fixed-size executor) in your Boot's `providers()`: + +```php +use Flytachi\Winter\Kernel\Concurrent\Executors; + +protected static function providers(Container $c): void +{ + // at most 5 running at once; unbounded wait queue (the default) + $c->singleton('mailPool', fn() => Executors::newFixedExecutor(5)); +} +``` + +Then reference it by id from `#[Async]`: + +```php +class MailService +{ + #[Scheduled(cron: '* * * * *')] // the scheduler fires it every minute + #[Async('mailPool')] // …onto mailPool + public function drain(): void { /* ... */ } +} + +// an API endpoint can trigger the same work onto the same pool +$this->mail->drain(); // returns immediately, queued on mailPool +``` + +Both paths now funnel through `mailPool`: at most 5 concurrent runs across the +scheduler and the web workers **of one process**. Two things to keep in mind: + +- **Register it as a `singleton`.** The cap lives in the pool instance's state, so + every `#[Async('mailPool')]` call must resolve the *same* instance. A `bind()` / + transient registration would hand out a fresh pool per call and the cap would + never hold. +- **The pool is per-process.** The scheduler process and each web worker hold + their own `mailPool` instance, so the cap is 5 *per process*, not 5 globally. + A single shared cap across processes/hosts needs an external broker, not this + in-memory pool. +- **With `#[Async]`, the scheduler's own no-overlap guard steps aside** (the async + hand-off returns immediately), so overlap is governed by the pool. That is what + the pool is for — bound it (`Executors::newFixedExecutor(5)`), and a slow run + simply queues instead of piling up unbounded coroutines. + +The pool enforces its cap only under Swoole (coroutines). Without coroutines +(FPM, plain CLI) there is no parallelism to bound, so tasks run sequentially and +the size is a no-op. For cost control set a bounded queue and a reject policy: + +```php +use Flytachi\Winter\Kernel\Concurrent\RejectPolicy; + +$c->singleton('mailPool', fn() => Executors::newFixedExecutor( + concurrency: 5, queue: 50, onReject: RejectPolicy::DISCARD, +)); +``` + +## Fork-safety under the non-Swoole runtime + +Under Swoole — the intended runtime — a scheduled task runs in a coroutine and +shares the process's resources safely. Under the fork runtime, `spawn()` forks a +child per run and, unlike a daemon worker, does **not** run the `ForkReset` hooks +(a scheduler is not a daemon). A task that uses a fork-unsafe resource (a shared DB +connection, a pool) directly from a forked run can therefore corrupt it. If you +must run the Scheduler without Swoole, keep scheduled tasks fork-safe — open and +close their own resources inside the run. + +## Discovering and running + +List every task without starting anything — a static scan: + +``` +$ call schedule list + TASK TRIGGER + App\ReportService::roll fixedDelay 5s + App\ReportService::poll fixedRate 2s +``` + +Run it in the foreground (blocks) or detached: + +``` +call schedule start +call schedule start -d +``` + +Inspect and stop it from any terminal: + +``` +call schedule status +call schedule stop # graceful SIGTERM +``` + +`SIGHUP` (or restarting) re-scans the annotated methods, so newly added or removed +tasks take effect without a full restart: + +``` +kill -HUP +``` + +## Advanced: sourcing tasks yourself + +Discovery is an override point. Subclass `Scheduler` and override `discover()` to +supply `ScheduledTask`s from another source — for example a table of cron rows — +instead of, or in addition to, annotation scanning: + +```php +final class DbScheduler extends Scheduler +{ + protected function discover(): array + { + $tasks = parent::discover(); // keep annotated methods + foreach ($this->rows() as $row) { + $tasks[] = new ScheduledTask( + $row->class, + $row->method, + new FixedRateTrigger($row->seconds), + ); + } + return $tasks; + } +} +``` diff --git a/docs/starter.md b/docs/starter.md deleted file mode 100644 index b8b100f..0000000 --- a/docs/starter.md +++ /dev/null @@ -1,457 +0,0 @@ -# Winter — Project Starter - -The recommended way to start a Winter project is the -[`flytachi/winter`](https://github.com/flytachi/winter) starter -repository. It is a minimal Composer **project** package that pulls in -`flytachi/winter-kernel`, ships a single `Boot` class with every hook -documented in-place, and is ready to run via FPM or CLI after one -command. - -This page walks the starter end-to-end so you can rebuild it by hand -in an existing repo, or simply understand what each file does. - ---- - -## TL;DR — create a project - -```bash -composer create-project flytachi/winter my-app -cd my-app -php call run dev # http://0.0.0.0:8000 -``` - -`composer create-project` runs `post-create-project-cmd`, which does: - -1. `chmod -R 777 storage` -2. `php call cfg init` — patches `composer.json` (rewrites `name` to - `project/`, blanks `authors`, removes keywords), copies `.env` - from template, generates a fresh 64-char `WINTER_KEY`, and drops the - PhpStorm meta stub. -3. Prints a tip about installing shell completion. - -After that you have a working project — open `main/MainController.php` -and start writing. - ---- - -## Directory layout - -``` -my-app/ -├── bootstrap.php — defines Boot extends BaseBoot (all hooks documented) -├── call — CLI entry; runs Boot::cli($argv) -├── composer.json — project package; depends on flytachi/winter-kernel -├── public/ -│ ├── index.php — FPM / Swoole front controller; runs Boot::web() -│ └── static/ — web-served assets -├── main/ — PSR-4 root (namespace Main\) -│ └── MainController.php -├── storage/ -│ ├── cache/ — kernel + app caches (mapping.php, di.php, …) -│ └── logs/ — log files when LOG_OUTPUT=file -├── .env — environment variables (WINTER_KEY, LOG_*, etc.) -└── vendor/ -``` - -Three things you'd add as the project grows: - -| Add when… | Path | -|-------------------------------------------|---------------------| -| You need view templates | `resources/` | -| You add a Swoole runtime | `server.php` | -| You need a thread/job executor binary | `wKernelExecutor` | - ---- - -## `composer.json` - -```json -{ - "name": "flytachi/winter", - "type": "project", - "scripts": { - "post-create-project-cmd": [ - "chmod -R 777 storage", - "@php call cfg init" - ], - "dev-server": "@php -S 0.0.0.0:8000 -t ./public" - }, - "autoload": { - "psr-4": { "Main\\": "main/" } - }, - "require": { - "php": ">=8.3", - "flytachi/winter-kernel": "^3.0" - } -} -``` - -Key points: - -- **`type: project`** — not a library; it's an application scaffold. -- **One PSR-4 prefix** (`Main\\` → `main/`) — add more as you split - things into modules. The kernel's `Scanner` and `Router` follow - every PSR-4 root, so any additional namespace is auto-discovered. -- **`post-create-project-cmd`** delegates the heavy lifting to - `cfg init` (see [`console/03-cfg.md`](console/03-cfg.md#cfg-init)) so - the recipe stays single-source. - ---- - -## `bootstrap.php` — the `Boot` class - -The whole framework configuration lives in **one file**: a class that -extends `BaseBoot` and overrides only the hooks you need. Hooks are -called in a fixed order from every entry point. - -### Hook order - -``` -1. configure() ← Kernel::init() — paths, .env, logging, timezone -2. DI scan ← auto-discovers #[Singleton] / #[Request] / #[Transient] -3. providers($c) ← manual bindings, factories, scalar values -4. channels() ← extra log channels beyond sys / http / cli -5. plugins() ← route-prefixed sub-applications -6. httpCors() ← global CORS policy -7. health() ← /actuator endpoints -``` - -### Minimal `Boot` - -```php -register(AppServiceProvider::class); - // $c->singleton(CacheInterface::class, RedisCache::class); - // $c->bind(MailerInterface::class, fn(Container $c) => - // new SmtpMailer(env('MAIL_HOST'), $c->make(LoggerInterface::class)) - // ); - // $c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); - } - - /** - * Logging — extra channels beyond sys / http / cli. - * Each channel reads LOG_{NAME}_* env with the same fallback chain. - */ - protected static function channels(): void - { - // Kernel::channel('job'); - // Kernel::channel('daemon'); - } - - /** - * CORS — global policy, applied to every response (incl. 404/500). - * Per-route overrides via #[CrossOrigin]. - */ - protected static function httpCors(): void - { - // Cors::configure( - // origins: ['https://app.example.com'], - // allowHeaders: ['Content-Type', 'Authorization'], - // credentials: true, - // maxAge: 3600, - // ); - } - - /** - * Health — diagnostic endpoints under /actuator. - * /actuator full report - * /actuator/health up | degraded | down - * /actuator/info PHP / SAPI / framework meta - * /actuator/metrics CPU / memory / disk / opcache / uptime - * /actuator/env custom env values - * /actuator/loggers active channels and levels - * /actuator/mappings registered route table - */ - protected static function health(): void - { - // Health::configure(); - // Health::configure( - // indicator: App\Health\AppHealthIndicator::class, - // middleware: App\Http\Middleware\InternalOnlyMiddleware::class, - // ); - } - - /** - * Plugins — route-prefixed sub-applications. Each plugin's src/ - * is scanned for controllers automatically. - */ - protected static function plugins(): void - { - // Plugin::registry('acme/auth', '/auth'); - // Plugin::registry('acme/billing', '/billing'); - } -} -``` - -Every hook is `protected static` — override only what you need. The -defaults are no-ops or sane fallbacks. For deep reference, see -[`configuration/01-kernel.md`](configuration/01-kernel.md). - ---- - -## Entry points - -Each runtime has its own one-liner: - -| File | Runtime | Body | -|-------------------------|-----------------------|-------------------| -| `public/index.php` | PHP-FPM / built-in dev| `Boot::web()` | -| `server.php` (optional) | Swoole HTTP server | `Boot::swoole()` | -| `call` | CLI | `Boot::cli($argv)`| -| `wKernelExecutor` (optional) | Thread / job runner | `Boot::executor($argv)` | - -### `public/index.php` - -```php -= 80300) { - chdir(__DIR__); - require './bootstrap.php'; - Boot::cli($argv); -} else { - echo "Please use PHP version 8.3 or higher.\n"; -} -``` - -Three things to note: - -1. **PHP version guard** — fail fast with a clear message instead of a - parse error on older runtimes. -2. **`chdir(__DIR__)`** — pins CWD to the project root so the `.env` - lookup and relative paths inside `bootstrap.php` resolve correctly - no matter where you invoke `call` from. -3. **`Boot::cli($argv)`** — dispatches to `console/Command/*` (see - [`console/00-overview.md`](console/00-overview.md)). - -Make sure it's executable: `chmod +x call`. - ---- - -## `.env` - -Minimal starter: - -```dotenv -WINTER_KEY=72aedae44ec31dec144eb297bdfcf64005b250b0562f428ab50bdd9da2e521d2 -TIME_ZONE=UTC -DEBUG=true - -LOG_LEVEL=info -LOG_FORMAT=line -LOG_OUTPUT=auto -#LOG_FILE=/var/log/app/winter.log -LOG_FILE_MAX=30 -LOG_SYSLOG_IDENT=winter -``` - -Variables: - -| Variable | Effect | -|--------------------|------------------------------------------------------------------------| -| `WINTER_KEY` | 32-byte project secret — signing keys, tokens | -| `TIME_ZONE` | `date_default_timezone_set()` source | -| `DEBUG` | `true` — disables route + DI caches (always live scan), surfaces stack traces in logs and exception responses | -| `LOG_LEVEL` | Minimum severity: `DEBUG / INFO / NOTICE / WARNING / ERROR / …`. **Empty → logging disabled** (NullLogger on all channels) | -| `LOG_FORMAT` | `line` or `json` | -| `LOG_OUTPUT` | `auto / stdout / stderr / syslog / file / null` — `auto` picks syslog in Docker/K8s, `stdout` under Swoole, `stderr` under FPM/CLI | -| `LOG_FILE` | Absolute path when `LOG_OUTPUT=file` | -| `LOG_FILE_MAX` | Number of daily-rotating files to keep | -| `LOG_SYSLOG_IDENT` | Program identity tag in syslog (`journalctl -t winter`) | - -For per-channel overrides (`LOG_HTTP_*`, `LOG_CLI_*`, custom channels) -see [`configuration/02-logging.md`](configuration/02-logging.md). - -**Regenerate the key** any time: - -```bash -php call cfg key -g -``` - ---- - -## `main/MainController.php` - -The starter ships one controller as a smoke test: - -```php - -- **Kernel**: -- **`BaseBoot`**: `vendor/flytachi/winter-kernel/src/BaseBoot.php` -- **`Kernel::init()`**: `vendor/flytachi/winter-kernel/src/Kernel.php` - -## See also - -- [`configuration/01-kernel.md`](configuration/01-kernel.md) — every `Boot` hook in depth -- [`configuration/02-logging.md`](configuration/02-logging.md) — `LOG_*` env reference -- [`configuration/03-cors.md`](configuration/03-cors.md) — global vs per-route CORS -- [`configuration/04-health.md`](configuration/04-health.md) — `/actuator` setup -- [`configuration/05-plugins.md`](configuration/05-plugins.md) — `Plugin::registry()` -- [`configuration/06-db.md`](configuration/06-db.md) — DB config classes -- [`console/00-overview.md`](console/00-overview.md) — the `call` CLI -- [`console/03-cfg.md`](console/03-cfg.md) — `cfg init` / `key` / `env` / `docker` -- [`architecture/01-routing.md`](architecture/01-routing.md) — controller discovery diff --git a/docs/starter/00-quickstart.md b/docs/starter/00-quickstart.md new file mode 100644 index 0000000..05b6f8b --- /dev/null +++ b/docs/starter/00-quickstart.md @@ -0,0 +1,284 @@ +# Winter — Quickstart (from zero to running) + +A Winter project is not scaffolded. There is no skeleton to clone and no directory tree +to create: you require the kernel, write one class that says what the application +contains, and run it. Everything else — the DI graph, the route table, the storage +directories — is discovered or created on demand. + +This page walks that from an empty directory to a served request, then to a background +worker. For the shortest possible path, the [README](../../README.md) has it in two files. + +--- + +## The mental model + +``` +call ──► bootstrap.php ──► Application::main($argv) + │ + ├─ boot: Kernel::init → scan → DI, configurers, plugins + │ + ├─ `run` → serve the components in the manifest + └─ else → hand the verb to the console +``` + +Two ideas carry the design: + +- **The manifest is declarative.** `#[Enable*]` attributes on the application class say + *what the application is made of*. Nothing is registered by hand. +- **Configuration is discovered, not injected.** There are no hooks to override on the + application class. A `WebConfigurer`, a `#[Configuration]` class, a `LoggingConfigurer` + — the scan finds them wherever they live. + +--- + +## Step 1 — composer + +```bash +composer require flytachi/winter-kernel +``` + +Give your own code a namespace in `composer.json`: + +```json +{ + "autoload": { + "psr-4": { "Main\\": "main/" } + } +} +``` + +```bash +composer dump-autoload +``` + +Nothing forces the name `Main\` or the directory `main/` — the scan walks the project +root, so any autoloadable layout works. + +## Step 2 — the application class + +`bootstrap.php` is the only file the entry points share. It loads the autoloader and +declares the application: + +```php + $id]; + } +} +``` + +Two requirements, both easy to miss: + +- the class **extends `Controller`** — the scan collects controllers by that, not by an + attribute, so a class carrying only mapping attributes contributes no routes; +- the class-level `#[RequestMapping]` prefix combines with each method path, making the + route above `/users/{id}`. + +```bash +php call run +curl localhost:8000/users/42 # {"id":42} +``` + +Method arguments are filled by annotation — `#[PathVariable]`, `#[RequestParam]`, +`#[RequestBody]`, `#[RequestHeader]` — or by type, where an `HttpRequest` or +`HttpResponse` parameter receives the raw object. See +[`../architecture/04-request/00-overview.md`](../architecture/04-request/00-overview.md). + +## Step 5 — configuration, when you need it + +`.env` is optional and every variable has a working default. Development usually wants: + +```dotenv +DEBUG=true +LOG_LEVEL=debug +``` + +Web settings live in a class the scan finds — not in the application class: + +```php +port(8000) + ->profile(Profile::Performance) // small requests — see 09-web-server.md + ->staticPath('resources/static'); // resources/static/app.css → /app.css + } +} +``` + +Static serving is opt-in: omit `staticPath()` and no file is ever served, which is what +an API-only service wants. CORS is configured in the same class — see +[`../configuration/03-cors.md`](../configuration/03-cors.md), and +[`../configuration/02-logging.md`](../configuration/02-logging.md) for log channels. + +## Step 6 — a background component + +Long-lived work is a **Process** (one worker) or a **Daemon** (a supervised fleet): + +```php +isRunning()) { + $this->sleep(1.0); + // ... work ... + } + } +} +``` + +Run it on its own: + +```bash +php call process main.process.EmailWorker start # foreground +php call process main.process.EmailWorker start -d # detached +php call process main.process.EmailWorker status +php call process main.process.EmailWorker stop +``` + +…or beside the server, by adding it to the manifest: + +```php +#[EnableWeb] +#[EnableProcess(\Main\Process\EmailWorker::class)] +final class Application extends WinterApplication { /* ... */ } +``` + +Scheduled methods work the same way — annotate with `#[Scheduled]`, add +`#[EnableScheduler]`. See [`../process/00-overview.md`](../process/00-overview.md) and +[`../schedule/00-overview.md`](../schedule/00-overview.md). + +--- + +## Project layout + +Only two directories are conventional, and both appear on demand: + +``` +composer.json +bootstrap.php the application class +call the entry point +main/ your code (any autoloadable namespace) +resources/ + static/ web assets — served only when staticPath() says so + views/ ResponseView's default root +storage/ + logs/ cache/ runnable/ created when first used, never committed +``` + +`storage/` is deliberately separate from `resources/`: one is written by the runtime, +the other is read by it. Views are `include`d PHP, so the directory the framework +executes from is never the directory it writes to. + +## Deployment shapes + +| Shape | Command | Notes | +|---|---|---| +| Server | `php call run` | one long-lived Swoole process; log to stdout and let the orchestrator collect | +| Server + workers | `php call run` with `#[EnableProcess]` / `#[EnableDaemon]` | workers supervised beside the server | +| Headless | `php call run` with no `#[EnableWeb]` | workers and scheduler only, no HTTP | +| One-off | `php call ` | console commands, migrations, diagnostics | + +In containers keep `LOG_OUTPUT` at its default (stdout) and let the orchestrator collect +it; give `storage/` a volume only if the runtime records must survive a restart. + +## Where to go next + +- [`../architecture/01-routing.md`](../architecture/01-routing.md) — routing and the request pipeline +- [`../configuration/07-di.md`](../configuration/07-di.md) — dependency injection +- [`../ppa/00-overview.md`](../ppa/00-overview.md) — the database layer +- [`../process/00-overview.md`](../process/00-overview.md) — processes and daemons +- [`../console/00-overview.md`](../console/00-overview.md) — the console diff --git a/docs/threads/00-overview.md b/docs/threads/00-overview.md deleted file mode 100644 index fd306d3..0000000 --- a/docs/threads/00-overview.md +++ /dev/null @@ -1,263 +0,0 @@ -# Winter Threads — Overview - -The Threads unit covers everything the kernel does outside the HTTP -request cycle: one-shot background jobs, long-running worker pools, -singleton daemons, and WebSocket servers. - -All four thread types share a single hierarchy: - -``` -Runnable (winter-thread) - ↑ -Dispatchable (src/Process/Core/Dispatchable.php) - ↑ -Dispatch ─── abstract base for every thread type - ↑ - ├── ThreadJob ← Stereotype\Job - ├── ThreadProcess ← Stereotype\Process - ├── ThreadDaemon ← Stereotype\Daemon - └── ThreadWebSocket ← Stereotype\WebSocket -``` - -The four `Stereotype\*` classes are paper-thin aliases — your code -extends one of them, the framework owns everything else. - ---- - -## Stereotype map - -| Stereotype | Internal class | `exNamespace` | Forks? | Singleton? | Use case | -|-------------|-------------------|---------------|---------|------------|-----------------------------------| -| `Job` | `ThreadJob` | `job` | no | no | One-shot fire-and-forget work | -| `Process` | `ThreadProcess` | `process` | yes | no | Long-lived worker pool | -| `Daemon` | `ThreadDaemon` | `daemon` | yes | yes | Single supervised long-running service | -| `WebSocket` | `ThreadWebSocket` | `web-socket` | no | no | TCP WebSocket server | - -`exNamespace` shows up in `ps` via `cli_set_process_title()`, so you can -spot Winter processes at a glance: - -``` -Winter daemon -> runnable App.Daemons.Cleanup -Winter job(fork) -> fork App.Jobs.SendInvoice -``` - ---- - -## Entry points - -Every `Dispatchable` exposes two static entry points (defined on -`Dispatch`, made `final` on each thread type): - -| Method | Behavior | -|------------------------------|-------------------------------------------------------| -| `Class::start($data = null)` | Foreground — runs in the **current** process, blocks | -| `Class::dispatch($data = null)` | Background — forks via `Thread::start()`, returns child PID | - -`Daemon` additionally exposes: - -| Method | Behavior | -|---------------------------------|----------------------------------------------------| -| `Class::status(bool $showStats=false)` | `?TDInfo` — current PID, condition, optional `ps` stats | -| `Class::stop()` | `bool` — send SIGINT to the running instance | - -From the CLI: - -```bash -call thread app.threads.jobs.SendInvoice # foreground -call thread app.threads.jobs.SendInvoice -d # background -call thread list # discover Dispatchable classes -call thread daemons # daemons with live status -``` - -See [`../console/09-thread.md`](../console/09-thread.md). - ---- - -## Lifecycle inside `Dispatch::run()` - -``` -Dispatch::dispatch($data) / ::start($data) - ↓ -Container::make(static::class) ← DI: constructor injection + - #[Autowired] both work here - ↓ -DispatchStore::push($key, $data) ← only if $data is non-empty - ↓ -Thread::start(['storeKey' => $key]) ← fork (or run in-place for ::start) - ↓ (child / current process) -Dispatch::run($args) - ├── resolutionStart() ← logger + signal handler - ├── resolution($data) ← YOUR CODE - └── resolutionEnd() ← cleanup hook (per-stereotype) -``` - -`run()` wraps `resolution()` in `try/catch/finally`; uncaught throws are -logged through the resolved logger, and the child exits cleanly. - ---- - -## DI in threads - -`Dispatch::dispatch()` / `Dispatch::start()` instantiate the class via -`Container::getInstance()->make(static::class)`. That means **both -constructor injection and `#[Autowired]` properties work** the same way -as in controllers / services / console commands: - -```php -use Flytachi\Winter\K2\Stereotype\Job; -use Flytachi\Winter\DI\Attribute\Autowired; - -class SendInvoice extends Job -{ - #[Autowired] - private MailService $mail; - - public function __construct( - private InvoiceRepository $invoices, // also injected - ) {} - - public function resolution(mixed $data = null): void - { - $invoice = $this->invoices->find($data['id']); - $this->mail->send($invoice); - } -} -``` - -The container is re-resolved **inside the child process** (after fork), -so dependencies are fresh — DB handles, log channels, file streams are -not inherited from the parent. - ---- - -## Passing data into a thread (`DispatchStore`) - -Forked PHP processes don't share heap. To get data into the child, -`Dispatch` marshals `$data` through `DispatchStore`: - -``` -parent: Class::dispatch(['orderId' => 42]) - ↓ -DispatchStore::push("cache-abc123", ['orderId' => 42]) ← Kernel::volatile('dispatcher') - ↓ -Thread::start(arguments: ['storeKey' => 'cache-abc123']) ← fork - ↓ -child: $data = DispatchStore::pop("cache-abc123") ← read + delete - ↓ -resolution($data) -``` - -`pop()` is destructive — the key is removed after the read, so the -side-channel file is short-lived. For `::start()` (foreground) the same -mechanism is used; the data round-trip just happens in the same process. - -Pass anything serializable — arrays, scalars, DTOs. Don't pass live -resources (connections, file handles), they don't survive the round trip. - ---- - -## Logger - -Each child gets its own logger in `resolutionStart()`: - -```php -$this->logger = LoggerFactory::getLogger(static::class); -``` - -The class name is the channel, so per-thread logs sort naturally by -`App\Jobs\SendInvoice`, `App\Daemons\Cleanup`, etc. Configure log sinks -via the `LOGGER_*` env vars — see -[`../configuration/02-logging.md`](../configuration/02-logging.md). - ---- - -## Signal handling - -`ThreadSignalHandler` (mixed into Job / Process / Daemon / WebSocket) -wires three POSIX signals to overridable hooks. Async signals are -enabled (`pcntl_async_signals(true)`): - -| Signal | Internal handler | Override | Default log | -|----------|--------------------|---------------------------|-------------| -| `SIGHUP` | `signClose()` | `asClose()` | notice CLOSE | -| `SIGINT` | `signInterrupt()` | `asInterrupt()` | notice INTERRUPTED | -| `SIGTERM`| `signTermination()`| `asTermination()` | warning TERMINATION | - -`Process` and `Daemon` additionally propagate the signal to their child -forks (`posix_kill($childPid, $sig)` + `pcntl_waitpid`) and expose a -`asChildXxx()` family — useful when the parent and children need -different shutdown logic (e.g. the parent flushes a queue, the child -just exits). - -For event loops that don't naturally `read()`/`select()`, call -`pcntl_signal_dispatch()` periodically so signals are delivered while -busy. - ---- - -## Process tagging (`ps` visibility) - -`cli_set_process_title()` is called by `Dispatch` and again on every -fork (`ThreadFork::forkStart()`), producing predictable patterns: - -``` -Winter -> -Winter (fork) -> fork -Winter (fork) -> anonymous -``` - -| Field | Default | Override via | -|----------------|----------------------|-----------------------------| -| `exNamespace` | `dispatch` | per stereotype (`job`, `daemon`, `process`, `web-socket`) | -| `exTag` | `runnable` | replaced inside forks | -| `exName` | `null` | set in subclass property | - ---- - -## Choosing a stereotype - -``` -Need: Stereotype: -───────────────────────────────────── ───────────── -One unit of work, then exit Job -Long-running worker that forks per task Process -Long-running singleton + state + control Daemon -Live WebSocket server WebSocket -``` - -| Concern | Job | Process | Daemon | WebSocket | -|--------------------------------------|:---:|:-------:|:------:|:---------:| -| Forks children | | ✓ | ✓ | | -| Single-instance lock (`status()`) | | | ✓ | | -| State persistence (`DaemonStore`) | | | ✓ | | -| Rate-limited stream (`streaming()`) | | | ✓ | | -| Built-in protocol loop | | | | ✓ | - ---- - -## Per-type pages - -| Page | Stereotype | When to read it | -|---------------------|-------------|------------------------------------------| -| [01-job.md](01-job.md) | `Job` | The simplest case — start here | -| [02-process.md](02-process.md) | `Process` | Forking workers + signal propagation | -| [03-daemon.md](03-daemon.md) | `Daemon` | Singletons, status, streaming | -| [04-websocket.md](04-websocket.md) | `WebSocket` | TCP WebSocket server | - ---- - -## Source - -- Contracts: `src/Process/Core/Dispatchable.php`, `Dispatch.php` -- Stores: `DispatchStore.php`, `DaemonStore.php` -- Stereotypes: `src/Stereotype/{Job,Process,Daemon,WebSocket}.php` -- Internal classes: `src/Process/Thread{Job,Process,Daemon}.php`, - `src/Process/Socket/Web/ThreadWebSocket.php` -- Traits: `src/Process/Traits/Thread*Handler.php`, `ThreadFork.php`, - `ThreadDaemonFork.php`, `ThreadDaemonStatement.php`, `ThreadSignalHandler.php` - -## See also - -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread ` / `list` / `daemons` -- [`../console/02-make.md`](../console/02-make.md) — scaffolding (`-J`, `-P`, `-N`, `-W`) -- [`../configuration/02-logging.md`](../configuration/02-logging.md) — log channels diff --git a/docs/threads/01-job.md b/docs/threads/01-job.md deleted file mode 100644 index d3f588b..0000000 --- a/docs/threads/01-job.md +++ /dev/null @@ -1,159 +0,0 @@ -# Job — fire-and-forget background tasks - -A `Job` is the simplest `Dispatchable` — runs `resolution()` once, -no forking inside the body, no state, no cluster lock. Use it for -one-shot work that must outlive the originating request. - ---- - -## Stereotype - -```php -namespace App\Threads\Jobs; - -use Flytachi\Winter\K2\Stereotype\Job; -use Flytachi\Winter\DI\Attribute\Autowired; - -class SendInvoice extends Job -{ - #[Autowired] - private MailService $mail; - - public function __construct( - private InvoiceRepository $invoices, - ) {} - - public function resolution(mixed $data = null): void - { - $invoice = $this->invoices->find($data['orderId']); - $this->mail->send($invoice); - } -} -``` - -`Job extends ThreadJob extends Dispatch` — three layers, but everything -you implement lives in `resolution()`. `exNamespace` is hardcoded to -`'job'`. - -DI is real: both constructor injection and `#[Autowired]` resolve -when `Container::make()` instantiates the class inside the child. - ---- - -## Running - -| Call | Behavior | -|-------------------------------------------------------------------|----------| -| `SendInvoice::start(['orderId' => 42])` | Foreground — blocks the caller | -| `SendInvoice::dispatch(['orderId' => 42])` | Background fork; returns child PID | -| `call thread app.threads.jobs.SendInvoice` | CLI foreground | -| `call thread app.threads.jobs.SendInvoice -d` | CLI background | - -`$data` is whatever serializable payload the job needs. It is marshaled -to the child through `DispatchStore` (see -[00-overview.md](00-overview.md#passing-data-into-a-thread-dispatchstore)). - ---- - -## Lifecycle - -``` -Dispatch::dispatch($data) - ↓ -Container::make(static::class) ← fresh DI instance - ↓ -DispatchStore::push($key, $data) ← if $data non-empty - ↓ -Thread::start(['storeKey' => $key]) ← fork - ↓ (inside the child) -Dispatch::run($args) - ├── resolutionStart() ← logger + signal handler - ├── resolution($data) ← YOUR CODE - └── resolutionEnd() ← no-op for Job -``` - -`ThreadJob::resolutionEnd()` is `final` and empty — Jobs don't need -post-work cleanup. If you need it, put it inside `resolution()`'s own -`try/finally`. - ---- - -## Signals - -`ThreadJobHandler` wires `SIGHUP` / `SIGINT` / `SIGTERM` to immediate -exit hooks. Each first calls `resolutionEnd()`, then your overridable -hook, then `exit()`: - -| Signal | Internal handler | Override | Default log | -|----------|--------------------|-------------------|---------------------| -| `SIGHUP` | `signClose()` | `asClose()` | `notice "CLOSE"` | -| `SIGINT` | `signInterrupt()` | `asInterrupt()` | `notice "INTERRUPTED"` | -| `SIGTERM`| `signTermination()`| `asTermination()` | `warning "TERMINATION"` | - -For short jobs you usually don't override anything. For long-running -`resolution()` bodies that loop over work, call -`pcntl_signal_dispatch()` once per iteration so signals are processed -between units of work. - -```php -public function resolution(mixed $data = null): void -{ - foreach ($this->invoices->pending() as $invoice) { - $this->mail->send($invoice); - pcntl_signal_dispatch(); // graceful Ctrl-C between sends - } -} -``` - ---- - -## Errors - -Exceptions in `resolution()` are caught by `Dispatch::run()`, logged -via the resolved logger, and the child exits cleanly (no zombie, no -re-throw to the parent). `DEBUG=true` appends the stack trace to the -log entry; otherwise just the message. - -If the job needs retry / DLQ semantics, build them on top — the kernel -does not retry failed jobs automatically. - ---- - -## Examples - -```php -// from an HTTP controller — don't block the response -SendInvoice::dispatch(['orderId' => $order->id]); - -// from another job (chained foreground) -RebuildSearchIndex::start(); - -// from CLI for ad-hoc execution -// call thread app.threads.jobs.SendInvoice -d -``` - ---- - -## When to use Job vs Process vs Daemon - -| Need | Stereotype | Why | -|-------------------------------------------|------------|--------------------------------------| -| One unit of work, then exit | `Job` | No state, no fork bookkeeping | -| Long-running worker that forks per task | `Process` | `ThreadFork` + child signal handling | -| Long-running singleton + state + control | `Daemon` | `status()` / `stop()` + cluster lock | - ---- - -## Source - -- `src/Stereotype/Job.php` -- `src/Process/ThreadJob.php` -- `src/Process/Core/Dispatch.php`, `Dispatchable.php`, `DispatchStore.php` -- `src/Process/Traits/ThreadJobHandler.php`, `ThreadSignalHandler.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle, DI, data passing -- [02-process.md](02-process.md) — forking workers -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread ` -- [`../console/02-make.md`](../console/02-make.md) — `call make .X -J` scaffold diff --git a/docs/threads/02-process.md b/docs/threads/02-process.md deleted file mode 100644 index 86d9967..0000000 --- a/docs/threads/02-process.md +++ /dev/null @@ -1,243 +0,0 @@ -# Process — long-running forking workers - -A `Process` is a long-lived `Dispatchable` that owns its own child -forks. Unlike `Job` (run-once) and `Daemon` (singleton with state), -`Process` is a general-purpose forking parent — you decide when to -fork, how many children to keep, and when to wait. - ---- - -## Stereotype - -```php -namespace App\Threads\Processes; - -use Flytachi\Winter\K2\Stereotype\Process; - -class QueueWorker extends Process -{ - public function __construct( - private QueueRepository $queue, - ) {} - - public function resolution(mixed $data = null): void - { - // parent loop — drains the queue, forks per batch - while (true) { - $batch = $this->queue->pull(size: 50); - if (!$batch) { - sleep(1); - pcntl_signal_dispatch(); - continue; - } - - foreach (array_chunk($batch, 10) as $chunk) { - $this->fork(function () use ($chunk) { - foreach ($chunk as $item) { - $this->process($item); - } - }); - } - - $this->waitAll(); // join all children before next pass - pcntl_signal_dispatch(); - } - } - - private function process(array $item): void - { - // child body - } -} -``` - -`Process extends ThreadProcess extends Dispatch`. `exNamespace = 'process'`. -`ThreadFork` brings the fork primitives; `ThreadProcessHandler` brings -signal propagation to children. - -DI works the same as in Job / Daemon — `Container::make()` instantiates -the parent, and forks **inherit** that instance (so injected services -are usable in children). - ---- - -## Running - -| Call | Behavior | -|---------------------------------------------------------------|----------| -| `QueueWorker::start()` | Foreground | -| `QueueWorker::dispatch()` | Background fork; returns PID | -| `call thread app.threads.processes.QueueWorker` | CLI foreground | -| `call thread app.threads.processes.QueueWorker -d` | CLI background | - -There is **no cluster lock** on `Process` — `dispatch()` of the same -class twice will spin up two independent supervisors. If you want -"one and only one" semantics, use [`Daemon`](03-daemon.md) instead. - ---- - -## Forking primitives (`ThreadFork`) - -| Method | Use | Returns | -|--------|-----|---------| -| `fork(callable $fn): int` | Run `$fn` in a child fork | child PID | -| `forkAnonymous(mixed $data = null): int` | Run `anonymousResolution($data)` in a child | child PID | -| `wait(int $pid, ?callable $cb = null)` | `waitpid` one specific child | — | -| `waitAll(?callable $cb = null)` | `waitpid` every child you've forked | — | - -State on the parent: - -| Property | Default | Meaning | -|-----------------------|---------|---------| -| `$childrenPidSave` | `true` | Track every child PID for later `waitAll()` | -| `$childrenPids` | `[]` | The tracked PIDs | -| `$iAmChild` | `false` | Flipped to `true` inside `forkStart()` so signal handlers can branch | - -Each fork: - -1. Sets `$this->pid = getmypid()` -2. Re-resolves the logger (`LoggerFactory::getLogger(static::class)`) -3. Rewrites `cli_set_process_title()` to include `(fork)` and the tag - (`fork` for `fork()`, `anonymous` for `forkAnonymous()`) -4. Runs your callable, swallows any throw → logs `critical` -5. **Always** calls `exit(0)` — the fork never returns to caller code - -```php -$pid = $this->fork(function () { - // child body -}); -// only the parent reaches here; child has already exit()-ed -``` - -### `forkAnonymous` + `anonymousResolution` - -For a default callable you don't want to write out every time, override -the protected `anonymousResolution(mixed $data = null)` method and call -`forkAnonymous($data)`: - -```php -public function anonymousResolution(mixed $data = null): void -{ - $this->process($data['item']); -} - -// elsewhere -$this->forkAnonymous(['item' => $row]); -``` - ---- - -## Signals (parent + children) - -`ThreadProcessHandler` is signal-aware of `iAmChild`: - -| Signal | Parent path | Child path | -|----------|---------------------------------------------------|----------------------| -| `SIGHUP` | propagate to children → `resolutionEnd()` → `asClose()` → exit | `asChildClose()` → exit | -| `SIGINT` | propagate to children → `resolutionEnd()` → `asInterrupt()` → exit | `asChildInterrupt()` → exit | -| `SIGTERM`| propagate to children → `resolutionEnd()` → `asTermination()` → exit | `asChildTermination()` → exit | - -The parent walks `$childrenPids`, sends the same signal to each, -`waitpid()`s, then exits. The child only runs its own -`asChildXxx()` hook and exits. Override either family to log custom -messages or do final cleanup: - -```php -protected function asInterrupt(): void -{ - parent::asInterrupt(); // notice "INTERRUPTED" - $this->queue->returnInFlight(); -} - -protected function asChildInterrupt(): void -{ - parent::asChildInterrupt(); // notice "INTERRUPTED CHILD" -} -``` - -For long loops without natural syscall yields, call -`pcntl_signal_dispatch()` per iteration so signals are delivered. - ---- - -## Errors - -Inside `fork()` / `forkAnonymous()`, exceptions are caught and logged -at `critical` level; the child exits `0` regardless. The parent never -sees a child's exception. - -Exceptions in the parent body (your `resolution()` loop) flow through -`Dispatch::run()`'s catch: logged, then `resolutionEnd()` runs, then -the parent exits. - ---- - -## Examples - -### Queue drain with bounded fork count - -```php -public function resolution(mixed $data = null): void -{ - while (true) { - while (count($this->childrenPids) >= 8) { - $finished = pcntl_wait($status); - $this->childrenPids = array_diff($this->childrenPids, [$finished]); - } - $batch = $this->queue->pull(1); - if ($batch) { - $this->fork(fn() => $this->process($batch[0])); - } else { - usleep(100_000); - } - pcntl_signal_dispatch(); - } -} -``` - -### Periodic supervision - -```php -public function resolution(mixed $data = null): void -{ - while (true) { - $this->fork(fn() => $this->tick()); - $this->waitAll(); - sleep(60); - pcntl_signal_dispatch(); - } -} -``` - ---- - -## When to use Process vs Daemon - -| Concern | `Process` | `Daemon` | -|------------------------------------------------|------------------------|-------------------------------------------| -| One-of-a-kind running on the box? | not enforced | enforced via `DaemonStore` cluster lock | -| Per-fork status tracking | manual (`$childrenPids`) | built-in (`forkList()`, `forkListInfo()`) | -| `Class::status()` / `Class::stop()` from outside | not provided | provided | -| `streaming()` rate-limited fork loop | not provided | provided | -| Just a parent loop with forks | ✓ | overkill | - -If you find yourself adding singleton checks, status persistence, or -external "stop me" hooks to a `Process`, you want [`Daemon`](03-daemon.md). - ---- - -## Source - -- `src/Stereotype/Process.php` -- `src/Process/ThreadProcess.php` -- `src/Process/Traits/ThreadFork.php`, `ThreadProcessHandler.php`, - `ThreadSignalHandler.php` -- `src/Process/Core/Dispatch.php`, `Dispatchable.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle and DI -- [01-job.md](01-job.md) — simpler one-shot variant -- [03-daemon.md](03-daemon.md) — singleton with state + control -- [`../console/09-thread.md`](../console/09-thread.md) — running via `call thread` -- [`../console/02-make.md`](../console/02-make.md) — scaffold with `-P` diff --git a/docs/threads/03-daemon.md b/docs/threads/03-daemon.md deleted file mode 100644 index ef17117..0000000 --- a/docs/threads/03-daemon.md +++ /dev/null @@ -1,349 +0,0 @@ -# Daemon — supervised, single-instance, stateful service - -A `Daemon` is a long-running `Dispatchable` with three things `Process` -doesn't have: - -1. **Cluster lock** — `dispatch()` refuses to start if an instance is - already running. -2. **Persistent status** via `DaemonStore` — `Class::status()` and - `Class::stop()` work from any other PHP process on the same box. -3. **A rate-limited fork engine** (`streaming()`) for "keep N children - busy" patterns. - -Use it for services you want supervised by a real init system -(systemd / Docker `restart: always`) and operable from CLI. - ---- - -## Stereotype - -```php -namespace App\Threads\Daemons; - -use Flytachi\Winter\K2\Stereotype\Daemon; -use Flytachi\Winter\DI\Attribute\Autowired; - -class Cleanup extends Daemon -{ - #[Autowired] - private LogRotator $rotator; - - public function resolution(mixed $data = null): void - { - $this->prepare(streamRps: 10); // optional — sets condition + rate - - $this->streaming( - complianceCallable: function () { - $this->fork(fn() => $this->rotator->rotateOne()); - }, - ); - } -} -``` - -`Daemon extends ThreadDaemon extends Dispatch`. `exNamespace = 'daemon'`. -Mixes in `ThreadDaemonFork`, `ThreadDaemonHandler`, `ThreadSignalHandler`, -and `ThreadDaemonStatement`. - -DI works the same — `Container::make()` instantiates inside the child, -forks inherit the instance. - ---- - -## Running - -| Call | Behavior | -|------------------------------------------------------------|----------| -| `Cleanup::dispatch()` | Background fork (typical). Throws if already running. | -| `Cleanup::start()` | Foreground (current process). Same lock-protected lifecycle inside `resolution()`. | -| `Cleanup::status($showStats = false)` | `?TDInfo` — `null` if not running | -| `Cleanup::stop()` | Send SIGINT to the running PID; `bool` | -| `call thread app.threads.daemons.Cleanup start` | CLI background start | -| `call thread app.threads.daemons.Cleanup stop` | CLI stop | -| `call thread app.threads.daemons.Cleanup status [-v]` | CLI status (`-v` = resources + forks) | -| `call thread app.threads.daemons.Cleanup` | CLI toggle (foreground); add `-d` for background | - -For production, run the daemon under a supervisor (`systemd`, Docker -`restart: always`) in foreground — let the supervisor own the lifecycle. -The `start` / `stop` / `-d` actions are for ad-hoc management; see -[../console/09-thread.md](../console/09-thread.md) for the full lifecycle. - ---- - -## Cluster lock (`DaemonStore`) - -The lock key is `xxh64(static::class)` — same class, same key, machine-wide. - -`dispatch()` does: - -``` -$info = Class::status() - ↓ -if $info: throw DaemonException("Cluster process already exist [PID:...]") -else: parent::dispatch($data) // proceed with fork -``` - -`resolutionStart()` writes a `TDStatus` row: - -```php -new TDStatus( - pid: getmypid(), - className: static::class, - condition: TCondition::STARTED, - startedAt: time(), - streamRps: $this->streamRps, - info: [], -); -``` - -`resolutionEnd()` removes it. So `status()` reflects the live state — -if it returns non-null but `posix_getpgid($status->pid)` is dead, the -key is auto-pruned and `status()` returns `null`. Crash-resilient. - -`DaemonStore` is two `FileStorage` slots under -`Kernel::runnable("")`: - -| Slot | Holds | -|--------------|------------------------------------------------------| -| `main()` | One key — the daemon's `TDStatus` (or `TDInfo` view) | -| `threads()` | One key per active fork — `__` → `TStatus` | - ---- - -## Status & stop from outside - -Anywhere in the same project (HTTP controller, CLI script, another -daemon) you can introspect: - -```php -$info = Cleanup::status(showStats: true); -if ($info) { - echo "Cleanup PID {$info->status->pid}"; - echo " started {$info->status->getStartedAt()}"; - echo " condition: {$info->status->condition->name}"; - if ($info->stats) { - echo " RSS {$info->stats->rssMb()} MB"; - } -} - -if ($info) { - Cleanup::stop(); // SIGINT to PID; bool -} -``` - -`stop()` throws `DaemonException` if there is no live instance. - ---- - -## State during a run - -`ThreadDaemonStatement` exposes a few self-mutators (the daemon updates -its own record): - -| Method | Effect | -|------------------------------------------------|--------| -| `prepare(int $streamRps = 0)` | Set condition `PREPARATION` → `ACTIVE`, store `streamRps`, call `preparation()` hook | -| `setCondition(TCondition $c)` | Update the daemon's `condition` | -| `setInfo(array $info)` | Update the freeform `info` blob on `TDStatus` | -| `preparation()` (override) | Hook called inside `prepare()` for one-time setup | - -Conditions (`TCondition` enum): - -| Case | Value | -|---------------|-------| -| `STARTED` | 0 | -| `ACTIVE` | 1 | -| `PREPARATION` | 2 | -| `WAITING` | 3 | -| `CHECKING` | 4 | -| `PASSIVE` | 5 | - ---- - -## Forks & fork-status - -`ThreadDaemonFork` overrides `forkStart()` / `forkEnd()` to: - -- write a `TStatus` for the fork into `store()->threads()` keyed - `__` (so the fork is observable); -- delete it on exit. - -That gives you these static introspection helpers (also from outside the -process): - -| Method | Returns | -|----------------------------------------------------------|---------| -| `Class::forkQty()` | `int` — current fork count | -| `Class::forkList()` | `int[]` — child PIDs | -| `Class::forkListInfo(bool $showStats = false)` | `TInfo[]` — one per child, optional `ps` stats | -| `Class::forkInfo(int $pid, bool $showStats = false)` | `?TInfo` for a single child | -| `Class::forkSetCondition(int $pid, TCondition)` | Update a fork's condition | - -Children's lifecycle hooks `preparationForkBefore()` / -`preparationForkAfter()` are open for override if you need to do more -than write a `TStatus`. - ---- - -## `streaming()` — rate-limited fork loop - -```php -final protected function streaming( - callable $complianceCallable, - ?callable $negationCallable = null, -): void -``` - -A built-in supervisor loop: - -``` -while (true) { - if (forkQty() < $this->streamRps) { - $complianceCallable(); // typically: $this->fork(...) - } else if ($negationCallable) { - $negationCallable(); // e.g. "log skipped", "yield" - } - usleep( $streamRps < 1000 ? 1e6/$streamRps : 1000 ); - pcntl_signal_dispatch(); -} -``` - -So `$streamRps` sets the cap on **concurrent forks**, not jobs/sec: - -- `streamRps = 10` → at most 10 child forks alive at once; spawn when - one finishes (because `forkQty()` drops below 10). -- `streamRps = 0` → never spawns (use the negation branch only). - -Use it when you want a constant in-flight worker count without writing -the loop yourself: - -```php -public function resolution(mixed $data = null): void -{ - $this->prepare(streamRps: 50); - $this->streaming( - complianceCallable: function () { - $this->fork(fn() => $this->doOne()); - }, - negationCallable: function () { - // optional — runs when at capacity - }, - ); -} -``` - ---- - -## Signals (parent + forks) - -`ThreadDaemonHandler` mirrors `ThreadProcessHandler` but walks -`forkList()` from the store (the source of truth) rather than the -in-memory `$childrenPids`: - -| Signal | Parent path | Fork path | -|----------|----------------------------------------------------------------|-----------| -| `SIGHUP` | propagate → `resolutionEnd()` → `asClose()` → exit | `preparationForkAfter()` → `asChildClose()` → exit | -| `SIGINT` | propagate → `resolutionEnd()` → `asInterrupt()` → exit | `preparationForkAfter()` → `asChildInterrupt()` → exit | -| `SIGTERM`| propagate → `resolutionEnd()` → `asTermination()` → exit | `preparationForkAfter()` → `asChildTermination()` → exit | - -`resolutionEnd()` is what deletes the main-store key, so a clean SIGINT -takes the daemon out of `status()` immediately. - -`Class::stop()` is just SIGINT to the recorded PID, so it produces the -same path as Ctrl-C on a foreground run. - ---- - -## Errors - -- `DaemonException` — produced by `dispatch()` (already running) and - `stop()` (not running). HTTP code `LOCKED` (423), critical log level. -- Exceptions inside `resolution()` flow through `Dispatch::run()`'s - catch: logged, `resolutionEnd()` runs (key is removed from the store), - daemon exits cleanly. -- Exceptions inside a fork go through `ThreadDaemonFork::fork()`'s catch - and exit `0` — they do not kill the daemon. - ---- - -## Examples - -### Periodic check / heartbeat - -```php -public function resolution(mixed $data = null): void -{ - $this->prepare(); // sets ACTIVE without streaming - while (true) { - try { - $this->fork(fn() => $this->healthCheck()); - } finally { - sleep(30); - pcntl_signal_dispatch(); - } - } -} - -protected function healthCheck(): void -{ - // child body -} -``` - -### Bounded concurrency over a queue - -```php -public function resolution(mixed $data = null): void -{ - $this->prepare(streamRps: 20); // cap at 20 in-flight - $this->streaming(function () { - $this->fork(fn() => $this->pullAndHandle()); - }); -} -``` - -### Operate from a controller - -```php -public function start(): array -{ - return ['pid' => Cleanup::dispatch()]; // throws if running -} - -public function stop(): bool -{ - return Cleanup::stop(); -} - -public function status(): ?array -{ - $info = Cleanup::status(showStats: true); - return $info ? [ - 'pid' => $info->status->pid, - 'condition' => $info->status->condition->name, - 'started' => $info->status->getStartedAt(), - 'rssMb' => $info->stats?->rssMb(), - 'forks' => Cleanup::forkQty(), - ] : null; -} -``` - ---- - -## Source - -- `src/Stereotype/Daemon.php` -- `src/Process/ThreadDaemon.php`, `DaemonException.php` -- `src/Process/Core/DaemonStore.php`, `Dispatch.php`, `Dispatchable.php` -- `src/Process/Traits/ThreadDaemonFork.php`, - `ThreadDaemonHandler.php`, `ThreadDaemonStatement.php`, - `ThreadSignalHandler.php` -- `src/Process/Entity/{TCondition,TDInfo,TDStatus,TInfo,TStatus,TStats}.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle and DI -- [02-process.md](02-process.md) — the unlocked, lighter forking variant -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread start|stop|status` / `daemons` -- [`../console/02-make.md`](../console/02-make.md) — scaffold with `-N` -- [`../configuration/02-logging.md`](../configuration/02-logging.md) — per-channel logs diff --git a/docs/threads/04-websocket.md b/docs/threads/04-websocket.md deleted file mode 100644 index 5875ae5..0000000 --- a/docs/threads/04-websocket.md +++ /dev/null @@ -1,307 +0,0 @@ -# WebSocket — TCP WebSocket server - -A `WebSocket` is a `Dispatchable` that owns a `stream_socket_server` -bound to a TCP port, performs the RFC 6455 handshake on accept, and -exposes three abstract hooks for connect / message / disconnect. - -It runs single-process (no fork inside the server loop) — concurrency -comes from `stream_select()` multiplexing. - ---- - -## Stereotype - -```php -namespace App\Threads\WebSockets; - -use Flytachi\Winter\K2\Stereotype\WebSocket; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\Msg; -use Flytachi\Winter\K2\Process\Socket\Web\PDU\WSResource; - -class Chat extends WebSocket -{ - protected string $ip = '0.0.0.0'; - protected int $port = 9001; - - protected function handleConnect(WSResource $resource): void - { - $this->logger->info("Joined: {$resource}"); - $this->send($resource, 'Welcome'); - } - - protected function handle(WSResource $resource, Msg $msg): void - { - if ($msg->type === 'text') { - // broadcast to everyone else - foreach ($this->connects as $peer) { - if ($peer !== $resource) { - $this->send($peer, $msg->payload); - } - } - } - } - - protected function handleDisconnect(WSResource $resource): void - { - $this->logger->info("Left: {$resource}"); - } -} -``` - -`WebSocket extends ThreadWebSocket extends Dispatch`. -`exNamespace = 'web-socket'`. - -DI works identically — `Container::make()` instantiates the class, -`#[Autowired]` resolves. - ---- - -## Running - -| Call | Behavior | -|-----------------------------------------------------------------|----------| -| `Chat::start(['ip' => '0.0.0.0', 'port' => 9001])` | Foreground | -| `Chat::dispatch(['ip' => '0.0.0.0', 'port' => 9001])` | Background fork; returns PID | -| `call thread app.threads.websockets.Chat` | CLI foreground | -| `call thread app.threads.websockets.Chat -d` | CLI background | - -The optional `$data` map is read by `resolution()` and overrides the -`$ip` / `$port` properties for that run, so the same class can be -bound to different ports without subclassing: - -```php -Chat::dispatch(['port' => 9002]); -Chat::dispatch(['port' => 9003]); -``` - -There is **no cluster lock** (unlike `Daemon`). If you need a -singleton bind per machine, wrap launching in a `Daemon` or let the -supervisor enforce it. - ---- - -## Abstract hooks - -Every concrete `WebSocket` must implement three methods. The server -loop catches `\Throwable` around each one and logs it — your code -won't take the server down. - -```php -abstract protected function handleConnect(WSResource $resource): void; -abstract protected function handle(WSResource $resource, Msg $msg): void; -abstract protected function handleDisconnect(WSResource $resource): void; -``` - -| Hook | Fires when | -|---------------------|------------------------------------------------------| -| `handleConnect()` | After successful handshake; `$resource` registered | -| `handle()` | A complete frame was decoded — `$msg->type` ∈ `text / binary / ping / pong` | -| `handleDisconnect()`| Before the connection is torn down (EOF, error frame, close, server stop) | - -Note `close` and `error` frames trigger `disconnectClient()` directly — -your `handle()` does not see them. Errors are logged as warnings; -malformed/unmasked frames are surfaced as a synthetic error msg and -the connection is closed. - ---- - -## Sending - -```php -public function send(WSResource $resource, string $payload, string $type = 'text'): void -``` - -- Frames the payload via `WebSocketProtocol::encode()` and **queues** - it on the resource's `writeBuffer`. -- The main loop flushes queued bytes when the connection is writable - (`stream_select` returns it in `$write`). -- Sending to a disconnected client is a no-op + warning log. - -Types accepted by `encode()`: `text` (default), `binary`, `ping`, -`pong`, `close`. Use `text` / `binary` from `send()`; `close` is -emitted internally by `disconnectClient()`. - -To broadcast, loop `$this->connects` (a `[string => WSResource]` map -keyed by the stream resource ID). - ---- - -## The server loop - -`ThreadWebSocket::resolution()` is `final` — you don't override it. -It does: - -``` -bind: stream_socket_server("tcp://$ip:$port", STREAM_SERVER_{BIND,LISTEN}) -nonblock: stream_set_blocking(false) - -while true: - select($read = clients + listen-sock, $write = those with queued bytes, - timeout = $loopInterval µs) - - if listener has activity: - accept new connection - try handshake → on success register WSResource, fire handleConnect() - on failure send "HTTP/1.1 400 Bad Request" and close - - for each readable client: - fread(65535) - if EOF: disconnectClient() - append to readBuffer - while a complete frame can be decoded: - consume bytes - if close/error frame → disconnectClient() - else → fire handle($resource, $msg) - - for each writable client: - fwrite(writeBuffer) — track partial writes - - if $timeWorkLimit > 0 and elapsed > limit: break the loop - - pcntl_signal_dispatch() - loop() ← optional override hook -``` - ---- - -## Override hooks - -| Member / hook | Purpose | -|-------------------------------------|---------| -| `$ip` / `$port` | Default bind (overridable per call via `$data`) | -| `$loopInterval` (µs, default `200_000`) | `stream_select` timeout — keeps `loop()` ticking on quiet sockets | -| `$timeWorkLimit` (s, default `0`) | If > 0, the loop exits cleanly after that many seconds | -| `protected function loop(): void` | Called once per iteration after I/O — use it for timers, periodic broadcasts, GC; default no-op | -| `handleConnect()` / `handle()` / `handleDisconnect()` | The three required event hooks | -| `asInterrupt()` / `asTermination()` / `asClose()` | Signal log overrides | - -A `loop()` example — broadcast a heartbeat every 5 seconds: - -```php -private int $lastBeat = 0; - -protected function loop(): void -{ - if (time() - $this->lastBeat >= 5) { - foreach ($this->connects as $peer) { - $this->send($peer, json_encode(['t' => time()])); - } - $this->lastBeat = time(); - } -} -``` - ---- - -## `WSResource` and `Msg` - -`WSResource` (`src/Process/Socket/Web/PDU/WSResource.php`) wraps one -connection — exposes the underlying stream (`getConnect()`), the -handshake info (URI, headers, query params, ip/port), and the -`readBuffer` / `writeBuffer` strings the loop manipulates. Casting it -to `(string)` returns a unique connection ID — that's also the key in -`$this->connects`. - -`Msg` carries the decoded frame: `type`, `payload`, and (for synthetic -errors) an error message. - ---- - -## Signals - -`SocketWebServerHandler` is the per-WebSocket signal trait: - -| Signal | Internal handler | Override | Default log | -|----------|--------------------|-------------------|-------------| -| `SIGHUP` | `signClose()` | `asClose()` | `notice "CLOSE"` | -| `SIGINT` | `signInterrupt()` | `asInterrupt()` | `notice "INTERRUPTED"` | -| `SIGTERM`| `signTermination()`| `asTermination()` | `warning "TERMINATION"` | - -Each path calls `resolutionEnd()` first, which calls `socketClose()` — -that disconnects every client (sending a 1000 close frame) and shuts -the listener. Clean. - -`pcntl_signal_dispatch()` is invoked once per iteration, so signals -deliver promptly even on quiet sockets. - ---- - -## Errors - -- Bind failure or `stream_socket_server` returning `false` → critical - log + clean shutdown (`socketClose()`). -- Per-hook throws — caught at the call site (each of - `handleConnect`/`handle`/`handleDisconnect`), logged, the loop - continues. -- Frame decode errors — synthetic `'error'` `Msg` produced; the loop - warns and closes the offending connection (it does not propagate to - `handle()`). - ---- - -## Examples - -```php -// Echo server -class Echo extends WebSocket -{ - protected function handleConnect(WSResource $r): void {} - protected function handle(WSResource $r, Msg $m): void - { - $this->send($r, $m->payload); - } - protected function handleDisconnect(WSResource $r): void {} -} - -Echo::dispatch(['port' => 9001]); -``` - -```php -// Path-aware routing — split traffic by URI -protected function handleConnect(WSResource $r): void -{ - $path = $r->info['path'] ?? '/'; - if ($path !== '/chat') { - $this->disconnectClient($r); // close non-/chat clients - } -} -``` - -```php -// Time-limited test server -class StressTest extends WebSocket -{ - protected int $timeWorkLimit = 60; // shuts itself down after 60s - // ... -} -``` - ---- - -## When NOT to use this - -- For high-throughput production WebSocket workloads, prefer Swoole's - built-in WebSocket server (it's coroutine-aware and works with the - HTTP server you already run via `call run`). The kernel ships - `ThreadWebSocket` as a self-contained, dependency-free alternative — - great for internal services, tests, sidecar protocols. It is **not** - designed to compete with Swoole / Workerman at scale. -- For request/response semantics use a normal `Controller`. - ---- - -## Source - -- `src/Stereotype/WebSocket.php` -- `src/Process/Socket/Web/ThreadWebSocket.php` -- `src/Process/Socket/Web/WebSocketProtocol.php` -- `src/Process/Socket/Web/SocketWebServerHandler.php` -- `src/Process/Socket/Web/PDU/*` (`WSResource`, `Msg`, `DecodedFrame`) -- `src/Process/Core/Dispatch.php`, `Dispatchable.php` - -## See also - -- [00-overview.md](00-overview.md) — `Dispatch` lifecycle and DI -- [03-daemon.md](03-daemon.md) — to make the server a supervised singleton -- [`../console/09-thread.md`](../console/09-thread.md) — `call thread ` -- [`../console/02-make.md`](../console/02-make.md) — scaffold with `-W` diff --git a/function/dependencies.php b/function/dependencies.php index b9617ec..534ba8d 100755 --- a/function/dependencies.php +++ b/function/dependencies.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Flytachi\Winter\K2\Localization\Locale; -use Flytachi\Winter\K2\Http\Response\RenderContext; +use Flytachi\Winter\Kernel\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Response\RenderContext; if (!function_exists('trans')) { /** diff --git a/phpunit.xml b/phpunit.xml index 5331cb6..38d1c4b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,6 +8,7 @@ tests/Http + tests/Http/Fixtures tests/Localization @@ -26,6 +27,42 @@ tests/Integration tests/Integration/Fixtures + + tests/Process + tests/Process/Fixtures + + + tests/Schedule + tests/Schedule/Fixtures + + + tests/Concurrent + + + tests/App + + + tests/Unit + + + tests/ConnectionPool + + + tests/Route + tests/Route/Fixtures + + + tests/Architecture + + + tests/Collector + + + tests/Core + + + tests/Console + diff --git a/src/App/ApplicationArguments.php b/src/App/ApplicationArguments.php new file mode 100644 index 0000000..44ed38b --- /dev/null +++ b/src/App/ApplicationArguments.php @@ -0,0 +1,112 @@ + .env > default. + * There are no Spring-style profiles; only real knobs (`--port`, `--host`, ...). + */ +final class ApplicationArguments +{ + /** + * @param list $raw Original argv (script name in [0]). + * @param list $positionals Bare words after the script name. + * @param array $options Long options (`true` = present, no value). + * @param array $flags Short flags. + */ + private function __construct( + private array $raw, + private array $positionals, + private array $options, + private array $flags, + ) { + } + + /** + * @param list $argv Raw argv (script name in [0]). + */ + public static function parse(array $argv): self + { + $tokens = array_slice(array_values($argv), 1); + + $positionals = []; + $options = []; + $flags = []; + + foreach ($tokens as $token) { + if (str_starts_with($token, '--')) { + $body = substr($token, 2); + if ($body === '') { + continue; + } + if (str_contains($body, '=')) { + [$key, $val] = explode('=', $body, 2); + $options[$key] = $val; + } else { + $options[$body] = true; + } + } elseif (str_starts_with($token, '-') && $token !== '-') { + foreach (str_split(substr($token, 1)) as $ch) { + $flags[$ch] = true; + } + } else { + $positionals[] = $token; + } + } + + return new self(array_values($argv), $positionals, $options, $flags); + } + + /** The command word (argv[1]) or null (bare invocation). */ + public function command(): ?string + { + return $this->positionals[0] ?? null; + } + + /** The sub-command word (argv[2]) or null. */ + public function sub(): ?string + { + return $this->positionals[1] ?? null; + } + + /** True if a long option `--key` was passed (with or without a value). */ + public function has(string $key): bool + { + return array_key_exists($key, $this->options); + } + + /** True if a short flag `-x` was passed. */ + public function flag(string $char): bool + { + return isset($this->flags[$char]); + } + + /** A long option's value, or $default if absent (or present without a value). */ + public function option(string $key, ?string $default = null): ?string + { + $value = $this->options[$key] ?? null; + return is_string($value) ? $value : $default; + } + + /** A long option parsed as int, or $default if absent / not numeric. */ + public function int(string $key, int $default): int + { + $value = $this->option($key); + return $value !== null && is_numeric($value) ? (int) $value : $default; + } + + /** The original argv (to hand off to the console dispatcher). */ + public function raw(): array + { + return $this->raw; + } +} diff --git a/src/App/ApplicationConfigException.php b/src/App/ApplicationConfigException.php new file mode 100644 index 0000000..cb9c5dd --- /dev/null +++ b/src/App/ApplicationConfigException.php @@ -0,0 +1,14 @@ +|null $middleware Optional guard middleware. + * @param class-string|null $indicator Full report override + * (defaults to the built-in indicator). + */ + public function __construct( + public ?string $middleware = null, + public ?string $indicator = null, + ) { + } +} diff --git a/src/App/Attribute/EnableAsync.php b/src/App/Attribute/EnableAsync.php new file mode 100644 index 0000000..cb69349 --- /dev/null +++ b/src/App/Attribute/EnableAsync.php @@ -0,0 +1,26 @@ + $class The Daemon class to supervise. + */ + public function __construct(public string $class) + { + } +} diff --git a/src/App/Attribute/EnableProcess.php b/src/App/Attribute/EnableProcess.php new file mode 100644 index 0000000..b8f221f --- /dev/null +++ b/src/App/Attribute/EnableProcess.php @@ -0,0 +1,29 @@ + $class The Process class to run. + */ + public function __construct(public string $class) + { + } +} diff --git a/src/App/Attribute/EnableScheduler.php b/src/App/Attribute/EnableScheduler.php new file mode 100644 index 0000000..81f14af --- /dev/null +++ b/src/App/Attribute/EnableScheduler.php @@ -0,0 +1,30 @@ + $class Scheduler class (default: the built-in one). + */ + public function __construct(public string $class = Scheduler::class) + { + } +} diff --git a/src/App/Attribute/EnableWeb.php b/src/App/Attribute/EnableWeb.php new file mode 100644 index 0000000..c04ef49 --- /dev/null +++ b/src/App/Attribute/EnableWeb.php @@ -0,0 +1,25 @@ + $rows Ordered label/value lines (web, daemon, …). + * @param float $elapsedMs Milliseconds from boot start to now. + */ + public static function print(array $rows, float $elapsedMs): void + { + echo self::render($rows, $elapsedMs); + } + + /** + * @param list $rows Ordered label/value lines. + * @param float $elapsedMs Milliseconds from boot start to now. + */ + public static function render(array $rows, float $elapsedMs): string + { + $last = count(self::MARK) - 1; + $out = "\n"; + foreach (self::MARK as $i => $line) { + $flake = ($i === 0 || $i === $last) ? '❄' : ' '; + $out .= ' ' . self::LABEL . $flake . self::RESET + . ' ' . self::MARK_COLOR . $line . self::RESET . "\n"; + } + $out .= "\n"; + + $out .= ' ' . self::META . ':: ' . self::RESET + . self::VALUE . 'winter-kernel' . self::RESET + . self::META . ' ::' . self::RESET + . ' ' . self::META . '(v' . self::version() . ')' . self::RESET + . ' ' . self::LABEL . implode(' · ', self::metaTail()) . self::RESET . "\n"; + + $out .= ' ' . self::LABEL . str_repeat('─', 53) . self::RESET . "\n"; + foreach ($rows as [$label, $value]) { + $out .= sprintf( + " %s%-11s%s %s%s%s\n", + self::LABEL, + $label, + self::RESET, + self::VALUE, + $value, + self::RESET, + ); + } + $out .= ' ' . self::LABEL . str_repeat('─', 53) . self::RESET . "\n"; + + $out .= ' ' . self::OK . '✔' . self::RESET + . ' ' . self::BOLD . 'Application up' . self::RESET + . self::META . ' in ' . self::elapsed($elapsedMs) . self::RESET . "\n\n"; + + return $out; + } + + /** + * True unless the banner is suppressed by `--no-banner`, `WINTER_BANNER=off`, or a + * non-interactive STDOUT (piped output, a service manager, a log file). + */ + public static function isEnabled(ApplicationArguments $args): bool + { + if ($args->has('no-banner')) { + return false; + } + + $flag = env('WINTER_BANNER'); + if ($flag !== null && in_array(strtolower((string) $flag), ['off', '0', 'false', 'no'], true)) { + return false; + } + + return defined('STDOUT') && stream_isatty(STDOUT); + } + + /** + * @return list Runtime / PHP / PID parts of the meta line. + */ + private static function metaTail(): array + { + $tail = []; + if (extension_loaded('swoole')) { + $tail[] = 'Swoole ' . (defined('SWOOLE_VERSION') ? SWOOLE_VERSION : (phpversion('swoole') ?: '?')); + } + $tail[] = 'PHP ' . PHP_VERSION; + $tail[] = 'PID ' . getmypid(); + + return $tail; + } + + private static function version(): string + { + try { + if (InstalledVersions::isInstalled(self::PKG)) { + return InstalledVersions::getPrettyVersion(self::PKG) ?? 'dev'; + } + } catch (\Throwable) { + // Fall through to 'dev' when Composer runtime metadata is unavailable. + } + + return 'dev'; + } + + private static function elapsed(float $ms): string + { + return $ms < 1000.0 + ? (int) round($ms) . ' ms' + : round($ms / 1000, 2) . ' s'; + } +} diff --git a/src/App/Component.php b/src/App/Component.php new file mode 100644 index 0000000..c19bad0 --- /dev/null +++ b/src/App/Component.php @@ -0,0 +1,93 @@ + $class + */ + public static function process(string $class): self + { + return new self(ComponentKind::Process, class: $class); + } + + /** + * A supervised {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon} fleet. + * + * @param class-string<\Flytachi\Winter\Kernel\Process\Stereotype\Daemon> $class + */ + public static function daemon(string $class): self + { + return new self(ComponentKind::Daemon, class: $class); + } + + /** + * The scheduler that runs #[Scheduled] tasks. Defaults to the built-in + * {@see Scheduler}; pass a subclass to override discovery. + * + * @param class-string $class + */ + public static function scheduler(string $class = Scheduler::class): self + { + return new self(ComponentKind::Scheduler, class: $class); + } +} diff --git a/src/App/ComponentKind.php b/src/App/ComponentKind.php new file mode 100644 index 0000000..a3deb7d --- /dev/null +++ b/src/App/ComponentKind.php @@ -0,0 +1,18 @@ +add('job')->add('audit'); + * ``` + */ +final class ChannelRegistry +{ + /** @var list */ + private array $names = []; + + public function add(string $name): self + { + $this->names[] = $name; + return $this; + } + + /** + * Registers every declared channel with the kernel. + */ + public function apply(): void + { + foreach ($this->names as $name) { + Kernel::channel($name); + } + } +} diff --git a/src/App/Config/CorsRegistry.php b/src/App/Config/CorsRegistry.php new file mode 100644 index 0000000..ee684a6 --- /dev/null +++ b/src/App/Config/CorsRegistry.php @@ -0,0 +1,97 @@ +allowedOrigins('https://app.example.com') + * ->allowedHeaders('Content-Type', 'Authorization') + * ->exposeHeaders('X-Request-Id') + * ->allowCredentials(true) + * ->maxAge(3600); + * ``` + */ +final class CorsRegistry +{ + /** @var list */ + private array $origins = []; + /** @var list */ + private array $allowHeaders = []; + /** @var list */ + private array $exposeHeaders = []; + /** @var list */ + private array $vary = []; + private bool $credentials = false; + private int $maxAge = 0; + private bool $touched = false; + + public function allowedOrigins(string ...$origins): self + { + $this->origins = array_merge($this->origins, array_values($origins)); + return $this->touch(); + } + + public function allowedHeaders(string ...$headers): self + { + $this->allowHeaders = array_merge($this->allowHeaders, array_values($headers)); + return $this->touch(); + } + + public function exposeHeaders(string ...$headers): self + { + $this->exposeHeaders = array_merge($this->exposeHeaders, array_values($headers)); + return $this->touch(); + } + + public function vary(string ...$headers): self + { + $this->vary = array_merge($this->vary, array_values($headers)); + return $this->touch(); + } + + public function allowCredentials(bool $enabled = true): self + { + $this->credentials = $enabled; + return $this->touch(); + } + + public function maxAge(int $seconds): self + { + $this->maxAge = $seconds; + return $this->touch(); + } + + /** True once any method has been called (so an empty configurer is a no-op). */ + public function isTouched(): bool + { + return $this->touched; + } + + /** + * Applies the collected policy to the global {@see Cors} config. + */ + public function apply(): void + { + Cors::configure( + origins: $this->origins, + allowHeaders: $this->allowHeaders, + exposeHeaders: $this->exposeHeaders, + credentials: $this->credentials, + maxAge: $this->maxAge, + vary: $this->vary, + ); + } + + private function touch(): self + { + $this->touched = true; + return $this; + } +} diff --git a/src/App/Config/LoggingConfigurer.php b/src/App/Config/LoggingConfigurer.php new file mode 100644 index 0000000..18acde9 --- /dev/null +++ b/src/App/Config/LoggingConfigurer.php @@ -0,0 +1,25 @@ +add('job')->add('audit'); + * } + * } + * ``` + */ +interface LoggingConfigurer +{ + public function configureChannels(ChannelRegistry $channels): void; +} diff --git a/src/App/Config/Profile.php b/src/App/Config/Profile.php new file mode 100644 index 0000000..5ca7db9 --- /dev/null +++ b/src/App/Config/Profile.php @@ -0,0 +1,250 @@ +profile(Profile::Performance); + * ``` + * + * **The axis is the shape of a request, not caution.** `Performance` is not "faster at + * the cost of safety" — it is the profile for services whose requests are *small*, and it + * gives such a service more concurrency than `Stable` would, not less. Picking one is + * answering "how much memory does one of my requests use?", which is measurable: + * + * ``` + * $before = memory_get_usage(); + * // ... the handler ... + * $after = memory_get_usage(); // under 64 KB → Performance; over 200 KB → Stable + * ``` + * + * Everything a profile decides is a **default**, resolved when the value is read. An + * explicit `maxConcurrency()`, `maxRequest()` or `SERVER_*` variable always wins, whatever + * order it was set in. + */ +enum Profile: string +{ + /** Heavy requests — reports, exports, a monolith with wide joins. 512 KB each. */ + case Stable = 'stable'; + + /** Ordinary CRUD. 128 KB per request, and the default when nothing is said. */ + case Balance = 'balance'; + + /** Small requests — a thin API, a proxy, an integration bridge. 64 KB each. */ + case Performance = 'performance'; + + /** + * Guards off — for benchmarks, not for production. + * + * Not "Performance, more so": throughput plateaus long before the memory ceiling + * (measured on a live application, 2 250 req/s at 500 concurrent, with more + * concurrency adding nothing), so removing the caps does not raise it. What it removes + * is **periodic interference that distorts a measurement**: handing memory back pauses + * for tens of milliseconds and shows up in p99, replacing a worker empties its + * connection pool mid-run, and the request watchdog runs a timer of its own. + * + * A run under this profile can exhaust the worker's memory, and over a long one + * Swoole's per-request leak accumulates with no replacement to clear it. Both are + * acceptable for a bounded benchmark and for nothing else. + */ + case Stress = 'stress'; + + /** + * Heap an in-flight request holds before it has allocated anything of its own — + * measured, not assumed: 600 requests suspended in a trivial handler cost 78.2 KB + * each, against 68.0 KB for the same connections idle. The difference is the + * coroutine and the request/response pair; the rest is the connection underneath. + */ + private const int REQUEST_FLOOR = 78 * 1024; + + /** + * Heap one open connection holds while its client is connected but asking nothing — + * measured linearly at 401, 801 and 1 201 connections, and released in full when they + * close. It is charged to PHP's own limit, not merely to RSS, which is why a worker + * with the default 128M dies at roughly 1 900 idle keep-alive connections. + */ + private const int CONNECTION_FLOOR = 68 * 1024; + + /** + * Bytes leaked by a request that **arms a timer** — `Coroutine::sleep()` and anything + * built on it. Measured against a live server with replacement disabled: 248 723 such + * requests grew the heap from 2.16 MB to 44.89 MB, or 180 bytes each. + * + * It is not a cost every request pays, and the distinction matters because + * {@see maxRequest()} is what the framework charges everyone for it. Measured on the + * same server: 4.6 million ordinary requests grew the heap by **zero** bytes, and so + * did `Coroutine::defer()`, a channel round-trip, and a pooled borrow that actually + * waits (`Channel::pop($timeout)` — the pool's own path). Only the fired timer leaks. + */ + private const int LEAK_PER_REQUEST = 180; + + /** + * Fraction of the heap the leak may reach before a worker is replaced. + * + * One value for every profile: the leak is per request and has nothing to do with how + * much memory a request uses, so there was never anything for it to vary with. + */ + private const float LEAK_BUDGET_SHARE = 0.20; + + /** Assumed heap when the limit cannot be read (`-1`, or a value PHP cannot parse). */ + private const int ASSUMED_LIMIT = 128 * 1024 * 1024; + + /** True while the profile imposes limits at all — false only for {@see Stress}. */ + public function guards(): bool + { + return $this !== self::Stress; + } + + /** + * Heap one in-flight request may use for **its own work** — the entities it loads, + * the string it serialises — on top of {@see REQUEST_FLOOR}, which is the framework's + * and which no application can influence. Zero for {@see Stress}, which budgets + * nothing because it caps nothing. + * + * It is an assumption about the application, however the profile is described: a + * ceiling of 1 170 in flight is the same statement as "a request here fits in 64 KB". + * Choose the profile knowing that, and an application whose requests are heavier is + * not made safe by the framework — it is made to die later. + */ + public function requestBudget(): int + { + return match ($this) { + self::Stable => 512 * 1024, + self::Balance => 128 * 1024, + self::Performance => 64 * 1024, + self::Stress => 0, + }; + } + + /** + * Requests this profile allows in flight at once, given the heap left after the + * worker's own baseline. `0` means no cap. + * + * Each is budgeted at floor + budget, plus a second connection at + * {@see CONNECTION_FLOOR} for the client that is connected and *not* currently asking + * — the ordinary keep-alive case. That one-to-one assumption is what + * {@see connections()} spends; a service whose clients hold connections open far + * longer says so with `maxConnections()`. + * + * How far this can be pushed is bounded by the floor, not by the profile: measured at + * 256M, cutting the budget to **zero** — an application allowed to allocate nothing at + * all — raises the ceiling from 1 170 to 1 683, and no further. At `Performance` the + * framework's own 146 KB is already 70 % of what a request costs. What multiplies the + * ceiling is memory: the same profile gives 2 418 at 512M and 4 915 at 1G. + */ + public function concurrency(int $availableBytes): int + { + if (!$this->guards()) { + return 0; + } + + $perRequest = self::REQUEST_FLOOR + $this->requestBudget() + self::CONNECTION_FLOOR; + + return max(1, intdiv(max(0, $availableBytes), $perRequest)); + } + + /** + * Connections allowed at once: the working ones and an idle one apiece, but never + * more than the process has file descriptors for. `0` = no cap. + * + * A socket is a descriptor, so `ulimit -n` is a second ceiling entirely independent of + * memory — and the tighter one on a stingy host. Swoole enforces it either way: asked + * for more it prints `max_connection is exceed the maximum value, it's reset to N` and + * silently uses N. Clamping here means the number the framework reports is the number + * that will apply, and the warning never appears. + * + * Only the derived value is clamped. An explicit `maxConnections()` above the limit is + * left to Swoole, so its warning still reaches the operator who asked for it — that + * warning is the one thing telling them to raise `ulimit -n`. + */ + public function connections(int $availableBytes, int $descriptorLimit): int + { + if (!$this->guards()) { + return 0; + } + + return min($this->concurrency($availableBytes) * 2, max(1, $descriptorLimit)); + } + + /** + * Idle heap a worker may hold before handing it back to the operating system. + * `0` never hands anything back. + * + * Scaled to the limit rather than fixed, because what counts as "suspiciously + * unused" depends on how much there is: an ordinary worker carries two or three + * megabytes of reserve whatever its ceiling. Handing memory back costs about 80 ms + * when there is a lot of it, so the profile that avoids pauses waits longer. + */ + public function trimThreshold(int $limitBytes): int + { + $limit = $limitBytes > 0 ? $limitBytes : self::ASSUMED_LIMIT; + + return match ($this) { + self::Stable => intdiv($limit, 16), + self::Balance => intdiv($limit, 8), + self::Performance => intdiv($limit, 4), + self::Stress => 0, + }; + } + + /** + * Requests a worker serves before it is replaced. `0` never replaces it. + * + * The same for every profile, because what it guards against does not vary with them: + * the leak is a fixed number of bytes per request and knows nothing about how large a + * request is. Tying it to the profile — which is what this did at first — made the + * most cautious profile replace its worker four times as often as the least, and + * replacement is the one thing here with a **certain** cost. + * + * That cost, measured on a live stand: replacing a worker kills the requests it was + * serving. Under load the three profiles differed only in how often they did it, and + * the dropped connections followed exactly — 6 805 for a worker replaced every 78 951 + * requests, 48 for one replaced every 315 806. Nothing was written to any log; only + * the client sees it. + * + * The benefit, by contrast, is **conditional**. Measured: 4.6 million ordinary + * requests grew the heap by nothing at all, and neither did `defer`, a channel, or a + * pooled borrow that waits. Only a **timer that fires** leaks — `Coroutine::sleep()` + * costs about 180 bytes a request. So an application that never sleeps in a handler + * does not leak, and pays for this in dropped requests without receiving anything. + * + * Hence one share for everyone, sized so a leaking application stays well inside its + * limit: at 20 %, a worker at 256M is replaced after ~315 000 requests, having leaked + * about 51 MB if every request slept. + */ + public function maxRequest(int $limitBytes): int + { + if (!$this->guards()) { + return 0; + } + $limit = $limitBytes > 0 ? $limitBytes : self::ASSUMED_LIMIT; + + return (int) ($limit * self::LEAK_BUDGET_SHARE / self::LEAK_PER_REQUEST); + } + + /** + * How far apart workers may drift before recycling — a tenth of {@see maxRequest()}. + * + * Swoole **adds** a random amount up to this to the limit, so the real one is + * `max_request + rand(0, grace)`: verified, at `max_request = 20, grace = 15` workers + * served 30, 27, 34 and 26 requests, against exactly 20 apiece at `grace = 0`. + * Without it, workers counting to the same number under even traffic recycle almost + * together, and several connection pools go cold at once. + */ + public function maxRequestGrace(int $limitBytes): int + { + return intdiv($this->maxRequest($limitBytes), 10); + } +} diff --git a/src/App/Config/ServerSettings.php b/src/App/Config/ServerSettings.php new file mode 100644 index 0000000..20153aa --- /dev/null +++ b/src/App/Config/ServerSettings.php @@ -0,0 +1,619 @@ +workers(swoole_cpu_num() * 2) + * ->maxRequest(5000) + * ->set('ssl_cert_file', '/etc/ssl/app.pem'); + * ``` + */ +final class ServerSettings +{ + /** + * Seconds a request may run before the watchdog cancels it. + * + * Chosen at 30 rather than PHP-FPM's 60: under Swoole a stuck request holds more + * than itself — a pooled connection is borrowed for the whole request, and the pool + * is shared by every request in the worker. .NET settles on the same 30. + * + * A route that legitimately runs longer says so with #[Timeout]; the global value is + * there to stop the ones that hang by accident. + */ + private const float DEFAULT_REQUEST_TIMEOUT = 30.0; + + /** + * Largest request accepted, in bytes — the whole packet, headers included. + * + * Swoole's own limit is **2 MB** — measured, 2 000 KB passes and 2 048 KB comes back + * `413` — which is tight for anything that accepts a document or an image, and + * invisible: the client gets a bare status with nothing explaining it. 8 MB matches + * PHP's own `post_max_size` default, so a PHP developer meets the number they expect. + * + * It is not raised further on purpose. The body is held in the worker's heap for the + * duration of the request, and the heap is shared by every request in flight — 64 MB + * bodies at a hundred concurrent uploads is 6.4 GB, and the worker dies before the + * upload does. + */ + private const int DEFAULT_MAX_REQUEST_SIZE = 8 * 1024 * 1024; + + /** Assumed heap when the limit cannot be read — `-1`, or a value PHP cannot parse. */ + private const int ASSUMED_MEMORY_LIMIT = 128 * 1024 * 1024; + + /** @param array $options */ + private function __construct( + private string $host, + private int $port, + private array $options = [], + private ?string $memoryLimit = null, + private float $requestTimeout = self::DEFAULT_REQUEST_TIMEOUT, + private ?string $memoryTrimThreshold = null, + private ?Profile $profile = null, + private int $baselineBytes = 0, + ) { + } + + /** + * Seeds the bind address and base Swoole options. Host/port are passed in (the + * framework's default policy is `--host`/`--port`); tuning options come from the + * environment — only variables that are actually set contribute a key (so Swoole + * defaults apply otherwise). + * + * Nothing the profile decides is seeded here. Those are resolved when read, so a + * {@see WebConfigurer} that raises `memoryLimit()` raises everything derived from it + * no matter which order the two calls are made in. + */ + public static function fromEnv(string $host = '0.0.0.0', int $port = 8000): self + { + $options = [ + 'package_max_length' => self::DEFAULT_MAX_REQUEST_SIZE, + ]; + $map = [ + 'SERVER_WORKERS' => 'worker_num', + 'SERVER_TASKS' => 'task_worker_num', + 'SERVER_MAX_REQUEST' => 'max_request', + 'SERVER_MAX_REQUEST_GRACE' => 'max_request_grace', + 'SERVER_MAX_REQUEST_SIZE' => 'package_max_length', + 'SERVER_MAX_CONNECTIONS' => 'max_connection', + 'SERVER_MAX_CONCURRENCY' => 'worker_max_concurrency', + 'SERVER_IDLE_TIMEOUT' => 'heartbeat_idle_time', + ]; + // `SERVER_MEMORY_LIMIT` is handled below — it is a PHP ini, not a Swoole key. + foreach ($map as $envKey => $swooleKey) { + $raw = env($envKey); + if ($raw !== null && is_numeric($raw)) { + $options[$swooleKey] = (int) $raw; + } + } + + // Not a Swoole option — a PHP ini, so it is carried separately and never + // reaches Swoole\Server::set(). Kept as written ('256M', '1G', '-1'). + $memoryLimit = env('SERVER_MEMORY_LIMIT'); + $memoryLimit = is_string($memoryLimit) && $memoryLimit !== '' ? $memoryLimit : null; + + $timeout = env('SERVER_REQUEST_TIMEOUT'); + $timeout = is_numeric($timeout) ? max(0.0, (float) $timeout) : self::DEFAULT_REQUEST_TIMEOUT; + + $trim = env('SERVER_MEMORY_TRIM'); + $trim = is_string($trim) && $trim !== '' ? $trim : null; + + $profile = env('SERVER_PROFILE'); + $profile = is_string($profile) ? Profile::tryFrom(strtolower(trim($profile))) : null; + + // Captured once, here, rather than on every read: this runs after bootstrap and + // before any worker exists, which is exactly the heap a worker starts from, and a + // value that changed between two reads would make the derived limits disagree + // with each other. + return new self($host, $port, $options, $memoryLimit, $timeout, $trim, $profile, memory_get_usage(true)); + } + + /** Bind host (e.g. '0.0.0.0', '127.0.0.1'). */ + public function host(string $host): self + { + $this->host = $host; + return $this; + } + + /** Bind port. */ + public function port(int $port): self + { + $this->port = $port; + return $this; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; + } + + public function workers(int $count): self + { + return $this->set('worker_num', $count); + } + + public function taskWorkers(int $count): self + { + return $this->set('task_worker_num', $count); + } + + /** + * Requests a worker serves before it is replaced by a fresh one. `0` never replaces + * it. Derived from the profile when not set — see {@see Profile::maxRequest()}. + * + * Replacement is not a precaution but a necessity: Swoole leaks 56 bytes on every + * coroutine suspend/resume, and an HTTP request always suspends. + */ + public function maxRequest(int $count): self + { + return $this->set('max_request', $count); + } + + /** + * How far apart workers may drift before recycling. Derived from the profile when not + * set — a tenth of {@see maxRequest()}. + * + * Swoole **adds** a random amount up to this to `max_request`, so the real limit is + * `max_request + rand(0, grace)`. + */ + public function maxRequestGrace(int $count): self + { + return $this->set('max_request_grace', $count); + } + + /** + * The stance this server takes on its own memory — and, through it, every limit that + * keeps a worker alive. {@see Profile::Balance} when nothing is said. + * + * ``` + * $server->profile(Profile::Performance); + * ``` + * + * It supplies {@see maxConcurrency()}, {@see maxConnections()}, {@see maxRequest()}, + * {@see maxRequestGrace()} and {@see memoryTrimThreshold()} as **defaults**, resolved + * when each is read. Anything set explicitly — here, or through a `SERVER_*` variable + * — wins regardless of the order the calls are made in: + * + * ``` + * $server->profile(Profile::Performance) + * ->maxConnections(10_000); // browsers hold connections open; the rest stands + * ``` + * + * Choosing one is answering how much memory a single request uses, which is + * measurable — see {@see Profile}. + */ + public function profile(Profile $profile): self + { + $this->profile = $profile; + return $this; + } + + /** The profile in force: the configured one, `SERVER_PROFILE`, or {@see Profile::Balance}. */ + public function getProfile(): Profile + { + return $this->profile ?? Profile::Balance; + } + + /** Requests a worker will serve before replacement; `0` when it is never replaced. */ + public function getMaxRequest(): int + { + $configured = $this->options['max_request'] ?? null; + + return is_int($configured) ? $configured : $this->getProfile()->maxRequest($this->limitBytes()); + } + + /** The random spread added to {@see getMaxRequest()}. */ + public function getMaxRequestGrace(): int + { + $configured = $this->options['max_request_grace'] ?? null; + + return is_int($configured) ? $configured : $this->getProfile()->maxRequestGrace($this->limitBytes()); + } + + /** + * Largest request accepted, in bytes — **headers included**. Default: 8 MB. + * + * ``` + * $server->maxRequestSize(32 * 1024 * 1024); // room for 32 MB uploads + * ``` + * + * The limit is on the whole packet, not the body alone: at the default, a body of + * 8 388 400 bytes passes and 8 388 608 does not — the difference is the request's own + * headers. Leave room for them when sizing an upload endpoint. + * + * Swoole's own limit is 2 MB and says nothing when it is hit — the client gets a bare + * `413`, which sends the reader looking anywhere but here. The default matches PHP's + * `post_max_size`, so a PHP developer meets the number they expect. + * + * Raise it knowing what it costs: the request sits in the worker's heap for its whole + * life, and that heap is shared by every request in flight. Large bodies and high + * concurrency multiply. + */ + public function maxRequestSize(int $bytes): self + { + return $this->set('package_max_length', $bytes); + } + + /** + * Largest number of simultaneous TCP connections. Derived from the profile when not + * set — twice {@see getMaxConcurrency()}, the working ones and an idle one apiece. + * + * Not the same thing as concurrent requests, though it is easy to read it that way: + * a keep-alive connection serves many requests one after another, and an idle one + * serves none. Measured — `max_connection = 2` did not slow six concurrent requests + * at all, while `worker_max_concurrency = 2` doubled their total time by queueing. + * + * It is not free, though. A held connection costs about **68 KB of PHP heap** — + * measured linearly at 401, 801 and 1 201 connections, and charged to `memory_limit`, + * not merely to RSS — so a worker with the default 128M dies at roughly 1 900 idle + * keep-alive connections. Swoole's own 100 000 is therefore no limit at all: it would + * take 6.8 GB to reach. + * + * Raise it for a service whose clients hold connections open while asking nothing — + * browsers with open tabs, mobile clients polling rarely. The profile assumes one such + * client per working request, which suits a service behind nginx or one called by + * other services. + */ + public function maxConnections(int $count): self + { + return $this->set('max_connection', $count); + } + + /** Connections allowed at once: the configured value, or the profile's derivation. */ + public function getMaxConnections(): int + { + $configured = $this->options['max_connection'] ?? null; + if (is_int($configured) && $configured > 0) { + return $configured; + } + + return $this->getProfile()->connections($this->availableBytes(), $this->descriptorLimit()); + } + + /** + * The process's file-descriptor ceiling, or {@see PHP_INT_MAX} when there is none to + * read — a socket is a descriptor, so this bounds connections independently of memory. + * + * The soft limit, because that is the one in force and the one Swoole clamps to. It is + * often the tighter of the two ceilings on a developer's own machine: measured, a + * container from the same image allows 1 048 576, while a macOS shell allows 1 024. + */ + private function descriptorLimit(): int + { + if (!function_exists('posix_getrlimit')) { + return PHP_INT_MAX; + } + $limit = posix_getrlimit()['soft openfiles'] ?? null; + + return is_numeric($limit) && (int) $limit > 0 ? (int) $limit : PHP_INT_MAX; + } + + /** + * Seconds a connection may go without sending data before the server closes it. + * Off by default, which is Swoole's own behaviour. + * + * ``` + * $server->idleConnectionTimeout(300); // must exceed the longest request + * ``` + * + * **It also cuts requests that are still running**, and this is the whole reason it is + * off. Swoole measures the time since the client last *sent* something, and a client + * waiting for a slow response sends nothing — so a request being worked on looks + * exactly like an abandoned connection. Measured: with the timeout at 2 seconds a + * 5-second request was cut at 2.07 s and the client got no response; at 8 seconds the + * same request returned normally at 5.00 s. Nothing was written to the log either + * time — the connection simply ends. + * + * So it is a ceiling on request duration as much as on idleness, and it would silently + * overrule {@see \Flytachi\Winter\Kernel\Route\Annotation\Timeout} — a route allowed + * ten minutes would still die here. Set it above the longest request the application + * permits, and prefer {@see requestTimeout()} for bounding request duration: that one + * cancels the coroutine, so `finally` runs, connections return to the pool, and the + * client is told what happened with a `504`. + * + * What it does buy: a client that opens a connection and vanishes otherwise holds a + * file descriptor for the life of the worker. That is a small prize — the connection + * table costs nothing measurable (1 024 vs 1 000 000 connections differ by 160 KB of + * RSS) and containers here allow about a million descriptors — which is why it does + * not pay for the hazard by default. + * + * A related detail worth knowing, since the two are usually mentioned together: + * `heartbeat_check_interval` is **not** required for this to work — verified, the + * timeout closes idle connections on its own. + */ + public function idleConnectionTimeout(int $seconds): self + { + return $this->set('heartbeat_idle_time', $seconds); + } + + /** + * Largest number of requests one worker processes at the same time. Derived from the + * profile when not set — see {@see getMaxConcurrency()}. + * + * ``` + * $server->maxConcurrency(10000); // a proxy: requests are cheap, mostly waiting + * ``` + * + * Swoole **queues** what exceeds it rather than refusing — measured: twenty concurrent + * 0.3-second requests against a limit of 2 all succeeded, taking 3.3 seconds in total + * instead of 0.3. So overload turns into latency, not errors, and no client is turned + * away because the server is briefly busy. + * + * This is the setting that actually protects the worker. `memory_limit` decides when + * it dies; this decides whether it gets there. Not to be confused with + * {@see maxConnections()}, which counts sockets: a keep-alive connection serves many + * requests in turn and an idle one serves none. + * + * It is not a rate limit, and cannot be used as one. It has no idea who is calling, so + * it cannot give one client 50 requests a second and another 20 — and it delays rather + * than rejects, where a quota has to answer `429`. A per-client quota also has to be + * shared between workers, which a per-worker number never is. + */ + public function maxConcurrency(int $count): self + { + return $this->set('worker_max_concurrency', $count); + } + + /** + * The concurrency ceiling that will be applied: the configured value, or the + * profile's derivation from the heap left after the worker's own baseline. + * + * At 256M with {@see Profile::Balance}, 896 in flight; at 512M, 1 853. Doubling the + * limit doubles the ceiling — but only turns into throughput if the ceiling was what + * bound. `worker_concurrency` in `Swoole\Server::stats()` says whether it was: + * sitting at the ceiling means requests are queueing, well below it means the + * bottleneck is somewhere else. + */ + public function getMaxConcurrency(): int + { + $configured = $this->options['worker_max_concurrency'] ?? null; + if (is_int($configured) && $configured > 0) { + return $configured; + } + + // Never more than there are connections to carry them: a request in flight holds + // a socket, so a descriptor ceiling below the memory one binds here too. + return min($this->getProfile()->concurrency($this->availableBytes()), $this->getMaxConnections()); + } + + /** + * The worker's memory ceiling in bytes, or {@see ASSUMED_MEMORY_LIMIT} when it cannot + * be read (`-1`, or a value PHP cannot parse) — an application that opts out of + * memory limits still gets limits derived, rather than none. + */ + private function limitBytes(): int + { + $bytes = WorkerMemory::bytes($this->memoryLimit ?? (string) ini_get('memory_limit')); + + return $bytes > 0 ? $bytes : self::ASSUMED_MEMORY_LIMIT; + } + + /** + * Heap the requests may actually share: the limit less what the application already + * holds before serving anything. + * + * The baseline is **measured, not assumed** — an application with a hundred routes, + * three connection pools and a wide dependency graph starts heavier than an empty + * one, and gets a correspondingly smaller ceiling without being asked. It is taken in + * the master, which has run the same bootstrap the workers inherit; a worker allocates + * a little more of its own (its pool fills lazily), so this errs slightly generous. + */ + private function availableBytes(): int + { + return max(0, $this->limitBytes() - $this->baselineBytes); + } + + /** + * Serves static files from `$path` using Swoole's own handler. + * + * Static content is opt-in: say nothing here and no file is ever served, which is + * what an API-only service wants. Swoole answers these requests in C, before PHP + * is involved — it streams the file instead of reading it into the worker, honours + * `Range`, and cannot be walked out of the directory with `..`. + * + * ``` + * $server->staticPath('resources/static'); // resources/static/app.css → /app.css + * ``` + * + * The directory *is* the URL root: Swoole appends the whole request path to it, so + * the layout on disk mirrors the layout in URLs. Point it at a directory that holds + * assets and nothing else — every file under it becomes downloadable. + * + * Because those requests never reach PHP, middleware, CORS and request logging do + * not apply to them. + * + * Swoole checks the filesystem for each request to decide whether it is a static + * one. To limit that to certain prefixes, set the underlying option directly: + * `->set('static_handler_locations', ['/assets'])`. + * + * @param string $path Directory to serve from; relative paths resolve against the + * project root. + * @throws ApplicationConfigException When the directory does not exist — a typo + * here would otherwise surface as silent 404s at runtime. + */ + public function staticPath(string $path): self + { + $dir = str_starts_with($path, '/') + ? $path + : rtrim(Kernel::$pathRoot, '/\\') . '/' . ltrim($path, '/\\'); + $dir = rtrim($dir, '/\\'); + + if (!is_dir($dir)) { + throw new ApplicationConfigException("Static directory does not exist: {$dir}"); + } + + return $this->set('document_root', $dir) + ->set('enable_static_handler', true); + } + + /** + * Memory ceiling for each worker process, applied on worker start. + * + * ``` + * $server->workers(4)->memoryLimit('256M'); + * ``` + * + * Say nothing and the framework does not touch the setting at all — PHP's own + * value stands (128M compiled in, unless a php.ini raises it). Nothing existing + * changes by upgrading. + * + * **This is a PHP ini value, not a Swoole option**, so it never reaches + * `Swoole\Server::set()` — see {@see toArray()}. It lives here because the limit + * is only half of an arithmetic whose other half, `worker_num`, is already set + * on this object: a box has to hold `worker_num × memoryLimit` plus opcache's + * shared memory, and keeping the two apart is how that product goes unnoticed + * until a worker dies. + * + * The limit is **per worker, shared by every coroutine in it** — a Swoole worker + * serves many requests at once out of one heap, so this bounds their sum, not any + * single request. Raising it moves the threshold; it does not remove it. What + * stops a worker dying is bounding concurrency, not the ceiling. + * + * `-1` (unlimited) is accepted but warned about at boot: without a limit PHP never + * stops, so the process grows until the kernel's OOM killer sends SIGKILL — no + * shutdown functions, no log line, and the whole container when the server is + * PID 1. A limit at least fails through PHP, which the manager can recover from. + * + * @param string $limit A PHP memory value: '256M', '1G', or '-1' for unlimited. + */ + public function memoryLimit(string $limit): self + { + $this->memoryLimit = $limit; + return $this; + } + + /** The configured per-worker memory limit, or null when the ini is left alone. */ + public function getMemoryLimit(): ?string + { + return $this->memoryLimit; + } + + /** + * How long a request may run before the server stops waiting for it, in seconds. + * `0` disables the deadline. Default: 30. + * + * ``` + * $server->requestTimeout(60); // globally + * $server->requestTimeout(0); // no deadline at all + * ``` + * + * Individual routes override it with + * {@see \Flytachi\Winter\Kernel\Route\Annotation\Timeout} — a report that legitimately + * takes ten minutes carries `#[Timeout(600)]`, and the global value protects + * everything else. + * + * Swoole has no request timeout of its own — verified against the extension: no + * option in its lists concerns execution time, and `max_request_execution_time` is + * absent from the binary and does nothing when set. The deadline is enforced by + * {@see \Flytachi\Winter\Kernel\Route\RequestWatchdog}, which cancels the request's + * coroutine; `finally` and `defer` run, so transactions close and pooled connections + * return, and the client receives `504`. + * + * It interrupts a request that **waits**. A request burning CPU is not interrupted — + * the event loop is single-threaded, so nothing else in the worker runs, the watchdog + * included. See the watchdog's docblock for the full picture. + */ + public function requestTimeout(float $seconds): self + { + $this->requestTimeout = max(0.0, $seconds); + return $this; + } + + /** The configured request deadline in seconds; `0.0` when disabled. */ + public function getRequestTimeout(): float + { + return $this->requestTimeout; + } + + /** + * How much idle memory a worker may hold before handing it back to the operating + * system, checked after each request. Default: 32M. `0` never hands anything back. + * + * ``` + * $server->memoryLimit('256M')->memoryTrimThreshold('64M'); + * ``` + * + * PHP releases memory to its own allocator, not to the kernel: many small objects + * live in chunks that are kept for reuse, so a worker that once built a large result + * goes on holding that memory for the rest of its life. On a host running several + * containers that is first-come-first-served — measured, a single request that built + * 600 000 objects left 258 MB reserved and unused. + * + * The threshold is compared against the **reserve** — what the allocator has taken + * from the kernel minus what is actually in use — so a busy worker is never trimmed + * (its memory is in use, the reserve is small) and one trim is enough (releasing + * closes the gap). 32M is far above the two or three megabytes an ordinary worker + * carries, and far below anything worth keeping. + * + * Handing memory back costs about 80 ms when there is a lot of it, and 5 µs when + * there is none — which is what an ordinary request pays. The next large allocation + * pays roughly 10 % more, having to take fresh chunks. + * + * Derived from the profile when not set, as a fraction of the memory limit — what + * counts as "suspiciously unused" depends on how much there is. + * + * @param string $bytes A PHP memory value: '32M', '512K', or '0' to disable. + */ + public function memoryTrimThreshold(string $bytes): self + { + $this->memoryTrimThreshold = $bytes; + return $this; + } + + /** The idle-memory threshold in bytes: the configured value, or the profile's. */ + public function getMemoryTrimThreshold(): int + { + if ($this->memoryTrimThreshold !== null) { + return max(0, WorkerMemory::bytes($this->memoryTrimThreshold)); + } + + return $this->getProfile()->trimThreshold($this->limitBytes()); + } + + /** Set any raw Swoole option. */ + public function set(string $key, mixed $value): self + { + $this->options[$key] = $value; + return $this; + } + + /** + * The Swoole options only. + * + * `memoryLimit` is deliberately absent: it is a PHP ini value, and Swoole answers + * an option it does not know with `unsupported option` on every start. + * + * The profile's four Swoole settings are filled in here rather than in + * {@see fromEnv()} because they derive from the memory limit, which a + * {@see WebConfigurer} may still change. A profile that caps nothing + * ({@see Profile::Stress}) contributes no key, leaving Swoole's own behaviour. + * + * @return array + */ + public function toArray(): array + { + $derived = array_filter([ + 'worker_max_concurrency' => $this->getMaxConcurrency(), + 'max_connection' => $this->getMaxConnections(), + 'max_request' => $this->getMaxRequest(), + 'max_request_grace' => $this->getMaxRequestGrace(), + ], static fn(int $value): bool => $value > 0); + + return $this->options + $derived; + } +} diff --git a/src/App/Config/WebConfigurer.php b/src/App/Config/WebConfigurer.php new file mode 100644 index 0000000..dc0221d --- /dev/null +++ b/src/App/Config/WebConfigurer.php @@ -0,0 +1,32 @@ +port($args->int('port', 8000))->workers(swoole_cpu_num() * 2); + * } + * } + * ``` + */ +abstract class WebConfigurerAdapter implements WebConfigurer +{ + public function configureCors(CorsRegistry $cors): void + { + } + + public function configureServer(ServerSettings $server, ApplicationArguments $args): void + { + } +} diff --git a/src/App/Config/WorkerMemory.php b/src/App/Config/WorkerMemory.php new file mode 100644 index 0000000..5523cae --- /dev/null +++ b/src/App/Config/WorkerMemory.php @@ -0,0 +1,244 @@ +warning( + 'memory_limit is -1 (unlimited) — under Swoole this does not remove the ' + . 'ceiling, it moves it to the kernel. PHP will never stop the process, so ' + . 'a runaway request grows until the OOM killer sends SIGKILL: no shutdown ' + . 'functions, no log entry, and the whole container when the server is PID 1. ' + . 'A real limit at least fails through PHP, which the manager recovers from.', + ); + return; + } + + if ($perWorker === 0) { + return; // unparseable value — PHP will complain about it far better than we can + } + + $fleet = $perWorker * max(1, $workers); + $opcache = self::opcacheBytes(); + $needed = $fleet + $opcache; + $box = $boxBytes ?? self::containerLimitBytes(); + + if ($box === null || $needed <= $box) { + return; + } + + $logger->warning(sprintf( + 'Memory over-commit: %d worker(s) × %s = %s, plus %s of opcache, needs %s — ' + . 'the container is limited to %s. Every worker peaking at once would exceed it, ' + . 'and the kernel kills the process rather than PHP failing the request. ' + . 'Lower memory_limit, run fewer workers, or give the container more memory.', + max(1, $workers), + self::format($perWorker), + self::format($fleet), + self::format($opcache), + self::format($needed), + self::format($box), + )); + } + + /** + * Hands the allocator's idle reserve back to the operating system, if it has grown + * past `$threshold` bytes. Returns the number of bytes released. + * + * PHP frees memory to **its own allocator**, not to the kernel. Whether the kernel + * ever sees it back depends on how it was taken: a single large block is mapped + * directly and unmapped on release, but many small objects live in the allocator's + * chunks, and those chunks are kept for reuse. Measured — 600 000 small objects: + * + * ``` + * in work : used 282 MB, taken from the OS 284 MB → reserve 1.7 MB + * request finished : used 10 MB, taken from the OS 268 MB → reserve 258.0 MB + * after this call : used 10 MB, taken from the OS 12 MB → reserve 2.0 MB + * ``` + * + * That middle line is the problem: the worker holds a quarter of a gigabyte it does + * not use, for the rest of its life. On a host running several containers it is + * first-come-first-served — one spike and the memory is spoken for. + * + * The trigger is the **reserve**, `memory_get_usage(true) - memory_get_usage()`, not + * the peak. Two reasons. While a request is genuinely working the reserve stays small + * (the memory is in use), so sustained load does not trip it — where the peak, which + * never decreases, would trip on every request after the first heavy one. And it is + * self-correcting: releasing closes the gap, so the next call finds nothing to do. + * + * Cost, measured: 80.7 ms when there is 128 MB to return, **5 µs** when there is + * nothing — so the ordinary request pays microseconds — and about 10 % on the next + * large allocation, which has to take fresh chunks. Worth it once after a spike; + * ruinous on every request, which is what the threshold prevents. + * + * `gc_collect_cycles()` does none of this — measured, it collects nothing here and + * frees nothing. Cycles are not what is being held. + */ + public static function trimIfIdle(int $threshold): int + { + if ($threshold <= 0) { + return 0; + } + if (self::idleReserve() < $threshold) { + return 0; + } + + return gc_mem_caches(); + } + + /** + * Memory the allocator has taken from the kernel and is not using — what + * {@see trimIfIdle()} decides on, and worth reading on its own when asking where a + * worker's resident size went. + * + * Its virtue over `memory_get_peak_usage()` is that it **falls again**. A worker in + * the middle of a large request has a high peak and almost no reserve, because the + * memory is in use; once the request ends the reserve is what stayed behind. The peak + * never decreases at all, so it says "something large happened here once" forever and + * cannot tell whether anything is being held now. + */ + public static function idleReserve(): int + { + return memory_get_usage(true) - memory_get_usage(); + } + + /** Parses a PHP memory value ('32M', '512K', '0') into bytes. */ + public static function bytes(string $value): int + { + return self::toBytes($value); + } + + /** The container's memory ceiling in bytes, or null when it is unlimited/unknown. */ + private static function containerLimitBytes(): ?int + { + foreach ([self::CGROUP_V2, self::CGROUP_V1] as $path) { + if (!is_readable($path)) { + continue; + } + $raw = trim((string) @file_get_contents($path)); + + // cgroup v2 writes the literal "max" when unbounded; v1 writes a number so + // large it means the same thing (typically PHP_INT_MAX rounded to page size). + if ($raw === '' || $raw === 'max' || !ctype_digit($raw)) { + return null; + } + $bytes = (int) $raw; + + return $bytes > 0 && $bytes < PHP_INT_MAX / 2 ? $bytes : null; + } + + return null; + } + + /** Opcache's shared memory, which is charged to the box once, not per worker. */ + private static function opcacheBytes(): int + { + if (!function_exists('opcache_get_status') || ini_get('opcache.enable') === false) { + return 0; + } + $mb = (int) ini_get('opcache.memory_consumption'); + + return $mb > 0 ? $mb * 1024 * 1024 : 0; + } + + /** + * PHP shorthand ('256M', '1G') to bytes. Returns -1 for unlimited, 0 when the value + * makes no sense — PHP itself reports that better than a second opinion would. + */ + private static function toBytes(string $value): int + { + $value = trim($value); + if ($value === '-1') { + return -1; + } + if (!preg_match('/^(\d+)\s*([KMG])?$/i', $value, $m)) { + return 0; + } + + return (int) $m[1] * match (strtoupper($m[2] ?? '')) { + 'K' => 1024, + 'M' => 1024 ** 2, + 'G' => 1024 ** 3, + default => 1, + }; + } + + private static function format(int $bytes): string + { + if ($bytes >= 1024 ** 3) { + return round($bytes / 1024 ** 3, 1) . ' GiB'; + } + + return round($bytes / 1024 ** 2) . ' MiB'; + } +} diff --git a/src/App/Scope.php b/src/App/Scope.php new file mode 100644 index 0000000..28ed0e1 --- /dev/null +++ b/src/App/Scope.php @@ -0,0 +1,23 @@ +register(AppServiceProvider::class); - * $c->register(DatabaseServiceProvider::class); - * - * // Manual bindings - * $c->singleton(CacheInterface::class, RedisCache::class); - * $c->bind(MailerInterface::class, fn($c) => - * new SmtpMailer(env('MAIL_HOST'), $c->make(LoggerInterface::class)) - * ); - * $c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); - * } - * ``` - */ - protected static function providers(Container $c): void - { - } - - /** - * Register additional log channels via Kernel::channel(). - * - * Built-in channels (sys, http, cli) are registered automatically by Kernel::init(). - * Call this hook to add custom channels. Each channel reads LOG_{NAME}_* env vars - * with the same fallback chain as the built-in channels. - * - * Rules: - * - Call AFTER configure() (already guaranteed by boot order). - * - Channel name is lowercase by convention; env prefix is uppercased automatically. - * - If a channel is requested but not registered, it falls back to the default channel. - * - * ``` - * protected static function channels(): void - * { - * Kernel::channel('job'); - * Kernel::channel('daemon'); - * } - * ``` - * - * Usage anywhere in application code: - * LoggerFactory::getLogger(MyJob::class, 'job')->info('started'); - * LoggerFactory::channel('daemon')->warning('slow tick'); - * - * .env for custom channels: - * LOG_JOB_LEVEL=debug - * LOG_JOB_OUTPUT=file - * LOG_JOB_FILE=/var/log/app/job.log - * LOG_JOB_FILE_MAX=7 - */ - protected static function channels(): void - { - } - - /** - * Configure global CORS policy via Cors::configure(). - * - * Applied to every response — including 404, 405, and 5xx — before route - * dispatch. Per-route overrides are available via #[CrossOrigin] on any - * controller class or method; method-level takes priority over class-level. - * - * ``` - * protected static function httpCors(): void - * { - * Cors::configure( - * origins: ['https://app.example.com', 'https://admin.example.com'], - * allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'], - * exposeHeaders: ['X-Request-Id'], - * credentials: true, - * maxAge: 3600, - * ); - * } - * ``` - * - * Cors::configure() parameters: - * origins string[] Allowed origins. Empty → wildcard '*'. - * allowHeaders string[] Headers the browser may send (preflight). - * exposeHeaders string[] Headers exposed to the browser (response). - * credentials bool Send Access-Control-Allow-Credentials: true. - * maxAge int Preflight cache lifetime in seconds. - */ - protected static function httpCors(): void - { - } - - /** - * Configure health / actuator endpoints via Health::configure(). - * - * Registers read-only diagnostic endpoints under /actuator. - * All endpoints return JSON. Useful for load-balancer probes and monitoring. - * - * Endpoints (GET): - * /actuator — full aggregated report - * /actuator/health — overall status: up | degraded | down - * degraded: ≥80% resource usage | down: ≥90% or connection failed - * /actuator/info — PHP version, SAPI, framework version, project meta - * /actuator/metrics — CPU load, memory, disk, opcache stats, uptime - * /actuator/env — custom env values (override env() in your indicator) - * /actuator/loggers — active log channels and their configured levels - * /actuator/mappings — registered route table - * - * ``` - * protected static function health(): void - * { - * // Default built-in indicator, open access: - * Health::configure(); - * - * // Custom indicator + middleware guard: - * Health::configure( - * indicator: App\Health\AppHealthIndicator::class, - * middleware: App\Http\Middleware\InternalOnlyMiddleware::class, - * ); - * } - * ``` - */ - protected static function health(): void - { - } - - /** - * Register plugins via Plugin::registry(). - * - * Registers Composer packages as route-prefixed sub-applications. - * Each plugin's src/ directory is scanned for controllers automatically - * by Router::fromScan() / Router::resolve() — no extra wiring required. - * - * ``` - * protected static function plugins(): void - * { - * Plugin::registry('acme/auth-plugin', '/auth'); - * Plugin::registry('acme/billing-plugin', '/billing'); - * } - * ``` - * - * Plugin::registry() parameters: - * package string Composer package name (e.g. 'acme/billing'). - * prefix string URL prefix (e.g. '/billing'). - * required bool Throw if package is not installed (default: true). - */ - protected static function plugins(): void - { - } - - /** - * Swoole HTTP server settings passed to \Swoole\Http\Server::set(). - * - * Override to tune concurrency, request limits, SSL, and other Swoole options. - * Return an empty array to use Swoole's built-in defaults. - * - * ``` - * protected static function swooleConfig(): array - * { - * return [ - * 'worker_num' => swoole_cpu_num() * 2, - * 'max_request' => 5000, - * 'max_request_grace' => 500, - * 'enable_coroutine' => true, - * ]; - * } - * ``` - * - * @return array - */ - public static function swooleConfig(): array - { - return []; - } - - /** - * Last-resort handler for errors thrown before Router::handle() installs - * its own try/catch — boot failures, DI scan errors, ambiguous routes, etc. - * - * Mirrors Router::sendError() so boot-time and request-time errors render - * through the same ExceptionWrapper pipeline. Override in your Boot class - * to customise (Sentry reporting, branded error page, etc.). - */ - protected static function handleBootError(\Throwable $e, HttpResponse $response): void - { - try { - LoggerFactory::getLogger(static::class)->alert( - 'Boot failure: ' . $e->getMessage(), - ['exception' => $e] - ); - } catch (\Throwable) { - error_log(sprintf( - '[winter-kernel] Boot failure: %s in %s:%d', - $e->getMessage(), - $e->getFile(), - $e->getLine() - )); - } - - while (ob_get_level() > 0) { - ob_end_clean(); - } - - try { - $exc = ExceptionWrapper::wrap($e); - $body = $exc->getBody(); - $response->status($exc->getHttpCode()->value); - foreach ($exc->getHeader() as $key => $value) { - $response->header($key, $value); - } - $response->end($body); - } catch (\Throwable) { - $response->status(500); - $response->header('Content-Type', 'text/plain; charset=utf-8'); - $response->end('Internal Server Error'); - } - } - - // ── Entry points ────────────────────────────────────────────────────────── - - public static function base(?string $defaultChannelName = null): void - { - self::boot(); - if ($defaultChannelName !== null) { - Kernel::channel($defaultChannelName); - LoggerFactory::setDefaultChannel($defaultChannelName); - } - } - - /** - * FPM entry point — one request per process lifecycle. - * - * Reads the HTTP request from PHP superglobals ($_SERVER, $_GET, $_POST, - * $_FILES, php://input) and writes the response via http_response_code() / - * header() / echo. No shared state between requests. - * - * Route caching: - * DEBUG=false → loads from storage/cache/mapping.php when it exists; - * scans and writes cache on first boot after deployment. - * DEBUG=true → always rescans (dev mode, no stale cache). - * - * Static files: - * GET requests whose URI maps to an existing file in Kernel::$pathPublic - * are served directly — skipping route dispatch entirely. In FPM+nginx - * setups nginx already handles this, so the check is a no-op in production - * if nginx is configured correctly. - * - * Request pipeline: - * 1. Header::init() — snapshot superglobals into the Header bag - * 2. Locale::initFromRequest() — detect Accept-Language / locale cookie - * 3. Static file check — short-circuit for existing public files - * 4. Global CORS headers — applied before dispatch (covers 404/500 too) - * 5. OPTIONS preflight — returns 204 before handler invocation - * 6. Route dispatch — O(1) static map → chunked regex dynamic scan - * 7. Per-route #[CrossOrigin] — overrides global CORS if present - * 8. Middleware before() — run in declaration order - * 9. Controller method — resolved via ReflectionCache + ParameterResolver - * 10. Middleware after() — run in reverse order - * 11. Response serialise — Sendable::send() or ResponseEntity::ok()->send() - * 12. Error handling — ExceptionWrapper maps Throwable → HTTP response - */ - final public static function web(): never - { - $isHead = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD'; - $response = new FpmResponse($isHead); - try { - self::boot(); - LoggerFactory::setDefaultChannel('http'); - - $router = Router::resolve(Kernel::$pathRoot); - $router->static(Kernel::$pathPublic); - $router->handle(new FpmRequest(), $response); - } catch (\Throwable $e) { - self::handleBootError($e, $response); - } - - exit(0); - } - - /** - * Swoole coroutine HTTP server entry point. - * - * Performs a single filesystem scan on startup — routes stay in memory for - * the entire server lifetime. All requests share the same Router instance - * via the on('request') callback; coroutine isolation keeps per-request - * state separate. - * - * Requires ext-swoole. SWOOLE_HOOK_ALL is enabled automatically so that - * all blocking I/O (PDO, cURL, file, sleep, …) is coroutine-friendly. - * - * Server configuration is supplied via swooleConfig() — override it in Boot: - * ``` - * protected static function swooleConfig(): array - * { - * return ['worker_num' => swoole_cpu_num() * 2, 'max_request' => 5000]; - * } - * ``` - * - * Request pipeline (per coroutine): - * 1. Header::init() — snapshot request headers into coroutine ctx - * 2. Locale::initFromRequest() — detect Accept-Language / locale cookie - * 3. Swoole ctx stamp — record start time, method, URI - * 4. Static file check — serve files from Kernel::$pathPublic - * 5. Global CORS headers — applied before dispatch - * 6. OPTIONS preflight — returns 204 before handler invocation - * 7. Route dispatch — same pipeline as web() - * 8–12. identical to web() - * - * @param string $host Listen address (default: 0.0.0.0) - * @param int $port Listen port (default: 9501) - */ - final public static function swoole(string $host = '0.0.0.0', int $port = 9501): never - { - self::boot(); - LoggerFactory::setDefaultChannel('http'); - - $router = Router::fromScan(Kernel::$pathRoot); - $router->static(Kernel::$pathPublic); - - \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); - Runtime::boot(RuntimeMode::Swoole); - - $server = new \Swoole\Http\Server($host, $port); - $server->set(static::swooleConfig()); - - $watcher = new MemoryWatcher(); - $watcher->attach($server); - - $server->on('request', $watcher->wrap( - static function (\Swoole\Http\Request $req, \Swoole\Http\Response $res) use ($router): void { - $request = new SwooleRequest($req); - $isHead = strtoupper($request->getMethod()) === 'HEAD'; - $router->handle($request, new SwooleResponse($res, $isHead)); - } - )); - - $server->start(); - exit(0); - } - - /** - * CLI console entry point. - * - * Parses $argv and dispatches to the matching console command class under - * Flytachi\Winter\Console\Command\{Name}. The first argument selects the - * command; aliases defined in Console\Core::$aliases are resolved first. - * - * Built-in commands: Make, Run, Script, Thread, Help, Cfg, Serve, Complete. - * Custom commands live in your project and are discovered via the scanner. - * - * Usage examples (from the 'call' binary): - * ``` - * ./call make:controller UserController - * ./call run MyDaemon - * ./call help - * ``` - * - * The 'cli' log channel is activated so all log writes go to the CLI output. - * To inject per-session fields into every log line: - * LoggerFactory::contextStorage()->set('job', 'import'); - * - * @param array $argv Raw $argv from the CLI script (script name in [0]) - */ - final public static function cli(array $argv = []): never - { - self::boot(); - LoggerFactory::setDefaultChannel('cli'); - - new Core($argv)->run(); - - exit(0); - } - - /** - * Thread / job executor entry point. - * - * Deserializes a Runnable object from stdin (PAYLOAD_PIPE) or shared memory - * (PAYLOAD_SHM, requires ext-shmop) and executes it in a child process - * spawned by the Thread dispatcher. - * - * This method is called by the wKernelExecutor binary — you do not invoke it - * directly. The binary is referenced by Thread::dispatch() internally. - * - * Payload sources (selected automatically by the dispatcher): - * PAYLOAD_PIPE — serialised Runnable written to the child's stdin pipe - * PAYLOAD_SHM — serialised Runnable placed in a shared memory segment - * (avoids fd conflicts in Swoole; requires ext-shmop) - * - * CLI flags accepted by the executor binary: - * --namespace=App Process title namespace prefix - * --name=MyJob Override the process title name (default: class short name) - * --tag=worker Process title tag (default: 'runnable') - * --shmkey=1234 Read payload from SHM segment with this key instead of stdin - * --debug Enable full error reporting in the child process - * --arg-key=value Pass custom arguments to Runnable::run() as ['key' => 'value'] - * --arg-flag Pass boolean flag to Runnable::run() as ['flag' => true] - * - * @param array $argv Raw $argv from the wKernelExecutor binary - */ - final public static function executor(array $argv = []): never - { - self::boot(); - LoggerFactory::setDefaultChannel('cli'); - $options = getopt('', ['namespace::', 'name::', 'tag::', 'debug', 'detach', 'shmkey::']); - exit(WinterRunner::adaptive()->execute($options)); - } - - // ── Internal ────────────────────────────────────────────────────────────── - - private static function boot(): void - { - self::$bootClass = static::class; - static::configure(); - - $c = Container::init(); - - Scanner::run( - rootDir: Kernel::$pathRoot, - cache: env('DEBUG', false) ? null - : Kernel::$pathStorageVolatile . '/di.php', - ) - ->collect(new DICollector($c)) - ->execute(); - - // Default contextual logger: #[Autowired] LoggerInterface $logger resolves to a - // logger named after the class it is injected into. Override in providers() by - // re-registering contextual(LoggerInterface::class, …). - $c->contextual(LoggerInterface::class, - static fn(Container $c, ?string $consumer) => LoggerFactory::getLogger($consumer ?? 'app'), - ); - - static::providers($c); - static::channels(); - static::plugins(); - static::httpCors(); - static::health(); - } -} diff --git a/src/Collector/ConfigurationCollector.php b/src/Collector/ConfigurationCollector.php new file mode 100644 index 0000000..29850d9 --- /dev/null +++ b/src/Collector/ConfigurationCollector.php @@ -0,0 +1,151 @@ +collect(new ConfigurationCollector($container))->execute(); + * ``` + */ +final readonly class ConfigurationCollector implements CollectorInterface +{ + public function __construct(private Container $container) + { + } + + public function collect(string $class, ReflectionClass $ref): void + { + if ($ref->getAttributes(Configuration::class) === []) { + return; + } + + // One shared instance of the configuration class for all of its beans. + $this->container->singleton($class); + + foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $attributes = $method->getAttributes(Bean::class); + if ($attributes === []) { + continue; + } + + /** @var Bean $bean */ + $bean = $attributes[0]->newInstance(); + $key = $bean->name ?? self::returnTypeOf($method, $class); + self::assertObjectReturn($method, $class); + $factory = $this->factoryFor($class, $method->getName()); + + match ($bean->scope) { + Scope::Singleton => $this->container->singleton($key, $factory), + Scope::Transient => $this->container->transient($key, $factory), + Scope::Request => $this->container->request($key, $factory), + }; + } + } + + /** + * Builds the closure that invokes the bean method with resolved arguments. + */ + private function factoryFor(string $class, string $method): \Closure + { + return function (Container $c) use ($class, $method): mixed { + $config = $c->make($class); + $args = self::resolveArguments($c, new ReflectionMethod($class, $method)); + return $config->{$method}(...$args); + }; + } + + /** + * @return list + */ + private static function resolveArguments(Container $c, ReflectionMethod $method): array + { + $args = []; + foreach ($method->getParameters() as $parameter) { + $args[] = self::resolveParameter($c, $parameter); + } + return $args; + } + + private static function resolveParameter(Container $c, ReflectionParameter $parameter): mixed + { + $valueAttributes = $parameter->getAttributes(Value::class); + if ($valueAttributes !== []) { + /** @var Value $value */ + $value = $valueAttributes[0]->newInstance(); + return env($value->key, $value->default); + } + + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + return $c->make($type->getName()); + } + + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + throw new \RuntimeException(sprintf( + 'Cannot resolve bean parameter $%s of %s::%s() — add a type hint, a #[Value], or a default.', + $parameter->getName(), + $parameter->getDeclaringClass()?->getName() ?? '?', + $parameter->getDeclaringFunction()->getName(), + )); + } + + /** + * A bean's value is stored in the container, which injects properties into + * every resolved instance — so a bean must produce an object, never a scalar. + * Scalars belong in .env / {@see Value}. + */ + private static function assertObjectReturn(ReflectionMethod $method, string $class): void + { + $type = $method->getReturnType(); + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw new \RuntimeException(sprintf( + '#[Bean] %s::%s() must return a class/interface — beans hold objects; ' + . 'use .env / #[Value] for scalar configuration.', + $class, + $method->getName(), + )); + } + } + + private static function returnTypeOf(ReflectionMethod $method, string $class): string + { + $type = $method->getReturnType(); + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw new \RuntimeException(sprintf( + '#[Bean] %s::%s() must declare a class/interface return type (the binding key), ' + . 'or pass an explicit name: #[Bean(name: ...)].', + $class, + $method->getName(), + )); + } + return $type->getName(); + } +} diff --git a/src/Collector/ImplementorCollector.php b/src/Collector/ImplementorCollector.php index 0bb33ce..533a1bc 100644 --- a/src/Collector/ImplementorCollector.php +++ b/src/Collector/ImplementorCollector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Collector; +namespace Flytachi\Winter\Kernel\Collector; use Flytachi\Winter\DI\Contract\CollectorInterface; use ReflectionClass; diff --git a/src/Collector/ScopeConflictException.php b/src/Collector/ScopeConflictException.php new file mode 100644 index 0000000..b4d7dfe --- /dev/null +++ b/src/Collector/ScopeConflictException.php @@ -0,0 +1,45 @@ + $conflicts */ + public static function of(array $conflicts): self + { + $paths = implode("\n ", $conflicts); + + return new self(<< Classes carrying `#[Singleton]`. */ + private array $singletons = []; + + /** @var array Classes carrying `#[Request]`. */ + private array $requestScoped = []; + + /** @var array> */ + private array $edges = []; + + public function collect(string $class, ReflectionClass $ref): void + { + if ($ref->getAttributes(Singleton::class) !== []) { + $this->singletons[$class] = true; + } + if ($ref->getAttributes(Request::class) !== []) { + $this->requestScoped[$class] = true; + } + + foreach ($ref->getProperties() as $property) { + if ($property->getAttributes(Autowired::class) === [] && $property->getAttributes(Inject::class) === []) { + continue; + } + + $type = $this->targetOf($property->getAttributes(Inject::class), $property->getType()); + if ($type !== null) { + $this->edges[$class][] = ['property' => $property->getName(), 'type' => $type]; + } + } + } + + /** + * Fails the boot when a singleton can reach a request-scoped bean. + * + * The message names the whole path rather than the endpoints, because the middle of + * the chain is where the mistake usually hides — each link looks correct on its own. + * + * @throws ScopeConflictException + */ + public function assertNoFrozenRequestScope(): void + { + $conflicts = []; + + foreach (array_keys($this->singletons) as $singleton) { + $path = $this->pathToRequestScope($singleton, [$singleton => true]); + if ($path !== null) { + $conflicts[] = $singleton . ' → ' . implode(' → ', $path); + } + } + + if ($conflicts !== []) { + throw ScopeConflictException::of($conflicts); + } + } + + /** + * Depth-first walk to the first request-scoped class reachable from $class. + * + * @param class-string $class + * @param array $seen Guards against a dependency cycle. + * @return list|null The remaining hops, or null when nothing is reachable. + */ + private function pathToRequestScope(string $class, array $seen): ?array + { + foreach ($this->edges[$class] ?? [] as $edge) { + $target = $edge['type']; + $hop = "\${$edge['property']}: {$target}"; + + if (isset($this->requestScoped[$target])) { + return [$hop]; + } + if (isset($seen[$target])) { + continue; + } + + $deeper = $this->pathToRequestScope($target, $seen + [$target => true]); + if ($deeper !== null) { + return [$hop, ...$deeper]; + } + } + + return null; + } + + /** + * The class a property resolves to: an explicit `#[Inject(Foo::class)]` wins over the + * declared type, mirroring how the container itself decides. + * + * @param list<\ReflectionAttribute> $injectAttributes + * @return class-string|null Null for scalars, unions and untyped properties — the + * container cannot resolve those either. + */ + private function targetOf(array $injectAttributes, ?\ReflectionType $declared): ?string + { + if ($injectAttributes !== []) { + $id = $injectAttributes[0]->newInstance()->id; + if (is_string($id) && $id !== '' && class_exists($id)) { + return $id; + } + } + + if ($declared instanceof ReflectionNamedType && !$declared->isBuiltin()) { + return $declared->getName(); + } + + return null; + } +} diff --git a/src/Collector/SubclassCollector.php b/src/Collector/SubclassCollector.php index e9e5f4f..4b93b54 100644 --- a/src/Collector/SubclassCollector.php +++ b/src/Collector/SubclassCollector.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Collector; +namespace Flytachi\Winter\Kernel\Collector; use Flytachi\Winter\DI\Contract\CollectorInterface; use ReflectionClass; diff --git a/src/Concurrent/Async/Async.php b/src/Concurrent/Async/Async.php new file mode 100644 index 0000000..9259143 --- /dev/null +++ b/src/Concurrent/Async/Async.php @@ -0,0 +1,76 @@ +mixpanel->push($userId, $event); + * } + * + * #[Async] + * public function send(int $userId): Future + * { + * $this->mailer->send($userId); + * + * return CompletableFuture::completedFuture(true); + * } + * } + * ``` + * + * --- + * ### Caveats + * + * Only instances obtained from the DI container are proxied. A service built + * with `new` runs synchronously. + * + * Under Swoole a body that never suspends — no I/O, no sleep — finishes before + * the call even returns, because a new coroutine is entered immediately. The + * result is still correct; only the "runs later" intuition does not hold for + * purely computational bodies. + * + * @see \Flytachi\Winter\Kernel\Concurrent\Future + */ +#[\Attribute(\Attribute::TARGET_METHOD)] +final class Async +{ + /** + * @param string|null $executor Container id of the executor to run on; null uses the shared one. + */ + public function __construct( + public readonly ?string $executor = null + ) { + } +} diff --git a/src/Concurrent/Async/AsyncCollector.php b/src/Concurrent/Async/AsyncCollector.php new file mode 100644 index 0000000..427015e --- /dev/null +++ b/src/Concurrent/Async/AsyncCollector.php @@ -0,0 +1,170 @@ +collect(new DICollector($container)) + * ->collect(new AsyncCollector($container, ProxyFactory::forKernel())) + * ->execute(); + * ``` + * + * @see Async + * @see ProxyFactory + */ +final class AsyncCollector implements CollectorInterface +{ + /** @var array Original class mapped to its proxy. */ + private array $proxied = []; + + /** + * Names of the classes known to be annotated, or null while discovering. + * + * Finding them means reflecting every method of every class in the project — + * roughly three times the cost of {@see \Flytachi\Winter\DI\Collector\DICollector} + * itself, paid on every boot, which under FPM means every request. The answer + * does not change between boots, so it is written next to the DI cache and + * replayed as a plain lookup. + * + * @var array|null + */ + private ?array $known = null; + + /** + * @param Container $container Container whose bindings are rewritten. + * @param ProxyFactory $factory Source of the generated proxies. + * @param string|null $cacheFile Where the discovered list is stored; null keeps discovery on every boot. + */ + public function __construct( + private readonly Container $container, + private readonly ProxyFactory $factory, + private readonly ?string $cacheFile = null + ) { + if ($cacheFile !== null && is_file($cacheFile)) { + $cached = require $cacheFile; + $this->known = is_array($cached) ? $cached : null; + } + } + + public function collect(string $class, \ReflectionClass $ref): void + { + // Generated proxies live under the project root as well and would + // otherwise be scanned as ordinary classes. + if (str_starts_with($class, ProxyGenerator::PROXY_NAMESPACE . '\\')) { + return; + } + + if ($this->known !== null) { + if (isset($this->known[$class])) { + $this->bind($class, $ref); + } + + return; + } + + if (ProxyGenerator::asyncMethods($ref) === []) { + return; + } + + $this->bind($class, $ref); + } + + /** + * Persists the discovered list so later boots skip the reflection pass. + * + * Call once, after the scan has finished. Writing an empty list matters as + * much as writing a full one: a project without any `#[Async]` service must + * not rediscover that fact on every boot. + */ + public function flush(): void + { + if ($this->cacheFile === null || $this->known !== null) { + return; + } + + $known = array_fill_keys(array_keys($this->proxied), true); + $export = var_export($known, true); + $temporary = $this->cacheFile . '.' . getmypid() . '.tmp'; + + Kernel::ensureDirectory(dirname($this->cacheFile)); + + if (file_put_contents($temporary, "cacheFile); + } else { + @unlink($temporary); + } + } + + /** + * Returns every substitution made during the scan. + * + * @return array Original class mapped to its proxy. + */ + public function proxied(): array + { + return $this->proxied; + } + + /** + * Generates the proxy if needed and points the container at it. + * + * @param class-string $class Original class. + * @param \ReflectionClass $ref Reflection of the original. + */ + private function bind(string $class, \ReflectionClass $ref): void + { + $proxy = $this->factory->proxyFor($ref); + $this->rebind($class, $proxy, $ref); + $this->proxied[$class] = $proxy; + } + + /** + * Points the container at the proxy while keeping the original lifetime. + * + * @param class-string $class Original class. + * @param class-string $proxy Generated subclass. + * @param \ReflectionClass $ref Reflection of the original. + */ + private function rebind(string $class, string $proxy, \ReflectionClass $ref): void + { + match (true) { + $ref->getAttributes(Singleton::class) !== [] => $this->container->singleton($class, $proxy), + $ref->getAttributes(Request::class) !== [] => $this->container->request($class, $proxy), + $ref->getAttributes(Transient::class) !== [] => $this->container->transient($class, $proxy), + default => $this->container->bind($class, $proxy), + }; + } +} diff --git a/src/Concurrent/Async/AsyncException.php b/src/Concurrent/Async/AsyncException.php new file mode 100644 index 0000000..6e8191f --- /dev/null +++ b/src/Concurrent/Async/AsyncException.php @@ -0,0 +1,25 @@ +submit(static function () use ($body): mixed { + $result = $body(); + + return $result instanceof Future ? $result->get() : $result; + }); + } + + /** + * Runs a void method body asynchronously, discarding its outcome. + * + * @param ExecutorService $executor Executor to run on. + * @param \Closure $body Bound call to the original method. + */ + public static function execute(ExecutorService $executor, \Closure $body): void + { + $executor->execute($body); + } +} diff --git a/src/Concurrent/Async/Proxy/BypassScanner.php b/src/Concurrent/Async/Proxy/BypassScanner.php new file mode 100644 index 0000000..b18bb5b --- /dev/null +++ b/src/Concurrent/Async/Proxy/BypassScanner.php @@ -0,0 +1,317 @@ + Fully qualified names of classes carrying #[Async]. */ + private array $targets; + + /** + * @param iterable $asyncClasses Classes whose instances must come from the container. + * @param list $exclude Absolute directory prefixes to skip. + */ + public function __construct(iterable $asyncClasses, private readonly array $exclude = []) + { + $this->targets = []; + foreach ($asyncClasses as $class) { + $this->targets[ltrim($class, '\\')] = true; + } + } + + /** + * Walks a project tree and reports every direct instantiation found. + * + * @param string $rootDir Directory to scan. + * @return list Findings in file order. + */ + public function scan(string $rootDir): array + { + if ($this->targets === []) { + return []; + } + + $findings = []; + + foreach ($this->files($rootDir) as $file) { + foreach ($this->scanFile($file) as $finding) { + $findings[] = $finding; + } + } + + return $findings; + } + + /** + * Reports direct instantiations inside a single file. + * + * @param string $file Absolute path of a PHP file. + * @return list + */ + public function scanFile(string $file): array + { + $source = @file_get_contents($file); + if ($source === false) { + return []; + } + + // Cheap pre-filter: no `new` at all means no work to do. + if (!str_contains($source, 'new')) { + return []; + } + + $tokens = \PhpToken::tokenize($source); + $namespace = ''; + $aliases = []; + $depth = 0; + $findings = []; + + for ($i = 0, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + + if ($token->is('{')) { + $depth++; + continue; + } + if ($token->is('}')) { + $depth--; + continue; + } + + if ($token->is(T_NAMESPACE)) { + $namespace = $this->readName($tokens, $i); + continue; + } + + // Only top-level `use` imports classes; inside a body it is a trait + // import or a closure binding. + if ($token->is(T_USE) && $depth === 0) { + $this->readUse($tokens, $i, $aliases); + continue; + } + + if (!$token->is(T_NEW)) { + continue; + } + + $name = $this->readName($tokens, $i); + if ($name === '' || in_array(strtolower($name), self::SELF_REFERENCES, true)) { + continue; + } + + $resolved = $this->resolve($name, $namespace, $aliases); + if (isset($this->targets[$resolved])) { + $findings[] = ['file' => $file, 'line' => $token->line, 'class' => $resolved]; + } + } + + return $findings; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Yields every PHP file under the root that is not excluded. + * + * @param string $rootDir Directory to walk. + * @return \Generator + */ + private function files(string $rootDir): \Generator + { + $root = rtrim($rootDir, '/\\'); + $skip = array_map(static fn(string $dir): string => rtrim($dir, '/\\'), $this->exclude); + $skip[] = $root . DIRECTORY_SEPARATOR . 'vendor'; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if ($file->getExtension() !== 'php') { + continue; + } + + $path = $file->getRealPath(); + if ($path === false) { + continue; + } + + foreach ($skip as $prefix) { + if (str_starts_with($path, $prefix)) { + continue 2; + } + } + + yield $path; + } + } + + /** + * Reads the name that follows the token at $index, advancing past it. + * + * @param list<\PhpToken> $tokens Token stream. + * @param int $index Cursor, moved to the last consumed token. + */ + private function readName(array $tokens, int &$index): string + { + for ($i = $index + 1, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + + if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + continue; + } + + if ($token->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE])) { + $index = $i; + + return $token->text; + } + + // Anything else — `new $var`, `new (expr)`, `new class {…}` — is not a name. + return ''; + } + + return ''; + } + + /** + * Records the imports of a top-level `use` statement. + * + * Handles plain, aliased and grouped forms; skips `use function` / `use const` + * and closure bindings. + * + * @param list<\PhpToken> $tokens Token stream. + * @param int $index Cursor, moved to the end of the statement. + * @param array $aliases Alias map to fill, short name to FQCN. + */ + private function readUse(array $tokens, int &$index, array &$aliases): void + { + $prefix = ''; + $current = ''; + $alias = null; + $expectAlias = false; + + for ($i = $index + 1, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + + if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + continue; + } + + // `use function foo;`, `use const BAR;` and closure `use (...)`. + if ($token->is([T_FUNCTION, T_CONST, '('])) { + $index = $i; + + return; + } + + if ($token->is(T_AS)) { + $expectAlias = true; + continue; + } + + if ($token->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NAME_RELATIVE])) { + if ($expectAlias) { + $alias = $token->text; + } else { + $current = $token->text; + } + continue; + } + + if ($token->is('{')) { + $prefix = rtrim($current, '\\') . '\\'; + $current = ''; + continue; + } + + if ($token->is(',') || $token->is('}') || $token->is(';')) { + if ($current !== '') { + $fqcn = ltrim($prefix . $current, '\\'); + $aliases[$alias ?? $this->shortName($current)] = $fqcn; + } + + $current = ''; + $alias = null; + $expectAlias = false; + + if ($token->is(';')) { + $index = $i; + + return; + } + if ($token->is('}')) { + $prefix = ''; + } + } + } + + $index = $count - 1; + } + + /** + * Turns a name as written into a fully qualified one. + * + * @param string $name Name as it appears after `new`. + * @param string $namespace Namespace of the file. + * @param array $aliases Import map of the file. + */ + private function resolve(string $name, string $namespace, array $aliases): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $segments = explode('\\', $name); + $first = array_shift($segments); + + if (isset($aliases[$first])) { + return $segments === [] + ? $aliases[$first] + : $aliases[$first] . '\\' . implode('\\', $segments); + } + + return $namespace === '' ? $name : $namespace . '\\' . $name; + } + + /** + * @param string $name Possibly qualified name. + */ + private function shortName(string $name): string + { + $position = strrpos($name, '\\'); + + return $position === false ? $name : substr($name, $position + 1); + } +} diff --git a/src/Concurrent/Async/Proxy/ProxyFactory.php b/src/Concurrent/Async/Proxy/ProxyFactory.php new file mode 100644 index 0000000..f685ef8 --- /dev/null +++ b/src/Concurrent/Async/Proxy/ProxyFactory.php @@ -0,0 +1,200 @@ +directory; + } + + /** + * Returns the loaded proxy class for the given class, generating it if needed. + * + * @param \ReflectionClass $class Class to proxy. + * @return class-string Name of the generated subclass. + * @throws AsyncException If generation or loading fails. + */ + public function proxyFor(\ReflectionClass $class): string + { + /** @var class-string $proxyClass */ + $proxyClass = ProxyGenerator::proxyClass($class->getName()); + $file = $this->fileFor($class->getName()); + + // The file is reconciled before the class is, because the two can + // disagree: `call di build` clears the directory in a process whose + // boot has already loaded the proxies, and skipping the write here + // would leave the build reporting success over an empty directory. + if ($this->isStale($file, $class)) { + $this->write($file, ProxyGenerator::generate($class)); + } + + if (class_exists($proxyClass, false)) { + return $proxyClass; + } + + require_once $file; + + if (!class_exists($proxyClass, false)) { + throw AsyncException::of( + $class->getName(), + 'the generated proxy ' . $proxyClass . ' was not declared by ' . $file, + 'Delete the file and let it be regenerated.' + ); + } + + return $proxyClass; + } + + /** + * Generates proxies for every given class without loading them. + * + * Used by the build step so a production image ships ready-made files. + * + * @param iterable<\ReflectionClass> $classes Classes to pre-build. + * @return array Original class name mapped to the written file. + */ + public function warm(iterable $classes): array + { + $written = []; + + foreach ($classes as $class) { + $file = $this->fileFor($class->getName()); + $this->write($file, ProxyGenerator::generate($class)); + $written[$class->getName()] = $file; + } + + return $written; + } + + /** + * Removes every generated proxy file. + * + * @return int Number of files deleted. + */ + public function clear(): int + { + $deleted = 0; + + foreach (glob($this->directory . DIRECTORY_SEPARATOR . '*.php') ?: [] as $file) { + if (unlink($file)) { + $deleted++; + } + } + + return $deleted; + } + + /** + * Returns the file a class's proxy is written to. + * + * @param string $class Fully qualified name of the original class. + */ + public function fileFor(string $class): string + { + return $this->directory + . DIRECTORY_SEPARATOR + . str_replace('\\', '_', ltrim($class, '\\')) + . '.php'; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * @param string $file Proxy file to check. + * @param \ReflectionClass $class Class the proxy was generated from. + */ + private function isStale(string $file, \ReflectionClass $class): bool + { + if (!is_file($file)) { + return true; + } + + if (!$this->refresh) { + return false; + } + + $source = $class->getFileName(); + + return $source !== false && filemtime($source) > filemtime($file); + } + + /** + * Writes the proxy atomically, so a concurrent worker never sees half a file. + * + * @param string $file Destination path. + * @param string $source Generated PHP source. + */ + private function write(string $file, string $source): void + { + Kernel::ensureDirectory($this->directory); + + $temporary = $file . '.' . getmypid() . '.tmp'; + + if (file_put_contents($temporary, $source, LOCK_EX) === false || !rename($temporary, $file)) { + @unlink($temporary); + + throw AsyncException::of( + $file, + 'the generated proxy could not be written', + 'Check that ' . $this->directory . ' exists and is writable.' + ); + } + } +} diff --git a/src/Concurrent/Async/Proxy/ProxyGenerator.php b/src/Concurrent/Async/Proxy/ProxyGenerator.php new file mode 100644 index 0000000..5d065c4 --- /dev/null +++ b/src/Concurrent/Async/Proxy/ProxyGenerator.php @@ -0,0 +1,294 @@ + Methods carrying the attribute. + */ + public static function asyncMethods(\ReflectionClass $class): array + { + $methods = []; + foreach ($class->getMethods() as $method) { + if ($method->getAttributes(Async::class) !== []) { + $methods[] = $method; + } + } + + return $methods; + } + + /** + * Renders the proxy of a class as PHP source. + * + * @param \ReflectionClass $class Class to proxy. + * @return string Complete PHP file, opening tag included. + * @throws AsyncException If the class or any of its methods breaks the contract. + */ + public static function generate(\ReflectionClass $class): string + { + $methods = self::asyncMethods($class); + if ($methods === []) { + throw AsyncException::of( + $class->getName(), + 'no method is marked with #[Async]', + 'Nothing to proxy.' + ); + } + + self::assertProxyable($class); + + $body = ''; + foreach ($methods as $method) { + $body .= self::renderMethod($method, $class); + } + + $short = substr(self::proxyClass($class->getName()), strlen(self::PROXY_NAMESPACE) + 1); + $modifiers = $class->isReadOnly() ? 'final readonly class' : 'final class'; + $namespace = self::PROXY_NAMESPACE; + $contract = self::PROXY_CONTRACT; + $origin = '\\' . $class->getName(); + $body = rtrim($body) . "\n"; + + return <<isFinal()) { + throw AsyncException::of( + $class->getName(), + 'the class is final and cannot be extended', + 'Drop "final" from the class, or move the #[Async] method to a non-final service.' + ); + } + + if ($class->isAbstract() || $class->isInterface() || $class->isEnum()) { + throw AsyncException::of( + $class->getName(), + 'only instantiable classes can be proxied', + 'Put #[Async] on the concrete service the container resolves.' + ); + } + + if ($class->isInternal()) { + throw AsyncException::of( + $class->getName(), + 'internal classes cannot be proxied', + 'Wrap the call in a service of your own.' + ); + } + } + + /** + * @param \ReflectionMethod $method Method to override. + * @param \ReflectionClass $class Class being proxied. + */ + private static function renderMethod(\ReflectionMethod $method, \ReflectionClass $class): string + { + $subject = $class->getName() . '::' . $method->getName() . '()'; + self::assertOverridable($method, $subject); + + $isVoid = self::returnsVoid($method, $subject); + $attribute = $method->getAttributes(Async::class)[0]->newInstance(); + $executor = self::renderExecutor($attribute); + + $signature = sprintf( + '%s function %s(%s)', + $method->isProtected() ? 'protected' : 'public', + $method->getName(), + SignatureWriter::parameters($method) + ); + + $returnType = SignatureWriter::type($method->getReturnType(), $method->getDeclaringClass()); + $call = sprintf('parent::%s(%s)', $method->getName(), SignatureWriter::arguments($method)); + + $line = $isVoid + ? sprintf('%s::execute(%s, fn() => %s);', self::SUPPORT, $executor, $call) + : sprintf('return %s::submit(%s, fn() => %s);', self::SUPPORT, $executor, $call); + + return <<isFinal() => ['the method is final', 'Drop "final" so the proxy can override it.'], + $method->isStatic() => [ + 'the method is static', + 'Asynchrony is applied per instance; make the method non-static.', + ], + $method->isAbstract() => [ + 'the method is abstract', + 'Put #[Async] on the implementation instead.', + ], + $method->isPrivate() => [ + 'the method is private', + 'A private method is resolved statically inside its own class, so no subclass can ' + . 'intercept it. Make it protected — self-calls still go through the proxy.', + ], + default => null, + }; + + if ($problem !== null) { + throw AsyncException::of($subject, $problem[0], $problem[1]); + } + } + + /** + * @param \ReflectionMethod $method Method to validate. + * @param string $subject Method name used in error messages. + * @return bool True when the method returns void. + * @throws AsyncException If the return type is not void or Future. + */ + private static function returnsVoid(\ReflectionMethod $method, string $subject): bool + { + $type = $method->getReturnType(); + $remedy = sprintf( + 'Declare "void" for fire-and-forget, or "\\%s" and return CompletableFuture::completedFuture($value).', + Future::class + ); + + if (!$type instanceof \ReflectionNamedType) { + throw AsyncException::of( + $subject, + $type === null ? 'the method has no return type' : 'the return type is not a single named type', + $remedy + ); + } + + $name = strtolower($type->getName()); + + if ($name === 'void') { + return true; + } + + if (!$type->allowsNull() && $type->getName() === Future::class) { + return false; + } + + throw AsyncException::of( + $subject, + 'the return type is "' . $type . '", which the proxy cannot produce', + $remedy + ); + } + + /** + * @param Async $attribute Attribute instance carrying the executor id. + */ + private static function renderExecutor(Async $attribute): string + { + if ($attribute->executor === null) { + return self::EXECUTORS . '::common()'; + } + + return sprintf( + '%s::getInstance()->get(%s)', + self::CONTAINER, + var_export($attribute->executor, true) + ); + } +} diff --git a/src/Concurrent/Async/Proxy/SignatureWriter.php b/src/Concurrent/Async/Proxy/SignatureWriter.php new file mode 100644 index 0000000..c0c50e1 --- /dev/null +++ b/src/Concurrent/Async/Proxy/SignatureWriter.php @@ -0,0 +1,298 @@ +getParameters() as $parameter) { + $rendered[] = self::parameter($parameter, $method); + } + + return implode(', ', $rendered); + } + + /** + * Renders the argument list forwarding the call to the parent method. + * + * @param \ReflectionMethod $method Method being overridden. + * @return string Rendered arguments, comma separated. + */ + public static function arguments(\ReflectionMethod $method): string + { + $rendered = []; + foreach ($method->getParameters() as $parameter) { + $rendered[] = ($parameter->isVariadic() ? '...$' : '$') . $parameter->getName(); + } + + return implode(', ', $rendered); + } + + /** + * Renders a type as it must appear in the overriding declaration. + * + * @param \ReflectionType|null $type Type to render, or null when untyped. + * @param \ReflectionClass $scope Class the original declaration lives in. + * @return string Rendered type, or an empty string when there is none. + */ + public static function type(?\ReflectionType $type, \ReflectionClass $scope): string + { + if ($type === null) { + return ''; + } + + if ($type instanceof \ReflectionUnionType) { + $members = []; + foreach ($type->getTypes() as $member) { + $members[] = $member instanceof \ReflectionIntersectionType + ? '(' . self::intersection($member, $scope) . ')' + : self::named($member, $scope, false); + } + + return implode('|', $members); + } + + if ($type instanceof \ReflectionIntersectionType) { + return self::intersection($type, $scope); + } + + if ($type instanceof \ReflectionNamedType) { + return self::named($type, $scope, true); + } + + throw AsyncException::of( + $scope->getName(), + 'type ' . $type . ' is not supported', + 'Use a named, union or intersection type.' + ); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * @param \ReflectionParameter $parameter Parameter to render. + * @param \ReflectionMethod $method Method the parameter belongs to. + */ + private static function parameter(\ReflectionParameter $parameter, \ReflectionMethod $method): string + { + $out = self::type($parameter->getType(), $method->getDeclaringClass()); + if ($out !== '') { + $out .= ' '; + } + + if ($parameter->isPassedByReference()) { + throw AsyncException::of( + $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()', + 'parameter $' . $parameter->getName() . ' is passed by reference', + 'An asynchronous call returns before the body runs, so writes to it could never be observed. ' + . 'Return the value instead.' + ); + } + + if ($parameter->isVariadic()) { + return $out . '...$' . $parameter->getName(); + } + + $out .= '$' . $parameter->getName(); + + if ($parameter->isDefaultValueAvailable()) { + $out .= ' = ' . self::defaultValue($parameter, $method); + } + + return $out; + } + + /** + * @param \ReflectionNamedType $type Type to render. + * @param \ReflectionClass $scope Class the original declaration lives in. + * @param bool $shorthand Whether the `?T` shorthand may be used. + */ + private static function named(\ReflectionNamedType $type, \ReflectionClass $scope, bool $shorthand): string + { + $name = $type->getName(); + $lowered = strtolower($name); + + $resolved = match ($lowered) { + // Relative to where it is written, so it must be spelled out. + 'self' => '\\' . $scope->getName(), + 'parent' => '\\' . self::parentOf($scope)->getName(), + default => $type->isBuiltin() ? $lowered : '\\' . $name, + }; + + if ($shorthand && $type->allowsNull() && $lowered !== 'mixed' && $lowered !== 'null') { + return '?' . $resolved; + } + + return $resolved; + } + + /** + * @param \ReflectionIntersectionType $type Type to render. + * @param \ReflectionClass $scope Class the original declaration lives in. + */ + private static function intersection(\ReflectionIntersectionType $type, \ReflectionClass $scope): string + { + $members = []; + foreach ($type->getTypes() as $member) { + $members[] = $member instanceof \ReflectionNamedType + ? self::named($member, $scope, false) + : self::type($member, $scope); + } + + return implode('&', $members); + } + + /** + * @param \ReflectionClass $scope Class whose parent is needed. + */ + private static function parentOf(\ReflectionClass $scope): \ReflectionClass + { + $parent = $scope->getParentClass(); + if ($parent === false) { + throw AsyncException::of( + $scope->getName(), + 'the signature refers to "parent" but the class has none', + 'Name the type explicitly.' + ); + } + + return $parent; + } + + /** + * Renders a parameter default. + * + * PHP only requires an overriding parameter to *stay* optional — the value + * itself is never compared — but reproducing it faithfully keeps reflection + * and IDE hints on the proxy truthful. + * + * @param \ReflectionParameter $parameter Parameter carrying the default. + * @param \ReflectionMethod $method Method the parameter belongs to. + */ + private static function defaultValue(\ReflectionParameter $parameter, \ReflectionMethod $method): string + { + if ($parameter->isDefaultValueConstant()) { + $reference = self::constantReference( + (string) $parameter->getDefaultValueConstantName(), + $method->getDeclaringClass() + ); + + // A private constant is invisible from the subclass, so the literal + // value is inlined instead of the reference. + if ($reference !== null) { + return $reference; + } + } + + return self::export( + $parameter->getDefaultValue(), + $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()', + '$' . $parameter->getName() + ); + } + + /** + * Resolves a constant default into source, or null when it is unreachable + * from the generated subclass. + * + * @param string $name Constant name as reported by reflection. + * @param \ReflectionClass $scope Class the original declaration lives in. + */ + private static function constantReference(string $name, \ReflectionClass $scope): ?string + { + if (!str_contains($name, '::')) { + return '\\' . ltrim($name, '\\'); + } + + [$owner, $constant] = explode('::', $name, 2); + $owner = match (strtolower($owner)) { + 'self', 'static' => $scope->getName(), + 'parent' => self::parentOf($scope)->getName(), + default => ltrim($owner, '\\'), + }; + + try { + if ((new \ReflectionClassConstant($owner, $constant))->isPrivate()) { + return null; + } + } catch (\ReflectionException) { + return null; + } + + return '\\' . $owner . '::' . $constant; + } + + /** + * Exports a runtime value as a PHP literal. + * + * @param mixed $value Value to export. + * @param string $subject Method the value belongs to, used for error messages. + * @param string $where Parameter name, used for error messages. + */ + private static function export(mixed $value, string $subject, string $where): string + { + if ($value === null) { + return 'null'; + } + + if ($value instanceof \UnitEnum) { + return '\\' . $value::class . '::' . $value->name; + } + + if (is_array($value)) { + $parts = []; + $list = array_is_list($value); + foreach ($value as $key => $item) { + $parts[] = ($list ? '' : self::export($key, $subject, $where) . ' => ') + . self::export($item, $subject, $where); + } + + return '[' . implode(', ', $parts) . ']'; + } + + if (is_object($value)) { + throw AsyncException::of( + $subject, + 'default value of ' . $where . ' is an object of ' . $value::class + . ', which cannot be written back as source', + 'Default to null and build the object inside the method.' + ); + } + + return var_export($value, true); + } +} diff --git a/src/Concurrent/BoundedExecutorService.php b/src/Concurrent/BoundedExecutorService.php new file mode 100644 index 0000000..4f13de8 --- /dev/null +++ b/src/Concurrent/BoundedExecutorService.php @@ -0,0 +1,31 @@ +api->call($id); + * return CompletableFuture::completedFuture($result); + * } + * ``` + * + * @see Future + * @see ExecutorService + */ +final class CompletableFuture implements Future +{ + private const int STATE_PENDING = 0; + private const int STATE_COMPLETED = 1; + private const int STATE_FAILED = 2; + private const int STATE_CANCELLED = 3; + + private int $state = self::STATE_PENDING; + private mixed $value = null; + private ?\Throwable $throwable = null; + + /** + * Backend-specific blocking strategy, invoked by {@see get()} while the + * future is still pending. Receives the remaining timeout in seconds. + * + * @var (\Closure(float|null): void)|null + */ + private ?\Closure $awaiter = null; + + /** + * Backend-specific interruption strategy, invoked by {@see cancel()}. + * + * @var (\Closure(): void)|null + */ + private ?\Closure $canceller = null; + + /** @var list<\Closure(mixed, \Throwable|null): void> */ + private array $listeners = []; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + /** + * Returns a future that is already completed with the given value. + */ + public static function completedFuture(mixed $value): self + { + $future = new self(); + $future->complete($value); + + return $future; + } + + /** + * Returns a future that has already failed with the given throwable. + */ + public static function failedFuture(\Throwable $throwable): self + { + $future = new self(); + $future->completeExceptionally($throwable); + + return $future; + } + + /** + * Submits a value-returning task to an executor. + * + * @param callable $supplier Task producing the result. + * @param ExecutorService|null $executor Executor to run on; defaults to {@see Executors::common()}. + */ + public static function supplyAsync(callable $supplier, ?ExecutorService $executor = null): Future + { + return ($executor ?? Executors::common())->submit($supplier); + } + + /** + * Submits a task whose result is discarded. + * + * @param callable $runnable Task to run. + * @param ExecutorService|null $executor Executor to run on; defaults to {@see Executors::common()}. + */ + public static function runAsync(callable $runnable, ?ExecutorService $executor = null): Future + { + return ($executor ?? Executors::common())->submit(static function () use ($runnable): null { + $runnable(); + + return null; + }); + } + + /** + * Returns a future completing when every given future has completed. + * + * The result is always null. If any input fails or is cancelled, the + * returned future fails with that same throwable. + * + * @param Future ...$futures Futures to await. + */ + public static function allOf(Future ...$futures): Future + { + $all = new self(); + $all->setAwaiter(static function (?float $timeout) use ($futures, $all): void { + $deadline = $timeout === null ? null : microtime(true) + $timeout; + + foreach ($futures as $future) { + try { + $future->get($deadline === null ? null : max(0.0, $deadline - microtime(true))); + } catch (\Throwable $throwable) { + $all->completeExceptionally($throwable); + + return; + } + } + + $all->complete(null); + }); + + return $all; + } + + // ------------------------------------------------------------------------- + // Producer side + // ------------------------------------------------------------------------- + + /** + * Completes the future with a value. + * + * @return bool False if the future was already settled. + */ + public function complete(mixed $value): bool + { + if ($this->state !== self::STATE_PENDING) { + return false; + } + + $this->value = $value; + $this->state = self::STATE_COMPLETED; + $this->settle(); + + return true; + } + + /** + * Completes the future with a throwable. + * + * @return bool False if the future was already settled. + */ + public function completeExceptionally(\Throwable $throwable): bool + { + if ($this->state !== self::STATE_PENDING) { + return false; + } + + $this->throwable = $throwable; + $this->state = self::STATE_FAILED; + $this->settle(); + + return true; + } + + /** + * Registers the blocking strategy used while the future is pending. + * + * Called by the executor that created the future. The closure must return + * once the future is settled or the timeout has elapsed. + * + * @internal + * @param \Closure(float|null): void $awaiter Blocking strategy. + */ + public function setAwaiter(\Closure $awaiter): void + { + $this->awaiter = $awaiter; + } + + /** + * Registers the interruption strategy used by {@see cancel()}. + * + * @internal + * @param \Closure(): void $canceller Interruption strategy. + */ + public function setCanceller(\Closure $canceller): void + { + $this->canceller = $canceller; + } + + /** + * Registers a callback fired once the future settles. + * + * The callback receives the value and the throwable; exactly one of them is + * null. If the future has already settled, the callback runs immediately. + * + * Unlike Java this returns the same instance rather than a new stage — the + * composition pipeline (`thenApply` and friends) is intentionally omitted. + * + * @param \Closure(mixed, \Throwable|null): void $action Completion callback. + */ + public function whenComplete(\Closure $action): self + { + if ($this->state === self::STATE_PENDING) { + $this->listeners[] = $action; + + return $this; + } + + $action($this->value, $this->failure()); + + return $this; + } + + // ------------------------------------------------------------------------- + // Consumer side + // ------------------------------------------------------------------------- + + public function get(?float $timeout = null): mixed + { + if ($this->state === self::STATE_PENDING && $this->awaiter !== null) { + ($this->awaiter)($timeout); + } + + return match ($this->state) { + self::STATE_COMPLETED => $this->value, + self::STATE_FAILED => throw new ExecutionException($this->throwable), + self::STATE_CANCELLED => throw new CancellationException('Task was cancelled'), + default => throw new TimeoutException( + $timeout === null + ? 'Future is still pending and no awaiter is attached' + : sprintf('Future did not complete within %.3f second(s)', $timeout) + ), + }; + } + + /** + * Waits indefinitely and returns the result. + * + * Convenience alias of `get(null)`. + */ + public function join(): mixed + { + return $this->get(); + } + + public function isDone(): bool + { + return $this->state !== self::STATE_PENDING; + } + + public function isCancelled(): bool + { + return $this->state === self::STATE_CANCELLED; + } + + /** + * Returns true when the task finished by throwing. + */ + public function isCompletedExceptionally(): bool + { + return $this->state === self::STATE_FAILED; + } + + /** + * Returns the throwable the task failed with, or null. + */ + public function failure(): ?\Throwable + { + return $this->throwable; + } + + public function cancel(bool $mayInterruptIfRunning = false): bool + { + if ($this->state !== self::STATE_PENDING) { + return false; + } + + $this->state = self::STATE_CANCELLED; + + if ($mayInterruptIfRunning && $this->canceller !== null) { + ($this->canceller)(); + } + + $this->settle(); + + return true; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Releases waiters and fires completion callbacks exactly once. + */ + private function settle(): void + { + $listeners = $this->listeners; + $this->listeners = []; + $this->awaiter = null; + $this->canceller = null; + + foreach ($listeners as $listener) { + $listener($this->value, $this->throwable); + } + } +} diff --git a/src/Concurrent/ExecutionException.php b/src/Concurrent/ExecutionException.php new file mode 100644 index 0000000..3e7840d --- /dev/null +++ b/src/Concurrent/ExecutionException.php @@ -0,0 +1,22 @@ +getMessage(), + (int) $cause->getCode(), + $cause + ); + } +} diff --git a/src/Concurrent/Executor/CoroutineExecutorService.php b/src/Concurrent/Executor/CoroutineExecutorService.php new file mode 100644 index 0000000..459a1d6 --- /dev/null +++ b/src/Concurrent/Executor/CoroutineExecutorService.php @@ -0,0 +1,237 @@ +ensureAccepting(); + + $future = new CompletableFuture(); + $this->attachAwaiter($future); + $this->spawn($task, $args, $future, false); + + return $future; + } + + public function execute(callable $task, mixed ...$args): void + { + $this->ensureAccepting(); + + $this->spawn($task, $args, new CompletableFuture(), true); + } + + public function invokeAll(iterable $tasks, ?float $timeout = null): array + { + $futures = []; + foreach ($tasks as $task) { + $futures[] = $this->submit($task); + } + + $deadline = $timeout === null ? null : microtime(true) + $timeout; + foreach ($futures as $future) { + try { + $future->get($deadline === null ? null : max(0.0, $deadline - microtime(true))); + } catch (\Throwable) { + // The outcome stays on the future; the caller inspects it there. + } + } + + return $futures; + } + + public function shutdown(): void + { + $this->shutdown = true; + } + + public function isShutdown(): bool + { + return $this->shutdown; + } + + /** + * {@inheritDoc} + * + * Only a coroutine can wait here: outside one the scheduler is not running, + * so busy-waiting would never let the pending tasks advance. In that case + * the current state is reported without waiting. + */ + public function awaitTermination(?float $timeout = null): bool + { + if (!Runtime::isSwooleCoroutine()) { + return $this->running === 0; + } + + $deadline = $timeout === null ? null : microtime(true) + $timeout; + + while ($this->running > 0) { + if ($deadline !== null && microtime(true) >= $deadline) { + return false; + } + \Swoole\Coroutine::sleep(self::DRAIN_TICK); + } + + return true; + } + + /** + * Returns the number of tasks that have not settled yet. + */ + public function runningCount(): int + { + return $this->running; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Starts the task in its own coroutine. + * + * Swoole switches into the new coroutine immediately, so a task that never + * suspends is already settled by the time this method returns. + * + * @param callable $task Task to run. + * @param list $args Arguments passed to the task. + * @param CompletableFuture $future Future carrying the outcome. + * @param bool $logFailure Whether a throwable must be logged instead of only stored. + */ + private function spawn(callable $task, array $args, CompletableFuture $future, bool $logFailure): void + { + $this->running++; + + $cid = \Swoole\Coroutine::create(function () use ($task, $args, $future, $logFailure): void { + $throwable = null; + $value = null; + + try { + $value = $task(...$args); + } catch (\Throwable $caught) { + $throwable = $caught; + } finally { + // Settling resumes the waiters, so the counter must already be + // accurate by then — otherwise they observe a stale value. + $this->running--; + } + + if ($throwable === null) { + $future->complete($value); + + return; + } + + $future->completeExceptionally($throwable); + if ($logFailure) { + $this->logFailure($throwable); + } + }); + + if ($cid === false) { + $this->running--; + $future->completeExceptionally( + new RejectedExecutionException('Swoole refused to create a coroutine for the task') + ); + + throw new RejectedExecutionException('Swoole refused to create a coroutine for the task'); + } + + if (!$future->isDone()) { + $future->setCanceller(static fn() => \Swoole\Coroutine::cancel($cid)); + } + } + + /** + * Wires the future to a channel so that waiting suspends the calling coroutine. + * + * The channel holds a single token. Whoever is woken pushes it back, so any + * number of coroutines may wait on the same future. + * + * @param CompletableFuture $future Future to attach the strategy to. + */ + private function attachAwaiter(CompletableFuture $future): void + { + $channel = new \Swoole\Coroutine\Channel(1); + + $future->whenComplete(static function () use ($channel): void { + $channel->push(true); + }); + + $future->setAwaiter(static function (?float $timeout) use ($channel, $future): void { + if ($future->isDone()) { + return; + } + + $token = $channel->pop($timeout ?? -1); + if ($token !== false) { + $channel->push($token); + } + }); + } + + /** + * Rejects the task when the executor cannot run it. + * + * @throws RejectedExecutionException If shut down or called outside a coroutine. + */ + private function ensureAccepting(): void + { + if ($this->shutdown) { + throw new RejectedExecutionException('Executor has been shut down'); + } + + if (!Runtime::isSwooleCoroutine()) { + throw new RejectedExecutionException( + 'CoroutineExecutorService requires an active Swoole coroutine. ' + . 'Use Executors::common() to pick the backend matching the current runtime.' + ); + } + } + + /** + * Reports a failure nobody is able to observe through a future. + */ + private function logFailure(\Throwable $throwable): void + { + LoggerFactory::getLogger(self::class)->error( + $throwable->getMessage() + . (env('DEBUG', false) ? "\n" . $throwable->getTraceAsString() : '') + ); + } +} diff --git a/src/Concurrent/Executor/DeferredExecutorService.php b/src/Concurrent/Executor/DeferredExecutorService.php new file mode 100644 index 0000000..93e4474 --- /dev/null +++ b/src/Concurrent/Executor/DeferredExecutorService.php @@ -0,0 +1,236 @@ + */ + private array $queue = []; + + private int $sequence = 0; + private bool $shutdown = false; + private bool $draining = false; + private bool $hooked = false; + + public function submit(callable $task, mixed ...$args): Future + { + $this->ensureAccepting(); + + $future = new CompletableFuture(); + $run = $this->runner($task, $args, $future); + $id = $this->enqueue(static fn() => $run(true)); + + // Awaiting runs the task right here, so its queue slot is no longer needed. + $future->setAwaiter(function (?float $timeout) use ($run, $id): void { + unset($this->queue[$id]); + $run(false); + }); + + return $future; + } + + public function execute(callable $task, mixed ...$args): void + { + $this->ensureAccepting(); + + $run = $this->runner($task, $args, new CompletableFuture()); + $this->enqueue(static fn() => $run(true)); + } + + public function invokeAll(iterable $tasks, ?float $timeout = null): array + { + $futures = []; + foreach ($tasks as $task) { + $future = $this->submit($task); + + try { + $future->get(); + } catch (\Throwable) { + // The outcome stays on the future; the caller inspects it there. + } + + $futures[] = $future; + } + + return $futures; + } + + public function shutdown(): void + { + $this->shutdown = true; + $this->drain(); + } + + public function isShutdown(): bool + { + return $this->shutdown; + } + + public function awaitTermination(?float $timeout = null): bool + { + $this->drain(); + + return true; + } + + /** + * Returns the number of tasks still waiting to be drained. + */ + public function pendingCount(): int + { + return count($this->queue); + } + + /** + * Flushes the response and runs every queued task. + * + * Registered as a shutdown function on the first enqueue, and safe to call + * manually at any point. + */ + public function drain(): void + { + if ($this->draining || $this->queue === []) { + return; + } + + $this->draining = true; + $this->releaseClient(); + + try { + while ($this->queue !== []) { + $id = array_key_first($this->queue); + $task = $this->queue[$id]; + unset($this->queue[$id]); + $task(); + } + } finally { + $this->draining = false; + } + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Builds the single runner shared by the lazy path and the drain path. + * + * The flag tells the runner whether nobody is holding the future any more, + * in which case a failure would otherwise vanish and must be logged. + * + * @param callable $task Task to run. + * @param list $args Arguments passed to the task. + * @param CompletableFuture $future Future carrying the outcome. + * @return \Closure(bool): void + */ + private function runner(callable $task, array $args, CompletableFuture $future): \Closure + { + return function (bool $unobserved) use ($task, $args, $future): void { + if ($future->isDone()) { + return; + } + + try { + $future->complete($task(...$args)); + } catch (\Throwable $throwable) { + $future->completeExceptionally($throwable); + if ($unobserved) { + $this->logFailure($throwable); + } + } + }; + } + + /** + * Queues a task and makes sure the drain will happen at shutdown. + * + * @param \Closure(): void $task Task to queue. + * @return int Slot identifier, used to drop the task once it has run early. + */ + private function enqueue(\Closure $task): int + { + $id = $this->sequence++; + $this->queue[$id] = $task; + + if (!$this->hooked) { + $this->hooked = true; + register_shutdown_function($this->drain(...)); + } + + return $id; + } + + /** + * Sends the response before the queue runs, when the SAPI allows it. + * + * Only PHP-FPM can do this. Under CLI and the built-in server the client + * simply receives the response once the script ends. + */ + private function releaseClient(): void + { + ignore_user_abort(true); + + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + + return; + } + + if (function_exists('litespeed_finish_request')) { + litespeed_finish_request(); + } + } + + /** + * @throws RejectedExecutionException If the executor has been shut down. + */ + private function ensureAccepting(): void + { + if ($this->shutdown) { + throw new RejectedExecutionException('Executor has been shut down'); + } + } + + /** + * Reports a failure nobody is able to observe through a future. + */ + private function logFailure(\Throwable $throwable): void + { + LoggerFactory::getLogger(self::class)->error( + $throwable->getMessage() + . (env('DEBUG', false) ? "\n" . $throwable->getTraceAsString() : '') + ); + } +} diff --git a/src/Concurrent/Executor/FixedExecutorService.php b/src/Concurrent/Executor/FixedExecutorService.php new file mode 100644 index 0000000..eb5d9a2 --- /dev/null +++ b/src/Concurrent/Executor/FixedExecutorService.php @@ -0,0 +1,260 @@ + 0`) is full, {@see RejectPolicy} decides the + * outcome. An unbounded pool (`queue = 0`, the default) never rejects. + * + * Obtain one through {@see \Flytachi\Winter\Kernel\Concurrent\Executors::newFixedExecutor()}; + * register it in the container to give an `#[Async('id')]` method a dedicated pool. + */ +final class FixedExecutorService implements BoundedExecutorService +{ + private readonly CoroutineExecutorService $coroutine; + private readonly DeferredExecutorService $deferred; + /** Semaphore of N tokens; lazily created on first use inside a coroutine. */ + private ?\Swoole\Coroutine\Channel $slots = null; + private int $active = 0; + private int $queued = 0; + private bool $shutdown = false; + + /** + * @param int $concurrency Maximum tasks running at once (>= 1). + * @param int $queue Waiting slots before the reject policy applies; 0 = unbounded. + * @param RejectPolicy $onReject What to do with a task when the queue is full. + */ + public function __construct( + private readonly int $concurrency, + private readonly int $queue = 0, + private readonly RejectPolicy $onReject = RejectPolicy::ABORT, + ) { + if ($concurrency < 1) { + throw new \InvalidArgumentException('Fixed executor concurrency must be >= 1.'); + } + if ($queue < 0) { + throw new \InvalidArgumentException('Fixed executor queue capacity must be >= 0.'); + } + $this->coroutine = new CoroutineExecutorService(); + $this->deferred = new DeferredExecutorService(); + } + + public function submit(callable $task, mixed ...$args): Future + { + $this->ensureAccepting(); + + if (!Runtime::isSwooleCoroutine()) { + return $this->deferred->submit($task, ...$args); + } + if ($this->isSaturated()) { + return $this->reject($task, $args); + } + $this->queued++; + + return $this->coroutine->submit($this->gate($task, $args)); + } + + public function execute(callable $task, mixed ...$args): void + { + $this->ensureAccepting(); + + if (!Runtime::isSwooleCoroutine()) { + $this->deferred->execute($task, ...$args); + + return; + } + if ($this->isSaturated()) { + $this->reject($task, $args); + + return; + } + $this->queued++; + $this->coroutine->execute($this->gate($task, $args)); + } + + public function invokeAll(iterable $tasks, ?float $timeout = null): array + { + $futures = []; + foreach ($tasks as $task) { + $futures[] = $this->submit($task); + } + + $deadline = $timeout === null ? null : microtime(true) + $timeout; + foreach ($futures as $future) { + try { + $future->get($deadline === null ? null : max(0.0, $deadline - microtime(true))); + } catch (\Throwable) { + // The outcome stays on the future; the caller inspects it there. + } + } + + return $futures; + } + + public function shutdown(): void + { + $this->shutdown = true; + $this->coroutine->shutdown(); + $this->deferred->shutdown(); + } + + public function isShutdown(): bool + { + return $this->shutdown; + } + + public function awaitTermination(?float $timeout = null): bool + { + $coroutine = $this->coroutine->awaitTermination($timeout); + $deferred = $this->deferred->awaitTermination($timeout); + + return $coroutine && $deferred; + } + + // ------------------------------------------------------------------------- + // Introspection + // ------------------------------------------------------------------------- + + public function concurrency(): int + { + return $this->concurrency; + } + + public function activeCount(): int + { + return $this->active; + } + + public function queuedCount(): int + { + return $this->queued; + } + + public function remainingCapacity(): int + { + if ($this->queue === 0) { + return PHP_INT_MAX; // unbounded — never rejects + } + + return max(0, $this->concurrency + $this->queue - ($this->active + $this->queued)); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Whether the pool is at its hard limit (only a bounded queue can saturate). + */ + private function isSaturated(): bool + { + return $this->queue > 0 && ($this->active + $this->queued) >= $this->concurrency + $this->queue; + } + + /** + * Wraps a task so it acquires a slot before running and releases it after — + * the coroutine parks on the semaphore while the pool is full, then advances + * the active/queued gauges around the body. + * + * @param list $args + */ + private function gate(callable $task, array $args): \Closure + { + return function () use ($task, $args): mixed { + $this->slots()->pop(); + $this->queued--; + $this->active++; + try { + return $task(...$args); + } finally { + $this->active--; + $this->slots()->push(true); + } + }; + } + + /** + * Applies the reject policy to a task the full queue cannot accept. + * + * @param list $args + */ + private function reject(callable $task, array $args): Future + { + return match ($this->onReject) { + RejectPolicy::ABORT => throw new RejectedExecutionException( + "Fixed executor saturated (concurrency={$this->concurrency}, queue={$this->queue})." + ), + RejectPolicy::CALLER_RUNS => $this->runInline($task, $args), + RejectPolicy::DISCARD => $this->discarded(), + }; + } + + /** + * Runs the task right here (back-pressure), reporting the outcome as a future. + * + * @param list $args + */ + private function runInline(callable $task, array $args): Future + { + try { + return CompletableFuture::completedFuture($task(...$args)); + } catch (\Throwable $throwable) { + return CompletableFuture::failedFuture($throwable); + } + } + + /** + * A future for a dropped task: cancelled, so a caller can tell it never ran. + */ + private function discarded(): Future + { + $future = new CompletableFuture(); + $future->cancel(); + + return $future; + } + + /** + * The semaphore, filled with N tokens on first use (inside a coroutine). + */ + private function slots(): \Swoole\Coroutine\Channel + { + if ($this->slots === null) { + $this->slots = new \Swoole\Coroutine\Channel($this->concurrency); + for ($i = 0; $i < $this->concurrency; $i++) { + $this->slots->push(true); + } + } + + return $this->slots; + } + + /** + * @throws RejectedExecutionException If the executor has been shut down. + */ + private function ensureAccepting(): void + { + if ($this->shutdown) { + throw new RejectedExecutionException('Executor has been shut down'); + } + } +} diff --git a/src/Concurrent/ExecutorService.php b/src/Concurrent/ExecutorService.php new file mode 100644 index 0000000..ac75d0c --- /dev/null +++ b/src/Concurrent/ExecutorService.php @@ -0,0 +1,92 @@ +execute(fn() => $mixpanel->track($userId, $event)); + * + * $future = $executor->submit($api->fetch(...), $id); + * $value = $future->get(); + * ``` + * + * @see Executors + * @see Future + */ +interface ExecutorService +{ + /** + * Submits a task and returns a handle to its future result. + * + * The task is guaranteed to run even if the returned future is never + * awaited. + * + * @param callable $task Task to run. + * @param mixed ...$args Arguments passed to the task. + * @throws RejectedExecutionException If the executor cannot accept the task. + */ + public function submit(callable $task, mixed ...$args): Future; + + /** + * Submits a task whose result and return value are discarded. + * + * Failures are logged rather than propagated — nobody is holding a handle + * to observe them. + * + * @param callable $task Task to run. + * @param mixed ...$args Arguments passed to the task. + * @throws RejectedExecutionException If the executor cannot accept the task. + */ + public function execute(callable $task, mixed ...$args): void; + + /** + * Submits every task and blocks until all of them have settled. + * + * @param iterable $tasks Tasks to run. + * @param float|null $timeout Seconds to wait for the whole batch, or null for no limit. + * @return list Futures in the order the tasks were given. + * @throws RejectedExecutionException If the executor cannot accept the tasks. + */ + public function invokeAll(iterable $tasks, ?float $timeout = null): array; + + /** + * Stops accepting new tasks; already submitted ones still run to completion. + */ + public function shutdown(): void; + + /** + * Returns true once {@see shutdown()} has been called. + */ + public function isShutdown(): bool; + + /** + * Blocks until every submitted task has settled. + * + * @param float|null $timeout Seconds to wait, or null for no limit. + * @return bool True if the executor drained, false if the timeout elapsed first. + */ + public function awaitTermination(?float $timeout = null): bool; +} diff --git a/src/Concurrent/Executors.php b/src/Concurrent/Executors.php new file mode 100644 index 0000000..a3071e0 --- /dev/null +++ b/src/Concurrent/Executors.php @@ -0,0 +1,138 @@ +execute(fn() => $mixpanel->track($userId, $event)); + * + * // Force a specific backend. + * $executor = Executors::newDeferredExecutor(); + * ``` + * + * @see ExecutorService + */ +final class Executors +{ + private static ?CoroutineExecutorService $coroutine = null; + private static ?DeferredExecutorService $deferred = null; + + private function __construct() + { + } + + /** + * Returns the shared executor matching the current runtime. + * + * Inside a Swoole coroutine this is the coroutine executor; everywhere else + * it is the deferred executor. The decision is made per call, never cached, + * so the same process may legitimately use both — for example a Swoole + * worker booting outside of a coroutine and then serving requests inside + * one. + */ + public static function common(): ExecutorService + { + return Runtime::isSwooleCoroutine() + ? self::coroutine() + : self::deferred(); + } + + /** + * Returns a fresh coroutine-backed executor. + * + * Requires an active Swoole coroutine at submit time. + */ + public static function newCoroutineExecutor(): ExecutorService + { + return new CoroutineExecutorService(); + } + + /** + * Returns a fresh executor that defers tasks until the response is flushed. + */ + public static function newDeferredExecutor(): ExecutorService + { + return new DeferredExecutorService(); + } + + /** + * Returns a fresh fixed-size pool: at most $concurrency tasks run at once. + * + * Mirrors `Executors.newFixedThreadPool(n)`. The bound is enforced under Swoole + * (coroutine semaphore); without coroutines the pool runs tasks sequentially + * (deferred), where the bound is a no-op. Register the result in the container + * to back an `#[Async('id')]` method with a dedicated, capped pool. + * + * ``` + * $c->singleton('mailPool', fn() => Executors::newFixedExecutor(5)); + * // then: #[Async('mailPool')] public function send(): void { ... } + * ``` + * + * @param int $concurrency Maximum simultaneous tasks (>= 1). + * @param int $queue Waiting slots before the reject policy applies; 0 = unbounded (never rejects). + * @param RejectPolicy $onReject What to do with a task when a bounded queue is full. + */ + public static function newFixedExecutor( + int $concurrency, + int $queue = 0, + RejectPolicy $onReject = RejectPolicy::ABORT, + ): BoundedExecutorService { + return new FixedExecutorService($concurrency, $queue, $onReject); + } + + /** + * Drains the shared executors and stops them accepting new tasks. + * + * Intended for graceful shutdown — worker recycling under Swoole, or an + * explicit flush at the end of a console command. + * + * @param float|null $timeout Seconds to wait for running tasks, or null for no limit. + * @return bool True if everything settled within the timeout. + */ + public static function shutdownCommon(?float $timeout = null): bool + { + $drained = true; + + foreach ([self::$coroutine, self::$deferred] as $executor) { + if ($executor === null) { + continue; + } + + $executor->shutdown(); + $drained = $executor->awaitTermination($timeout) && $drained; + } + + return $drained; + } + + private static function coroutine(): CoroutineExecutorService + { + return self::$coroutine ??= new CoroutineExecutorService(); + } + + private static function deferred(): DeferredExecutorService + { + return self::$deferred ??= new DeferredExecutorService(); + } +} diff --git a/src/Concurrent/Future.php b/src/Concurrent/Future.php new file mode 100644 index 0000000..716b71b --- /dev/null +++ b/src/Concurrent/Future.php @@ -0,0 +1,59 @@ +submit(fn() => $api->fetch($id)); + * $value = $future->get(); + * ``` + * + * @see CompletableFuture + * @see ExecutorService + */ +interface Future +{ + /** + * Waits for the computation to finish and returns its result. + * + * @param float|null $timeout Seconds to wait, or null to wait indefinitely. + * @return mixed The value produced by the task. + * @throws ExecutionException If the task threw; the original throwable is the previous exception. + * @throws TimeoutException If $timeout elapsed before completion. + * @throws CancellationException If the task was cancelled. + */ + public function get(?float $timeout = null): mixed; + + /** + * Returns true once the task has completed, failed or been cancelled. + */ + public function isDone(): bool; + + /** + * Returns true if the task was cancelled before it completed. + */ + public function isCancelled(): bool; + + /** + * Attempts to cancel the task. + * + * @param bool $mayInterruptIfRunning Whether an already running task may be interrupted. + * @return bool True if the task was cancelled by this call. + */ + public function cancel(bool $mayInterruptIfRunning = false): bool; +} diff --git a/src/Concurrent/RejectPolicy.php b/src/Concurrent/RejectPolicy.php new file mode 100644 index 0000000..686fed1 --- /dev/null +++ b/src/Concurrent/RejectPolicy.php @@ -0,0 +1,24 @@ + 0`) is actually full — an unbounded + * pool never rejects. + */ +enum RejectPolicy +{ + /** Throw {@see RejectedExecutionException} — the caller learns the pool is saturated. */ + case ABORT; + /** Run the task synchronously in the calling context — natural back-pressure. */ + case CALLER_RUNS; + /** Silently drop the task; a submitted future comes back cancelled. */ + case DISCARD; +} diff --git a/src/Concurrent/RejectedExecutionException.php b/src/Concurrent/RejectedExecutionException.php new file mode 100644 index 0000000..61ef500 --- /dev/null +++ b/src/Concurrent/RejectedExecutionException.php @@ -0,0 +1,16 @@ +clock = $clock ?? static fn(): float => hrtime(true) / 1e9; + $this->idle = new Channel($this->policy->maximumPoolSize); + } + + /** + * Borrows a live connection: reuse an idle one (idle-gated probe), grow up to + * maximumPoolSize, or wait connectionTimeout for a release. Expired or dead + * connections are retired and a fresh one is obtained. + * + * @throws PoolException On exhaustion, connect failure, or repeated dead borrows. + */ + public function borrow(): PoolEntry + { + $this->ensureHousekeeper(); + for ($attempt = 0; $attempt <= self::MAX_RETIRE_LOOPS; ++$attempt) { + $entry = $this->acquire(); + if ($entry === null) { + throw PoolException::exhausted($this->policy->connectionTimeout); + } + if ($this->isExpired($entry) || ($this->needsProbe($entry) && !$this->probe($entry))) { + $this->discard($entry); + continue; + } + $entry->lastUsedAt = $this->now(); + return $entry; + } + + throw PoolException::unusable(self::MAX_RETIRE_LOOPS + 1); + } + + /** Returns a borrowed connection to the pool for reuse. */ + public function release(PoolEntry $entry): void + { + if ($this->idle === null) { + $this->discard($entry); + return; + } + $entry->lastUsedAt = $this->now(); + $this->idle->push($entry); + } + + /** + * Retires a connection (close + free the slot) instead of returning it — for a + * connection-level failure (SQLSTATE 08xxx etc.) detected during use, so the dead + * connection is never handed out again. + */ + public function evict(PoolEntry $entry): void + { + $this->discard($entry); + } + + /** @return array{total: int, idle: int, active: int, maximum: int} */ + public function stats(): array + { + $idle = $this->idle?->length() ?? 0; + return [ + 'total' => $this->total, + 'idle' => $idle, + 'active' => $this->total - $idle, + 'maximum' => $this->policy->maximumPoolSize, + ]; + } + + /** + * Stops the housekeeper and drops every pooled connection **without closing it** + * — the fork-safe counterpart of {@see close()}. + * + * A fork copies file descriptors, so a child must never close an inherited socket + * (that would tear down the connection its parent is still using); it must simply + * forget it and open its own. Clearing the timer is the part {@see close()} and + * this share: a `Timer::tick` callback holds a reference to the pool, so a pool + * merely dereferenced would stay alive and keep maintaining connections nobody + * uses. See {@see \Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool::reset()}. + */ + public function abandon(): void + { + $this->clearHousekeeper(); + $this->idle = null; + $this->total = 0; + } + + /** + * Stops the housekeeper, closes every idle connection and the pool itself. + * + * Called outside a coroutine (worker shutdown) only the housekeeper is stopped — + * see the comment in the body for why the connections are left to the kernel. + */ + public function close(): void + { + $this->clearHousekeeper(); + if ($this->idle === null) { + return; + } + + // Draining needs a coroutine: Channel::pop() is a coroutine API and raises a + // *fatal* outside one — not catchable, the process dies. The caller that closes + // outside a coroutine is Swoole's `workerExit`, which fires while the reactor is + // already winding down; the worker then dies mid-shutdown and, under `run dev`, + // never comes back. + // + // So the drain is skipped there rather than forced. The point of closing at that + // moment is the housekeeping timer above — a live repeating timer keeps the + // reactor from draining at all. The sockets need no ceremony: the process is + // ending, the kernel closes them, and a dropped client is routine for a database. + // Starting a scheduler just to say goodbye politely would risk hanging the very + // exit this is meant to keep clean. + if (Coroutine::getCid() > 0) { + while ($this->idle->length() > 0) { + $entry = $this->idle->pop(0.001); + if ($entry instanceof PoolEntry) { + $this->safeClose($entry->resource); + } + } + } + + $this->idle->close(); + $this->idle = null; + $this->total = 0; + } + + // ── housekeeping ───────────────────────────────────────────────────────────── + + /** + * Arms the background housekeeper on first borrow, once, when maintenance is + * enabled and Swoole is present. The first borrow always runs inside a coroutine, + * so the reactor exists to host the timer. A no-op when maintenance is off (an + * unconfigured pool never arms a timer). + */ + private function ensureHousekeeper(): void + { + if ($this->timerId !== null + || !$this->policy->housekeepingEnabled() + || !extension_loaded('swoole') + ) { + return; + } + $ms = (int) max(1000.0, $this->policy->housekeepingInterval * 1000.0); + $this->timerId = \Swoole\Timer::tick($ms, function (): void { + try { + $this->maintain(); + } catch (Throwable) { + // A maintenance pass must never kill the timer. + } + }); + } + + /** Disarms the housekeeping timer, if armed. */ + private function clearHousekeeper(): void + { + if ($this->timerId === null) { + return; + } + if (extension_loaded('swoole')) { + \Swoole\Timer::clear($this->timerId); + } + $this->timerId = null; + } + + /** + * One background maintenance pass over the idle connections: retire aged ones + * (maxLifetime), shrink idle-too-long ones toward `minimumIdle` (idleTimeout), + * proactively probe long-idle survivors (keepaliveTime), then top up warm + * connections to `minimumIdle`. Borrowed-out connections are not in the channel, + * so this never touches an in-use connection. + */ + private function maintain(): void + { + if ($this->idle === null) { + return; + } + $now = $this->now(); + $count = $this->idle->length(); + $shrinkable = max(0, $this->total - $this->policy->minimumIdle); + $survivors = []; + + for ($i = 0; $i < $count; ++$i) { + $entry = $this->idle->pop(0.001); + if (!$entry instanceof PoolEntry) { + break; // drained by a concurrent borrow + } + // maxLifetime — always retire; the floor is refilled by top-up below. + if ($this->isExpired($entry)) { + $this->discard($entry); + continue; + } + // idleTimeout — shrink toward minimumIdle, within budget. + if ($this->policy->idleTimeout > 0.0 + && $shrinkable > 0 + && ($now - $entry->lastUsedAt) >= $this->policy->idleTimeout + ) { + --$shrinkable; + $this->discard($entry); + continue; + } + // keepalive — proactive probe. Does NOT reset lastUsedAt, so idleTimeout + // keeps measuring real application idleness. + if ($this->policy->keepaliveTime > 0.0 + && ($now - $entry->lastUsedAt) >= $this->policy->keepaliveTime + && !$this->probe($entry) + ) { + $this->discard($entry); + continue; + } + $survivors[] = $entry; + } + + foreach ($survivors as $entry) { + $this->idle->push($entry); + } + + // minimumIdle — reopen warm connections up to the floor (best-effort). + while ($this->total < $this->policy->minimumIdle + && $this->total < $this->policy->maximumPoolSize + ) { + try { + $this->idle->push($this->make()); + } catch (PoolException) { + break; // DB unreachable — retry on the next pass + } + } + } + + // ── internals ────────────────────────────────────────────────────────────── + + /** Gets an idle connection, grows the pool, or waits for a release. */ + private function acquire(): ?PoolEntry + { + if ($this->idle !== null && $this->idle->length() > 0) { + $entry = $this->idle->pop(0.001); + if ($entry instanceof PoolEntry) { + return $entry; + } + } + if ($this->total < $this->policy->maximumPoolSize) { + return $this->make(); + } + $entry = $this->idle?->pop($this->policy->connectionTimeout); + return $entry instanceof PoolEntry ? $entry : null; + } + + private function make(): PoolEntry + { + // Reserve the slot before the (possibly yielding) connect so concurrent + // borrows don't over-provision past maximumPoolSize. + ++$this->total; + try { + $resource = $this->factory->create(); + } catch (Throwable $e) { + --$this->total; + throw PoolException::connectFailed($e); + } + $now = $this->now(); + return new PoolEntry($resource, $now, $now, $this->computeExpiry($now)); + } + + private function discard(PoolEntry $entry): void + { + $this->safeClose($entry->resource); + if ($this->total > 0) { + --$this->total; + } + } + + private function isExpired(PoolEntry $entry): bool + { + return $entry->expiresAt !== null && $this->now() >= $entry->expiresAt; + } + + private function needsProbe(PoolEntry $entry): bool + { + return ($this->now() - $entry->lastUsedAt) > $this->policy->aliveBypassWindow; + } + + private function probe(PoolEntry $entry): bool + { + try { + return $this->factory->validate($entry->resource); + } catch (Throwable) { + return false; + } + } + + private function computeExpiry(float $now): ?float + { + if ($this->policy->maxLifetime <= 0.0) { + return null; + } + $life = $this->policy->maxLifetime; + $jit = $this->policy->maxLifetimeJitter; + if ($jit > 0.0) { + $spread = $life * $jit; + $life += (mt_rand() / mt_getrandmax()) * 2 * $spread - $spread; + } + return $now + $life; + } + + private function safeClose(object $resource): void + { + try { + $this->factory->close($resource); + } catch (Throwable) { + } + } + + private function now(): float + { + return ($this->clock)(); + } +} diff --git a/src/ConnectionPool/PoolEntry.php b/src/ConnectionPool/PoolEntry.php new file mode 100644 index 0000000..8dfc971 --- /dev/null +++ b/src/ConnectionPool/PoolEntry.php @@ -0,0 +1,26 @@ +getMessage(), + previous: $previous, + ); + } + + public static function unusable(int $attempts): self + { + return new self("ConnectionPool: could not obtain a live connection after {$attempts} attempts."); + } +} diff --git a/src/ConnectionPool/PoolPolicy.php b/src/ConnectionPool/PoolPolicy.php new file mode 100644 index 0000000..602d24c --- /dev/null +++ b/src/ConnectionPool/PoolPolicy.php @@ -0,0 +1,70 @@ +keepaliveTime > 0.0 || $this->idleTimeout > 0.0 || $this->minimumIdle > 0; + } +} diff --git a/src/ConnectionPool/SingleConnection.php b/src/ConnectionPool/SingleConnection.php new file mode 100644 index 0000000..212ef4c --- /dev/null +++ b/src/ConnectionPool/SingleConnection.php @@ -0,0 +1,153 @@ +clock = $clock ?? static fn(): float => hrtime(true) / 1e9; + } + + /** + * Returns the live connection, reopening it when the current one has died + * (idle-gated probe) or aged past maxLifetime. + * + * @throws PoolException On connect failure. + */ + public function get(): object + { + $now = $this->now(); + if ($this->entry !== null + && ($this->isExpired($this->entry, $now) + || ($this->needsProbe($this->entry, $now) && !$this->probe($this->entry))) + ) { + $this->discard(); + } + if ($this->entry === null) { + $this->entry = $this->open($now); + } + $this->entry->lastUsedAt = $this->now(); + return $this->entry->resource; + } + + /** + * The connection currently held, without any lifecycle checks — for a caller that + * needs to inspect it (a liveness probe after a failure) rather than use it. + * `null` when nothing is open. + */ + public function peek(): ?object + { + return $this->entry?->resource; + } + + /** + * Retires the current connection (close + forget) — for a connection-level failure + * detected during use, so the next {@see get()} opens a fresh one. + */ + public function evict(): void + { + $this->discard(); + } + + /** Closes the connection and forgets it. */ + public function close(): void + { + $this->discard(); + } + + // ── internals ────────────────────────────────────────────────────────────── + + private function open(float $now): PoolEntry + { + try { + $resource = $this->factory->create(); + } catch (Throwable $e) { + throw PoolException::connectFailed($e); + } + $expiresAt = $this->policy->maxLifetime > 0.0 ? $now + $this->policy->maxLifetime : null; + return new PoolEntry($resource, $now, $now, $expiresAt); + } + + private function discard(): void + { + if ($this->entry !== null) { + $this->safeClose($this->entry->resource); + $this->entry = null; + } + } + + private function isExpired(PoolEntry $entry, float $now): bool + { + return $entry->expiresAt !== null && $now >= $entry->expiresAt; + } + + private function needsProbe(PoolEntry $entry, float $now): bool + { + return ($now - $entry->lastUsedAt) > $this->policy->aliveBypassWindow; + } + + private function probe(PoolEntry $entry): bool + { + try { + return $this->factory->validate($entry->resource); + } catch (Throwable) { + return false; + } + } + + private function safeClose(object $resource): void + { + try { + $this->factory->close($resource); + } catch (Throwable) { + } + } + + private function now(): float + { + return ($this->clock)(); + } +} diff --git a/src/Core/ClassScanner.php b/src/Core/ClassScanner.php index 3962e47..d963fb2 100644 --- a/src/Core/ClassScanner.php +++ b/src/Core/ClassScanner.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Core; +namespace Flytachi\Winter\Kernel\Core; use Flytachi\Winter\DI\Contract\CollectorInterface; use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Plugin; /** * Project-wide class discovery — runs a {@see Scanner} pass over the project @@ -18,7 +18,7 @@ * (vendor excluded automatically) plus each plugin's `src` directory. * * Usage: - * $collector = new ImplementorCollector(Dispatchable::class); + * $collector = new ImplementorCollector(HealthContributor::class); * ClassScanner::scan($collector); * $refs = $collector->getResult(); // ReflectionClass[] */ @@ -41,12 +41,55 @@ public static function scan(CollectorInterface ...$collectors): void } } + /** + * A {@see Scanner} carrying the project's standard exclusions. + * + * Use this instead of {@see Scanner::run()} anywhere the scan starts at the project + * root. `Scanner` drops `vendor/` on its own; the two directories named here hold + * PHP that is not application code, and the scanner cannot tell the difference — it + * reads every `.php` file looking for a class declaration, then `require_once`s + * whatever it finds. + * + * - **storage** holds *generated* code: the DI cache and the `#[Async]` proxies are + * written there, so scanning it is at best wasted work and at worst + * self-referential — a scan reading what the previous scan produced. Under the + * default `isTmpVolatile: true` it lives in the system temp directory and the scan + * never reaches it; under `false` it sits inside the project root. + * - **resources** holds *views*, which are PHP files by nature and classes by + * accident. A template that happens to declare a helper class matches the + * scanner's regex, gets required at boot, and **executes** — echoing into the + * output and running whatever else sits at its top level. Verified, not theorised. + * Even without a class declaration every template is read from disk on each cold + * scan for nothing. + * + * @param string|null $cache Cache file path, or null to always walk the filesystem. + */ + public static function scanner(string $rootDir, ?string $cache = null): Scanner + { + return Scanner::run($rootDir, $cache)->exclude(self::excluded()); + } + + /** + * Directories excluded from every project scan, in addition to `vendor/`. + * + * @return list + */ + private static function excluded(): array + { + // Kernel::init() may not have run yet — a bare Scanner is still correct then, + // it simply has fewer exclusions. + return array_values(array_filter([ + isset(Kernel::$pathStorage) ? Kernel::$pathStorage : null, + isset(Kernel::$pathResource) ? Kernel::$pathResource : null, + ])); + } + /** * @param CollectorInterface[] $collectors */ private static function run(string $rootDir, array $collectors): void { - $scanner = Scanner::run($rootDir); + $scanner = self::scanner($rootDir); foreach ($collectors as $collector) { $scanner->collect($collector); } diff --git a/src/Core/KernelConfig.php b/src/Core/KernelConfig.php index ff9320a..9a71268 100644 --- a/src/Core/KernelConfig.php +++ b/src/Core/KernelConfig.php @@ -2,13 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Core; +namespace Flytachi\Winter\Kernel\Core; abstract class KernelConfig { public static string $pathRoot; public static string $pathEnv; - public static string $pathPublic; public static string $pathResource; public static string $pathStorage; public static string $pathStorageLog; @@ -19,7 +18,6 @@ abstract class KernelConfig /** * @param string|null $pathRoot * @param string|null $pathEnv - * @param string|null $pathPublic * @param string|null $pathResource * @param string|null $pathStorage * @param string|null $pathStorageLog @@ -31,7 +29,6 @@ abstract class KernelConfig public static function init( ?string $pathRoot = null, ?string $pathEnv = null, - ?string $pathPublic = null, ?string $pathResource = null, ?string $pathStorage = null, ?string $pathStorageLog = null, @@ -49,13 +46,8 @@ public static function init( $pathEnv = $pathRoot . '/.env'; } - // public - if ($pathPublic === null) { - $pathPublic = $pathRoot . '/public'; - } - // resource - if ($pathStorageLog === null) { + if ($pathResource === null) { $pathResource = $pathRoot . '/resources'; } @@ -81,7 +73,6 @@ public static function init( self::$pathRoot = $pathRoot; self::$pathEnv = $pathEnv; - self::$pathPublic = $pathPublic; self::$pathResource = $pathResource; self::$pathStorage = $pathStorage; self::$pathStorageLog = $pathStorageLog; diff --git a/src/Core/KernelStore.php b/src/Core/KernelStore.php index c85fe87..5098603 100644 --- a/src/Core/KernelStore.php +++ b/src/Core/KernelStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Core; +namespace Flytachi\Winter\Kernel\Core; use Flytachi\FileStore\FileStorage; use Flytachi\FileStore\FileStorageException; diff --git a/src/Core/RequestLocal.php b/src/Core/RequestLocal.php new file mode 100644 index 0000000..8a037a0 --- /dev/null +++ b/src/Core/RequestLocal.php @@ -0,0 +1,101 @@ +request->files ?? []; } - public function getServerParam(string $key): ?string + public function getServerParam(string $key): string|int|float|null { return $this->request->server[$key] ?? null; } diff --git a/src/Http/Adapter/SwooleResponse.php b/src/Http/Adapter/SwooleResponse.php index 898e310..3707d13 100644 --- a/src/Http/Adapter/SwooleResponse.php +++ b/src/Http/Adapter/SwooleResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Adapter; +namespace Flytachi\Winter\Kernel\Http\Adapter; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; use Swoole\Http\Response; /** diff --git a/src/Http/Contracts/HttpRequest.php b/src/Http/Contracts/HttpRequest.php index aa9f79d..35c154c 100644 --- a/src/Http/Contracts/HttpRequest.php +++ b/src/Http/Contracts/HttpRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Contracts; +namespace Flytachi\Winter\Kernel\Http\Contracts; /** * Unified HTTP request abstraction. @@ -11,7 +11,7 @@ * - SwooleRequest — wraps Swoole\Http\Request (coroutine-safe) * - FpmRequest — wraps $_SERVER / $_GET / $_POST / php://input * - * All K2 internals (Router, ParameterResolver, Middleware) + * All kernel internals (Router, ParameterResolver, Middleware) * depend only on this interface — never on a concrete transport. */ interface HttpRequest @@ -46,8 +46,16 @@ public function getHeaders(): array; /** Uploaded files ($_FILES equivalent). */ public function getUploadedFiles(): array; - /** Server / environment variable (e.g. 'remote_addr', 'request_method'). */ - public function getServerParam(string $key): ?string; + /** + * Server / environment variable (e.g. 'remote_addr', 'request_method'). + * + * Not every one of them is a string, which is why the return type is not `?string`: + * Swoole stores `server_port`, `remote_port`, `request_time` and `master_time` as + * integers and `request_time_float` as a float, and PHP does the same for + * `REQUEST_TIME` and `REQUEST_TIME_FLOAT` under FPM. Asking for a port used to fail + * with a `TypeError` under `strict_types` rather than answer. + */ + public function getServerParam(string $key): string|int|float|null; /** Resolved client IP address (respects X-Forwarded-For / Forwarded). */ public function getClientIp(): string; diff --git a/src/Http/Contracts/HttpResponse.php b/src/Http/Contracts/HttpResponse.php index 77ad678..8ebb71b 100644 --- a/src/Http/Contracts/HttpResponse.php +++ b/src/Http/Contracts/HttpResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Contracts; +namespace Flytachi\Winter\Kernel\Http\Contracts; /** * Unified HTTP response abstraction. diff --git a/src/Http/Cors.php b/src/Http/Cors.php index f8bba15..57280cc 100644 --- a/src/Http/Cors.php +++ b/src/Http/Cors.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http; +namespace Flytachi\Winter\Kernel\Http; final class Cors { diff --git a/src/Http/Header.php b/src/Http/Header.php index a54a9c4..551efd9 100644 --- a/src/Http/Header.php +++ b/src/Http/Header.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http; +namespace Flytachi\Winter\Kernel\Http; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; use Flytachi\Winter\Base\Runtime; /** @@ -24,8 +24,17 @@ */ final class Header { - private const CTX_KEY = '__k2_headers__'; - private const CTX_ORIGIN = '__k2_origin__'; + private const CTX_KEY = '__k2_headers__'; + private const CTX_ORIGIN = '__k2_origin__'; + private const CTX_REQUEST = '__k2_request__'; + + /** + * Upper bound on the header-name normalisation memo. + * + * Real traffic uses a few dozen distinct names, so this is never reached in practice; + * it exists because the names are client-controlled. See {@see normalizeKey()}. + */ + private const int NORMALIZE_MEMO_LIMIT = 256; /** FPM fallback storage */ private static array $bag = []; @@ -33,6 +42,9 @@ final class Header /** FPM fallback storage for the request origin (scheme/host/port/baseUrl). */ private static array $origin = []; + /** FPM fallback storage for the request the origin is derived from, on demand. */ + private static ?HttpRequest $request = null; + private function __construct() { } @@ -45,22 +57,20 @@ public static function init(HttpRequest $request): void $headers = self::normalizeMap($request->getHeaders()); $headers['Ip-Address'] = $request->getClientIp(); - // Snapshot the request origin separately — `host` must not clobber the - // raw `Host` header (which may carry a port) in the header bag. - $origin = [ - 'scheme' => $request->getScheme(), - 'host' => $request->getHost(), - 'port' => $request->getPort(), - 'baseUrl' => $request->getBaseUrl(), - ]; - + // The origin (scheme/host/port/baseUrl) is NOT snapshotted here — it is derived + // on first read by origin(). Nothing in the kernel asks for it, so computing it + // eagerly charged every request for something only some applications use, and + // charged it twice over: getBaseUrl() re-derives scheme, host and port itself. + // The request is kept instead, and the derived values are memoised beside it. if (Runtime::isSwooleCoroutine()) { $ctx = \Swoole\Coroutine::getContext(); - $ctx[self::CTX_KEY] = $headers; - $ctx[self::CTX_ORIGIN] = $origin; + $ctx[self::CTX_KEY] = $headers; + $ctx[self::CTX_REQUEST] = $request; + unset($ctx[self::CTX_ORIGIN]); } else { - self::$bag = $headers; - self::$origin = $origin; + self::$bag = $headers; + self::$request = $request; + self::$origin = []; } } @@ -169,18 +179,77 @@ private static function storage(): array return self::$bag; } + /** + * The request origin, derived on first read and memoised for the rest of the request. + * + * `baseUrl` is assembled from the parts already read rather than through + * {@see HttpRequest::getBaseUrl()}, which would derive scheme, host and port a + * second time — the reason the eager version cost twice what it looked like. + * + * @return array{scheme?: string, host?: string, port?: int, baseUrl?: string} + */ private static function origin(): array { - if (Runtime::isSwooleCoroutine()) { - return \Swoole\Coroutine::getContext()[self::CTX_ORIGIN] ?? []; + $swoole = Runtime::isSwooleCoroutine(); + $ctx = $swoole ? \Swoole\Coroutine::getContext() : null; + + $cached = $swoole ? ($ctx[self::CTX_ORIGIN] ?? null) : (self::$origin ?: null); + if ($cached !== null) { + return $cached; + } + + $request = $swoole ? ($ctx[self::CTX_REQUEST] ?? null) : self::$request; + if (!$request instanceof HttpRequest) { + return []; + } + + $scheme = $request->getScheme(); + $host = $request->getHost(); + $port = $request->getPort(); + $standard = ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443); + + $origin = [ + 'scheme' => $scheme, + 'host' => $host, + 'port' => $port, + 'baseUrl' => $standard ? "{$scheme}://{$host}" : "{$scheme}://{$host}:{$port}", + ]; + + if ($swoole) { + $ctx[self::CTX_ORIGIN] = $origin; + } else { + self::$origin = $origin; } - return self::$origin; + + return $origin; } - /** Normalize a single header key to Title-Case. */ + /** + * Normalize a single header key to Title-Case. + * + * Memoised: the four string operations below run for every key of every request, + * and real traffic reuses the same few dozen names, so the memo hits almost always. + * + * The cap is not a tuning knob — it is the safety property. Header names come from + * the client, so an uncapped map would let one caller grow a long-lived worker's + * memory until it dies. Past the cap normalisation simply runs as it did before, + * which is correct, only not memoised. + */ private static function normalizeKey(string $key): string { - return str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower($key)))); + static $memo = []; + + if (isset($memo[$key])) { + return $memo[$key]; + } + + $normalized = str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower($key)))); + + if (count($memo) < self::NORMALIZE_MEMO_LIMIT) { + $memo[$key] = $normalized; + } + + return $normalized; } /** Normalize all keys in a header map to Title-Case. */ diff --git a/src/Http/Health/Health.php b/src/Http/Health/Health.php index bf27ac8..bc4497c 100644 --- a/src/Http/Health/Health.php +++ b/src/Http/Health/Health.php @@ -2,15 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; final class Health { private static ?array $config = null; private static string $rootDir = ''; private static array $mappings = []; + /** @var list> */ + private static array $contributors = []; private function __construct() { @@ -54,6 +56,25 @@ public static function getMappings(): array return self::$mappings; } + /** + * The discovered {@see HealthContributor} classes, merged into `/actuator/health` + * by the aggregator. Resolved from the container per request. + * + * @param list> $contributors + */ + public static function setContributors(array $contributors): void + { + self::$contributors = $contributors; + } + + /** + * @return list> + */ + public static function getContributors(): array + { + return self::$contributors; + } + // ── System info helpers (used by HealthIndicator) ───────────────────────── public static function cpu(): array diff --git a/src/Http/Health/HealthContributor.php b/src/Http/Health/HealthContributor.php new file mode 100644 index 0000000..81ff8bf --- /dev/null +++ b/src/Http/Health/HealthContributor.php @@ -0,0 +1,40 @@ +db->ping() + * ? HealthStatus::up()->withDetail('latency_ms', $this->db->latency()) + * : HealthStatus::down()->withDetail('reason', 'connection failed'); + * } + * } + * ``` + */ +interface HealthContributor +{ + /** The component key this check appears under in the report (e.g. 'db'). */ + public function name(): string; + + /** Runs the check; called live on every `/actuator/health` request. */ + public function check(): HealthStatus; +} diff --git a/src/Http/Health/HealthIndicator.php b/src/Http/Health/HealthIndicator.php index 87a00e9..04b8ef8 100644 --- a/src/Http/Health/HealthIndicator.php +++ b/src/Http/Health/HealthIndicator.php @@ -2,13 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; use Composer\InstalledVersions; use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Collector\ImplementorCollector; -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\DI\Container; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Collector\ImplementorCollector; +use Flytachi\Winter\Kernel\Http\Header; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; class HealthIndicator implements HealthIndicatorInterface { @@ -24,9 +26,14 @@ public function health(): array 'cache' => $this->cacheHealth($rootDir), 'disk' => $this->diskHealth(), 'memory' => $this->memoryHealth(), - 'custom' => $this->customHealth(), ]; + // Merge every discovered HealthContributor, keyed by its name(). A contributor + // may override a built-in component by reusing its key. + foreach ($this->contributors() as $name => $status) { + $components[$name] = $status; + } + $statuses = array_column($components, 'status'); $overall = 'up'; @@ -110,7 +117,7 @@ public function loggers(): array $globalFormat = env('LOG_FORMAT', 'line'); $channels = []; - foreach (['sys', 'http', 'cli'] as $name) { + foreach (['sys', 'http'] as $name) { $prefix = 'LOG_' . strtoupper($name) . '_'; $level = env($prefix . 'LEVEL') ?? $globalLevel; $output = env($prefix . 'OUTPUT') ?? $globalOutput; @@ -125,8 +132,8 @@ public function loggers(): array ]; if ($output === 'file' || $file) { - $root = \Flytachi\Winter\K2\Kernel::$pathRoot; - $logDir = \Flytachi\Winter\K2\Kernel::$pathStorageLog; + $root = \Flytachi\Winter\Kernel\Kernel::$pathRoot; + $logDir = \Flytachi\Winter\Kernel\Kernel::$pathStorageLog; $entry['file'] = [ 'path' => $file ?? (str_starts_with($logDir, $root) ? ltrim(substr($logDir, strlen($root)), DIRECTORY_SEPARATOR) @@ -151,56 +158,98 @@ public function mappings(): array final protected function dbHealth(string $rootDir): array { $interface = 'Flytachi\Winter\Cdo\Config\Common\DbConfigInterface'; - if ($rootDir === '' || !interface_exists($interface)) { - return ['status' => 'up', 'details' => []]; + $details = []; + + if ($rootDir !== '' && interface_exists($interface)) { + $collector = new ImplementorCollector($interface); + ClassScanner::scanner($rootDir)->collect($collector)->execute(); + + foreach ($collector->getResult() as $ref) { + /** @var \Flytachi\Winter\Cdo\Config\Common\DbConfigInterface $config */ + $config = $ref->newInstance(); + $config->setUp(); + + try { + $result = $config->pingDetail(); + $latency = $result['latency'] ?? null; + + if (!$result['status']) { + $status = 'down'; + } elseif ($latency !== null && $latency >= self::DEGRADED_LATENCY_MS) { + $status = 'degraded'; + } else { + $status = 'up'; + } + + $details[$ref->getName()] = [ + 'status' => $status, + 'driver' => $config->getDriver(), + 'latency' => $latency, + 'error' => $result['error'] ?? null, + ]; + } catch (\Throwable $e) { + $details[$ref->getName()] = [ + 'status' => 'down', + 'driver' => $config->getDriver(), + 'latency' => null, + 'error' => $e->getMessage(), + ]; + } + } } - $collector = new ImplementorCollector($interface); - Scanner::run($rootDir)->collect($collector)->execute(); - $details = []; - $worstStatus = 'up'; + $details = $this->mergePoolUtilisation($details); + $statuses = array_column($details, 'status'); - foreach ($collector->getResult() as $ref) { - /** @var \Flytachi\Winter\Cdo\Config\Common\DbConfigInterface $config */ - $config = $ref->newInstance(); - $config->setUp(); + return [ + 'status' => match (true) { + in_array('down', $statuses, true) => 'down', + in_array('degraded', $statuses, true) => 'degraded', + default => 'up', + }, + 'details' => $details, + ]; + } - try { - $result = $config->pingDetail(); - $latency = $result['latency'] ?? null; + /** + * Folds live pool utilisation ({@see PpaConnectionPool::stats()}) into the per + * datasource entries, so one entry carries both reachability (fresh ping) and + * how loaded its pool is — they are keyed by the same config FQCN. + * + * A saturated pool (every connection handed out — `active >= maximum`, none idle) + * degrades that datasource: it is the signal that borrows are starting to queue. + * A datasource with no pool in this worker reports `pool: null` — the FPM path, + * or simply a config not used yet. Numbers are per worker (see + * {@see PpaConnectionPool::stats()}); a pool whose config the scan did not reach + * still gets an entry, so a live pool is never invisible. + * + * @param array> $details + * @return array> + */ + private function mergePoolUtilisation(array $details): array + { + foreach (PpaConnectionPool::stats() as $config => $stat) { + $details[$config] ??= [ + 'status' => 'up', + 'driver' => null, + 'latency' => null, + 'error' => null, + ]; - if (!$result['status']) { - $status = 'down'; - } elseif ($latency !== null && $latency >= self::DEGRADED_LATENCY_MS) { - $status = 'degraded'; - } else { - $status = 'up'; - } + if ($stat['maximum'] > 0 && $stat['active'] >= $stat['maximum'] + && $details[$config]['status'] === 'up' + ) { + $details[$config]['status'] = 'degraded'; + } - if ($status === 'down') { - $worstStatus = 'down'; - } elseif ($status === 'degraded' && $worstStatus !== 'down') { - $worstStatus = 'degraded'; - } + $details[$config]['pool'] = $stat; + } - $details[$ref->getName()] = [ - 'status' => $status, - 'driver' => $config->getDriver(), - 'latency' => $latency, - 'error' => $result['error'] ?? null, - ]; - } catch (\Throwable $e) { - $details[$ref->getName()] = [ - 'status' => 'down', - 'driver' => $config->getDriver(), - 'latency' => null, - 'error' => $e->getMessage(), - ]; - $worstStatus = 'down'; - } + foreach ($details as $config => $entry) { + $details[$config]['pool'] = $entry['pool'] ?? null; } - return ['status' => $worstStatus, 'details' => $details]; + return $details; } // ── Cache health (requires flytachi/winter-cache) ───────────────────────── @@ -213,7 +262,7 @@ final protected function cacheHealth(string $rootDir): array } $collector = new ImplementorCollector($interface); - Scanner::run($rootDir)->collect($collector)->execute(); + ClassScanner::scanner($rootDir)->collect($collector)->execute(); $details = []; $worstStatus = 'up'; @@ -296,10 +345,24 @@ private function thresholdStatus(float $percent, string $label): array return ['up', null]; } - // ── Override to add custom health checks ────────────────────────────────── + // ── Custom health checks (discovered HealthContributor implementations) ──── - protected function customHealth(): array + /** + * Resolves every registered {@see HealthContributor} from the container and runs + * it live. Keyed by {@see HealthContributor::name()}. + * + * @return array}> + */ + private function contributors(): array { - return ['status' => 'up', 'details' => []]; + $container = Container::getInstance(); + $out = []; + foreach (Health::getContributors() as $class) { + /** @var HealthContributor $contributor */ + $contributor = $container->make($class); + $out[$contributor->name()] = $contributor->check()->toArray(); + } + + return $out; } } diff --git a/src/Http/Health/HealthIndicatorInterface.php b/src/Http/Health/HealthIndicatorInterface.php index dbbc113..4a10960 100644 --- a/src/Http/Health/HealthIndicatorInterface.php +++ b/src/Http/Health/HealthIndicatorInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Health; +namespace Flytachi\Winter\Kernel\Http\Health; interface HealthIndicatorInterface { diff --git a/src/Http/Health/HealthStatus.php b/src/Http/Health/HealthStatus.php new file mode 100644 index 0000000..5d021fa --- /dev/null +++ b/src/Http/Health/HealthStatus.php @@ -0,0 +1,61 @@ +db->ping() + * ? HealthStatus::up()->withDetail('latency_ms', $this->db->latency()) + * : HealthStatus::down()->withDetail('reason', 'connection failed'); + * ``` + */ +final class HealthStatus +{ + /** @var array */ + private array $details = []; + + private function __construct(private readonly Status $status) + { + } + + public static function up(): self + { + return new self(Status::Up); + } + + public static function degraded(): self + { + return new self(Status::Degraded); + } + + public static function down(): self + { + return new self(Status::Down); + } + + public function withDetail(string $key, mixed $value): self + { + $this->details[$key] = $value; + return $this; + } + + public function status(): Status + { + return $this->status; + } + + /** + * The wire shape merged into the actuator report. + * + * @return array{status: string, details: array} + */ + public function toArray(): array + { + return ['status' => $this->status->value, 'details' => $this->details]; + } +} diff --git a/src/Http/Health/Status.php b/src/Http/Health/Status.php new file mode 100644 index 0000000..2a05928 --- /dev/null +++ b/src/Http/Health/Status.php @@ -0,0 +1,17 @@ +getClientTimezone() explicitly in handlers. + * - {@see Timezone} — **the source of truth.** Coroutine-local, so concurrent requests + * cannot overwrite each other. Everything the framework does on the request's behalf + * reads this, including the timezone of the database session + * ({@see \Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool}). + * - `date_default_timezone_set()` — **a convenience, safe under one condition only.** + * It is a PHP engine global shared by every coroutine in the worker, so a bare + * `date()` or `new DateTime()` reflects whichever request wrote last. That is correct + * while concurrent requests share a timezone and wrong the moment they do not; no + * library can fix it, that is how PHP stores the default. + * + * So read {@see Timezone::current()} wherever the answer must belong to the requesting + * user, and treat the global as a best-effort default for code not yet adapted: + * + * ``` + * $when = new \DateTimeImmutable('now', new \DateTimeZone(Timezone::current())); + * ``` + * + * Swoole note: if the handler throws, `after()` does not run. The coroutine-local value + * dies with the coroutine either way, but the engine global keeps the client's value + * until the next request through this middleware resets it — one more reason not to + * lean on it. * * Usage: * #[ClientTimezoneMiddleware] @@ -30,8 +47,10 @@ final class ClientTimezoneMiddleware extends Middleware { public function before(HttpRequest $request, HttpResponse $response): void { - $tz = $request->getClientTimezone() ?? env('TIME_ZONE', 'UTC'); - date_default_timezone_set((string) $tz); + $tz = (string) ($request->getClientTimezone() ?? env('TIME_ZONE', 'UTC')); + + Timezone::set($tz); + date_default_timezone_set($tz); } public function after(mixed $result): mixed diff --git a/src/Http/Middleware/MiddlewareException.php b/src/Http/Middleware/MiddlewareException.php index a64f251..1dfb66f 100644 --- a/src/Http/Middleware/MiddlewareException.php +++ b/src/Http/Middleware/MiddlewareException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Middleware; +namespace Flytachi\Winter\Kernel\Http\Middleware; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; /** * Throw from any middleware to abort the request with a specific HTTP status. diff --git a/src/Http/Middleware/MiddlewareInterface.php b/src/Http/Middleware/MiddlewareInterface.php index d1b7162..3cb67ad 100644 --- a/src/Http/Middleware/MiddlewareInterface.php +++ b/src/Http/Middleware/MiddlewareInterface.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Middleware; +namespace Flytachi\Winter\Kernel\Http\Middleware; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** - * K2 Middleware contract. + * Middleware contract. * * Implement in application middleware: * class AuthMiddleware extends Middleware { ... } diff --git a/src/Http/ParameterResolver.php b/src/Http/ParameterResolver.php index a8f3d01..15f35db 100644 --- a/src/Http/ParameterResolver.php +++ b/src/Http/ParameterResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http; +namespace Flytachi\Winter\Kernel\Http; use BcMath\Number as BcNumber; use BackedEnum; @@ -13,23 +13,23 @@ use finfo; use Flytachi\Winter\Base\Tool; use Flytachi\Winter\DI\ReflectionCache; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestFile; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestHeader; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestQuery; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\ListOf; -use Flytachi\Winter\K2\Http\Request\Validation\Constraint; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestFile; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestHeader; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestQuery; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf; +use Flytachi\Winter\Kernel\Http\Request\Validation\Constraint; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Localization\Locale; use LogicException; use ReflectionAttribute; use ReflectionMethod; @@ -69,8 +69,17 @@ * - #[ListOf] collections cascade constraints when #[Valid] is on the outer param. * - Variadic params: #[Valid] validates each element; all errors collected with [i].field keys. */ -class ParameterResolver +final class ParameterResolver { + /** + * Declared types that accept an array, so binding one to them is not an error. + * + * `mixed` and `iterable` are here because PHP itself accepts an array for both; the + * absence of `object` is deliberate, since an array bound to it would only fail later + * at construction, and failing here says why. + */ + private const array ARRAY_COMPATIBLE = ['array', 'mixed', 'iterable']; + // ── Public API ──────────────────────────────────────────────────────────── public static function resolve( @@ -870,7 +879,13 @@ private static function resolveMessage(string $message, string $field, Constrain private static function cast(mixed $value, ?string $typeName, string $label = 'Parameter'): mixed { - if (is_array($value) && $typeName !== null && $typeName !== 'array') { + // An array reaching a scalar is a client error worth reporting — `?id[]=1` bound + // to an int would otherwise cast to the string "Array". But the check has to name + // the types that genuinely reject an array: `mixed` and `iterable` accept one, and + // comparing against 'array' alone refused them. A DataGrid filter is where this + // surfaced — its `value` is `mixed` because `isAnyOf` sends a list where + // `contains` sends a string. + if (is_array($value) && $typeName !== null && !in_array($typeName, self::ARRAY_COMPATIBLE, true)) { RequestException::throw("$label must be $typeName, got array"); } diff --git a/src/Http/Request/Annotation/PathVariable.php b/src/Http/Request/Annotation/PathVariable.php index a0a484a..342f476 100644 --- a/src/Http/Request/Annotation/PathVariable.php +++ b/src/Http/Request/Annotation/PathVariable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -18,7 +18,7 @@ * If $name is omitted the PHP parameter name is used. */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class PathVariable +final readonly class PathVariable { /** * @param string|null $name Path segment name as declared in the route pattern (e.g. {id:\d+} → 'id'). diff --git a/src/Http/Request/Annotation/RequestBody.php b/src/Http/Request/Annotation/RequestBody.php index 661805e..9a3ff8a 100644 --- a/src/Http/Request/Annotation/RequestBody.php +++ b/src/Http/Request/Annotation/RequestBody.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -40,7 +40,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestBody +final readonly class RequestBody { /** * @param string|null $field Extract a single value from the parsed body by key diff --git a/src/Http/Request/Annotation/RequestFile.php b/src/Http/Request/Annotation/RequestFile.php index d59ea3a..3a7a1e4 100644 --- a/src/Http/Request/Annotation/RequestFile.php +++ b/src/Http/Request/Annotation/RequestFile.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -39,7 +39,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestFile +final readonly class RequestFile { /** * @param string|null $name Form field name from the multipart request. diff --git a/src/Http/Request/Annotation/RequestForm.php b/src/Http/Request/Annotation/RequestForm.php index 6c9a7dd..75e16f2 100644 --- a/src/Http/Request/Annotation/RequestForm.php +++ b/src/Http/Request/Annotation/RequestForm.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -31,7 +31,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestForm +final readonly class RequestForm { /** * @param string|null $field Extract a single value from the merged form/query data diff --git a/src/Http/Request/Annotation/RequestHeader.php b/src/Http/Request/Annotation/RequestHeader.php index 6543da7..380c2fb 100644 --- a/src/Http/Request/Annotation/RequestHeader.php +++ b/src/Http/Request/Annotation/RequestHeader.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestHeader +final readonly class RequestHeader { /** * @param string|null $name Exact HTTP header name (e.g. 'X-Trace-Id', 'Authorization'). diff --git a/src/Http/Request/Annotation/RequestJson.php b/src/Http/Request/Annotation/RequestJson.php index 62d34cb..46591da 100644 --- a/src/Http/Request/Annotation/RequestJson.php +++ b/src/Http/Request/Annotation/RequestJson.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -38,7 +38,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestJson +final readonly class RequestJson { /** * @param string|null $field Extract a single value from the JSON body by key diff --git a/src/Http/Request/Annotation/RequestParam.php b/src/Http/Request/Annotation/RequestParam.php index e5322fc..a93bd12 100644 --- a/src/Http/Request/Annotation/RequestParam.php +++ b/src/Http/Request/Annotation/RequestParam.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -21,7 +21,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestParam +final readonly class RequestParam { /** * @param string|null $name Exact query string key (e.g. 'page_size' for ?page_size=25). diff --git a/src/Http/Request/Annotation/RequestQuery.php b/src/Http/Request/Annotation/RequestQuery.php index bcff219..d836d97 100644 --- a/src/Http/Request/Annotation/RequestQuery.php +++ b/src/Http/Request/Annotation/RequestQuery.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -35,6 +35,6 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestQuery +final readonly class RequestQuery { } diff --git a/src/Http/Request/Annotation/RequestXml.php b/src/Http/Request/Annotation/RequestXml.php index 49497a3..be2ea9b 100644 --- a/src/Http/Request/Annotation/RequestXml.php +++ b/src/Http/Request/Annotation/RequestXml.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Annotation; +namespace Flytachi\Winter\Kernel\Http\Request\Annotation; use Attribute; @@ -35,7 +35,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class RequestXml +final readonly class RequestXml { /** * @param string|null $field Extract a single value from the parsed XML by key diff --git a/src/Http/Request/K1ValidationTrait.php b/src/Http/Request/K1ValidationTrait.php index 3523777..8a603d2 100644 --- a/src/Http/Request/K1ValidationTrait.php +++ b/src/Http/Request/K1ValidationTrait.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request; +namespace Flytachi\Winter\Kernel\Http\Request; use DateTime; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\Locale; /** - * Legacy string-rule validation helpers extracted from RequestObject. + * Legacy string-rule validation helpers, usable on any request DTO. * * Kept for backwards compatibility — new code should prefer the attribute-based - * system (#[Valid] + #[Constraint] under Flytachi\Winter\K2\Http\Request\Validation\*), + * system (#[Valid] + #[Constraint] under Flytachi\Winter\Kernel\Http\Request\Validation\*), * which collects all errors at once and integrates with i18n natively. * * This trait still supports the same {key} translation-key syntax in the optional diff --git a/src/Http/Request/RequestException.php b/src/Http/Request/RequestException.php index ac4eb77..ac945b4 100644 --- a/src/Http/Request/RequestException.php +++ b/src/Http/Request/RequestException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request; +namespace Flytachi\Winter\Kernel\Http\Request; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; /** * Thrown when request data is invalid or required fields are missing. diff --git a/src/Http/Request/RequestObject.php b/src/Http/Request/RequestObject.php deleted file mode 100644 index 87c858a..0000000 --- a/src/Http/Request/RequestObject.php +++ /dev/null @@ -1,180 +0,0 @@ -getRawBody(); - if ($required && (!$raw || !json_validate($raw))) { - RequestException::throw('Missing or invalid JSON body'); - } - $data = $raw ? (json_decode($raw, true) ?? []) : []; - return self::make($data); - } - - /** Build from XML body (Content-Type: application/xml | text/xml). */ - final public static function xml(HttpRequest $request, bool $required = true): static - { - $raw = $request->getRawBody(); - if ($required && !$raw) { - RequestException::throw('Missing XML body'); - } - $xml = $raw ? @simplexml_load_string($raw) : false; - if ($required && $xml === false) { - RequestException::throw('Invalid XML body'); - } - $data = $xml ? (json_decode(json_encode($xml), true) ?: []) : []; - return self::make($data); - } - - /** Build from URL-encoded / multipart form data. */ - final public static function form(HttpRequest $request, bool $required = true): static - { - $data = $request->getParsedBody(); - if ($required && empty($data)) { - RequestException::throw('Missing required form data'); - } - return self::make($data); - } - - /** Build directly from an associative array (e.g. one item from a JSON array). */ - final public static function fromArray(array $data, bool $required = true): static - { - if ($required && empty($data)) { - RequestException::throw('Empty data array'); - } - return self::make($data); - } - - /** Build from query string parameters. */ - final public static function query(HttpRequest $request, bool $required = true): static - { - $data = $request->getQueryParams(); - if ($required && empty($data)) { - RequestException::throw('Missing required query parameters'); - } - return self::make($data); - } - - /** - * Auto-select source by Content-Type header. - * Falls back to JSON for unknown content types. - */ - final public static function fromRequest(HttpRequest $request, bool $required = true): static - { - $ct = strtolower($request->getHeader('content-type') ?? ''); - - if (str_contains($ct, 'application/json')) { - return static::json($request, $required); - } - if (str_contains($ct, 'multipart/form-data') || str_contains($ct, 'application/x-www-form-urlencoded')) { - return static::form($request, $required); - } - if (str_contains($ct, 'application/xml') || str_contains($ct, 'text/xml')) { - return static::xml($request, $required); - } - - return static::json($request, $required); - } - - /** Override to declare validation rules — called automatically after construction. */ - public function rules(): void - { - } - - // ── Internals ───────────────────────────────────────────────────────────── - - private static function make(array $data): static - { - $normalized = []; - foreach ($data as $key => $value) { - $normalized[self::dashToCamel($key)] = $value; - } - - if (!empty($normalized)) { - $normalized = self::castToConstructorTypes($normalized); - } - - try { - $instance = empty($normalized) ? new static() : new static(...$normalized); - $instance->rules(); - return $instance; - } catch (\ArgumentCountError $e) { - $msg = preg_replace( - '/.*Argument #\d+ \(\$(\w+)\) not passed.*/', - "Required field '\$1' not found", - $e->getMessage() - ) ?: 'Missing required data'; - RequestException::throw($msg, previous: $e); - } catch (\TypeError $e) { - $msg = preg_replace( - '/.*Argument #\d+ \(\$(\w+)\) must be of type (\S+), (\S+) given.*/', - "Invalid type for '\$1' (expected: '\$2', got: '\$3')", - $e->getMessage() - ) ?: $e->getMessage(); - RequestException::throw($msg, previous: $e); - } catch (\Error $e) { - $msg = preg_replace( - '/Unknown named parameter \$(\w+)/', - "Unknown field '\$1'", - $e->getMessage() - ) ?: $e->getMessage(); - RequestException::throw($msg, previous: $e); - } - } - - private static function castToConstructorTypes(array $data): array - { - $constructor = (new \ReflectionClass(static::class))->getConstructor(); - if ($constructor === null) { - return $data; - } - - foreach ($constructor->getParameters() as $param) { - $name = $param->getName(); - if (!array_key_exists($name, $data)) { - continue; - } - $type = $param->getType(); - if (!$type instanceof \ReflectionNamedType || !$type->isBuiltin()) { - continue; - } - $value = $data[$name]; - if ($value === null) { - continue; - } - $data[$name] = match ($type->getName()) { - 'int' => (int) $value, - 'float' => (float) $value, - 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value, - 'string' => (string) $value, - default => $value, - }; - } - - return $data; - } - - private static function dashToCamel(string $key): string - { - return lcfirst(str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key)))); - } -} diff --git a/src/Http/Request/Validation/Assert.php b/src/Http/Request/Validation/Assert.php index 189dc54..6b264a1 100644 --- a/src/Http/Request/Validation/Assert.php +++ b/src/Http/Request/Validation/Assert.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -31,7 +31,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)] -readonly class Assert implements Constraint +final readonly class Assert implements Constraint { /** * @param string $callable Callable string reference: 'ClassName::method' or 'functionName'. diff --git a/src/Http/Request/Validation/Constraint.php b/src/Http/Request/Validation/Constraint.php index c921858..7bc13d7 100644 --- a/src/Http/Request/Validation/Constraint.php +++ b/src/Http/Request/Validation/Constraint.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; /** * Contract for all parameter-level validation attributes. diff --git a/src/Http/Request/Validation/Date.php b/src/Http/Request/Validation/Date.php index c402ef9..5230230 100644 --- a/src/Http/Request/Validation/Date.php +++ b/src/Http/Request/Validation/Date.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Date implements Constraint +final readonly class Date implements Constraint { /** * @param string $format PHP date format string. Defaults to 'Y-m-d' (e.g. "2024-01-31"). diff --git a/src/Http/Request/Validation/Datetime.php b/src/Http/Request/Validation/Datetime.php index 13a42b5..f17fa83 100644 --- a/src/Http/Request/Validation/Datetime.php +++ b/src/Http/Request/Validation/Datetime.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -19,7 +19,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Datetime implements Constraint +final readonly class Datetime implements Constraint { /** * @param string|null $format PHP datetime format string. null = flexible ISO 8601 via DateTimeImmutable. diff --git a/src/Http/Request/Validation/Digits.php b/src/Http/Request/Validation/Digits.php index 799f78a..a2e622d 100644 --- a/src/Http/Request/Validation/Digits.php +++ b/src/Http/Request/Validation/Digits.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -22,7 +22,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Digits implements Constraint +final readonly class Digits implements Constraint { /** * @param int $integer Max allowed digits in the integer part (before decimal point). diff --git a/src/Http/Request/Validation/Email.php b/src/Http/Request/Validation/Email.php index 418bfc4..bedf505 100644 --- a/src/Http/Request/Validation/Email.php +++ b/src/Http/Request/Validation/Email.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Email implements Constraint +final readonly class Email implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/In.php b/src/Http/Request/Validation/In.php index c3659f7..246212f 100644 --- a/src/Http/Request/Validation/In.php +++ b/src/Http/Request/Validation/In.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class In implements Constraint +final readonly class In implements Constraint { /** * @param array $values Allowed values to check against. diff --git a/src/Http/Request/Validation/Ip.php b/src/Http/Request/Validation/Ip.php index 467f777..161d6e8 100644 --- a/src/Http/Request/Validation/Ip.php +++ b/src/Http/Request/Validation/Ip.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -15,7 +15,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Ip implements Constraint +final readonly class Ip implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Ipv4.php b/src/Http/Request/Validation/Ipv4.php index f4b1c7a..8d3ad31 100644 --- a/src/Http/Request/Validation/Ipv4.php +++ b/src/Http/Request/Validation/Ipv4.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -15,7 +15,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Ipv4 implements Constraint +final readonly class Ipv4 implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Ipv6.php b/src/Http/Request/Validation/Ipv6.php index bd65892..160451e 100644 --- a/src/Http/Request/Validation/Ipv6.php +++ b/src/Http/Request/Validation/Ipv6.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -15,7 +15,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Ipv6 implements Constraint +final readonly class Ipv6 implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/ListOf.php b/src/Http/Request/Validation/ListOf.php index b7015b8..830b90e 100644 --- a/src/Http/Request/Validation/ListOf.php +++ b/src/Http/Request/Validation/ListOf.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -24,7 +24,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class ListOf +final readonly class ListOf { /** @param string $class Fully-qualified class name of the element DTO. */ public function __construct(public string $class) diff --git a/src/Http/Request/Validation/Max.php b/src/Http/Request/Validation/Max.php index 722b7f5..4876e84 100644 --- a/src/Http/Request/Validation/Max.php +++ b/src/Http/Request/Validation/Max.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Max implements Constraint +final readonly class Max implements Constraint { /** * @param int|float $value Upper bound (inclusive). Value must be ≤ this. diff --git a/src/Http/Request/Validation/Min.php b/src/Http/Request/Validation/Min.php index 8590184..a415c8c 100644 --- a/src/Http/Request/Validation/Min.php +++ b/src/Http/Request/Validation/Min.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Min implements Constraint +final readonly class Min implements Constraint { /** * @param int|float $value Lower bound (inclusive). Value must be ≥ this. diff --git a/src/Http/Request/Validation/Msisdn.php b/src/Http/Request/Validation/Msisdn.php index a560d9e..1a045a6 100644 --- a/src/Http/Request/Validation/Msisdn.php +++ b/src/Http/Request/Validation/Msisdn.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Msisdn implements Constraint +final readonly class Msisdn implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Negative.php b/src/Http/Request/Validation/Negative.php index d665817..5e945ea 100644 --- a/src/Http/Request/Validation/Negative.php +++ b/src/Http/Request/Validation/Negative.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Negative implements Constraint +final readonly class Negative implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/NegativeOrZero.php b/src/Http/Request/Validation/NegativeOrZero.php index 64f4751..3b8ad83 100644 --- a/src/Http/Request/Validation/NegativeOrZero.php +++ b/src/Http/Request/Validation/NegativeOrZero.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class NegativeOrZero implements Constraint +final readonly class NegativeOrZero implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/NotBlank.php b/src/Http/Request/Validation/NotBlank.php index dad0dd3..8b96422 100644 --- a/src/Http/Request/Validation/NotBlank.php +++ b/src/Http/Request/Validation/NotBlank.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class NotBlank implements Constraint +final readonly class NotBlank implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Phone.php b/src/Http/Request/Validation/Phone.php index 22c4efb..47032d9 100644 --- a/src/Http/Request/Validation/Phone.php +++ b/src/Http/Request/Validation/Phone.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Phone implements Constraint +final readonly class Phone implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Positive.php b/src/Http/Request/Validation/Positive.php index 9f8244d..a8516d8 100644 --- a/src/Http/Request/Validation/Positive.php +++ b/src/Http/Request/Validation/Positive.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Positive implements Constraint +final readonly class Positive implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/PositiveOrZero.php b/src/Http/Request/Validation/PositiveOrZero.php index fa64151..cc53d49 100644 --- a/src/Http/Request/Validation/PositiveOrZero.php +++ b/src/Http/Request/Validation/PositiveOrZero.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -17,7 +17,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class PositiveOrZero implements Constraint +final readonly class PositiveOrZero implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Regex.php b/src/Http/Request/Validation/Regex.php index cf922c0..f28f6a0 100644 --- a/src/Http/Request/Validation/Regex.php +++ b/src/Http/Request/Validation/Regex.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -20,7 +20,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Regex implements Constraint +final readonly class Regex implements Constraint { /** * @param string $pattern Full PHP regex with delimiters, e.g. '/^\d{4}$/'. diff --git a/src/Http/Request/Validation/Required.php b/src/Http/Request/Validation/Required.php index eabf595..89c18b5 100644 --- a/src/Http/Request/Validation/Required.php +++ b/src/Http/Request/Validation/Required.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -21,7 +21,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Required implements Constraint +final readonly class Required implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Size.php b/src/Http/Request/Validation/Size.php index e099ac2..e8f1f83 100644 --- a/src/Http/Request/Validation/Size.php +++ b/src/Http/Request/Validation/Size.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -24,7 +24,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Size implements Constraint +final readonly class Size implements Constraint { /** * @param int $min Required. Lower bound (inclusive). When `$max` is omitted, also acts as the exact required size. diff --git a/src/Http/Request/Validation/Time.php b/src/Http/Request/Validation/Time.php index e117bec..38580d4 100644 --- a/src/Http/Request/Validation/Time.php +++ b/src/Http/Request/Validation/Time.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Time implements Constraint +final readonly class Time implements Constraint { /** * @param string|null $format PHP time format string. null = accept 'H:i' or 'H:i:s'. diff --git a/src/Http/Request/Validation/Url.php b/src/Http/Request/Validation/Url.php index 4853a03..38ff4ee 100644 --- a/src/Http/Request/Validation/Url.php +++ b/src/Http/Request/Validation/Url.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -16,7 +16,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Url implements Constraint +final readonly class Url implements Constraint { /** * @param string|null $message Custom error message that overrides the default one. diff --git a/src/Http/Request/Validation/Uuid.php b/src/Http/Request/Validation/Uuid.php index 33d74cc..1c02164 100644 --- a/src/Http/Request/Validation/Uuid.php +++ b/src/Http/Request/Validation/Uuid.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -18,7 +18,7 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Uuid implements Constraint +final readonly class Uuid implements Constraint { private const string PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; private const string PATTERN_VER = '/^[0-9a-f]{8}-[0-9a-f]{4}-%d[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; diff --git a/src/Http/Request/Validation/Valid.php b/src/Http/Request/Validation/Valid.php index 6351889..25004e3 100644 --- a/src/Http/Request/Validation/Valid.php +++ b/src/Http/Request/Validation/Valid.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Attribute; @@ -34,6 +34,6 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -readonly class Valid +final readonly class Valid { } diff --git a/src/Http/Request/Validation/ValidationException.php b/src/Http/Request/Validation/ValidationException.php index 9ed36eb..8fad828 100644 --- a/src/Http/Request/Validation/ValidationException.php +++ b/src/Http/Request/Validation/ValidationException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Http\Request\Validation; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; /** * Thrown when #[Valid] constraint validation fails on a DTO parameter. diff --git a/src/Http/Response/AcceptHeaderParser.php b/src/Http/Response/AcceptHeaderParser.php index 5c67682..9f44116 100644 --- a/src/Http/Response/AcceptHeaderParser.php +++ b/src/Http/Response/AcceptHeaderParser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; final class AcceptHeaderParser { diff --git a/src/Http/Response/AdviceException.php b/src/Http/Response/AdviceException.php index 805ce7b..dd5324c 100644 --- a/src/Http/Response/AdviceException.php +++ b/src/Http/Response/AdviceException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Attribute; use Throwable; @@ -17,6 +17,8 @@ * ExceptionWrapper scans the project for these at startup and routes * Throwables to the most specific matching handler. * + * The base class to extend is {@see \Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase}. + * * Example — catch specific exception: * #[AdviceException(NotFoundException::class)] * class NotFoundResponse extends ExceptionResponseBase { ... } @@ -26,7 +28,7 @@ * class GlobalErrorResponse extends ExceptionResponseBase { ... } */ #[Attribute(Attribute::TARGET_CLASS)] -readonly class AdviceException +final readonly class AdviceException { /** @var class-string[] */ public array $exceptionClassNames; diff --git a/src/Http/Response/Collector/ExceptionCollector.php b/src/Http/Response/Collector/ExceptionCollector.php index 35b1042..bbc2b93 100644 --- a/src/Http/Response/Collector/ExceptionCollector.php +++ b/src/Http/Response/Collector/ExceptionCollector.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response\Collector; +namespace Flytachi\Winter\Kernel\Http\Response\Collector; use Flytachi\Winter\DI\Contract\CollectorInterface; -use Flytachi\Winter\K2\Http\Response\AdviceException; -use Flytachi\Winter\K2\Http\Response\ResponseExceptionInterface; +use Flytachi\Winter\Kernel\Http\Response\AdviceException; +use Flytachi\Winter\Kernel\Http\Response\ResponseExceptionInterface; use ReflectionClass; final class ExceptionCollector implements CollectorInterface diff --git a/src/Http/Response/ContentType.php b/src/Http/Response/ContentType.php index e86b834..f4ab8d6 100644 --- a/src/Http/Response/ContentType.php +++ b/src/Http/Response/ContentType.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; -use Flytachi\Winter\K2\File\XML; +use Flytachi\Winter\Kernel\File\XML; enum ContentType: string { diff --git a/src/Http/Response/ExceptionWrapper.php b/src/Http/Response/ExceptionWrapper.php index dc50781..3a4caf8 100644 --- a/src/Http/Response/ExceptionWrapper.php +++ b/src/Http/Response/ExceptionWrapper.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Composer\Autoload\ClassLoader; use Flytachi\Winter\DI\ReflectionCache; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; use ReflectionClass; use ReflectionException; @@ -30,8 +31,7 @@ * match expression to mask new sensitive exception types. redactMessage() * MUTATES the Throwable's $message in place (clone is not used — some * Throwables in PHP are not cloneable), so callers must log the original - * message BEFORE invoking wrap(). Router::sendError() and - * BaseBoot::handleBootError() already follow this order. + * message BEFORE invoking wrap(). Router::sendError() already follows this order. * * Specific handlers (with exception class names) are tried first. * Catch-all handlers (without class names) are tried last. diff --git a/src/Http/Response/FileResponseHeaders.php b/src/Http/Response/FileResponseHeaders.php index ab8624e..b1c251a 100644 --- a/src/Http/Response/FileResponseHeaders.php +++ b/src/Http/Response/FileResponseHeaders.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** * Shared builder + header logic for file-style responses. diff --git a/src/Http/Response/RenderContext.php b/src/Http/Response/RenderContext.php index ae75721..979113c 100644 --- a/src/Http/Response/RenderContext.php +++ b/src/Http/Response/RenderContext.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\Runtime; @@ -15,26 +15,13 @@ * Lifecycle (managed by ResponseView::renderContent()): * RenderContext::push(...) — before rendering begins * RenderContext::current() — inside any template or partial - * RenderContext::pop() — after debug output is appended (via finally) - * - * Debug meta (only when DEBUG=true, set by Router before send()): - * RenderContext::setMeta($class, $method) - * RenderContext::setRoutes($routes) + * RenderContext::pop() — after rendering finishes (via finally) */ final class RenderContext { // ── Static stack (FPM) ──────────────────────────────────────────────────── private static array $stack = []; - // ── Pending debug meta (Router → push, consumed once) ──────────────────── - private static ?string $pendingController = null; - private static ?string $pendingMethod = null; - private static array $pendingRoutes = []; - - // ── Instance fields ─────────────────────────────────────────────────────── - private ?string $controllerClass; - private ?string $controllerMethod; - private array $routes = []; private array $resourceAdditional = []; private function __construct( @@ -43,57 +30,6 @@ private function __construct( private readonly ?string $templateName, private readonly string $resourceName, ) { - [$this->controllerClass, $this->controllerMethod] = self::consumeMeta(); - $this->routes = self::consumeRoutes(); - } - - // ── Debug meta (called by Router, only when DEBUG=true) ─────────────────── - - public static function setMeta(string $controllerClass, string $controllerMethod): void - { - if (Runtime::isSwooleCoroutine()) { - \Swoole\Coroutine::getContext()['__render_meta'] = [$controllerClass, $controllerMethod]; - } else { - self::$pendingController = $controllerClass; - self::$pendingMethod = $controllerMethod; - } - } - - /** @param list $routes */ - public static function setRoutes(array $routes): void - { - if (Runtime::isSwooleCoroutine()) { - \Swoole\Coroutine::getContext()['__render_routes'] = $routes; - } else { - self::$pendingRoutes = $routes; - } - } - - private static function consumeMeta(): array - { - if (Runtime::isSwooleCoroutine()) { - $co = \Swoole\Coroutine::getContext(); - $meta = $co['__render_meta'] ?? [null, null]; - unset($co['__render_meta']); - return $meta; - } - $meta = [self::$pendingController, self::$pendingMethod]; - self::$pendingController = null; - self::$pendingMethod = null; - return $meta; - } - - private static function consumeRoutes(): array - { - if (Runtime::isSwooleCoroutine()) { - $co = \Swoole\Coroutine::getContext(); - $routes = $co['__render_routes'] ?? []; - unset($co['__render_routes']); - return $routes; - } - $routes = self::$pendingRoutes; - self::$pendingRoutes = []; - return $routes; } // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -184,155 +120,4 @@ public function isActiveLink( return $uri === $link ? $classNameSuccess : $classNameNone; } - - // ── Debug panel ─────────────────────────────────────────────────────────── - - public function debugger(): string - { - if (!env('DEBUG', false)) { - return ''; - } - - if (Runtime::isSwooleCoroutine()) { - $start = \Swoole\Coroutine::getContext()['__request_start'] ?? microtime(true); - } else { - $start = defined('WINTER_STARTUP_TIME') ? WINTER_STARTUP_TIME : microtime(true); - } - $delta = max(round(microtime(true) - $start, 3), 0.001); - $memory = function_exists('bytes') - ? bytes(memory_get_usage(), 'MiB') - : round(memory_get_usage() / 1048576, 2) . ' MiB'; - - if (Runtime::isSwooleCoroutine()) { - $ctx = \Swoole\Coroutine::getContext(); - $method = htmlspecialchars($ctx['__request_method'] ?? 'CLI', ENT_QUOTES); - $uri = htmlspecialchars($ctx['__request_uri'] ?? '/', ENT_QUOTES); - } else { - $method = htmlspecialchars($_SERVER['REQUEST_METHOD'] ?? 'CLI', ENT_QUOTES); - $uri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES); - } - - $templateDisplay = $this->templateName - ? str_replace($this->basePath, '', $this->basePath . '/' . $this->templateName . '.php') - : null; - $resourceDisplay = str_replace($this->basePath, '', $this->basePath . '/' . $this->resourceName . '.php'); - $additionalDisplay = array_map( - fn($p) => str_replace($this->basePath, '', $p), - $this->resourceAdditional - ); - - $general = $this->esc(print_r([ - 'sapi' => PHP_SAPI, - 'runtime' => Runtime::mode()->name, - 'timezone' => date_default_timezone_get(), - 'date' => date(DATE_ATOM), - 'controllerClass' => $this->controllerClass, - 'controllerClassMethod' => $this->controllerMethod, - 'template' => $templateDisplay, - 'resource' => $resourceDisplay, - 'resourceAdditional' => $additionalDisplay, - 'resourceData' => $this->data, - ], true)); - - $routingRows = ''; - foreach ($this->routes as $r) { - $m = htmlspecialchars($r['method'], ENT_QUOTES); - $p = htmlspecialchars($r['path'], ENT_QUOTES); - $h = htmlspecialchars($r['handler'], ENT_QUOTES); - $color = self::methodColor($r['method']); - $routingRows .= << - - $m - - $p - $h - - HTML; - } - - $routingTable = $routingRows !== '' - ? << - - - - - - - - - $routingRows -
MethodPathHandler
- - HTML - : '
No routes registered
'; - - $globals = ''; - foreach ($GLOBALS as $name => $info) { - if (empty($info)) { - continue; - } - $safeName = htmlspecialchars(ltrim($name, '_'), ENT_QUOTES); - $safeContent = $this->esc(print_r($info, true)); - $globals .= << - -
-
$safeContent
-
- HTML; - } - - return << - - - -
-
- [$method] - $uri - Memory: $memory  |  - Time: $delta sec -
-
- - - -
-
$general
-
- - - -
- $routingTable -
- -
- $globals -
-
- HTML; - } - - private static function methodColor(string $method): string - { - return match (strtoupper($method)) { - 'GET' => '#28a745', - 'POST' => '#007bff', - 'PUT' => '#fd7e14', - 'PATCH' => '#6f42c1', - 'DELETE' => '#dc3545', - 'OPTIONS' => '#6c757d', - default => '#343a40', - }; - } - - private function esc(string $value): string - { - return htmlspecialchars($value, ENT_QUOTES); - } } diff --git a/src/Http/Response/ResponseEntity.php b/src/Http/Response/ResponseEntity.php index 1243b9f..15c4644 100644 --- a/src/Http/Response/ResponseEntity.php +++ b/src/Http/Response/ResponseEntity.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Header; /** * Spring-Boot-style response wrapper — works in both Swoole and FPM modes. @@ -31,7 +31,7 @@ * Custom headers: * ResponseEntity::ok($data)->header('X-Request-Id', $id) */ -class ResponseEntity implements Sendable +final class ResponseEntity implements Sendable { private mixed $body = null; private array $headers = []; diff --git a/src/Http/Response/ResponseException.php b/src/Http/Response/ResponseException.php index 89a53cd..932f6d5 100644 --- a/src/Http/Response/ResponseException.php +++ b/src/Http/Response/ResponseException.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\Exception\ExceptionHeader; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Exception\ExceptionHeaderTrait; +use Flytachi\Winter\Kernel\Exception\ExceptionHeaderTrait; use Psr\Log\LogLevel; /** @@ -28,8 +28,21 @@ class ResponseException extends \RuntimeException implements ExceptionLogLevel, protected $code = HttpCode::BAD_REQUEST->value; + /** + * `WARNING` for a client error, `ERROR` for a server one — the same split the rest + * of the kernel uses ({@see \Flytachi\Winter\Kernel\Exception\ClientError} is + * `WARNING`, {@see \Flytachi\Winter\Kernel\Exception\ServerError} is `ERROR`). + * + * This class is thrown with whatever status the caller passes, so a flat level was + * right for the 4xx it mostly carries and wrong for the 5xx it also can: a request + * that timed out or hit an upstream failure was filed next to a plain 404, in the + * stream nobody pages on. + * + * A status below 400 keeps `WARNING` too — an exception is not the normal way to + * return one, and saying so quietly is enough. + */ public function getLogLevel(): string { - return LogLevel::WARNING; + return $this->getCode() >= 500 ? LogLevel::ERROR : LogLevel::WARNING; } } diff --git a/src/Http/Response/ResponseExceptionInterface.php b/src/Http/Response/ResponseExceptionInterface.php index b572e56..5243ebf 100644 --- a/src/Http/Response/ResponseExceptionInterface.php +++ b/src/Http/Response/ResponseExceptionInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; diff --git a/src/Http/Response/ResponseFile.php b/src/Http/Response/ResponseFile.php index c565ea7..6c90d93 100644 --- a/src/Http/Response/ResponseFile.php +++ b/src/Http/Response/ResponseFile.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\File\XML; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\File\XML; use SimpleXMLElement; /** @@ -26,7 +26,7 @@ * ->inline() — Content-Disposition: inline (render in browser) * ->maxAge(3600) — Cache-Control: public, max-age=3600 */ -class ResponseFile implements Sendable +final class ResponseFile implements Sendable { use FileResponseHeaders; diff --git a/src/Http/Response/ResponseStreamFile.php b/src/Http/Response/ResponseStreamFile.php index b45175a..32ce1b9 100644 --- a/src/Http/Response/ResponseStreamFile.php +++ b/src/Http/Response/ResponseStreamFile.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** * Streams a file from disk via HttpResponse::sendfile() (zero-copy on Swoole). diff --git a/src/Http/Response/ResponseTrait.php b/src/Http/Response/ResponseTrait.php index a820cd5..8accbeb 100644 --- a/src/Http/Response/ResponseTrait.php +++ b/src/Http/Response/ResponseTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; trait ResponseTrait { diff --git a/src/Http/Response/ResponseView.php b/src/Http/Response/ResponseView.php index 4688566..c4f1147 100644 --- a/src/Http/Response/ResponseView.php +++ b/src/Http/Response/ResponseView.php @@ -2,28 +2,35 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Kernel; /** * PHP template response — port of ViewBase + View. * - * Configure once per app (e.g. in bootstrap): - * ResponseView::setBasePath(__DIR__ . '/resources/views'); + * Views live in `resources/views` by default; no configuration is needed. Override + * the root only for a non-standard layout: + * ResponseView::setBasePath(__DIR__ . '/theme'); * * Factory methods: * ResponseView::view('user/profile', ['user' => $user]) * ResponseView::render('layouts/main', 'user/profile', ['user' => $user]) * - * Template receives all $data keys as variables plus $content (rendered resource). - * Resource receives all $data keys as variables. + * The two names are not interchangeable: the **template** is the layout, and it + * receives all $data keys plus $content (the rendered resource); the **resource** is + * the page, and it receives the $data keys. Both are resolved under the same root, so + * the directory is named after neither — `views` covers both, with layouts + * conventionally under `views/layouts`. */ -class ResponseView implements Sendable +final class ResponseView implements Sendable { + /** Directory under {@see Kernel::$pathResource} holding the views. */ + private const string DEFAULT_DIR = 'views'; + private static string $basePath = ''; private ?string $templateName; @@ -39,7 +46,7 @@ private function __construct( HttpCode $httpCode, ) { if (empty(self::getBasePath())) { - self::setBasePath(Kernel::$pathResource); + self::setBasePath(Kernel::$pathResource . '/' . self::DEFAULT_DIR); } $this->templateName = $templateName; $this->resourceName = $resourceName; @@ -127,7 +134,7 @@ private function renderContent(): string ? $this->capture($this->templatePath(), $this->data) : $resource; - return $html . RenderContext::current()?->debugger(); + return $html; } finally { RenderContext::pop(); } diff --git a/src/Http/Response/Sendable.php b/src/Http/Response/Sendable.php index f9bc628..aeb2da3 100644 --- a/src/Http/Response/Sendable.php +++ b/src/Http/Response/Sendable.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Response; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; /** - * Common contract for all K2 response objects. + * Common contract for all kernel response objects. * * Any value returned from a controller method that implements this interface * will be serialized to the HttpResponse by the Router automatically. diff --git a/src/Stereotype/Controller.php b/src/Http/Stereotype/Controller.php similarity index 74% rename from src/Stereotype/Controller.php rename to src/Http/Stereotype/Controller.php index a54c3ab..aa08f97 100644 --- a/src/Stereotype/Controller.php +++ b/src/Http/Stereotype/Controller.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Stereotype; +namespace Flytachi\Winter\Kernel\Http\Stereotype; abstract class Controller implements ControllerInterface { diff --git a/src/Stereotype/ControllerInterface.php b/src/Http/Stereotype/ControllerInterface.php similarity index 67% rename from src/Stereotype/ControllerInterface.php rename to src/Http/Stereotype/ControllerInterface.php index 35a72bc..760adc2 100644 --- a/src/Stereotype/ControllerInterface.php +++ b/src/Http/Stereotype/ControllerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Stereotype; +namespace Flytachi\Winter\Kernel\Http\Stereotype; interface ControllerInterface { diff --git a/src/Http/Response/ExceptionResponseBase.php b/src/Http/Stereotype/ExceptionResponseBase.php similarity index 81% rename from src/Http/Response/ExceptionResponseBase.php rename to src/Http/Stereotype/ExceptionResponseBase.php index d649a2b..cbd6434 100644 --- a/src/Http/Response/ExceptionResponseBase.php +++ b/src/Http/Stereotype/ExceptionResponseBase.php @@ -2,12 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Http\Response; +namespace Flytachi\Winter\Kernel\Http\Stereotype; use Flytachi\Winter\Base\Exception\ExceptionHeader; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Header; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Header; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Response\AcceptHeaderParser; +use Flytachi\Winter\Kernel\Http\Response\ContentType; +use Flytachi\Winter\Kernel\Http\Response\ResponseExceptionInterface; +use Flytachi\Winter\Kernel\Http\Response\ResponseTrait; /** * Default exception response — implements ResponseExceptionInterface. @@ -87,17 +91,18 @@ protected function contentHtml(): string $httpMessage = $this->httpCode->message(); $message = htmlspecialchars($this->throwable->getMessage(), ENT_QUOTES); + $logo = self::logo(); + return << - {$code} {$httpMessage}
-
logotype
+
{$logo}
Winter {$code} — {$httpMessage}

{$message}

@@ -106,6 +111,29 @@ protected function contentHtml(): string HTML; } + /** + * The mark, inlined rather than linked. + * + * This page is what a visitor sees when the application is already failing, so it + * must not depend on the application being configured correctly: static serving is + * opt-in, and a linked asset would simply 404. Inlining the element (rather than a + * `data:` URI) also keeps it working under a strict `img-src` policy. + */ + private static function logo(): string + { + return <<<'SVG' + + + + + + + + + + SVG; + } + final protected function validationRequests(): array { if ($this->throwable instanceof ValidationException) { diff --git a/src/Stereotype/Middleware.php b/src/Http/Stereotype/Middleware.php similarity index 68% rename from src/Stereotype/Middleware.php rename to src/Http/Stereotype/Middleware.php index d3fb143..ee489f6 100644 --- a/src/Stereotype/Middleware.php +++ b/src/Http/Stereotype/Middleware.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Stereotype; +namespace Flytachi\Winter\Kernel\Http\Stereotype; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Middleware\MiddlewareInterface; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareInterface; /** * Base middleware — extend and override before() / after() as needed. diff --git a/src/Kernel.php b/src/Kernel.php index 8a3a6da..36e594a 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2; +namespace Flytachi\Winter\Kernel; -use Flytachi\Winter\Base\Runtime; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\Thread\Launch\CliLauncher; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Process\ForkReset; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Thread\Launch\AdaptiveLauncher; use Flytachi\Winter\Thread\Thread; use Flytachi\Winter\Logger\Context\ProcessContext; use Flytachi\Winter\Logger\LoggerFactory; @@ -24,7 +25,6 @@ final class Kernel extends KernelStore public static function init( ?string $pathRoot = null, ?string $pathEnv = null, - ?string $pathPublic = null, ?string $pathResource = null, ?string $pathStorage = null, ?string $pathStorageLog = null, @@ -36,7 +36,6 @@ public static function init( parent::init( $pathRoot, $pathEnv, - $pathPublic, $pathResource, $pathStorage, $pathStorageLog, @@ -62,11 +61,19 @@ public static function init( self::bootLogger(); - // thread - Thread::bindLauncher(CliLauncher::adaptive( + // thread — both backends spawn the same `php ` child; only the way + // the shell is invoked differs. Inside a coroutine proc_open corrupts the + // reactor's descriptors and Swoole\Process is refused while its async-io + // threads are up, so the launcher shells out via Coroutine\System::exec(); + // everywhere else proc_open is used unchanged. + Thread::bindLauncher(AdaptiveLauncher::adaptive( secret: env('WINTER_KEY', ''), runnerPath: self::threadRunnerPath(), )); + + // fork-safety — a forked daemon worker inherits the parent's DB sockets; + // reset the pool in the child (Process::afterFork) so it reconnects fresh. + ForkReset::register(static fn() => PpaConnectionPool::reset()); } private static function bootLogger(): void @@ -85,8 +92,9 @@ private static function bootLogger(): void if (empty($levelStr)) { LoggerFactory::setManager(new LoggerManager( contextStorage: new ProcessContext(), - channels: ['sys' => $null, 'http' => $null, 'cli' => $null], + channels: ['sys' => $null, 'http' => $null], )); + LoggerFactory::setDefaultChannel('sys'); return; } @@ -95,7 +103,6 @@ private static function bootLogger(): void channels: [ 'sys' => self::buildChannelConfig('sys'), 'http' => self::buildChannelConfig('http'), - 'cli' => self::buildChannelConfig('cli'), ], )); @@ -120,31 +127,50 @@ private static function buildChannelConfig(string $channel): array $rawOutput = (string) (env($prefix . 'OUTPUT') ?? env('LOG_OUTPUT', 'auto')); $output = self::resolveOutput($rawOutput); + $format = (string) (env($prefix . 'FORMAT') ?? env('LOG_FORMAT', 'line')); $filePath = env($prefix . 'FILE') ?? env('LOG_FILE'); if ($output === 'file' && empty($filePath)) { $filePath = self::$pathStorageLog . '/' . $channel . '.log'; } + // ANSI colour — line format only, never JSON. LOG_COLOR = auto|always|never + // (auto = colour only when the output is an interactive terminal, mirroring + // Spring's `detect` / Postgres' PG_COLOR). + $colorMode = strtolower((string) (env($prefix . 'COLOR') ?? env('LOG_COLOR', 'auto'))); + $color = $format === 'line' && match ($colorMode) { + 'always' => true, + 'never' => false, + default => self::outputIsTty($output), + }; + return [ 'level' => $levelStr, - 'format' => (string) (env($prefix . 'FORMAT') ?? env('LOG_FORMAT', 'line')), + 'format' => $format, 'output' => $output, + 'color' => $color, 'file_path' => $filePath ? (string) $filePath : null, 'file_max' => (int) (env($prefix . 'FILE_MAX') ?? env('LOG_FILE_MAX', 30)), - 'syslog_ident' => (string) (env($prefix . 'SYSLOG_IDENT') ?? env('LOG_SYSLOG_IDENT', 'winter')), + // Fixed syslog program tag — winter-logger requires the key; not a knob. + 'syslog_ident' => 'winter', ]; } private static function resolveOutput(string $raw): string { - if ($raw !== 'auto') { - return $raw; - } - if (getenv('KUBERNETES_SERVICE_HOST') !== false || file_exists('/.dockerenv')) { - return 'syslog'; - } - return Runtime::isSwoole() ? 'stdout' : 'stderr'; + // `auto` → stdout everywhere; whatever runs the process (orchestrator, + // supervisor, terminal) captures stdout. Explicit values pass through. + return $raw === 'auto' ? 'stdout' : $raw; + } + + /** True when the resolved log output is an interactive terminal (for LOG_COLOR=auto). */ + private static function outputIsTty(string $output): bool + { + return match ($output) { + 'stdout' => defined('STDOUT') && stream_isatty(STDOUT), + 'stderr' => defined('STDERR') && stream_isatty(STDERR), + default => false, + }; } private static function threadRunnerPath(): string diff --git a/src/Localization/LanguageNegotiator.php b/src/Localization/LanguageNegotiator.php index 1310159..eeb7ba4 100644 --- a/src/Localization/LanguageNegotiator.php +++ b/src/Localization/LanguageNegotiator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Localization; +namespace Flytachi\Winter\Kernel\Localization; /** * Parses Accept-Language header and picks the best available locale. diff --git a/src/Localization/Locale.php b/src/Localization/Locale.php index 9f79a0a..7ad218d 100644 --- a/src/Localization/Locale.php +++ b/src/Localization/Locale.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Localization; +namespace Flytachi\Winter\Kernel\Localization; -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Header; use Flytachi\Winter\Base\Runtime; /** diff --git a/src/Localization/LocaleService.php b/src/Localization/LocaleService.php index df3bafc..df0f0c1 100644 --- a/src/Localization/LocaleService.php +++ b/src/Localization/LocaleService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Localization; +namespace Flytachi\Winter\Kernel\Localization; use Flytachi\Winter\Base\Tool; @@ -23,7 +23,7 @@ * $svc->translate('user.greet', ['name' => 'Alice']) → ':name' placeholder via strtr (assoc params) * $svc->translate('unknown.key') → 'unknown.key' */ -class LocaleService +final class LocaleService { private array $dictionary = []; private bool $loaded = false; diff --git a/src/Localization/Timezone.php b/src/Localization/Timezone.php new file mode 100644 index 0000000..6d1487e --- /dev/null +++ b/src/Localization/Timezone.php @@ -0,0 +1,71 @@ + expression map; null or [] ignores conflicts * @return void * @throws RepositoryException */ - public function upsertGroup(array $entities, array $conflictColumns, ?array $updateColumns = null): void; + public function upsertBatch(iterable $entities, array $conflictColumns, ?array $updateColumns = null): void; } diff --git a/src/Ppa/Entity/RepositoryInterface.php b/src/Ppa/Entity/RepositoryInterface.php index dfef5a0..18c8489 100644 --- a/src/Ppa/Entity/RepositoryInterface.php +++ b/src/Ppa/Entity/RepositoryInterface.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Entity; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; use ValueError; /** @@ -19,7 +19,7 @@ * * Extended by {@see RepositoryCrudInterface} (write operations) and * {@see RepositoryViewInterface} (read operations). Implemented by - * {@see \Flytachi\Winter\K2\Ppa\Repository\RepositoryCore}. + * {@see \Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore}. */ interface RepositoryInterface { diff --git a/src/Ppa/Entity/RepositoryViewInterface.php b/src/Ppa/Entity/RepositoryViewInterface.php index a0da7a3..908a1dd 100644 --- a/src/Ppa/Entity/RepositoryViewInterface.php +++ b/src/Ppa/Entity/RepositoryViewInterface.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Entity; use Flytachi\Winter\Base\HttpCode; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; /** * Contract for repository classes that support read operations. @@ -16,8 +16,8 @@ * raw SQL execution, single/collection fetch, count, exists, and * static convenience finders with optional throw-on-miss variants. * - * Implemented by {@see \Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait} - * and exposed via {@see \Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView} and + * Implemented by {@see \Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait} + * and exposed via {@see \Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView} and * {@see Repository}. * * `TEntity` is the entity class declared by a concrete repository via diff --git a/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php b/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php index 238dc86..49e4f92 100644 --- a/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php +++ b/src/Ppa/Mapping/Attributes/Additive/AttributeDbAdditive.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; interface AttributeDbAdditive extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php b/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php index a95c2aa..fef9028 100644 --- a/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php +++ b/src/Ppa/Mapping/Attributes/Additive/DefaultVal.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class DefaultVal implements AttributeDbAdditive +final readonly class DefaultVal implements AttributeDbAdditive { public function __construct( private string $definition, diff --git a/src/Ppa/Mapping/Attributes/Additive/NullableIs.php b/src/Ppa/Mapping/Attributes/Additive/NullableIs.php index 87d0fb5..1abac9a 100644 --- a/src/Ppa/Mapping/Attributes/Additive/NullableIs.php +++ b/src/Ppa/Mapping/Attributes/Additive/NullableIs.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class NullableIs implements AttributeDbAdditive +final readonly class NullableIs implements AttributeDbAdditive { public function __construct( private bool $isNullable = true, diff --git a/src/Ppa/Mapping/Attributes/AttributeDb.php b/src/Ppa/Mapping/Attributes/AttributeDb.php index 69a63e4..ec39c03 100644 --- a/src/Ppa/Mapping/Attributes/AttributeDb.php +++ b/src/Ppa/Mapping/Attributes/AttributeDb.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes; interface AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/AttributeDbConfig.php b/src/Ppa/Mapping/Attributes/AttributeDbConfig.php index caf3e86..0762438 100644 --- a/src/Ppa/Mapping/Attributes/AttributeDbConfig.php +++ b/src/Ppa/Mapping/Attributes/AttributeDbConfig.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes; interface AttributeDbConfig { diff --git a/src/Ppa/Mapping/Attributes/AttributeDbEntity.php b/src/Ppa/Mapping/Attributes/AttributeDbEntity.php index b0c3d45..02cf853 100644 --- a/src/Ppa/Mapping/Attributes/AttributeDbEntity.php +++ b/src/Ppa/Mapping/Attributes/AttributeDbEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes; interface AttributeDbEntity { diff --git a/src/Ppa/Mapping/Attributes/Config/Extension.php b/src/Ppa/Mapping/Attributes/Config/Extension.php index 3282134..ab3f76b 100644 --- a/src/Ppa/Mapping/Attributes/Config/Extension.php +++ b/src/Ppa/Mapping/Attributes/Config/Extension.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; /** * Declares a database extension required by the configuration. * * Stack one attribute per extension on a DbConfig class. Aggregated by - * {@see \Flytachi\Winter\K2\Ppa\DeclarationItem} and emitted as + * {@see \Flytachi\Winter\Kernel\Ppa\DeclarationItem} and emitted as * `CREATE EXTENSION IF NOT EXISTS …` at migration time. * * Driver support: PostgreSQL only. Putting this attribute on a non-pgsql @@ -26,7 +26,7 @@ * ``` */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] -readonly class Extension implements AttributeDbConfig +final readonly class Extension implements AttributeDbConfig { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Attributes/Config/Migratable.php b/src/Ppa/Mapping/Attributes/Config/Migratable.php index d4321b8..0fd4b36 100644 --- a/src/Ppa/Mapping/Attributes/Config/Migratable.php +++ b/src/Ppa/Mapping/Attributes/Config/Migratable.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; /** * Opts a DbConfig into `db migrate` / `db sql` tooling. @@ -31,7 +31,7 @@ * ``` */ #[Attribute(Attribute::TARGET_CLASS)] -readonly class Migratable implements AttributeDbConfig +final readonly class Migratable implements AttributeDbConfig { public function __construct( public MigratablePriority $priority = MigratablePriority::Normal, diff --git a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php index 0070953..dfb842a 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php +++ b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraint.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; interface AttributeDbConstraint extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php index c52c34d..4892fc3 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php +++ b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintCheck.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; interface AttributeDbConstraintCheck extends AttributeDbConstraint { diff --git a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php index 24392f8..4320376 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php +++ b/src/Ppa/Mapping/Attributes/Constraint/AttributeDbConstraintForeign.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; interface AttributeDbConstraintForeign extends AttributeDbConstraint { diff --git a/src/Ppa/Mapping/Attributes/Constraint/Check.php b/src/Ppa/Mapping/Attributes/Constraint/Check.php index c008d2b..e4994c5 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/Check.php +++ b/src/Ppa/Mapping/Attributes/Constraint/Check.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -readonly class Check implements AttributeDbConstraintCheck +final readonly class Check implements AttributeDbConstraintCheck { public function __construct( public string $expression, diff --git a/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php b/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php index c8c7b07..7290d50 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php +++ b/src/Ppa/Mapping/Attributes/Constraint/CheckEnum.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; use BackedEnum; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class CheckEnum implements AttributeDbConstraintCheck +final readonly class CheckEnum implements AttributeDbConstraintCheck { /** * @param class-string $enumClassName diff --git a/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php b/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php index a316b49..06cbc5c 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php +++ b/src/Ppa/Mapping/Attributes/Constraint/ForeignKey.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class ForeignKey implements AttributeDbConstraintForeign +final readonly class ForeignKey implements AttributeDbConstraintForeign { public function __construct( public string $referencedTable, @@ -22,8 +22,8 @@ public function __construct( public function toObject( string $columnName, string $dialect = 'mysql' - ): \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey( + ): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey { + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey( referencedTable: $this->referencedTable, referencedColumn: $this->referencedColumn, onUpdate: $this->onUpdate, diff --git a/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php b/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php index 148448e..a71dd50 100644 --- a/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php +++ b/src/Ppa/Mapping/Attributes/Constraint/ForeignRepo.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\RepositoryMappingInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\RepositoryMappingInterface; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class ForeignRepo implements AttributeDbConstraintForeign +final readonly class ForeignRepo implements AttributeDbConstraintForeign { /** * @param class-string $referencedRepoClass @@ -28,7 +28,7 @@ public function __construct( public function toObject( string $columnName, string $dialect = 'mysql' - ): \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey { + ): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey { $referencedRepoInstance = new $this->referencedRepoClass(); if (!($referencedRepoInstance instanceof RepositoryMappingInterface)) { @@ -38,7 +38,7 @@ public function toObject( )); } - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey( referencedTable: $referencedRepoInstance->originTable(), referencedColumn: $referencedRepoInstance->mapIdentifierColumnName(), onUpdate: $this->onUpdate, diff --git a/src/Ppa/Mapping/Attributes/Entity/Table.php b/src/Ppa/Mapping/Attributes/Entity/Table.php index 30cadb3..4ba9e48 100644 --- a/src/Ppa/Mapping/Attributes/Entity/Table.php +++ b/src/Ppa/Mapping/Attributes/Entity/Table.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbEntity; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbEntity; #[Attribute(Attribute::TARGET_CLASS)] final class Table implements AttributeDbEntity diff --git a/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php b/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php index 9a6b2db..0650502 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/AttributeDbHybrid.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\AttributeDbType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\AttributeDbType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; interface AttributeDbHybrid extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Hybrid/BigId.php b/src/Ppa/Mapping/Attributes/Hybrid/BigId.php index e06b6ec..273d470 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/BigId.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/BigId.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\BigInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\BigInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class BigId implements AttributeDbHybrid +final readonly class BigId implements AttributeDbHybrid { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. diff --git a/src/Ppa/Mapping/Attributes/Hybrid/Id.php b/src/Ppa/Mapping/Attributes/Hybrid/Id.php index 4c3c747..8edba3c 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/Id.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/Id.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Id implements AttributeDbHybrid +final readonly class Id implements AttributeDbHybrid { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. diff --git a/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php b/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php index 70888a9..1c520c4 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/SmallId.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\SmallInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\SmallInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class SmallId implements AttributeDbHybrid +final readonly class SmallId implements AttributeDbHybrid { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. diff --git a/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php b/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php index 5d59345..0bccd85 100644 --- a/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php +++ b/src/Ppa/Mapping/Attributes/Hybrid/UuidPk.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Uuid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Uuid; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class UuidPk implements AttributeDbHybrid +final readonly class UuidPk implements AttributeDbHybrid { public function getInstances(string $dialect = 'mysql'): array { diff --git a/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php b/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php index a573226..3638b89 100644 --- a/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php +++ b/src/Ppa/Mapping/Attributes/Idx/AttributeDbIdx.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; interface AttributeDbIdx extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Idx/Index.php b/src/Ppa/Mapping/Attributes/Idx/Index.php index cd30cda..478d6e6 100644 --- a/src/Ppa/Mapping/Attributes/Idx/Index.php +++ b/src/Ppa/Mapping/Attributes/Idx/Index.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] -class Index implements AttributeDbIdx +final class Index implements AttributeDbIdx { public function __construct( private array $columns = [], @@ -27,9 +27,9 @@ public function columnPreparation(string $columnMain): void } } - public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index + public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index( columns: $this->columns, name: $this->name, type: IndexType::INDEX, diff --git a/src/Ppa/Mapping/Attributes/Idx/Primary.php b/src/Ppa/Mapping/Attributes/Idx/Primary.php index fdc2754..10813be 100644 --- a/src/Ppa/Mapping/Attributes/Idx/Primary.php +++ b/src/Ppa/Mapping/Attributes/Idx/Primary.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; #[Attribute(Attribute::TARGET_PROPERTY)] -class Primary implements AttributeDbIdx +final class Primary implements AttributeDbIdx { private array $columns = []; @@ -20,9 +20,9 @@ public function columnPreparation(string $columnMain): void } } - public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index + public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index( columns: $this->columns, type: IndexType::PRIMARY, method: IndexMethod::BTREE, diff --git a/src/Ppa/Mapping/Attributes/Idx/Unique.php b/src/Ppa/Mapping/Attributes/Idx/Unique.php index 364cec1..c81553b 100644 --- a/src/Ppa/Mapping/Attributes/Idx/Unique.php +++ b/src/Ppa/Mapping/Attributes/Idx/Unique.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] -class Unique implements AttributeDbIdx +final class Unique implements AttributeDbIdx { public function __construct( private array $columns = [], @@ -27,9 +27,9 @@ public function columnPreparation(string $columnMain): void } } - public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index + public function toObject(string $dialect = 'mysql'): \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index { - return new \Flytachi\Winter\K2\Ppa\Mapping\Structure\Index( + return new \Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index( columns: $this->columns, name: $this->name, type: IndexType::UNIQUE, diff --git a/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php b/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php index 51a1ff8..7ddb4ef 100644 --- a/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php +++ b/src/Ppa/Mapping/Attributes/Primal/AttributeDbType.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; interface AttributeDbType extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Primal/BigInteger.php b/src/Ppa/Mapping/Attributes/Primal/BigInteger.php index f1c6f71..a65d7ae 100644 --- a/src/Ppa/Mapping/Attributes/Primal/BigInteger.php +++ b/src/Ppa/Mapping/Attributes/Primal/BigInteger.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class BigInteger implements AttributeDbType +final readonly class BigInteger implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Binary.php b/src/Ppa/Mapping/Attributes/Primal/Binary.php index 5a7cc50..d391b6d 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Binary.php +++ b/src/Ppa/Mapping/Attributes/Primal/Binary.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Binary implements AttributeDbType +final readonly class Binary implements AttributeDbType { public function __construct( private int $length = 255 diff --git a/src/Ppa/Mapping/Attributes/Primal/Blob.php b/src/Ppa/Mapping/Attributes/Primal/Blob.php index b40fa66..2ee32cb 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Blob.php +++ b/src/Ppa/Mapping/Attributes/Primal/Blob.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Blob implements AttributeDbType +final readonly class Blob implements AttributeDbType { /** * @param 'default'|'tiny'|'medium'|'long' $size 'tiny', 'medium', 'long' diff --git a/src/Ppa/Mapping/Attributes/Primal/Boolean.php b/src/Ppa/Mapping/Attributes/Primal/Boolean.php index c3e8d63..2661f61 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Boolean.php +++ b/src/Ppa/Mapping/Attributes/Primal/Boolean.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Boolean implements AttributeDbType +final readonly class Boolean implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Char.php b/src/Ppa/Mapping/Attributes/Primal/Char.php index c2be4f2..f829360 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Char.php +++ b/src/Ppa/Mapping/Attributes/Primal/Char.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Char implements AttributeDbType +final readonly class Char implements AttributeDbType { /** * Defines a fixed-length character string column. diff --git a/src/Ppa/Mapping/Attributes/Primal/Date.php b/src/Ppa/Mapping/Attributes/Primal/Date.php index fec4766..11f0867 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Date.php +++ b/src/Ppa/Mapping/Attributes/Primal/Date.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Date extends DateTime implements AttributeDbType +final readonly class Date extends DateTime implements AttributeDbType { public function toSql(string $dialect = 'mysql'): string { diff --git a/src/Ppa/Mapping/Attributes/Primal/DateTime.php b/src/Ppa/Mapping/Attributes/Primal/DateTime.php index ebb6d12..57de017 100644 --- a/src/Ppa/Mapping/Attributes/Primal/DateTime.php +++ b/src/Ppa/Mapping/Attributes/Primal/DateTime.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; diff --git a/src/Ppa/Mapping/Attributes/Primal/Decimal.php b/src/Ppa/Mapping/Attributes/Primal/Decimal.php index 8d79d99..5bad8ff 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Decimal.php +++ b/src/Ppa/Mapping/Attributes/Primal/Decimal.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Decimal extends FloatType implements AttributeDbType +final readonly class Decimal extends FloatType implements AttributeDbType { /** * @param int $precision The total number of digits that can be stored @@ -44,8 +44,12 @@ final public function supports(array $phpTypes): bool public function toSql(string $dialect = 'mysql'): string { return match ($dialect) { - 'mysql' => "DECIMAL({$this->precision}, {$this->scale})", 'pgsql' => "NUMERIC({$this->precision}, {$this->scale})", + // SQLite has no fixed-point type; NUMERIC affinity keeps the value exact + // for integers and falls back to REAL otherwise, which is the closest it + // offers. Precision is accepted and ignored. + 'sqlite' => "NUMERIC({$this->precision}, {$this->scale})", + default => "DECIMAL({$this->precision}, {$this->scale})", }; } } diff --git a/src/Ppa/Mapping/Attributes/Primal/Double.php b/src/Ppa/Mapping/Attributes/Primal/Double.php index 5a55117..96961a6 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Double.php +++ b/src/Ppa/Mapping/Attributes/Primal/Double.php @@ -2,18 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Double extends FloatType implements AttributeDbType +final readonly class Double extends FloatType implements AttributeDbType { public function toSql(string $dialect = 'mysql'): string { return match ($dialect) { - 'mysql' => "DOUBLE", 'pgsql' => "DOUBLE PRECISION", + // Every SQLite float is an 8-byte IEEE double; REAL is the only spelling. + 'sqlite' => "REAL", + default => "DOUBLE", }; } } diff --git a/src/Ppa/Mapping/Attributes/Primal/FloatType.php b/src/Ppa/Mapping/Attributes/Primal/FloatType.php index 8d1df14..104c6a8 100644 --- a/src/Ppa/Mapping/Attributes/Primal/FloatType.php +++ b/src/Ppa/Mapping/Attributes/Primal/FloatType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; @@ -29,8 +29,9 @@ public function supports(array $phpTypes): bool public function toSql(string $dialect = 'mysql'): string { return match ($dialect) { - 'mysql' => "FLOAT", 'pgsql' => "REAL", + 'sqlite' => "REAL", + default => "FLOAT", }; } } diff --git a/src/Ppa/Mapping/Attributes/Primal/Integer.php b/src/Ppa/Mapping/Attributes/Primal/Integer.php index b4f37d1..72396cd 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Integer.php +++ b/src/Ppa/Mapping/Attributes/Primal/Integer.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Integer implements AttributeDbType +final readonly class Integer implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Json.php b/src/Ppa/Mapping/Attributes/Primal/Json.php index bc8f897..f2f139a 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Json.php +++ b/src/Ppa/Mapping/Attributes/Primal/Json.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Json implements AttributeDbType +final readonly class Json implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php b/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php index 7e42707..c44fca3 100644 --- a/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php +++ b/src/Ppa/Mapping/Attributes/Primal/SmallInteger.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class SmallInteger implements AttributeDbType +final readonly class SmallInteger implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Text.php b/src/Ppa/Mapping/Attributes/Primal/Text.php index ef7a8a2..07dc683 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Text.php +++ b/src/Ppa/Mapping/Attributes/Primal/Text.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Text implements AttributeDbType +final readonly class Text implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/TextArray.php b/src/Ppa/Mapping/Attributes/Primal/TextArray.php index 980a899..fe7f112 100644 --- a/src/Ppa/Mapping/Attributes/Primal/TextArray.php +++ b/src/Ppa/Mapping/Attributes/Primal/TextArray.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class TextArray implements AttributeDbType +final readonly class TextArray implements AttributeDbType { public function supports(array $phpTypes): bool { diff --git a/src/Ppa/Mapping/Attributes/Primal/Time.php b/src/Ppa/Mapping/Attributes/Primal/Time.php index 42a75db..2f2fddd 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Time.php +++ b/src/Ppa/Mapping/Attributes/Primal/Time.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Time extends DateTime implements AttributeDbType +final readonly class Time extends DateTime implements AttributeDbType { public function toSql(string $dialect = 'mysql'): string { diff --git a/src/Ppa/Mapping/Attributes/Primal/Timestamp.php b/src/Ppa/Mapping/Attributes/Primal/Timestamp.php index c9fceb8..5935595 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Timestamp.php +++ b/src/Ppa/Mapping/Attributes/Primal/Timestamp.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Timestamp extends DateTime implements AttributeDbType +final readonly class Timestamp extends DateTime implements AttributeDbType { /** * @param bool $withTimeZone If true, the timestamp will diff --git a/src/Ppa/Mapping/Attributes/Primal/Type.php b/src/Ppa/Mapping/Attributes/Primal/Type.php index cc1936b..256e416 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Type.php +++ b/src/Ppa/Mapping/Attributes/Primal/Type.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Type implements AttributeDbType +final readonly class Type implements AttributeDbType { /** * @param string $definition The SQL type definition string (e.g., 'VARCHAR(255)', 'INT', 'TEXT'). diff --git a/src/Ppa/Mapping/Attributes/Primal/Uuid.php b/src/Ppa/Mapping/Attributes/Primal/Uuid.php index 1700835..9c5c467 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Uuid.php +++ b/src/Ppa/Mapping/Attributes/Primal/Uuid.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Uuid implements AttributeDbType +final readonly class Uuid implements AttributeDbType { public function __construct( private bool $asBinary = false diff --git a/src/Ppa/Mapping/Attributes/Primal/Varchar.php b/src/Ppa/Mapping/Attributes/Primal/Varchar.php index d25d8f5..a56b5dc 100644 --- a/src/Ppa/Mapping/Attributes/Primal/Varchar.php +++ b/src/Ppa/Mapping/Attributes/Primal/Varchar.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class Varchar implements AttributeDbType +final readonly class Varchar implements AttributeDbType { /** * @param int $length The maximum length of the VARCHAR string. Defaults to 255. diff --git a/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php b/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php index e97fe69..9b012cb 100644 --- a/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php +++ b/src/Ppa/Mapping/Attributes/Sub/AttributeDbSubType.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; interface AttributeDbSubType extends AttributeDb { diff --git a/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php b/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php index f49c475..4ffb655 100644 --- a/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php +++ b/src/Ppa/Mapping/Attributes/Sub/AutoIncrement.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] -readonly class AutoIncrement implements AttributeDbSubType +final readonly class AutoIncrement implements AttributeDbSubType { /** * AutoIncrement attribute for marking a property as an auto-incrementing column. @@ -46,7 +46,15 @@ public function toSql(string $type, string $dialect = 'mysql'): string ? ' GENERATED ALWAYS AS IDENTITY' : ' GENERATED BY DEFAULT AS IDENTITY' ), - 'mysql' => $type . ' AUTO_INCREMENT', + // SQLite auto-increments through the rowid alias, and a column only + // becomes one when its type is spelled exactly INTEGER — INT, BIGINT and + // SMALLINT are ordinary columns that would reject the omitted value with + // a NOT NULL violation. There is one integer width anyway (64-bit), so + // widening the declared type loses nothing. The AUTOINCREMENT keyword is + // deliberately not emitted: it only forbids reusing ids after a delete, + // at the cost of an extra bookkeeping table. + 'sqlite' => 'INTEGER', + default => $type . ' AUTO_INCREMENT', }; } } diff --git a/src/Ppa/Mapping/ColumnMapping.php b/src/Ppa/Mapping/ColumnMapping.php index fc34c0f..d355165 100644 --- a/src/Ppa/Mapping/ColumnMapping.php +++ b/src/Ppa/Mapping/ColumnMapping.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDb; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintCheck; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintForeign; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\AttributeDbHybrid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\AttributeDbType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; +namespace Flytachi\Winter\Kernel\Ppa\Mapping; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\AttributeDbAdditive; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDb; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintCheck; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\AttributeDbConstraintForeign; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\AttributeDbHybrid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\AttributeDbIdx; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\AttributeDbType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AttributeDbSubType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; use ReflectionAttribute; use ReflectionProperty; diff --git a/src/Ppa/Mapping/Constants/FKAction.php b/src/Ppa/Mapping/Constants/FKAction.php index 2f2fb2d..6b78bb0 100644 --- a/src/Ppa/Mapping/Constants/FKAction.php +++ b/src/Ppa/Mapping/Constants/FKAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; enum FKAction: string { diff --git a/src/Ppa/Mapping/Constants/IndexMethod.php b/src/Ppa/Mapping/Constants/IndexMethod.php index 3d81a34..d712592 100644 --- a/src/Ppa/Mapping/Constants/IndexMethod.php +++ b/src/Ppa/Mapping/Constants/IndexMethod.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; enum IndexMethod: string { diff --git a/src/Ppa/Mapping/Constants/IndexType.php b/src/Ppa/Mapping/Constants/IndexType.php index 79a4ba9..80b9d55 100644 --- a/src/Ppa/Mapping/Constants/IndexType.php +++ b/src/Ppa/Mapping/Constants/IndexType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; enum IndexType: string { diff --git a/src/Ppa/Mapping/Constants/MigratablePriority.php b/src/Ppa/Mapping/Constants/MigratablePriority.php index a40dc5e..dc89acc 100644 --- a/src/Ppa/Mapping/Constants/MigratablePriority.php +++ b/src/Ppa/Mapping/Constants/MigratablePriority.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Constants; /** - * Migration ordering priority for {@see \Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable}. + * Migration ordering priority for {@see \Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable}. * * Lower value = earlier in the migration order. Sort ascending. */ diff --git a/src/Ppa/Mapping/RepositoryMappingInterface.php b/src/Ppa/Mapping/RepositoryMappingInterface.php index 8cceb12..db6a7ce 100644 --- a/src/Ppa/Mapping/RepositoryMappingInterface.php +++ b/src/Ppa/Mapping/RepositoryMappingInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping; +namespace Flytachi\Winter\Kernel\Ppa\Mapping; interface RepositoryMappingInterface { diff --git a/src/Ppa/Mapping/Structure/CheckConstraint.php b/src/Ppa/Mapping/Structure/CheckConstraint.php index ddfb079..140965f 100644 --- a/src/Ppa/Mapping/Structure/CheckConstraint.php +++ b/src/Ppa/Mapping/Structure/CheckConstraint.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class CheckConstraint implements StructureInterface +final class CheckConstraint implements StructureInterface { public function __construct( public string $expression, diff --git a/src/Ppa/Mapping/Structure/Column.php b/src/Ppa/Mapping/Structure/Column.php index d5405f5..88e966f 100644 --- a/src/Ppa/Mapping/Structure/Column.php +++ b/src/Ppa/Mapping/Structure/Column.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class Column implements StructureInterface +final class Column implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Structure/Extension.php b/src/Ppa/Mapping/Structure/Extension.php index 2f1a048..47aba8a 100644 --- a/src/Ppa/Mapping/Structure/Extension.php +++ b/src/Ppa/Mapping/Structure/Extension.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; /** * SQL emitter for a PostgreSQL extension declaration. * - * Built from {@see \Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension} - * attributes during {@see \Flytachi\Winter\K2\Ppa\DeclarationItem} construction. + * Built from {@see \Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension} + * attributes during {@see \Flytachi\Winter\Kernel\Ppa\DeclarationItem} construction. */ final class Extension implements StructureInterface { diff --git a/src/Ppa/Mapping/Structure/ForeignKey.php b/src/Ppa/Mapping/Structure/ForeignKey.php index 16d705c..ca00e06 100644 --- a/src/Ppa/Mapping/Structure/ForeignKey.php +++ b/src/Ppa/Mapping/Structure/ForeignKey.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; -class ForeignKey implements StructureInterface +final class ForeignKey implements StructureInterface { public function __construct( public string $referencedTable, diff --git a/src/Ppa/Mapping/Structure/Index.php b/src/Ppa/Mapping/Structure/Index.php index f244633..ea3d510 100644 --- a/src/Ppa/Mapping/Structure/Index.php +++ b/src/Ppa/Mapping/Structure/Index.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; -class Index implements StructureInterface +final class Index implements StructureInterface { public function __construct( public array $columns, @@ -80,6 +80,18 @@ public function toSql(string $tableName, string $dialect = 'mysql'): string }; } + if ($dialect === 'sqlite') { + // SQLite has one index implementation, so there is no USING clause; partial + // indexes (WHERE) are supported, covering indexes (INCLUDE) are not. + $whereSql = $this->where ? " WHERE {$this->where}" : ''; + + return match ($this->type) { + IndexType::PRIMARY => "PRIMARY KEY" . $columnsSql, + IndexType::UNIQUE => "CREATE UNIQUE INDEX {$nameSql} ON {$tableName}{$columnsSql}{$whereSql}", + IndexType::INDEX => "CREATE INDEX {$nameSql} ON {$tableName}{$columnsSql}{$whereSql}", + }; + } + throw new \InvalidArgumentException("Unsupported dialect: {$dialect}"); } } diff --git a/src/Ppa/Mapping/Structure/NameValidator.php b/src/Ppa/Mapping/Structure/NameValidator.php index a4a3793..e2abcd6 100644 --- a/src/Ppa/Mapping/Structure/NameValidator.php +++ b/src/Ppa/Mapping/Structure/NameValidator.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class NameValidator +final class NameValidator { public static function validate(string $name, int $maxLength = 63): void { diff --git a/src/Ppa/Mapping/Structure/StoredProcedure.php b/src/Ppa/Mapping/Structure/StoredProcedure.php index 74a4249..d5b3219 100644 --- a/src/Ppa/Mapping/Structure/StoredProcedure.php +++ b/src/Ppa/Mapping/Structure/StoredProcedure.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class StoredProcedure implements StructureInterface +final class StoredProcedure implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Structure/StructureInterface.php b/src/Ppa/Mapping/Structure/StructureInterface.php index 5cdeea6..1dc430f 100644 --- a/src/Ppa/Mapping/Structure/StructureInterface.php +++ b/src/Ppa/Mapping/Structure/StructureInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; interface StructureInterface { diff --git a/src/Ppa/Mapping/Structure/Table.php b/src/Ppa/Mapping/Structure/Table.php index aeba8e4..0f88e88 100644 --- a/src/Ppa/Mapping/Structure/Table.php +++ b/src/Ppa/Mapping/Structure/Table.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class Table implements StructureInterface +final class Table implements StructureInterface { /** @var Column[] */ public array $columns; diff --git a/src/Ppa/Mapping/Structure/Trigger.php b/src/Ppa/Mapping/Structure/Trigger.php index 7a06520..d6ad417 100644 --- a/src/Ppa/Mapping/Structure/Trigger.php +++ b/src/Ppa/Mapping/Structure/Trigger.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class Trigger implements StructureInterface +final class Trigger implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/Mapping/Structure/View.php b/src/Ppa/Mapping/Structure/View.php index 19851dd..87ef4d1 100644 --- a/src/Ppa/Mapping/Structure/View.php +++ b/src/Ppa/Mapping/Structure/View.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Ppa\Mapping\Structure; -class View implements StructureInterface +final class View implements StructureInterface { public function __construct( public string $name, diff --git a/src/Ppa/PPAMapping.php b/src/Ppa/PPAMapping.php index 50028ef..d7644f7 100644 --- a/src/Ppa/PPAMapping.php +++ b/src/Ppa/PPAMapping.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa; +namespace Flytachi\Winter\Kernel\Ppa; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; -use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Collector\ImplementorCollector; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table as EntityTable; -use Flytachi\Winter\K2\Ppa\Mapping\ColumnMapping; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Collector\ImplementorCollector; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table as EntityTable; +use Flytachi\Winter\Kernel\Ppa\Mapping\ColumnMapping; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; use ReflectionAttribute; use ReflectionClass; use ReflectionException; -class PPAMapping +final class PPAMapping { /** * @return DbConfigInterface[] @@ -24,7 +24,7 @@ class PPAMapping public static function scanningConfigs(?string $rootDir = null): array { $collector = new ImplementorCollector(DbConfigInterface::class); - Scanner::run($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); + ClassScanner::scanner($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); $configs = []; foreach ($collector->getResult() as $ref) { @@ -39,7 +39,7 @@ public static function scanningConfigs(?string $rootDir = null): array public static function scanningDeclaration(?string $rootDir = null): Declaration { $collector = new ImplementorCollector(RepositoryInterface::class); - Scanner::run($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); + ClassScanner::scanner($rootDir ?? Kernel::$pathRoot)->collect($collector)->execute(); return self::scanDeclarationFilter($collector->getResult()); } diff --git a/src/Ppa/Pool/BorrowedConnection.php b/src/Ppa/Pool/BorrowedConnection.php new file mode 100644 index 0000000..1d0de13 --- /dev/null +++ b/src/Ppa/Pool/BorrowedConnection.php @@ -0,0 +1,26 @@ + $configClass Config to instantiate per slot. + * @param LoggerInterface $logger PPA channel logger, injected into each config. + */ + public function __construct( + private string $configClass, + private LoggerInterface $logger, + ) { + } + + /** Opens one independent connection (own socket) via a fresh config instance. */ + public function create(): object + { + /** @var DbConfigInterface $config */ + $config = new ($this->configClass)(); + $config->setUp(); + $config->setLogger($this->logger); + $config->connect(); + $this->logger->debug("slot opened: {$this->configClass} dsn={$config->getDns()}"); + return $config; + } + + /** Liveness probe — `false` when the connection is dead. */ + public function validate(object $connection): bool + { + /** @var DbConfigInterface $connection */ + return self::probe($connection); + } + + /** + * Round-trips `SELECT 1` and reports whether the connection answered. + * + * It deliberately does **not** use `DbConfigInterface::ping()`: that method + * catches `CDOException` only, while `PDO::query()` raises a `PDOException` + * (unrelated to it), and its `return` inside `finally` swallows the exception — + * so it answers `true` for a connection that is already dead. Verified against + * live PostgreSQL and MariaDB: a killed connection still pinged `true`. Relying + * on it would silently disable the idle-gate and keepalive, which exist + * precisely to retire dead connections. + * + * Catching `Throwable` is the point: any failure to complete the round trip + * means the connection cannot be handed out. + */ + public static function probe(DbConfigInterface $config): bool + { + try { + return $config->connection()->query('SELECT 1') !== false; + } catch (\Throwable) { + return false; + } + } + + /** Drops the CDO reference so its socket is closed. */ + public function close(object $connection): void + { + /** @var DbConfigInterface $connection */ + $connection->disconnect(); + } +} diff --git a/src/Ppa/Pool/ConnectionLoss.php b/src/Ppa/Pool/ConnectionLoss.php new file mode 100644 index 0000000..1f89748 --- /dev/null +++ b/src/Ppa/Pool/ConnectionLoss.php @@ -0,0 +1,100 @@ +getPrevious()) { + if ($cause instanceof PDOException && self::matches($cause)) { + return true; + } + } + + return false; + } + + /** + * Whether the driver's verdict is inconclusive, so only a live probe can tell a + * dead connection from a rejected query. + * + * PDO_pgsql is the reason this exists. A killed PostgreSQL connection does **not** + * arrive as SQLSTATE `08006`: with the socket gone there is no result object to + * take a SQLSTATE from, so PDO falls back to `HY000` with driver code `7` — the + * very same code it reports for an ordinary syntax error. Verified live: a + * terminated backend yields `["HY000", 7, "terminating connection…"]`. Matching on + * the message instead is not an option either, since PostgreSQL translates it + * (`lc_messages`). + * + * A well-formed SQLSTATE from any other class means the server answered and is + * alive, so those are decided here and never probed. + */ + public static function isUndecided(Throwable $error): bool + { + if (self::isLost($error)) { + return false; + } + + for ($cause = $error; $cause !== null; $cause = $cause->getPrevious()) { + if (!$cause instanceof PDOException) { + continue; + } + $info = $cause->errorInfo; + $sqlState = is_array($info) && isset($info[0]) ? (string) $info[0] : (string) $cause->getCode(); + + return $sqlState === self::UNMAPPED || $sqlState === '' || $sqlState === '0'; + } + + return false; + } + + private static function matches(PDOException $error): bool + { + $info = $error->errorInfo; + // errorInfo is authoritative; fall back to the code, which carries the + // SQLSTATE for exceptions raised outside a statement. + $sqlState = is_array($info) && isset($info[0]) ? (string) $info[0] : (string) $error->getCode(); + $driverCode = is_array($info) && isset($info[1]) ? $info[1] : null; + + return str_starts_with($sqlState, self::CONNECTION_CLASS) + || in_array($sqlState, self::SERVER_SHUTDOWN, true) + || (is_int($driverCode) && in_array($driverCode, self::MYSQL_LOST, true)); + } +} diff --git a/src/Ppa/Pool/PoolTelemetry.php b/src/Ppa/Pool/PoolTelemetry.php new file mode 100644 index 0000000..45bee11 --- /dev/null +++ b/src/Ppa/Pool/PoolTelemetry.php @@ -0,0 +1,242 @@ + self::publish($workerId, $ttl), + ); + } + + /** Stops publishing and drops this worker's record. */ + public static function stop(int $workerId): void + { + if (self::$timerId !== null && extension_loaded('swoole')) { + \Swoole\Timer::clear(self::$timerId); + } + self::$timerId = null; + self::$workerId = null; + + if (!self::$published) { + return; + } + self::$published = false; + + try { + self::store()->del(self::recordKey($workerId)); + } catch (\Throwable) { + // Telemetry must never break a shutdown. + } + } + + /** + * Drops this process's publishing identity without touching the store — the + * fork-safe counterpart of {@see stop()}, called from + * {@see PpaConnectionPool::reset()}. + * + * A forked child inherits these statics, so without this it would publish under its + * parent's worker id and overwrite the parent's record with its own numbers. + */ + public static function forget(): void + { + self::$timerId = null; + self::$workerId = null; + self::$published = false; + } + + /** + * Reads every worker record still alive, newest state as each worker last published + * it. Expired records (dead workers) are skipped by the store's TTL. + * + * @return list}> + */ + public static function snapshot(): array + { + try { + $store = self::store(); + } catch (\Throwable) { + return []; + } + + $records = []; + foreach ($store->keys() as $key) { + $record = $store->read($key); + if (is_array($record) && isset($record['worker'], $record['pools'])) { + $records[] = $record; + } + } + + usort($records, static fn(array $a, array $b): int => $a['worker'] <=> $b['worker']); + + return $records; + } + + /** + * Aggregates {@see snapshot()} across workers, per config — the fleet-wide view a + * single actuator response cannot give. + * + * `saturated` counts the workers whose pool for that config is fully handed out. + * It is deliberately **not** derived from the summed totals: a borrow queues on + * its own worker's pool, so one saturated worker is a real stall even while the + * fleet as a whole looks to have slack. + * + * @return array + */ + public static function aggregate(): array + { + $out = []; + foreach (self::snapshot() as $record) { + foreach ($record['pools'] as $config => $stat) { + $acc = $out[$config] ??= [ + 'total' => 0, 'idle' => 0, 'active' => 0, 'maximum' => 0, 'workers' => 0, 'saturated' => 0, + ]; + $saturated = $stat['maximum'] > 0 && $stat['active'] >= $stat['maximum']; + $out[$config] = [ + 'total' => $acc['total'] + $stat['total'], + 'idle' => $acc['idle'] + $stat['idle'], + 'active' => $acc['active'] + $stat['active'], + 'maximum' => $acc['maximum'] + $stat['maximum'], + 'workers' => $acc['workers'] + 1, + 'saturated' => $acc['saturated'] + ($saturated ? 1 : 0), + ]; + } + } + + return $out; + } + + // ── internals ────────────────────────────────────────────────────────────── + + /** + * Writes this worker's current utilisation. A worker holding no pool writes + * nothing — an application that never touches PPA leaves no records behind. + */ + private static function publish(int $workerId, int $ttl): void + { + try { + $pools = PpaConnectionPool::stats(); + if ($pools === []) { + return; + } + + self::store()->write( + self::recordKey($workerId), + ['worker' => $workerId, 'at' => time(), 'pools' => $pools], + time() + $ttl, + ); + self::$published = true; + } catch (\Throwable) { + // Telemetry is best-effort: a failed write must never disturb the worker. + } + } + + private static function recordKey(int $workerId): string + { + return 'worker.' . $workerId; + } + + /** Non-hashed keys so {@see FileStorage::keys()} round-trips back into `read()`. */ + private static function store(): FileStorage + { + return Kernel::runnable(self::STORE, false); + } +} diff --git a/src/Ppa/Pool/PpaConnectionPool.php b/src/Ppa/Pool/PpaConnectionPool.php index beb7d88..4186709 100644 --- a/src/Ppa/Pool/PpaConnectionPool.php +++ b/src/Ppa/Pool/PpaConnectionPool.php @@ -2,13 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; use Flytachi\Winter\Logger\LoggerFactory; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Base\Runtime; +use Flytachi\Winter\Kernel\ConnectionPool\ConnectionPool; +use Flytachi\Winter\Kernel\ConnectionPool\PoolEntry; +use Flytachi\Winter\Kernel\ConnectionPool\PoolException; +use Flytachi\Winter\Kernel\ConnectionPool\PoolPolicy; +use Flytachi\Winter\Kernel\ConnectionPool\SingleConnection; +use Flytachi\Winter\Kernel\Localization\Timezone; use Psr\Log\LoggerInterface; +use Throwable; /** * PpaConnectionPool — driver-agnostic connection pool for FPM and Swoole. @@ -18,14 +25,20 @@ * one `CDO` instance per config class per process, reused for the entire request. * * ## Swoole (coroutines) - * Uses {@see \Swoole\ConnectionPool} (which wraps `Swoole\Coroutine\Channel`) - * per config class. Connections are created **lazily** — only when first requested, - * up to `poolMaxConnections`. On the **first** `db()` call inside a coroutine one - * CDO is borrowed and cached in the coroutine context; a `defer` returns it - * automatically when the coroutine ends — no manual release anywhere in the codebase. + * Uses the framework's {@see ConnectionPool} (a HikariCP-inspired pool over a + * `Swoole\Coroutine\Channel`) per config class. Connections are created **lazily** — + * only when first requested, up to `poolMaxConnections`. On the **first** `db()` call + * inside a coroutine one connection is borrowed and cached in the coroutine context; + * a `defer` returns it automatically when the coroutine ends — no manual release + * anywhere in the codebase. * - * Broken connections: pass `null` to `Swoole\ConnectionPool::put()` and the pool - * will discard and recreate the slot automatically. + * Unlike a plain `Swoole\ConnectionPool` (a dumb channel), {@see ConnectionPool} + * actively keeps connections usable across a database outage: a connection idle beyond + * `aliveBypassWindow` is probed on borrow ({@see CdoConnectionFactory::validate()} → + * `ping()`) and a dead one is retired for a fresh socket, and a connection older than + * `maxLifetime` is rotated before it can go stale — restoring the FPM-era resilience + * (fresh connection ⇒ self-heal after recovery) without a per-borrow probe on hot + * connections. * * ## Pool size * Configs that implement {@see PpaPoolConfigInterface} (via {@see PpaPoolTrait}) @@ -53,7 +66,7 @@ final class PpaConnectionPool /** * Swoole: one ConnectionPool per config class. - * @var array + * @var array */ private static array $pools = []; @@ -64,11 +77,27 @@ final class PpaConnectionPool private static array $configs = []; /** - * FPM: one CDO per config class for the lifetime of the process/request. - * @var array + * FPM / non-coroutine: one self-maintaining {@see SingleConnection} per config + * class for the lifetime of the process. + * @var array */ private static array $static = []; + /** + * Timezone last applied to each pooled connection, so {@see syncTimezone()} can skip + * a `SET TIMEZONE` the connection does not need. + * + * Keyed by the pooled resource itself — {@see CdoConnectionFactory} pools the config + * instance — and weak, so the entry disappears with the connection instead of + * pinning a closed one in memory. + * + * Built lazily — `new WeakMap()` is not a constant expression, so it cannot be a + * property default. + * + * @var \WeakMap|null + */ + private static ?\WeakMap $appliedTimezone = null; + private static function logger(): LoggerInterface { return LoggerFactory::getLogger('PPA'); @@ -129,26 +158,177 @@ public static function showDbConfigs(): array return self::$configs; } + /** + * Reports a failure that happened **while using** a borrowed connection, so a dead + * one is retired instead of being handed to the next caller. + * + * Only a genuine connection loss evicts ({@see ConnectionLoss}) — a constraint + * violation or a syntax error means the connection is healthy and is left alone. + * + * The pool deliberately does **not** retry the failed statement. It cannot know + * what was executed: the break may have happened after the server applied the + * write, so replaying it could duplicate the effect, and replaying one statement + * of an interrupted transaction is meaningless. The request fails once; the + * connection is thrown away, so the next one — including the next query in this + * same request — gets a healthy connection. + * + * @param class-string $configClass Config whose connection failed. + * @param Throwable $error The failure as thrown by CDO/PDO. + * @return bool Whether the connection was classified as lost and evicted. + */ + public static function reportFailure(string $configClass, Throwable $error): bool + { + $lost = ConnectionLoss::isLost($error); + $undecided = !$lost && ConnectionLoss::isUndecided($error); + if (!$lost && !$undecided) { + return false; + } + + $key = base64_encode($configClass); + + if (Runtime::isSwooleCoroutine()) { + $ctxKey = 'ppa_cdo_' . $key; + $ctx = \Swoole\Coroutine::getContext(); + $held = $ctx[$ctxKey] ?? null; + if (!$held instanceof BorrowedConnection) { + return false; + } + $config = $held->entry->resource; + if ($undecided && $config instanceof DbConfigInterface && CdoConnectionFactory::probe($config)) { + return false; // the driver was vague but the connection answered + } + // Mark for the defer to evict, and drop it from the context so the next + // query in this same coroutine borrows a fresh connection. + $held->dead = true; + unset($ctx[$ctxKey]); + self::logger()->warning("evict: {$configClass} (connection lost in use)"); + + return true; + } + + // Static (FPM / non-coroutine) path: close now, reopen lazily on next use. + if (!isset(self::$static[$key])) { + return false; + } + $config = self::$static[$key]->peek(); + if ($undecided && $config instanceof DbConfigInterface && CdoConnectionFactory::probe($config)) { + return false; + } + self::$static[$key]->evict(); + self::logger()->warning("evict: {$configClass} (connection lost in use)"); + + return true; + } + + /** + * Live utilisation of every Swoole coroutine pool, keyed by config FQCN — the + * HikariCP-style view (active / idle / total vs maximum) for `/actuator/health`. + * + * These numbers are **per worker**: each Swoole worker holds its own in-memory + * pool (as HikariCP is per-JVM), so a health request reflects the worker that + * served it. The static FPM/non-coroutine path has no pool and is not reported. + * + * @return array + */ + public static function stats(): array + { + $out = []; + foreach (self::$pools as $key => $pool) { + $out[base64_decode($key)] = $pool->stats(); + } + return $out; + } + + /** + * Drops every cached connection, pool and config so the next `db()` opens + * fresh sockets — the fork-safety reset. + * + * A fork copies file descriptors, so any connection cached before the fork + * would be shared with the parent and corrupt the wire protocol. A forked + * daemon worker runs this via {@see \Flytachi\Winter\Kernel\Process\ForkReset} + * (registered in {@see \Flytachi\Winter\Kernel\Kernel::init()}), then re-opens + * lazily in the child. Because access is static — repositories call + * `PpaConnectionPool::db()`, never an injected instance — clearing the caches + * is a complete "reconnect": nothing holds a stale reference. + * + * Keep connections lazy (do not query from a supervisor before it forks + * workers) so this stays a cheap no-op in the common case. + */ + /** + * Closes every pool and connection this process owns — the worker-shutdown + * counterpart of {@see reset()}. + * + * The difference matters. {@see reset()} is for a **forked child**, which must + * forget inherited sockets without closing them. Here the process genuinely owns + * them, so they are closed properly; just as importantly, closing a pool releases + * its housekeeping timer, and a live timer would keep the worker's reactor from + * draining until Swoole force-kills it. + */ + public static function shutdown(): void + { + foreach (self::$pools as $pool) { + $pool->close(); + } + foreach (self::$static as $connection) { + $connection->close(); + } + self::$pools = []; + self::$static = []; + self::$configs = []; + } + + public static function reset(): void + { + // Abandon (never close) each pool first: a housekeeping Timer::tick callback + // holds a reference to its pool, so a pool that is merely dereferenced would + // stay alive and keep maintaining connections this process no longer owns. + // abandon() clears that timer without touching the inherited sockets. + foreach (self::$pools as $pool) { + $pool->abandon(); + } + self::$pools = []; + self::$static = []; + self::$configs = []; + + // The child inherited the parent's publishing identity along with everything + // else; keeping it would let the child overwrite the parent's telemetry record. + PoolTelemetry::forget(); + } + // ------------------------------------------------------------------------- // Internals // ------------------------------------------------------------------------- /** - * FPM path: singleton CDO per config class for the process lifetime. + * FPM / non-coroutine path: one {@see SingleConnection} per config class for the + * process lifetime. For a short FPM request the connection is freshly opened, so + * the liveness checks are near no-ops; for a long-running non-coroutine process + * (e.g. a Sync daemon querying the DB) the same idle-gate + maxLifetime that the + * coroutine pool applies keep the connection healthy across a DB outage. */ private static function staticDb(string $configClass): CDO { $key = base64_encode($configClass); if (!isset(self::$static[$key])) { - self::$static[$key] = self::getConfigDb($configClass)->connection(); + // Register the config for diagnostics (showDbConfigs) and future static knobs. + self::getConfigDb($configClass); + self::$static[$key] = new SingleConnection( + new CdoConnectionFactory($configClass, self::logger()), + new PoolPolicy(), + ); self::logger()->debug("FPM connection opened: {$configClass}"); } - return self::$static[$key]; + + /** @var DbConfigInterface $config */ + $config = self::$static[$key]->get(); + return $config->connection(); } /** - * Swoole path: borrow from Swoole\ConnectionPool on first call in this coroutine, - * cache in coroutine context, auto-release via defer on coroutine end. + * Swoole path: borrow one connection from the {@see ConnectionPool} on the first + * call in this coroutine, cache the {@see PoolEntry} in coroutine context, and + * auto-release via defer when the coroutine ends. The pool validates idle + * connections and rotates aged ones on borrow (see the class docblock). */ private static function coroutineDb(string $configClass): CDO { @@ -156,83 +336,118 @@ private static function coroutineDb(string $configClass): CDO $ctx = \Swoole\Coroutine::getContext(); if (!isset($ctx[$ctxKey])) { - $swPool = self::swPool($configClass); - $config = self::getConfigDb($configClass); - $timeout = $config instanceof PpaPoolConfigInterface - ? $config->getPoolWaitTimeout() - : 3.0; - - $cid = \Swoole\Coroutine::getCid(); + $pool = self::pool($configClass); + $cid = \Swoole\Coroutine::getCid(); self::logger()->debug("cid={$cid} borrow: {$configClass}"); try { - /** @var CDO|false $cdo */ - $cdo = $swPool->get($timeout); - } catch (\Throwable $e) { - self::logger()->error("cid={$cid} connect failed: {$configClass} — {$e->getMessage()}"); + $entry = $pool->borrow(); + } catch (PoolException $e) { + self::logger()->error("cid={$cid} borrow failed: {$configClass} — {$e->getMessage()}"); throw new PpaPoolException( "PpaConnectionPool: connection failed for [{$configClass}] — {$e->getMessage()}", previous: $e ); } - if ($cdo === false) { - self::logger()->error("cid={$cid} exhausted: {$configClass} (timeout={$timeout}s)"); - throw new PpaPoolException( - "PpaConnectionPool: no free connection for [{$configClass}] " - . "within {$timeout}s — increase poolMaxConnections or poolWaitTimeout" - ); - } - - $ctx[$ctxKey] = $cdo; + $held = new BorrowedConnection($entry); + $ctx[$ctxKey] = $held; // Auto-return when the coroutine finishes (normal exit OR exception). - // $cdo is captured directly — safer than reading from $ctx during teardown. - \Swoole\Coroutine::defer(static function () use ($swPool, $cdo, $cid, $configClass): void { + // $held is captured directly — safer than reading from $ctx during teardown — + // and carries the verdict {@see reportFailure()} may have left on it. + \Swoole\Coroutine::defer(static function () use ($pool, $held, $cid, $configClass): void { + if ($held->dead) { + self::logger()->warning("cid={$cid} evict: {$configClass} (connection lost in use)"); + $pool->evict($held->entry); + return; + } self::logger()->debug("cid={$cid} release: {$configClass}"); - $swPool->put($cdo); + $pool->release($held->entry); }); } - $driver = $ctx[$ctxKey]->getAttribute(\PDO::ATTR_DRIVER_NAME); - if (!empty($driver)) { - $ctx[$ctxKey]->applyDatabaseTimezone($driver, date_default_timezone_get()); + /** @var BorrowedConnection $held */ + $held = $ctx[$ctxKey]; + /** @var DbConfigInterface $config */ + $config = $held->entry->resource; + $cdo = $config->connection(); + + self::syncTimezone($config, $cdo); + + return $cdo; + } + + /** + * Makes the connection's session timezone match the request's. + * + * This runs on **every** `db()` call, not once per borrow, and that is deliberate: + * a pooled connection passes from one user to the next, so the previous user's + * timezone must never be left in place — a client in London would receive dates in + * Tashkent's zone. See {@see \Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware}. + * + * The zone comes from {@see Timezone}, which is coroutine-local. Reading PHP's + * `date_default_timezone_get()` here — as this did — meant reading an engine global + * shared by every request in the worker: a request that yielded on I/O could resume + * after a concurrent request had overwritten it, and then hand *that* zone to its + * own database session. Measured, not theorised. + * + * The command itself is skipped when the connection already carries the right zone. + * That is not the same as hoisting it out of the hot path: the check is per + * connection, so a connection arriving from a user in another timezone is still + * corrected, including mid-request. It removes two round-trips per request in the + * ordinary case where everyone shares one zone. + */ + private static function syncTimezone(object $config, CDO $cdo): void + { + $driver = $cdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + if (empty($driver)) { + return; + } + + $applied = self::$appliedTimezone ??= new \WeakMap(); + $tz = Timezone::current(); + if (($applied[$config] ?? null) === $tz) { + return; } - return $ctx[$ctxKey]; + $cdo->applyDatabaseTimezone($driver, $tz); + $applied[$config] = $tz; } /** - * Returns (and lazily creates) the Swoole\ConnectionPool for the given config class. + * Returns (and lazily creates) the {@see ConnectionPool} for the given config class. * - * The factory callable passed to Swoole\ConnectionPool creates a fresh CDO - * from a dedicated config instance per slot — guaranteeing independent sockets. - * Swoole\ConnectionPool itself is lazy: it calls the factory only when a slot - * is needed (up to `poolMaxConnections`). + * The {@see CdoConnectionFactory} opens one independent CDO per slot (own socket). + * The pool is lazy: it opens a connection only when a slot is needed (up to + * `maximumPoolSize`). Sizing/timeout come from {@see PpaPoolConfigInterface} when + * the config implements it; `maxLifetime`/`aliveBypassWindow` use the + * {@see PoolPolicy} defaults. */ - private static function swPool(string $configClass): \Swoole\ConnectionPool + private static function pool(string $configClass): ConnectionPool { $key = base64_encode($configClass); if (!isset(self::$pools[$key])) { - $config = self::getConfigDb($configClass); - $maxConn = $config instanceof PpaPoolConfigInterface - ? $config->getPoolMaxConnections() - : self::DEFAULT_POOL_SIZE; - - self::logger()->debug("pool created: {$configClass} maxConnections={$maxConn}"); - - // Factory: each call creates one independent CDO (own socket). - $factory = static function () use ($configClass): CDO { - /** @var DbConfigInterface $slotConfig */ - $slotConfig = new $configClass(); - $slotConfig->setUp(); - $slotConfig->setLogger(self::logger()); - $cdo = $slotConfig->connection(); - self::logger()->debug("slot opened: {$configClass} dsn={$slotConfig->getDns()}"); - return $cdo; - }; - - self::$pools[$key] = new \Swoole\ConnectionPool($factory, $maxConn); + $config = self::getConfigDb($configClass); + $policy = $config instanceof PpaPoolConfigInterface + ? new PoolPolicy( + maximumPoolSize: $config->getPoolMaxConnections(), + connectionTimeout: $config->getPoolWaitTimeout(), + keepaliveTime: $config->getKeepaliveTime(), + idleTimeout: $config->getIdleTimeout(), + minimumIdle: $config->getMinimumIdle(), + ) + : new PoolPolicy(maximumPoolSize: self::DEFAULT_POOL_SIZE, connectionTimeout: 3.0); + + self::logger()->debug("pool created: {$configClass} maxConnections={$policy->maximumPoolSize}"); + + self::$pools[$key] = new ConnectionPool( + new CdoConnectionFactory($configClass, self::logger()), + $policy, + ); + + // First pool in this worker — from here on there is something to report. + PoolTelemetry::arm(); } return self::$pools[$key]; } diff --git a/src/Ppa/Pool/PpaPoolConfigInterface.php b/src/Ppa/Pool/PpaPoolConfigInterface.php index 69c3bbb..e72fd5c 100644 --- a/src/Ppa/Pool/PpaPoolConfigInterface.php +++ b/src/Ppa/Pool/PpaPoolConfigInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; /** * Marks a DbConfig as pool-aware. @@ -26,4 +26,25 @@ interface PpaPoolConfigInterface { public function getPoolMaxConnections(): int; public function getPoolWaitTimeout(): float; + + /** + * Seconds after which the background housekeeper proactively probes an idle + * connection (retiring dead ones before a borrow sees them). `0` = disabled. + * Swoole only — has no effect under FPM. See HikariCP `keepaliveTime`. + */ + public function getKeepaliveTime(): float; + + /** + * Seconds after which the housekeeper closes an idle connection, shrinking the + * pool down to {@see getMinimumIdle()}. `0` = never shrink. Swoole only. See + * HikariCP `idleTimeout`. + */ + public function getIdleTimeout(): float; + + /** + * Warm connection floor the housekeeper maintains (reopens up to this count, + * never shrinks below it). `0` = fully lazy. Swoole only. See HikariCP + * `minimumIdle`. + */ + public function getMinimumIdle(): int; } diff --git a/src/Ppa/Pool/PpaPoolException.php b/src/Ppa/Pool/PpaPoolException.php index 9c56e9f..33f9c16 100644 --- a/src/Ppa/Pool/PpaPoolException.php +++ b/src/Ppa/Pool/PpaPoolException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; class PpaPoolException extends \RuntimeException { diff --git a/src/Ppa/Pool/PpaPoolTrait.php b/src/Ppa/Pool/PpaPoolTrait.php index 8c5b299..4a0fcda 100644 --- a/src/Ppa/Pool/PpaPoolTrait.php +++ b/src/Ppa/Pool/PpaPoolTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Pool; +namespace Flytachi\Winter\Kernel\Ppa\Pool; /** * Default pool-settings implementation for {@see PpaPoolConfigInterface}. @@ -23,6 +23,9 @@ * * @property int $poolMaxConnections Maximum number of CDO connections in the pool (default: 5). * @property float $poolWaitTimeout Seconds to wait for a free slot before {@see PpaPoolException} (default: 3.0). + * @property float $keepaliveTime Background probe of idle connections; 0 = off (default: 0.0). Swoole only. + * @property float $idleTimeout Close idle connections after N seconds; 0 = never (default: 0.0). Swoole only. + * @property int $minimumIdle Warm connection floor; 0 = fully lazy (default: 0). Swoole only. */ trait PpaPoolTrait { @@ -35,4 +38,19 @@ public function getPoolWaitTimeout(): float { return $this->poolWaitTimeout ?? 3.0; } + + public function getKeepaliveTime(): float + { + return $this->keepaliveTime ?? 0.0; + } + + public function getIdleTimeout(): float + { + return $this->idleTimeout ?? 0.0; + } + + public function getMinimumIdle(): int + { + return $this->minimumIdle ?? 0; + } } diff --git a/src/Ppa/PpaCallTrait.php b/src/Ppa/PpaCallTrait.php index 070dd40..ada421b 100644 --- a/src/Ppa/PpaCallTrait.php +++ b/src/Ppa/PpaCallTrait.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa; +namespace Flytachi\Winter\Kernel\Ppa; use Flytachi\Winter\Cdo\Connection\CDO; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Ppa\Stereotype\CteRepo; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Ppa\Stereotype\CteRepo; /** * PpaCallTrait — PPA-aware static shortcuts for config classes. @@ -34,7 +34,7 @@ * DbConfig::cte()->from('orders o')->where(Qb::eq('status', 'new'))->findAll(); * ``` * - * @package Flytachi\Winter\K2\Ppa + * @package Flytachi\Winter\Kernel\Ppa */ trait PpaCallTrait { diff --git a/src/Ppa/Repository/RepositoryCore.php b/src/Ppa/Repository/RepositoryCore.php index f6a2d4d..76484b8 100644 --- a/src/Ppa/Repository/RepositoryCore.php +++ b/src/Ppa/Repository/RepositoryCore.php @@ -2,18 +2,23 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Cdo\Connection\CDOStatement; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\EntityInterface; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryInterface; -use Flytachi\Winter\K2\Ppa\Mapping\RepositoryMappingInterface; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\DI\Attribute\Autowired; +use Flytachi\Winter\DI\Attribute\Inject; +use Flytachi\Winter\DI\Container; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\RepositoryMappingInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use Flytachi\Winter\Base\Runtime; use PDOStatement; +use ReflectionClass; +use ReflectionProperty; use stdClass; use Swoole\Coroutine; use Throwable; @@ -49,7 +54,7 @@ * * `TEntity` is the entity class declared by a concrete repository via * {@see $entityClassName}. Subclasses bind it through an `@extends` PHPDoc tag - * pinning the template parameter — see {@see \Flytachi\Winter\K2\Ppa\Stereotype\Repository} + * pinning the template parameter — see {@see \Flytachi\Winter\Kernel\Ppa\Stereotype\Repository} * for details. When unbound, `TEntity` defaults to {@see stdClass}. * * @template TEntity of object @@ -87,18 +92,55 @@ public function __construct() /** * Creates and returns a new repository instance, optionally with a table alias. * + * Deliberately a fresh object rather than a container lookup: the alias lives in + * per-object state, so joining one table twice needs two distinct handles. Resolving + * this through the container would return the shared instance for a `#[Singleton]` + * repository, and the second alias would silently overwrite the first. + * + * The container still fills `#[Autowired]` / `#[Inject]` properties, so a repository + * with dependencies behaves the same however it was obtained. Injection is skipped + * when no container exists — PPA is usable from a bare script, and that path simply + * has nothing to inject, exactly as before this was added. + * * @param string|null $as Optional table alias — calls {@see as()} before returning * @return static */ public static function instance(?string $as = null): static { $repository = new static(); + + if (self::hasInjectableProperties(static::class) && Container::isInitialized()) { + Container::getInstance()->inject($repository); + } + if (!empty($as)) { $repository->as($as); } return $repository; } + /** + * Whether a repository class declares anything for the container to fill. + * + * Answered once per class and remembered, because {@see instance()} runs on every + * join of every request while the answer is almost always no — a repository normally + * carries a config class name and nothing else. Asking the container regardless cost + * roughly 2 µs per call, which is the wrong price for a rarely used capability. + * + * @param class-string $class + */ + private static function hasInjectableProperties(string $class): bool + { + static $known = []; + + return $known[$class] ??= array_any( + new ReflectionClass($class)->getProperties(), + static fn(ReflectionProperty $property): bool => + $property->getAttributes(Autowired::class) !== [] + || $property->getAttributes(Inject::class) !== [], + ); + } + // ------------------------------------------------------------------------- // Coroutine-safe state // ------------------------------------------------------------------------- diff --git a/src/Ppa/Repository/RepositoryCrudTrait.php b/src/Ppa/Repository/RepositoryCrudTrait.php index 649d4f8..f8a4ab5 100644 --- a/src/Ppa/Repository/RepositoryCrudTrait.php +++ b/src/Ppa/Repository/RepositoryCrudTrait.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Cdo\Connection\CDO; use Flytachi\Winter\Cdo\Connection\CDOException; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; /** * Provides concrete write-operation implementations for repository classes. @@ -40,23 +41,45 @@ public function insert(object|array $entity): mixed try { return $this->db()->insert($this->originTable(), $entity); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } /** - * Inserts multiple entities in a single batch statement. + * Inserts many entities, sent to the database in batches. * - * @see CDO::insertGroup() - * @param array|object ...$entities One or more entities to insert + * Takes entities directly, or a stream of them, or both: + * + * ``` + * $repo->insertBatch($user1, $user2); // entities + * $repo->insertBatch(['name' => 'John']); // an array is one row + * $repo->insertBatch(...$entities); // an unpacked array + * $repo->insertBatch($generator); // a stream — nothing is held + * $repo->insertBatch($fromCsv, $extraRow); // mixed, in one call + * ``` + * + * The rule is per argument: **an array is one row, anything traversable is a + * stream of rows.** There is no ambiguity — an array is a valid entity here and + * a `Traversable` never is. + * + * Streaming is the reason this shape exists. Rows reach {@see CDO::insertBatch()} + * lazily, and it flushes each batch as it fills, so peak memory follows the batch + * size rather than the size of the job. Building the collection eagerly first — + * `insertBatch(...$halfAMillionEntities)` — costs whatever that array costs; + * handing over a generator costs a batch. + * + * @see CDO::insertBatch() + * @param iterable|object ...$entities Entities, streams of entities, or both. * @return void * @throws RepositoryException */ - public function insertGroup(array|object ...$entities): void + public function insertBatch(iterable|object ...$entities): void { try { - $this->db()->insertGroup($this->originTable(), $entities); + $this->db()->insertBatch($this->originTable(), self::flatten($entities)); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -75,6 +98,7 @@ public function update(object|array $entity, Qb $qb): int|string try { return $this->db()->update($this->originTable(), $entity, $qb); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -92,6 +116,7 @@ public function delete(Qb $qb): int|string try { return $this->db()->delete($this->originTable(), $qb); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } @@ -114,29 +139,65 @@ public function upsert( try { return $this->db()->upsert($this->originTable(), $entity, $conflictColumns, $updateColumns); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } /** - * Batch-inserts multiple entities, updating specified columns on conflict. + * Upserts many entities, sent to the database in batches. * - * @see CDO::upsertGroup() - * @param array $entities Array of entities to upsert - * @param array $conflictColumns Columns that define the conflict target - * @param array|null $updateColumns Columns to update on conflict; null updates all non-conflict columns + * `$entities` is any `iterable` — an array of entities, a generator, any + * `Traversable`. A generator keeps peak memory at one batch whatever the total; + * see {@see insertBatch()}. + * + * `$updateColumns` maps **column => expression** (`':new'` for the incoming + * value, `':current'` for the stored one). A plain list of column names is + * refused by CDO with a message showing the corrected call; pass `[]` or `null` + * to ignore conflicts entirely. + * + * @see CDO::upsertBatch() + * @param iterable $entities Entities to upsert. + * @param array $conflictColumns Columns that define the conflict target. + * @param array|null $updateColumns Column => expression map; null or [] ignores conflicts. * @return void * @throws RepositoryException */ - public function upsertGroup( - array $entities, + public function upsertBatch( + iterable $entities, array $conflictColumns, ?array $updateColumns = null ): void { try { - $this->db()->upsertGroup($this->originTable(), $entities, $conflictColumns, $updateColumns); + $this->db()->upsertBatch($this->originTable(), $entities, $conflictColumns, $updateColumns); } catch (CDOException $exception) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $exception); throw new RepositoryException($exception->getMessage(), $exception->getCode(), $exception); } } + + /** + * Turns the variadic arguments into one lazy stream of rows. + * + * An array is a row (a column-value map — the form {@see insert()} takes), while + * anything traversable is a stream of rows to be drained. That asymmetry is not a + * heuristic: an array is a legal entity in this API and a `Traversable` never is, + * so no call is ambiguous. + * + * A generator, so a stream stays a stream all the way into the driver. Collecting + * into an array here would put the whole job back in memory and undo the point. + * + * @param array $entities + * @return \Generator> + */ + private static function flatten(array $entities): \Generator + { + foreach ($entities as $entity) { + if ($entity instanceof \Traversable) { + yield from $entity; + continue; + } + yield $entity; + } + } } diff --git a/src/Ppa/Repository/RepositoryException.php b/src/Ppa/Repository/RepositoryException.php index 62cdaa2..111ced6 100644 --- a/src/Ppa/Repository/RepositoryException.php +++ b/src/Ppa/Repository/RepositoryException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; use Flytachi\Winter\Base\Exception\ExceptionTrait; diff --git a/src/Ppa/Repository/RepositoryViewTrait.php b/src/Ppa/Repository/RepositoryViewTrait.php index 55cc01b..2d6ba0a 100644 --- a/src/Ppa/Repository/RepositoryViewTrait.php +++ b/src/Ppa/Repository/RepositoryViewTrait.php @@ -2,14 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Ppa\Repository; use Flytachi\Winter\Base\HttpCode; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Connection\CDOStatement; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\EntityException; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityException; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use PDO; use Throwable; @@ -60,6 +61,7 @@ final public function rawFetch(string $sql, array $binds = [], ?string $entityCl $entityClassName ?: $this->state()->entityClassName ); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -85,6 +87,7 @@ final public function find(?string $entityClassName = null): ?object $this->cleanCache(); return $stmt->getStmt()->fetchObject($resolvedClass) ?: null; } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -108,6 +111,7 @@ final public function findColumn(int $column = 0): mixed $this->cleanCache(); return $stmt->getStmt()->fetchColumn($column); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -132,6 +136,7 @@ final public function findAll(?string $entityClassName = null): array $this->cleanCache(); return $stmt->getStmt()->fetchAll(PDO::FETCH_CLASS, $resolvedClass); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -156,6 +161,7 @@ final public function count(): int $this->cleanCache(); return (int) $stmt->getStmt()->fetchColumn(); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } @@ -181,6 +187,7 @@ final public function exists(): bool $this->cleanCache(); return (bool) $stmt->getStmt()->fetchColumn(); } catch (Throwable $th) { + PpaConnectionPool::reportFailure($this->dbConfigClassName, $th); throw new RepositoryException($th->getMessage(), previous: $th); } } diff --git a/src/Ppa/Stereotype/CteRepo.php b/src/Ppa/Stereotype/CteRepo.php index 1215532..8ecd933 100644 --- a/src/Ppa/Stereotype/CteRepo.php +++ b/src/Ppa/Stereotype/CteRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait; use stdClass; /** diff --git a/src/Ppa/Stereotype/Repository.php b/src/Ppa/Stereotype/Repository.php index d974d5c..53ec6a0 100644 --- a/src/Ppa/Stereotype/Repository.php +++ b/src/Ppa/Stereotype/Repository.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryCrudInterface; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCrudTrait; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCrudTrait; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait; /** * Base class for full-access repository implementations (CRUD and View). diff --git a/src/Ppa/Stereotype/RepositoryCrud.php b/src/Ppa/Stereotype/RepositoryCrud.php index a781fd9..a214b72 100644 --- a/src/Ppa/Stereotype/RepositoryCrud.php +++ b/src/Ppa/Stereotype/RepositoryCrud.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryCrudInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCrudTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryCrudInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCrudTrait; /** * Base class for write-only repository implementations. diff --git a/src/Ppa/Stereotype/RepositoryView.php b/src/Ppa/Stereotype/RepositoryView.php index cafa98d..61184cc 100644 --- a/src/Ppa/Stereotype/RepositoryView.php +++ b/src/Ppa/Stereotype/RepositoryView.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Ppa\Stereotype; +namespace Flytachi\Winter\Kernel\Ppa\Stereotype; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryCore; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryCore; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait; /** * Base class for read-only repository implementations. diff --git a/src/Process/Activity.php b/src/Process/Activity.php new file mode 100644 index 0000000..f0c99b0 --- /dev/null +++ b/src/Process/Activity.php @@ -0,0 +1,22 @@ +mainKey = str_replace('\\', '.', $className); - } - - final public function main(): FileStorage - { - return Kernel::runnable($this->mainKey); - } - - final public function threads(): FileStorage - { - return Kernel::runnable($this->mainKey . '/threads', false); - } -} diff --git a/src/Process/Core/Dispatch.php b/src/Process/Core/Dispatch.php deleted file mode 100644 index 72ee977..0000000 --- a/src/Process/Core/Dispatch.php +++ /dev/null @@ -1,84 +0,0 @@ -make(static::class); - $thread = new Thread( - $runnable, - $runnable->exNamespace, - $runnable->exName, - $runnable->exTag - ); - $arguments = []; - if (!empty($data)) { - $storeKey = uniqid('cache-'); - DispatchStore::push($storeKey, $data); - $arguments['storeKey'] = $storeKey; - } - - return $thread->start( - arguments: $arguments - ); - } - - final public static function start(mixed $data = null): void - { - $runnable = Container::getInstance()->make(static::class); - $arguments = []; - if (!empty($data)) { - $storeKey = uniqid('cache-'); - DispatchStore::push($storeKey, $data); - $arguments['storeKey'] = $storeKey; - } - $runnable->run($arguments); - } - - final public function run(array $args): void - { - try { - $this->resolutionStart(); - $this->resolution(isset($args['storeKey']) - ? DispatchStore::pop($args['storeKey']) - : null); - } catch (\Throwable $e) { - $this->logger->error( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - } finally { - $this->resolutionEnd(); - } - } - - protected function resolutionStart(): void - { - $this->pid = getmypid(); - $this->logger = LoggerFactory::getLogger(static::class); - } - - abstract protected function resolutionEnd(): void; -} diff --git a/src/Process/Core/DispatchStore.php b/src/Process/Core/DispatchStore.php deleted file mode 100644 index f128218..0000000 --- a/src/Process/Core/DispatchStore.php +++ /dev/null @@ -1,25 +0,0 @@ -write($storeKey, $data); - } - - final public static function pop(string $storeKey): mixed - { - $fs = Kernel::volatile(self::$ES_NAME); - $data = $fs->read($storeKey); - $fs->del($storeKey); - return $data; - } -} diff --git a/src/Process/Core/Dispatchable.php b/src/Process/Core/Dispatchable.php deleted file mode 100644 index 35663d4..0000000 --- a/src/Process/Core/Dispatchable.php +++ /dev/null @@ -1,14 +0,0 @@ -receive($options); - } catch (\Throwable $e) { - $logger->alert($e->getMessage()); - return 1; - } - if ($payload === '') { - $logger->alert('No payload received.'); - return 1; - } - - try { - $runnable = \Opis\Closure\unserialize($payload, $this->security); - } catch (\Throwable $e) { - $logger->alert('Failed to deserialize payload: ' . $e->getMessage()); - return 1; - } - - unset($payload); - - if (!$runnable instanceof Runnable) { - $logger->critical('The provided payload is not a valid Runnable object.'); - return 1; - } - - if (isset($options['detach'])) { - try { - $this->daemonize(); - } catch (ThreadException $e) { - $logger->alert($e->getMessage()); - return 1; - } - } - - $this->setProcessTitle($options, $runnable); - - try { - $runnable->run($this->parseArgs()); - return 0; - } catch (\Throwable $e) { - $logger->critical('Uncaught exception in background process: ' . $e->getMessage()); - if (env('DEBUG' , false)) { - $logger->critical($e->getTraceAsString()); - } - return 1; - } - } -} \ No newline at end of file diff --git a/src/Process/Daemon/DaemonConfigException.php b/src/Process/Daemon/DaemonConfigException.php new file mode 100644 index 0000000..a17c74e --- /dev/null +++ b/src/Process/Daemon/DaemonConfigException.php @@ -0,0 +1,17 @@ + $workers Live fleet snapshot, one entry per non-empty slot. + */ + public function __construct( + int $pid, + string $className, + ProcessState $state, + Activity $activity, + int $startedAt, + int $concurrency, + public int $restarts, + public array $workers, + ?ResourceUsage $usage = null, + ) { + parent::__construct($pid, $className, $state, $activity, $startedAt, $concurrency, $usage); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_merge(parent::jsonSerialize(), [ + 'restarts' => $this->restarts, + 'workers' => $this->workers, + ]); + } +} diff --git a/src/Process/Daemon/RestartMode.php b/src/Process/Daemon/RestartMode.php new file mode 100644 index 0000000..93b61f1 --- /dev/null +++ b/src/Process/Daemon/RestartMode.php @@ -0,0 +1,34 @@ + true, + self::ON_FAILURE => $crashed, + self::NEVER => false, + }; + } +} diff --git a/src/Process/Daemon/RestartPolicy.php b/src/Process/Daemon/RestartPolicy.php new file mode 100644 index 0000000..ea69553 --- /dev/null +++ b/src/Process/Daemon/RestartPolicy.php @@ -0,0 +1,52 @@ +mode->shouldRestart($crashed); + } +} diff --git a/src/Process/Daemon/ScalingPolicy.php b/src/Process/Daemon/ScalingPolicy.php new file mode 100644 index 0000000..3c63875 --- /dev/null +++ b/src/Process/Daemon/ScalingPolicy.php @@ -0,0 +1,48 @@ + Slots keyed by their stable index. */ + private array $slots = []; + private bool $stop = false; + private int $totalRestarts = 0; + private ProcessState $finalState = ProcessState::TERMINATED; + + /** @var list Ring of [microtime, rawDesired] for scaling damping. */ + private array $desiredHistory = []; + private float $lastScaleAt = 0.0; + private float $lastReconcileAt = 0.0; + private float $lastStatusAt = 0.0; + + // ------------------------------------------------------------------------- + // Main loop + // ------------------------------------------------------------------------- + + /** + * Runs the supervision loop until every worker is done or a stop signal + * arrives. Returns the terminal state. + * + * @param callable $onChange Persist the DaemonStatus; called whenever the fleet changes. + */ + private function superviseFleet(callable $onChange): ProcessState + { + pcntl_async_signals(true); + pcntl_signal(SIGTERM, fn() => $this->requestFleetStop()); + pcntl_signal(SIGINT, fn() => $this->requestFleetStop()); + pcntl_signal(SIGHUP, fn() => $this->reload()); + pcntl_signal(SIGUSR1, fn() => $this->forward(SIGUSR1)); + pcntl_signal(SIGUSR2, fn() => $this->forward(SIGUSR2)); + + $replicas = $this->replicas(); + for ($i = 0; $i < $replicas; $i++) { + $this->slots[$i] = new Slot($i); + } + + while (true) { + $changed = $this->refreshWorkers(); + if (!$this->stop) { + $this->watchdog(); + } + $this->reapAndEnforce($onChange); + + if ($this->stop) { + if ($this->aliveCount() === 0) { + break; + } + } else { + $now = microtime(true); + $interval = max(0.05, $this->scalingPolicy()->scaleInterval); + if (($now - $this->lastReconcileAt) >= $interval) { + $this->lastReconcileAt = $now; + $this->fireTick(); + $this->reconcile($onChange); + } + } + + // Heartbeat the status so activity and STARTING → RUNNING promotions + // reach the store even when the fleet size does not change. + $now = microtime(true); + if ($changed || ($now - $this->lastStatusAt) >= self::STATUS_INTERVAL) { + $this->lastStatusAt = $now; + $onChange(); + } + + usleep((int) (self::TICK * 1_000_000)); + pcntl_signal_dispatch(); + } + + return $this->finalState; + } + + // ------------------------------------------------------------------------- + // Reaping and deadline enforcement (runs every tick) + // ------------------------------------------------------------------------- + + /** + * One reaping pass: harvests exited workers (routing each to {@see handleExit()}), + * SIGKILLs a RETIRING worker past its drain deadline, and re-forks a RESTARTING + * slot once its back-off has elapsed. + */ + private function reapAndEnforce(callable $onChange): void + { + $now = microtime(true); + foreach ($this->slots as $slot) { + if ($slot->state->isAlive()) { + $res = pcntl_waitpid($slot->pid, $status, WNOHANG); + if ($res === $slot->pid) { + $crashed = !(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0); + $this->handleExit($slot, $crashed, $onChange); + } elseif ($slot->state === SlotState::RETIRING && $now > $slot->deadline) { + // Outlived its drain deadline (or a force-stop collapsed it) — SIGKILL. + @posix_kill($slot->pid, SIGKILL); + $slot->state = SlotState::KILLING; + } + } elseif ($slot->state === SlotState::RESTARTING && !$this->stop) { + if ($now >= $slot->restartAt) { + $this->forkInto($slot, $onChange); + } + } + } + } + + /** + * Reacts to a worker exit by slot intent: an intentionally retired worker is + * freed (never refilled); an unexpected death is restarted per policy. + */ + private function handleExit(Slot $slot, bool $crashed, callable $onChange): void + { + $idx = $slot->index; + $pid = $slot->pid; + $intentional = $this->stop + || $slot->state === SlotState::RETIRING + || $slot->state === SlotState::KILLING; + + $this->fireWorkerExit($idx, $pid, $intentional ? false : $crashed); + + if ($intentional) { + $this->free($slot); + $onChange(); + return; + } + + $restart = $this->restartPolicy(); + if ($restart->shouldRestart($crashed)) { + $this->totalRestarts++; + $slot->restarts++; + if ($restart->maxRestarts > 0 && $this->totalRestarts >= $restart->maxRestarts) { + $this->finalState = ProcessState::FAILED; + // This worker is already dead and reaped — free its slot here, then + // stop the rest. Leaving it for beginStop() would mark an already + // reaped slot RETIRING, and it would never drain (aliveCount stuck). + $this->free($slot); + $this->beginStop(); + $onChange(); + return; + } + // Restart into the SAME slot so worker#{n} stays stable; back off first. + $this->clearWorkerRecord($idx); + $slot->state = SlotState::RESTARTING; + $slot->pid = 0; + $slot->startedAt = 0; + $slot->restartAt = microtime(true) + $this->backoff($restart->backoff, $slot->restarts); + } else { + // Policy declined to replace this death (NEVER, or a clean exit under + // ON_FAILURE): retire the slot terminally. It counts as committed, so + // reconcile does NOT immediately refill it — which would bypass the + // back-off and the policy and could crash-loop. + $this->retirePermanently($slot); + } + $onChange(); + } + + // ------------------------------------------------------------------------- + // Reconcile — drive the committed fleet to the (damped) desired size + // ------------------------------------------------------------------------- + + /** + * Drives the committed fleet toward the damped desired size: scales up or down + * by at most `scaleStep`, gated by `cooldown`. Skipped entirely while stopping. + */ + private function reconcile(callable $onChange): void + { + $policy = $this->scalingPolicy(); + $now = microtime(true); + if (($now - $this->lastScaleAt) < max(0.0, $policy->cooldown)) { + return; // cooldown gate between actions + } + + $committed = $this->committedCount(); + $desired = $this->effectiveDesired($committed, $policy); + if ($desired === $committed) { + return; + } + + $step = $policy->scaleStep; + $from = $committed; + + if ($desired > $committed) { + $need = $desired - $committed; + if ($step > 0) { + $need = min($need, $step); + } + $added = $this->scaleUp($need, $onChange); + if ($added > 0) { + $this->lastScaleAt = $now; + $this->fireScale($from, $from + $added); + $onChange(); + } + } else { + $excess = $committed - $desired; + if ($step > 0) { + $excess = min($excess, $step); + } + $removed = $this->scaleDown($excess); + if ($removed > 0) { + $this->lastScaleAt = $now; + $this->fireScale($from, $from - $removed); + $onChange(); + } + } + } + + /** + * Resolves the target size with damping: react to a rise quickly (once it is + * sustained over scaleUpDelay), shrink only to the high-water demand over the + * stabilization window — so a transient dip never sheds workers. + */ + private function effectiveDesired(int $committed, ScalingPolicy $policy): int + { + $now = microtime(true); + $raw = $this->computeDesired(); + $this->desiredHistory[] = [$now, $raw]; + + $window = max($policy->scaleUpDelay, $policy->scaleDownStabilization); + $cutoff = $now - $window; + $this->desiredHistory = array_values(array_filter( + $this->desiredHistory, + static fn(array $e): bool => $e[0] >= $cutoff + )); + + if ($raw > $committed) { + // Scale up to the sustained floor of demand; never below current here. + $floor = $this->windowExtreme($now - max(0.0, $policy->scaleUpDelay), false); + return max($committed, $floor); + } + if ($raw < $committed) { + // Scale down only to the high-water demand over the window; never above current. + $ceil = $this->windowExtreme($now - max(0.0, $policy->scaleDownStabilization), true); + return min($committed, $ceil); + } + return $committed; + } + + /** + * Max ($max=true) or min of the recorded raw desired values within [$since, now]. + */ + private function windowExtreme(float $since, bool $max): int + { + $result = null; + foreach ($this->desiredHistory as [$t, $v]) { + if ($t < $since) { + continue; + } + if ($result === null) { + $result = $v; + } else { + $result = $max ? max($result, $v) : min($result, $v); + } + } + return $result ?? $this->computeDesired(); + } + + /** + * Grows the fleet by up to $need: reclaim still-draining workers first + * (anti-flap), then spawn on free slots, allocating new ones if short. + */ + private function scaleUp(int $need, callable $onChange): int + { + $added = 0; + foreach ($this->slots as $slot) { + if ($added >= $need) { + break; + } + if ($slot->state === SlotState::RETIRING) { + $slot->state = SlotState::RUNNING; + $slot->deadline = INF; + $added++; + } + } + foreach ($this->slots as $slot) { + if ($added >= $need) { + break; + } + if ($slot->state === SlotState::EMPTY) { + $this->forkInto($slot, $onChange); + $added++; + } + } + while ($added < $need) { + $idx = $this->nextFreeIndex(); + $this->slots[$idx] = new Slot($idx); + $this->forkInto($this->slots[$idx], $onChange); + $added++; + } + return $added; + } + + /** + * Shrinks the fleet by up to $excess: cancel back-off restarts first (no live + * process), then retire live workers gracefully, IDLE ones first. + */ + private function scaleDown(int $excess): int + { + $removed = 0; + // 0) shed already-dead RETIRED slots first — reclaiming them is free. + foreach ($this->slots as $slot) { + if ($removed >= $excess) { + break; + } + if ($slot->state === SlotState::RETIRED) { + $this->free($slot); + $removed++; + } + } + // 1) cancel pending back-off restarts (no live process yet). + foreach ($this->slots as $slot) { + if ($removed >= $excess) { + break; + } + if ($slot->state === SlotState::RESTARTING) { + $this->free($slot); + $removed++; + } + } + $grace = $this->graceSeconds(); + foreach ($this->pickVictims($excess - $removed) as $slot) { + $this->retire($slot, $grace); + $removed++; + } + return $removed; + } + + /** + * Picks up to $k live workers to retire — IDLE first (reclaimed at once, no + * work lost), then BUSY by highest slot (keeping low slots stable). A BUSY + * victim still drains gracefully; this only orders which go first. + * + * @return list + */ + private function pickVictims(int $k): array + { + if ($k <= 0) { + return []; + } + $candidates = []; + foreach ($this->slots as $slot) { + if ($slot->state === SlotState::RUNNING || $slot->state === SlotState::STARTING) { + $candidates[] = $slot; + } + } + usort($candidates, static function (Slot $a, Slot $b): int { + $ai = $a->activity === Activity::IDLE ? 0 : 1; + $bi = $b->activity === Activity::IDLE ? 0 : 1; + return ($ai <=> $bi) ?: ($b->index <=> $a->index); + }); + return array_slice($candidates, 0, $k); + } + + // ------------------------------------------------------------------------- + // Stop sequence + // ------------------------------------------------------------------------- + + /** + * First stop signal drains the whole fleet; a second one forces it down now. + */ + private function requestFleetStop(): void + { + if (!$this->stop) { + $this->beginStop(); + } else { + $this->forceStop(); + } + } + + /** + * Freezes reconcile and retires the whole fleet at once (a full stop is + * "retire every slot"), reusing the graceful drain + deadline machinery. + */ + private function beginStop(): void + { + $this->stop = true; + $grace = $this->graceSeconds(); + foreach ($this->slots as $slot) { + if ($slot->state === SlotState::STARTING || $slot->state === SlotState::RUNNING) { + $this->retire($slot, $grace); + } elseif ($slot->state === SlotState::RESTARTING) { + $this->free($slot); // cancel a pending restart + } + } + } + + /** + * Collapses every drain deadline to now, so the next enforcement pass SIGKILLs + * any worker still alive — the operator's "stop now" on a repeated signal. + */ + private function forceStop(): void + { + $now = microtime(true); + foreach ($this->slots as $slot) { + if ($slot->state->isAlive()) { + $slot->deadline = $now; + } + } + } + + /** + * SIGHUP handling: runs the master's {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon::onReload()} hook and + * forwards the signal to every worker. Reload, not stop. + */ + private function reload(): void + { + $this->fireReload(); + $this->forward(SIGHUP); + } + + /** + * Forwards a control signal to every live worker; the supervisor stays up. + */ + private function forward(int $signo): void + { + foreach ($this->slots as $slot) { + if ($slot->pid > 0 && $slot->state->isAlive()) { + @posix_kill($slot->pid, $signo); + } + } + } + + // ------------------------------------------------------------------------- + // Slot transitions + // ------------------------------------------------------------------------- + + /** + * Forks a worker into the slot. In the child, inherited signal handlers are + * reset so the worker's own runtime installs its own — then the body runs and + * its outcome maps to the exit code. + */ + private function forkInto(Slot $slot, callable $onChange): void + { + $slot->startedAt = time(); + $pid = pcntl_fork(); + if ($pid === 0) { + pcntl_signal(SIGTERM, SIG_DFL); + pcntl_signal(SIGINT, SIG_DFL); + pcntl_signal(SIGHUP, SIG_DFL); + pcntl_signal(SIGUSR1, SIG_DFL); + pcntl_signal(SIGUSR2, SIG_DFL); + try { + $this->bootWorker($slot->index); + exit(0); + } catch (\Throwable) { + // The worker logs its own failure; the non-zero code is the signal. + exit(1); + } + } + $slot->pid = $pid; + $slot->state = SlotState::STARTING; + $slot->deadline = INF; + $slot->activity = Activity::IDLE; + $slot->heartbeatAt = 0; + $slot->killed = false; + $this->fireWorkerStart($slot->index, $pid); + $onChange(); + } + + /** + * Retires a worker: SIGTERM to begin a graceful drain, and stamp the deadline + * the enforcement pass uses to SIGKILL a straggler (INF = wait forever). + */ + private function retire(Slot $slot, float $grace): void + { + $slot->state = SlotState::RETIRING; + @posix_kill($slot->pid, SIGTERM); + $slot->deadline = $grace > 0 ? microtime(true) + $grace : INF; + } + + /** + * Returns a slot to EMPTY (reusable), clearing its heartbeat record. + */ + private function free(Slot $slot): void + { + $this->clearWorkerRecord($slot->index); + $slot->state = SlotState::EMPTY; + $slot->pid = 0; + $slot->deadline = INF; + $slot->restartAt = 0.0; + $slot->startedAt = 0; + $slot->activity = Activity::IDLE; + $slot->heartbeatAt = 0; + $slot->killed = false; + } + + /** + * Terminally retires a slot whose worker died and the policy declined to + * replace. It counts as committed (so reconcile leaves it alone) until a + * scale-down reclaims it. + */ + private function retirePermanently(Slot $slot): void + { + $this->clearWorkerRecord($slot->index); + $slot->state = SlotState::RETIRED; + $slot->pid = 0; + $slot->deadline = INF; + $slot->restartAt = 0.0; + $slot->startedAt = 0; + $slot->activity = Activity::IDLE; + $slot->heartbeatAt = 0; + $slot->killed = false; + } + + // ------------------------------------------------------------------------- + // Worker observation + // ------------------------------------------------------------------------- + + /** + * Refreshes each live slot's activity from the worker heartbeat, and promotes + * STARTING → RUNNING on the first heartbeat seen. Returns whether any slot's + * state or activity changed (so the caller can persist the fleet view). + */ + private function refreshWorkers(): bool + { + $changed = false; + foreach ($this->slots as $slot) { + if ( + $slot->state === SlotState::STARTING + || $slot->state === SlotState::RUNNING + || $slot->state === SlotState::RETIRING + ) { + $record = $this->workerRecord($slot->index); + if ($record !== null) { + if ($slot->activity !== $record->activity) { + $slot->activity = $record->activity; + $changed = true; + } + $slot->heartbeatAt = $record->heartbeatAt; + if ($slot->state === SlotState::STARTING) { + $slot->state = SlotState::RUNNING; + $changed = true; + } + } + } + } + return $changed; + } + + /** + * Kills a worker whose heartbeat has gone silent past the liveness timeout — + * a wedged worker (deadlock, hung I/O, a boot that never finishes) that a + * plain pid check misses. The reap then restarts it through the crash path + * (back-off + policy). Disabled when the timeout is 0. + */ + private function watchdog(): void + { + $timeout = $this->livenessTimeout(); + if ($timeout <= 0.0) { + return; + } + $now = time(); + foreach ($this->slots as $slot) { + if ($slot->killed) { + continue; + } + if ($slot->state !== SlotState::RUNNING && $slot->state !== SlotState::STARTING) { + continue; + } + // A running worker is judged by its last heartbeat; a STARTING worker + // that has not beat yet is judged from when it was forked. + $last = $slot->heartbeatAt > 0 ? $slot->heartbeatAt : $slot->startedAt; + if ($last > 0 && ($now - $last) > $timeout) { + $this->logger->warning( + 'worker#' . ($slot->index + 1) . " [PID {$slot->pid}] hung — no heartbeat for " + . ($now - $last) . "s (> {$timeout}s); killing." + ); + @posix_kill($slot->pid, SIGKILL); + $slot->killed = true; + } + } + } + + // ------------------------------------------------------------------------- + // Introspection (consumed by the Daemon status writer) + // ------------------------------------------------------------------------- + + /** + * @return list One entry per non-empty slot. + */ + private function snapshot(): array + { + $out = []; + foreach ($this->slots as $slot) { + if ($slot->state === SlotState::EMPTY) { + continue; + } + $out[] = new WorkerStatus( + slot: $slot->index, + pid: $slot->pid, + state: $slot->state, + activity: $slot->activity, + startedAt: $slot->startedAt, + restarts: $slot->restarts, + ); + } + return $out; + } + + /** Total worker restarts across the fleet's lifetime (for {@see DaemonStatus}). */ + private function restartsTotal(): int + { + return $this->totalRestarts; + } + + /** Whether a stop is in progress (reconcile frozen, fleet draining). */ + private function isStopping(): bool + { + return $this->stop; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Slots that are or will be running — the size reconcile drives to desired. */ + private function committedCount(): int + { + $n = 0; + foreach ($this->slots as $slot) { + if ($slot->state->isCommitted()) { + $n++; + } + } + return $n; + } + + /** Slots with a live OS process attached. */ + private function aliveCount(): int + { + $n = 0; + foreach ($this->slots as $slot) { + if ($slot->state->isAlive()) { + $n++; + } + } + return $n; + } + + /** Lowest free slot index (reuses an EMPTY slot, else appends a new one). */ + private function nextFreeIndex(): int + { + $i = 0; + while (isset($this->slots[$i]) && $this->slots[$i]->state !== SlotState::EMPTY) { + $i++; + } + return $i; + } + + /** + * Exponential back-off `base × 2^(failures-1)`, capped at {@see BACKOFF_CAP}; + * 0 for no failures or no base. + */ + private function backoff(float $base, int $failures): float + { + if ($base <= 0.0 || $failures <= 0) { + return 0.0; + } + return min($base * (2 ** ($failures - 1)), self::BACKOFF_CAP); + } +} diff --git a/src/Process/Daemon/WorkerStatus.php b/src/Process/Daemon/WorkerStatus.php new file mode 100644 index 0000000..3c8e17f --- /dev/null +++ b/src/Process/Daemon/WorkerStatus.php @@ -0,0 +1,44 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'slot' => $this->slot, + 'pid' => $this->pid, + 'state' => $this->state->value, + 'activity' => $this->activity->value, + 'started_at' => $this->startedAt, + 'uptime' => $this->startedAt > 0 ? time() - $this->startedAt : 0, + 'restarts' => $this->restarts, + ]; + } +} diff --git a/src/Process/DaemonException.php b/src/Process/DaemonException.php deleted file mode 100644 index eea7b9d..0000000 --- a/src/Process/DaemonException.php +++ /dev/null @@ -1,15 +0,0 @@ - $signals Map of signal number to handler, installed with the runtime-appropriate mechanism. + * @param callable|null $onForceExit Run just before the grace timer forces the process down. + * @param callable|null $onHeartbeat Run about once a second while the body runs (e.g. to flush status). + */ + public function enter( + callable $body, + array $signals = [], + ?callable $onForceExit = null, + ?callable $onHeartbeat = null, + ): void; + + /** + * Dispatches a task concurrently, capped by the configured concurrency. + * + * When the cap is reached the call applies back-pressure: it suspends the + * caller (Swoole) or blocks it (fork) until a slot frees up. + * + * @param callable $task Task to run. + */ + public function spawn(callable $task): Future; + + /** + * Pauses the body without blocking sibling tasks under Swoole. Throws + * {@see \Flytachi\Winter\Kernel\Process\InterruptedException} once if the body + * was interrupted (an IDLE wait woken by a stop request). + * + * @param float $seconds Seconds to pause. + */ + public function sleep(float $seconds): void; + + /** + * Returns false once a stop has been requested. + */ + public function running(): bool; + + /** + * Requests a graceful stop, flipping {@see running()} to false. + * + * @param bool $interrupt Wake a blocked (IDLE) body so it unwinds at once. + * Pass false while an inline unit is BUSY so it is not + * aborted mid-work. + */ + public function requestStop(bool $interrupt): void; + + /** + * Number of dispatched tasks that have not settled yet. + */ + public function inFlight(): int; +} diff --git a/src/Process/Engine/SwooleEngine.php b/src/Process/Engine/SwooleEngine.php new file mode 100644 index 0000000..2a15591 --- /dev/null +++ b/src/Process/Engine/SwooleEngine.php @@ -0,0 +1,201 @@ +onForceExit = $onForceExit; + + \Swoole\Coroutine\run(function () use ($body, $signals, $onHeartbeat, &$error): void { + $this->bodyCid = \Swoole\Coroutine::getCid(); + + if ($this->concurrency > 0) { + $this->semaphore = new \Swoole\Coroutine\Channel($this->concurrency); + for ($i = 0; $i < $this->concurrency; $i++) { + $this->semaphore->push(true); + } + } + + // A broken pipe must not kill a long-lived process; handle EPIPE in code. + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGPIPE, SIG_IGN); + } + foreach ($signals as $signo => $handler) { + \Swoole\Process::signal($signo, $handler); + } + if ($onHeartbeat !== null) { + $this->heartbeatTimerId = \Swoole\Timer::tick(1000, static fn() => $onHeartbeat()); + } + + $signos = array_keys($signals); + + try { + $body(); + // Drain in-flight tasks on the normal path; skip when cancelled + // (Coroutine\run drains the rest) to avoid busy-waiting. + while ($this->inFlight > 0 && !\Swoole\Coroutine::isCanceled()) { + \Swoole\Coroutine::sleep(0.01); + } + } catch (InterruptedException) { + } catch (\Throwable $e) { + $error = $e; + } finally { + // Drop timers and signal listeners so the reactor can go idle and + // Coroutine\run returns. Registered signal handlers keep the event + // loop alive on their own — without this the process hangs after the + // body is done, only escaping via the grace timer (or never, grace=0). + $this->disarmGrace(); + if ($this->heartbeatTimerId !== null) { + \Swoole\Timer::clear($this->heartbeatTimerId); + $this->heartbeatTimerId = null; + } + foreach ($signos as $signo) { + \Swoole\Process::signal($signo, null); + } + } + }); + + if ($error !== null) { + throw $error; + } + } + + /** + * {@inheritDoc} + */ + public function spawn(callable $task): Future + { + $this->semaphore?->pop(); + $this->inFlight++; + + $wrapped = function () use ($task): mixed { + try { + return $task(); + } finally { + $this->inFlight--; + $this->semaphore?->push(true); + } + }; + + return Executors::common()->submit($wrapped); + } + + /** + * {@inheritDoc} + */ + public function sleep(float $seconds): void + { + \Swoole\Coroutine::sleep($seconds); + // Throw only if this coroutine was cancelled (an IDLE wait woken by a stop). + // A BUSY unit is not cancelled, so its sleeps return normally. + if (\Swoole\Coroutine::isCanceled() && !$this->interruptDelivered) { + $this->interruptDelivered = true; + throw new InterruptedException(); + } + } + + /** + * {@inheritDoc} + */ + public function running(): bool + { + return !$this->stop; + } + + /** + * {@inheritDoc} + */ + public function requestStop(bool $interrupt): void + { + if ($this->stop) { + return; + } + $this->stop = true; + + // Arm the grace backstop BEFORE the cancel. Arming a timer in the same + // handler *after* Coroutine::cancel() swallows the body's pending resume, + // freezing it until the timer fires — so a fast IDLE drain would instead + // always wait the full grace. Order matters here. + if ($this->graceTimerId === null && $this->grace > 0) { + $this->graceTimerId = \Swoole\Timer::after( + (int) ($this->grace * 1000), + function (): void { + if ($this->onForceExit !== null) { + ($this->onForceExit)(); + } + exit(1); + } + ); + } + + // Wake an IDLE body blocked in an interruptible point; leave a BUSY unit + // to finish (do not cancel it). + if ($interrupt && $this->bodyCid !== null) { + \Swoole\Coroutine::cancel($this->bodyCid); + } + } + + /** + * {@inheritDoc} + */ + public function inFlight(): int + { + return $this->inFlight; + } + + /** + * Cancels the grace timer, if armed, so a finished process exits at once. + */ + private function disarmGrace(): void + { + if ($this->graceTimerId !== null) { + \Swoole\Timer::clear($this->graceTimerId); + $this->graceTimerId = null; + } + } +} diff --git a/src/Process/Engine/SyncEngine.php b/src/Process/Engine/SyncEngine.php new file mode 100644 index 0000000..8374457 --- /dev/null +++ b/src/Process/Engine/SyncEngine.php @@ -0,0 +1,221 @@ + Live child PIDs. */ + private array $children = []; + + /** + * @param int $concurrency Maximum simultaneous children; 0 means unlimited. + * @param float $grace Seconds to wait after a stop request before forcing exit; 0 disables. + */ + public function __construct( + private readonly int $concurrency, + private readonly float $grace, + ) { + $this->hasPcntl = extension_loaded('pcntl'); + } + + /** + * {@inheritDoc} + */ + public function enter( + callable $body, + array $signals = [], + ?callable $onForceExit = null, + ?callable $onHeartbeat = null, + ): void { + $this->onForceExit = $onForceExit; + $this->onHeartbeat = $onHeartbeat; + + if ($this->hasPcntl) { + pcntl_async_signals(true); + pcntl_signal(SIGPIPE, SIG_IGN); + foreach ($signals as $signo => $handler) { + pcntl_signal($signo, $handler); + } + } + + try { + $body(); + } catch (InterruptedException) { + // Stop requested mid-block: unwind cleanly. + } + + $this->waitAll(); + } + + /** + * {@inheritDoc} + */ + public function spawn(callable $task): Future + { + if (!$this->hasPcntl) { + return CompletableFuture::completedFuture($task()); + } + + $this->reap(); + while ($this->concurrency > 0 && count($this->children) >= $this->concurrency) { + $pid = pcntl_wait($status); + if ($pid > 0) { + unset($this->children[$pid]); + } + } + + $pid = pcntl_fork(); + if ($pid === 0) { + try { + $task(); + } catch (\Throwable) { + // Isolated in the child; nothing to propagate to the parent. + } finally { + exit(0); + } + } + + if ($pid > 0) { + $this->children[$pid] = true; + } + + return CompletableFuture::completedFuture(null); + } + + /** + * {@inheritDoc} + */ + public function sleep(float $seconds): void + { + $remaining = $seconds; + while ($remaining > 0) { + $step = min($remaining, self::SLEEP_STEP); + usleep((int) ($step * 1_000_000)); + if ($this->hasPcntl) { + pcntl_signal_dispatch(); + } + // Throw only when an interrupt was requested (an IDLE wait); a BUSY unit + // is stopped cooperatively and its sleeps return normally. + if ($this->interruptRequested && !$this->interruptDelivered) { + $this->interruptDelivered = true; + throw new InterruptedException(); + } + $this->heartbeat(); + $remaining -= $step; + } + } + + /** + * Fires the heartbeat callback at most once a second (the sync backend has no + * event loop, so this is the periodic hook available). + */ + private function heartbeat(): void + { + if ($this->onHeartbeat === null) { + return; + } + $now = microtime(true); + if (($now - $this->lastHeartbeat) >= 1.0) { + $this->lastHeartbeat = $now; + ($this->onHeartbeat)(); + } + } + + /** + * {@inheritDoc} + */ + public function running(): bool + { + return !$this->stop; + } + + /** + * {@inheritDoc} + */ + public function requestStop(bool $interrupt): void + { + if ($this->stop) { + return; + } + $this->stop = true; + if ($interrupt) { + $this->interruptRequested = true; + } + + // Backstop: SIGALRM force-exits after the grace window, interrupting even a + // native blocking call. + if ($this->hasPcntl && $this->grace > 0) { + pcntl_signal(SIGALRM, function (): void { + if ($this->onForceExit !== null) { + ($this->onForceExit)(); + } + exit(1); + }); + pcntl_alarm((int) ceil($this->grace)); + } + } + + /** + * {@inheritDoc} + */ + public function inFlight(): int + { + $this->reap(); + return count($this->children); + } + + /** + * Harvests any finished spawn children without blocking (avoids zombies). + */ + private function reap(): void + { + if (!$this->hasPcntl) { + return; + } + while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) { + unset($this->children[$pid]); + } + } + + /** + * Blocks until every spawn child has finished — the structured-concurrency + * drain that runs after the body returns. + */ + private function waitAll(): void + { + if (!$this->hasPcntl) { + return; + } + foreach (array_keys($this->children) as $pid) { + pcntl_waitpid($pid, $status); + unset($this->children[$pid]); + } + } +} diff --git a/src/Process/Entity/TCondition.php b/src/Process/Entity/TCondition.php deleted file mode 100644 index 88c04ee..0000000 --- a/src/Process/Entity/TCondition.php +++ /dev/null @@ -1,15 +0,0 @@ -startedAt); - } -} diff --git a/src/Process/Entity/TInfo.php b/src/Process/Entity/TInfo.php deleted file mode 100644 index 722ff0e..0000000 --- a/src/Process/Entity/TInfo.php +++ /dev/null @@ -1,14 +0,0 @@ -rssKb / 1024; - } -} diff --git a/src/Process/Entity/TStatus.php b/src/Process/Entity/TStatus.php deleted file mode 100644 index 2b53a46..0000000 --- a/src/Process/Entity/TStatus.php +++ /dev/null @@ -1,21 +0,0 @@ -startedAt); - } -} diff --git a/src/Process/ForkReset.php b/src/Process/ForkReset.php new file mode 100644 index 0000000..ff792cd --- /dev/null +++ b/src/Process/ForkReset.php @@ -0,0 +1,61 @@ + */ + private static array $handlers = []; + + /** Static-only registry — not instantiable. */ + private function __construct() + { + } + + /** + * Registers a reset to run in every forked worker. Call once at bootstrap. + */ + public static function register(callable $handler): void + { + self::$handlers[] = $handler; + } + + /** + * Runs every registered reset. A throwing handler is swallowed so one bad + * reset never aborts worker boot or blocks the others. + */ + public static function runAll(): void + { + foreach (self::$handlers as $handler) { + try { + $handler(); + } catch (\Throwable) { + // best-effort — a failing reset must not abort worker boot + } + } + } + + /** + * Clears all handlers (mainly for tests). + */ + public static function clear(): void + { + self::$handlers = []; + } +} diff --git a/src/Process/Internal/SingletonLock.php b/src/Process/Internal/SingletonLock.php new file mode 100644 index 0000000..4161679 --- /dev/null +++ b/src/Process/Internal/SingletonLock.php @@ -0,0 +1,63 @@ +lockPath(), 'c'); + if ($handle === false) { + return true; + } + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return false; + } + $this->lockHandle = $handle; + return true; + } + + /** + * Releases the lock, if held. The OS also releases it when the process dies. + */ + private function releaseLock(): void + { + if ($this->lockHandle !== null) { + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + } + } + + /** + * Path of this class's lock file, under the runnable storage directory. + */ + private function lockPath(): string + { + return Kernel::$pathStorageRunnable . '/' . str_replace('\\', '.', static::class) . '.lock'; + } +} diff --git a/src/Process/InterruptedException.php b/src/Process/InterruptedException.php new file mode 100644 index 0000000..a348fa9 --- /dev/null +++ b/src/Process/InterruptedException.php @@ -0,0 +1,23 @@ + $class + */ + public function __construct(private string $class) + { + } + + /** + * Runs in the detached child: starts the process in the foreground, so the + * child becomes the process. {@inheritDoc} + * + * @param array $args Launch arguments (unused — a process is self-configuring). + */ + public function run(array $args): void + { + ($this->class)::start(); + } +} diff --git a/src/Process/ProcessState.php b/src/Process/ProcessState.php new file mode 100644 index 0000000..2483bd5 --- /dev/null +++ b/src/Process/ProcessState.php @@ -0,0 +1,28 @@ +startedAt); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'pid' => $this->pid, + 'class' => $this->className, + 'state' => $this->state->name, // NEW | RUNNING | STOPPING | … + 'activity' => $this->activity->value, // idle | busy + 'started_at' => $this->startedAt, + 'uptime' => time() - $this->startedAt, + 'concurrency' => $this->concurrency, + 'heartbeat_at' => $this->heartbeatAt, // last liveness beat (0 = none) + 'usage' => $this->usage, // ResourceUsage|null (also JsonSerializable) + ]; + } +} diff --git a/src/Process/ProcessStore.php b/src/Process/ProcessStore.php new file mode 100644 index 0000000..62b298e --- /dev/null +++ b/src/Process/ProcessStore.php @@ -0,0 +1,36 @@ +key = str_replace('\\', '.', $className); + } + + /** + * The backing {@see FileStorage} for this class's runnable records. + */ + public function main(): FileStorage + { + return Kernel::runnable($this->key); + } +} diff --git a/src/Process/ResourceUsage.php b/src/Process/ResourceUsage.php new file mode 100644 index 0000000..d3a7bd9 --- /dev/null +++ b/src/Process/ResourceUsage.php @@ -0,0 +1,84 @@ +/dev/null', + $pid + ); + exec($command, $output, $exitCode); + if ($exitCode !== 0 || $output === []) { + return null; + } + + $parts = preg_split('/\s+/', trim($output[0]), 8); + if ($parts === false || count($parts) < 8) { + return null; + } + + [$pid, $ppid, $user, $cpu, $memory, $rss, $elapsed, $command] = $parts; + return new self( + pid: (int) $pid, + ppid: (int) $ppid, + user: $user, + cpu: (float) $cpu, + memory: (float) $memory, + rssKb: (int) $rss, + elapsed: $elapsed, + command: $command, + ); + } + + /** + * Resident set size in megabytes. + */ + public function rssMb(): float + { + return $this->rssKb / 1024; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'pid' => $this->pid, + 'ppid' => $this->ppid, + 'user' => $this->user, + 'cpu' => $this->cpu, + 'memory' => $this->memory, + 'rss_mb' => round($this->rssMb(), 1), + 'elapsed' => $this->elapsed, + 'command' => $this->command, + ]; + } +} diff --git a/src/Process/Socket/Web/PDU/DecodedFrame.php b/src/Process/Socket/Web/PDU/DecodedFrame.php deleted file mode 100644 index d440248..0000000 --- a/src/Process/Socket/Web/PDU/DecodedFrame.php +++ /dev/null @@ -1,14 +0,0 @@ -error) { - return "[type:{$this->type}, error:{$this->error}]"; - } - return "[type:{$this->type}, payload:{$this->payload}]"; - } -} diff --git a/src/Process/Socket/Web/PDU/WSResource.php b/src/Process/Socket/Web/PDU/WSResource.php deleted file mode 100644 index 2d97219..0000000 --- a/src/Process/Socket/Web/PDU/WSResource.php +++ /dev/null @@ -1,53 +0,0 @@ -connect = $connect; - $this->info = $info; - } - - public function getConnect() - { - return $this->connect; - } - - public function getInfo(string $key): mixed - { - return $this->info[$key] ?? null; - } - - public function info(): array - { - return $this->info ?? []; - } - - public function getStore(): array - { - return $this->store; - } - - public function setStore(array $store): void - { - $this->store = $store; - } - - public function __toString(): string - { - return (string) $this->connect; - } -} diff --git a/src/Process/Socket/Web/SocketWebServerHandler.php b/src/Process/Socket/Web/SocketWebServerHandler.php deleted file mode 100644 index 8eeb411..0000000 --- a/src/Process/Socket/Web/SocketWebServerHandler.php +++ /dev/null @@ -1,42 +0,0 @@ -resolutionEnd(); - $this->asInterrupt(); - exit(); - } - - private function signTermination(): never - { - $this->resolutionEnd(); - $this->asTermination(); - exit(1); - } - - private function signClose(): never - { - $this->resolutionEnd(); - $this->asClose(); - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } -} diff --git a/src/Process/Socket/Web/ThreadWebSocket.php b/src/Process/Socket/Web/ThreadWebSocket.php deleted file mode 100644 index 0a9fa3c..0000000 --- a/src/Process/Socket/Web/ThreadWebSocket.php +++ /dev/null @@ -1,227 +0,0 @@ -prepareSignalHandler(); - } - - final public static function dispatch(mixed $data = null): int - { - return parent::dispatch($data); - } - - /** @throws ThreadException */ - final public function resolution(mixed $data = null): void - { - if (is_array($data)) { - $this->ip = (string) ($data['ip'] ?? $this->ip); - $this->port = (int) ($data['port'] ?? $this->port); - } - - $this->logger->debug("Starting the Web Server...[tcp://{$this->ip}:{$this->port}]"); - - try { - $this->resourceConnection = stream_socket_server( - "tcp://{$this->ip}:{$this->port}", - $errno, - $errorStr, - STREAM_SERVER_BIND | STREAM_SERVER_LISTEN - ); - if (!$this->resourceConnection) { - throw new ThreadException("Cannot start server: {$errorStr}({$errno})"); - } - - stream_set_blocking($this->resourceConnection, false); - $this->logger->debug("Server is running. Listening for connections..."); - $this->startTime = time(); - $this->listen(); - } catch (\Throwable $exception) { - $this->logger->critical($exception->getMessage()); - } finally { - $this->socketClose(); - } - } - - final protected function resolutionEnd(): void - { - $this->socketClose(); - } - - final protected function disconnectClient(WSResource $resource): void - { - try { - $this->handleDisconnect($resource); - } catch (\Throwable $exception) { - $this->logger->error('handlerDisconnect: ' . $exception->getMessage()); - } - - @fwrite($resource->getConnect(), WebSocketProtocol::encode('Connection closed', 'close', false, 1000)); - @fclose($resource->getConnect()); - unset($this->connects[(string) $resource]); - $this->logger->debug("Client disconnected: {$resource}"); - } - - final protected function socketClose(): void - { - foreach ($this->connects as $resource) { - $this->disconnectClient($resource); - } - $this->connects = []; - - if (is_resource($this->resourceConnection)) { - fclose($this->resourceConnection); - $this->resourceConnection = null; - } - $this->logger->debug("All connections closed."); - } - - public function send(WSResource $resource, string $payload, string $type = 'text'): void - { - if (!isset($this->connects[(string) $resource])) { - $this->logger->warning("Attempted to send to a non-existent or closed connection: {$resource}"); - return; - } - $frame = WebSocketProtocol::encode($payload, $type); - $resource->writeBuffer .= $frame; - $this->logger->debug("Queued " . strlen($frame) . " bytes to send to {$resource}"); - } - - private function listen(): void - { - while (true) { - $read = array_map(fn(WSResource $res) => $res->getConnect(), $this->connects); - $read[] = $this->resourceConnection; - $write = []; - foreach ($this->connects as $resource) { - if (strlen($resource->writeBuffer) > 0) { - $write[] = $resource->getConnect(); - } - } - $except = null; - - $seconds = intdiv($this->loopInterval, 1_000_000); - $microseconds = $this->loopInterval % 1_000_000; - $activity = @stream_select($read, $write, $except, $seconds, $microseconds); - if ($activity === false) { - continue; - } - - if (in_array($this->resourceConnection, $read, true)) { - if ($newConnection = stream_socket_accept($this->resourceConnection, 0)) { - stream_set_blocking($newConnection, false); - $info = WebSocketProtocol::handshake($newConnection); - if ($info !== false) { - $resource = new WSResource($newConnection, $info); - $this->connects[(string) $newConnection] = $resource; - $this->logger->debug("New client connected: {$resource}"); - try { - $this->handleConnect($resource); - } catch (\Throwable $exception) { - $this->logger->error('handlerConnect: ' . $exception->getMessage()); - } - } - } - unset($read[array_search($this->resourceConnection, $read, true)]); - } - - foreach ($read as $connect) { - $resource = $this->connects[(string) $connect]; - $data = @fread($connect, 65535); - - if ($data === false || ($data === '' && feof($connect))) { - $this->logger->debug("Client {$resource} has disconnected (EOF)."); - $this->disconnectClient($resource); - continue; - } - if ($data === '') { - continue; - } - - $resource->readBuffer .= $data; - while (strlen($resource->readBuffer) > 0) { - $decodedFrame = WebSocketProtocol::decode($resource->readBuffer); - if ($decodedFrame === false) { - break; - } - - $resource->readBuffer = substr($resource->readBuffer, $decodedFrame->frameLength); - $msg = $decodedFrame->msg; - - if ($msg->type === 'error' || $msg->type === 'close') { - if ($msg->type === 'error') { - $this->logger->warning( - "Received '{$msg->type}' frame from {$resource}. Closing connection." - ); - } - $this->disconnectClient($resource); - break; - } - - try { - $this->handle($resource, $msg); - } catch (\Throwable $exception) { - $this->logger->error('handler: ' . $exception->getMessage()); - } - } - } - - foreach ($write as $connect) { - $resource = $this->connects[(string) $connect]; - $bytesWritten = @fwrite($connect, $resource->writeBuffer); - if ($bytesWritten === false) { - $this->disconnectClient($resource); - continue; - } - $resource->writeBuffer = $bytesWritten === strlen($resource->writeBuffer) - ? '' - : substr($resource->writeBuffer, $bytesWritten); - } - - if ($this->timeWorkLimit > 0 && (time() - $this->startTime) > $this->timeWorkLimit) { - $this->logger->notice('Time limit reached. Stopping server.'); - break; - } - - pcntl_signal_dispatch(); - $this->loop(); - } - } - - protected function loop(): void - { - } -} diff --git a/src/Process/Socket/Web/WebSocketProtocol.php b/src/Process/Socket/Web/WebSocketProtocol.php deleted file mode 100644 index 359be5d..0000000 --- a/src/Process/Socket/Web/WebSocketProtocol.php +++ /dev/null @@ -1,195 +0,0 @@ - 136, - 'ping' => 137, - 'pong' => 138, - default => 129, // text - }; - - if ($payloadLength > 65535) { - $payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8); - $frameHead[1] = $masked ? 255 : 127; - for ($i = 0; $i < 8; $i++) { - $frameHead[$i + 2] = bindec($payloadLengthBin[$i]); - } - if ($frameHead[2] > 127) { - throw new \Exception('Frame too large (1004)'); - } - } elseif ($payloadLength > 125) { - $payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8); - $frameHead[1] = $masked ? 254 : 126; - $frameHead[2] = bindec($payloadLengthBin[0]); - $frameHead[3] = bindec($payloadLengthBin[1]); - } else { - $frameHead[1] = $masked ? $payloadLength + 128 : $payloadLength; - } - - foreach (array_keys($frameHead) as $i) { - $frameHead[$i] = chr($frameHead[$i]); - } - - $mask = []; - if ($masked) { - for ($i = 0; $i < 4; $i++) { - $mask[$i] = chr(rand(0, 255)); - } - $frameHead = array_merge($frameHead, $mask); - } - $frame = implode('', $frameHead); - - for ($i = 0; $i < $payloadLength; $i++) { - $frame .= $masked ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; - } - - return $frame; - } - - public static function decode(string $buffer): DecodedFrame|false - { - $bufferLength = strlen($buffer); - if ($bufferLength < 2) { - return false; - } - - $firstByte = ord($buffer[0]); - $secondByte = ord($buffer[1]); - $opcode = $firstByte & 15; - $isMasked = ($secondByte & 128) === 128; - $payloadLength = $secondByte & 127; - - if (!$isMasked) { - return new DecodedFrame(new Msg('error', '', 'Protocol error: Frame not masked (1002)'), 2); - } - - $type = match ($opcode) { - 1 => 'text', - 2 => 'binary', - 8 => 'close', - 9 => 'ping', - 10 => 'pong', - default => null, - }; - - if ($type === null) { - return new DecodedFrame(new Msg('error', '', "Unknown opcode: {$opcode} (1003)"), 2); - } - - $headerOffset = 2; - if ($payloadLength === 126) { - if ($bufferLength < 4) { - return false; - } - $payloadLength = unpack('n', substr($buffer, 2, 2))[1]; - $headerOffset = 4; - } elseif ($payloadLength === 127) { - if ($bufferLength < 10) { - return false; - } - $parts = unpack('N2', substr($buffer, 2, 8)); - if ($parts[1] > 0 || $parts[2] < 0) { - return new DecodedFrame(new Msg('error', '', 'Frame too large (1009)'), 10); - } - $payloadLength = $parts[2]; - $headerOffset = 10; - } - - $payloadOffset = $headerOffset + 4; - $frameLength = $payloadOffset + $payloadLength; - - if ($bufferLength < $frameLength) { - return false; - } - - $mask = substr($buffer, $headerOffset, 4); - $payload = ''; - for ($i = 0; $i < $payloadLength; $i++) { - $payload .= $buffer[$payloadOffset + $i] ^ $mask[$i % 4]; - } - - return new DecodedFrame(new Msg($type, $payload), $frameLength); - } -} diff --git a/src/Process/Stereotype/Daemon.php b/src/Process/Stereotype/Daemon.php new file mode 100644 index 0000000..3c14836 --- /dev/null +++ b/src/Process/Stereotype/Daemon.php @@ -0,0 +1,421 @@ +isRunning()) { + * $this->markBusy(); + * // ... work ... + * $this->markIdle(); + * } + * } + * } + * + * // external — supervise a reusable Process class + * class Emails extends Daemon { + * protected int $replicas = 3; + * protected ?string $workerClass = SendProcess::class; + * } + * ``` + * + * The whole process tree, forking/reaping, restart, drain and singleton lock are + * handled by the fleet supervision ({@see SupervisesFleet}); you write only + * config + body (+ optional autoscaling and hooks). + */ +abstract class Daemon extends Process +{ + use SingletonLock; + use SupervisesFleet; + + /** Baseline number of workers to keep running (the default scale target). */ + protected int $replicas = 1; + /** Worker Process class to supervise when {@see workerRun()} is not defined. */ + protected ?string $workerClass = null; + /** + * How long the master waits for a worker to drain on stop / scale-down before + * SIGKILL, in seconds. Overrides {@see Process::$grace} (0) with a bounded, + * deploy-safe default: a stuck worker can never hang the whole fleet's + * shutdown — mirroring Kubernetes' 30s terminationGracePeriodSeconds. Set to + * 0 to wait forever (never cut off in-flight work). + */ + protected float $grace = 30.0; + /** + * Watchdog: kill and restart a worker whose heartbeat has been silent this + * long, in seconds. Catches a wedged worker (deadlock, hung I/O, a boot that + * never finishes) that a plain liveness check misses. 0 disables it (the + * default). Under the fork runtime, set it longer than your longest + * non-yielding unit, or call {@see touch()} inside such a unit, to avoid + * killing a healthy-but-busy worker. + */ + protected float $livenessTimeout = 0.0; + + // ------------------------------------------------------------------------- + // Overridable policy (all optional — sane defaults apply) + // ------------------------------------------------------------------------- + + /** + * How many workers should be running right now. Override for autoscaling + * (e.g. from queue depth); the supervisor damps the value per {@see scaling()}. + */ + protected function desiredReplicas(): int + { + return $this->replicas(); + } + + /** + * Scaling damping policy (stability over speed). Override to tune. + */ + protected function scaling(): ScalingPolicy + { + return ScalingPolicy::default(); + } + + /** + * Restart policy for an unexpectedly dead worker. Override to tune. + */ + protected function restart(): RestartPolicy + { + return RestartPolicy::default(); + } + + /** + * Inline worker body. Define it to run the daemon itself as the worker; if it + * is not defined, {@see $workerClass} is supervised instead. + */ + protected function workerRun(): void + { + throw new DaemonConfigException( + static::class . ': workerRun() is not defined and $workerClass is not set.' + ); + } + + // ------------------------------------------------------------------------- + // Master lifecycle hooks (all optional) + // ------------------------------------------------------------------------- + + /** A worker was forked into a slot. */ + protected function onWorkerStart(int $slot, int $pid): void + { + } + + /** A worker exited ($crashed = a non-zero/abnormal exit of a live worker). */ + protected function onWorkerExit(int $slot, int $pid, bool $crashed): void + { + } + + /** The fleet size changed. */ + protected function onScale(int $from, int $to): void + { + } + + /** Periodic master callback (about once per scaleInterval) — poll metrics here. */ + protected function tick(): void + { + } + + // ------------------------------------------------------------------------- + // Worker body wiring — the engine calls run(), which delegates to workerRun() + // ------------------------------------------------------------------------- + + /** + * Satisfies the {@see Process} body contract by delegating to + * {@see workerRun()}. Final — a daemon defines its inline body in + * `workerRun()` (or supervises {@see $workerClass}), never by overriding run(). + */ + final public function run(): void + { + $this->workerRun(); + } + + // ------------------------------------------------------------------------- + // Control surface + // ------------------------------------------------------------------------- + + /** + * Launches the supervisor in the foreground, registering it in the store so + * {@see status()} / {@see stop()} reach it from another terminal. + */ + final public static function start(): void + { + static::ensureNotRunning(); + + /** @var static $self */ + $self = Container::getInstance()->make(static::class); + $self->supervise(); + } + + // ------------------------------------------------------------------------- + // Internal surface consumed by the SupervisesFleet trait (all private) + // ------------------------------------------------------------------------- + + /** @internal */ + private function replicas(): int + { + return max(1, $this->replicas); + } + + /** @internal */ + private function computeDesired(): int + { + return max(0, $this->desiredReplicas()); + } + + /** @internal */ + private function scalingPolicy(): ScalingPolicy + { + return $this->scaling(); + } + + /** @internal */ + private function restartPolicy(): RestartPolicy + { + return $this->restart(); + } + + /** @internal Master's drain-deadline budget for a stopping/retiring worker. */ + private function graceSeconds(): float + { + return max(0.0, $this->grace); + } + + /** @internal Watchdog silence threshold (0 = disabled). */ + private function livenessTimeout(): float + { + return max(0.0, $this->livenessTimeout); + } + + /** + * Child side: resolves the worker body and runs it in the forked worker. + * + * @internal Called by the supervisor in the fork child. + */ + private function bootWorker(int $slot): void + { + $title = $this->workerTitle($slot); + + if ($this->definesWorkerRun()) { + $this->runWorker($slot, $title, static::class); + return; + } + + if ($this->workerClass !== null) { + $worker = Container::getInstance()->make($this->workerClass); + if (!$worker instanceof Process) { + throw new DaemonConfigException( + static::class . ": \$workerClass {$this->workerClass} must extend Process." + ); + } + $worker->runWorker($slot, $title, static::class); + return; + } + + throw new DaemonConfigException( + static::class . ': no worker body — define workerRun() or set $workerClass.' + ); + } + + /** @internal Best-effort read of a worker's last heartbeat record. */ + private function workerRecord(int $slot): ?ProcessStatus + { + try { + $record = static::store()->read($this->workerRecordKey($slot)); + return $record instanceof ProcessStatus ? $record : null; + } catch (\Throwable) { + return null; + } + } + + /** @internal Removes a worker's per-slot heartbeat record. */ + private function clearWorkerRecord(int $slot): void + { + try { + static::store()->del($this->workerRecordKey($slot)); + } catch (\Throwable) { + // best-effort cleanup + } + } + + /** @internal */ + private function fireWorkerStart(int $slot, int $pid): void + { + $this->guard(fn() => $this->onWorkerStart($slot, $pid)); + } + + /** @internal */ + private function fireWorkerExit(int $slot, int $pid, bool $crashed): void + { + $this->guard(fn() => $this->onWorkerExit($slot, $pid, $crashed)); + } + + /** @internal */ + private function fireScale(int $from, int $to): void + { + $this->guard(fn() => $this->onScale($from, $to)); + } + + /** @internal */ + private function fireTick(): void + { + $this->guard(fn() => $this->tick()); + } + + /** @internal */ + private function fireReload(): void + { + $this->guard(fn() => $this->onReload()); + } + + // ------------------------------------------------------------------------- + // Title + // ------------------------------------------------------------------------- + + /** + * The master's `ps` title, e.g. `winter-daemon: Emails master`. + */ + protected function buildProcessTitle(): string + { + return 'winter-daemon: ' . $this->titleName() . ' master'; + } + + /** + * A worker's `ps` title, e.g. `winter-daemon: Emails worker#2`. The number is + * one-based (slot 0 → worker#1), so the whole tree shares a `winter-daemon:` + * prefix and can be found — or killed — together. + */ + private function workerTitle(int $slot): string + { + return 'winter-daemon: ' . $this->titleName() . ' worker#' . ($slot + 1); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * The master loop: takes the singleton lock, publishes the {@see DaemonStatus} + * record, runs the fleet loop ({@see superviseFleet()}), and on exit runs {@see onShutdown()}, + * removes the record and releases the lock. + */ + private function supervise(): void + { + if (!$this->acquireLock()) { + LoggerFactory::getLogger(static::class)->notice( + static::class . ' is already running; not starting a second supervisor.' + ); + return; + } + + $this->pid = getmypid(); + $this->logger = LoggerFactory::getLogger(static::class); + if (function_exists('cli_set_process_title')) { + @cli_set_process_title($this->buildProcessTitle()); // master title + } + + $store = static::store(); + $key = static::key(); + $startedAt = time(); + + $write = function () use ($store, $key, $startedAt): void { + $workers = $this->snapshot(); + $busy = false; + foreach ($workers as $worker) { + if ($worker->activity === Activity::BUSY) { + $busy = true; + break; + } + } + try { + $store->write($key, new DaemonStatus( + pid: $this->pid, + className: static::class, + state: $this->isStopping() ? ProcessState::STOPPING : ProcessState::RUNNING, + activity: $busy ? Activity::BUSY : Activity::IDLE, + startedAt: $startedAt, + concurrency: $this->concurrency, + restarts: $this->restartsTotal(), + workers: $workers, + )); + } catch (\Throwable $e) { + $this->logger->warning('Daemon status write failed: ' . $e->getMessage()); + } + }; + + $write(); + // Backstop the finally below against a forced/fatal exit that skips it. + register_shutdown_function(static fn() => $store->del($key)); + + try { + $final = $this->superviseFleet($write); + if ($final === ProcessState::FAILED) { + $this->logger->critical('Daemon reached maxRestarts; giving up.'); + } + } catch (\Throwable $e) { + $this->logger->critical( + $e->getMessage() + . (env('DEBUG', false) ? "\n" . $e->getTraceAsString() : '') + ); + } finally { + $this->guard(fn() => $this->onShutdown()); + $store->del($key); + $this->releaseLock(); + } + } + + /** + * Runs a user hook, swallowing (and logging) any exception so a faulty hook + * can never take the supervisor down. + */ + private function guard(callable $hook): void + { + try { + $hook(); + } catch (\Throwable $e) { + $this->logger->error('Daemon hook failed: ' . $e->getMessage()); + } + } + + /** + * Whether a subclass overrode {@see workerRun()} — the signal to run the + * daemon itself as the worker rather than supervising {@see $workerClass}. + */ + private function definesWorkerRun(): bool + { + return (new \ReflectionMethod($this, 'workerRun'))->getDeclaringClass()->getName() !== self::class; + } + + /** + * Store key of a worker's per-slot heartbeat record (this daemon's class + slot). + */ + private function workerRecordKey(int $slot): string + { + return hash('xxh64', static::class . '#' . $slot); + } +} diff --git a/src/Process/Stereotype/Process.php b/src/Process/Stereotype/Process.php new file mode 100644 index 0000000..d70b51b --- /dev/null +++ b/src/Process/Stereotype/Process.php @@ -0,0 +1,630 @@ +rabbit->connect(); + * while ($this->isRunning()) { + * $msg = $ch->get(timeout: 1.0); + * if ($msg === null) { continue; } + * $this->markBusy(); + * // ... process $msg ... + * $ch->ack($msg); + * $this->markIdle(); + * } + * $ch->close(); + * } + * } + * ``` + */ +abstract class Process +{ + use SingletonLock; + + /** Maximum simultaneous {@see spawn()} tasks; 0 means unlimited. */ + protected int $concurrency = 0; + /** Seconds to wait for a BUSY unit / in-flight spawns to drain on stop before forcing. 0 = wait forever. */ + protected float $grace = 0.0; + /** Title shown in `ps` / status; defaults to the short class name. */ + protected ?string $processTitle = null; + + protected LoggerInterface $logger; + protected int $pid; + private ProcessEngine $engine; + private bool $stopping = false; + private bool $shutdownDone = false; + private bool $inlineBusy = false; + + // Status store bookkeeping (a bare process owns its record; a daemon worker + // instead writes a per-slot heartbeat record under its owner daemon's store). + private bool $ownsRecord = false; + private int $startedAt = 0; + private ProcessState $state = ProcessState::NEW; + private ?Activity $writtenActivity = null; + + // Daemon-worker context, injected by the supervisor before runWorker(). + private ?int $workerSlot = null; + private ?string $ownerClass = null; + private ?string $titleOverride = null; + /** Microtime of the last worker heartbeat write; throttles {@see touch()}. */ + private float $lastHeartbeatAt = 0.0; + /** Minimum seconds between heartbeat writes (throttle for a tight touch() loop). */ + private const float HEARTBEAT_THROTTLE = 1.0; + + /** + * Constructed by the framework via the DI container (so `#[Autowired]` + * dependencies resolve) — never `new`ed by application code. Use the static + * control surface ({@see start()} / {@see dispatch()}) instead. + */ + final public function __construct() + { + } + + /** + * The process body — your long-running work. Runs inside the chosen runtime + * (Swoole coroutines, or a plain process). Loop on {@see isRunning()} so a + * stop signal can end it cleanly. + * + * ``` + * public function run(): void + * { + * while ($this->isRunning()) { + * // ... do one unit of work ... + * $this->sleep(0.5); + * } + * } + * ``` + */ + abstract public function run(): void; + + // ------------------------------------------------------------------------- + // Primitives available to the body + // ------------------------------------------------------------------------- + + /** + * Keep looping? False once a stop has been requested (signal or {@see requestStop()}). + */ + final protected function isRunning(): bool + { + return $this->engine->running(); + } + + /** + * Interruptible pause — non-blocking under Swoole. Throws + * {@see \Flytachi\Winter\Kernel\Process\InterruptedException} if an IDLE wait is woken by a stop. + */ + final protected function sleep(float $seconds): void + { + $this->engine->sleep($seconds); + } + + /** + * Dispatches a task concurrently (coroutine under Swoole, fork otherwise), + * capped by {@see $concurrency}. The process always waits for its spawns to + * finish before exiting; while any is in flight the process is BUSY. + */ + final protected function spawn(callable $task): Future + { + return $this->engine->spawn($task); + } + + /** + * Requests a graceful stop of this process from inside the body — the + * cooperative equivalent of receiving SIGTERM. + */ + final protected function requestStop(): void + { + // Do not abort an inline BUSY unit; wake only an IDLE wait. + $this->engine->requestStop(!$this->inlineBusy); + } + + /** + * Marks the start of an inline unit of work (no {@see spawn()}). Keeps the + * process BUSY so it is not interrupted mid-unit and not scaled down. + * + * This is also where the **request scope ends and a new one begins**. Under HTTP a + * request is a coroutine and its scope dies with it; a worker has no such boundary — + * its whole body runs in one coroutine, so a `#[Request]` bean resolved inside would + * survive every iteration and carry the previous job's state into the next. Verified: + * four iterations, one object, each seeing what the one before it wrote. + * + * Only the body knows where a unit ends, and this call already says so — the activity + * flag and the scope boundary are two readings of the same event, so the developer + * gets correct scoping without having to know the scope exists. + * + * A body that never calls this has declared no units, and nothing is reset. + */ + final protected function markBusy(): void + { + $this->inlineBusy = true; + + if (Container::isInitialized()) { + Container::getInstance()->flushRequestScope(); + } + } + + /** + * Marks the end of an inline unit of work. + */ + final protected function markIdle(): void + { + $this->inlineBusy = false; + } + + /** + * Current activity: BUSY while an inline unit is marked or any spawn is in + * flight, IDLE otherwise. + */ + final protected function activity(): Activity + { + return $this->inlineBusy || $this->engine->inFlight() > 0 + ? Activity::BUSY + : Activity::IDLE; + } + + /** + * Explicit liveness beat for a daemon watchdog. The engine already beats + * about once a second, which covers a worker that yields (sleep / coroutine + * I/O). Call {@see touch()} from inside a long, non-yielding unit of work so a + * heartbeat still lands and the supervisor does not mistake progress for a + * hang — mainly needed under the fork runtime, where nothing beats while a + * native blocking call runs. Throttled, so a tight loop is safe; a no-op for + * a bare process (the watchdog is a daemon concern). + */ + final protected function touch(): void + { + if ($this->workerSlot === null) { + return; + } + if ((microtime(true) - $this->lastHeartbeatAt) >= self::HEARTBEAT_THROTTLE) { + $this->writeWorkerRecord(); + } + } + + // ------------------------------------------------------------------------- + // Signal hooks — override to react to a specific signal + // ------------------------------------------------------------------------- + + /** SIGTERM — stop is guaranteed; override only to react. */ + protected function onTerminate(): void + { + } + + /** SIGINT — Ctrl-C; stop is guaranteed. */ + protected function onInterrupt(): void + { + } + + /** SIGHUP — reload configuration. Does NOT stop by default. */ + protected function onReload(): void + { + } + + /** SIGUSR1 — a user-defined action (e.g. reopen log files). */ + protected function onUser1(): void + { + } + + /** SIGUSR2 — a user-defined action (e.g. dump stats, toggle debug). */ + protected function onUser2(): void + { + } + + /** Guaranteed teardown — runs on every exit path (graceful, forced, fatal). */ + protected function onShutdown(): void + { + } + + /** + * Runs in a freshly forked daemon worker, before {@see run()}. Resets + * inherited fork-unsafe resources — every framework reset registered with + * {@see ForkReset} runs here (e.g. a DB pool reconnect). Override to reset + * your own resources (call `parent::afterFork()` first). A bare foreground + * process is not forked and never runs this. + */ + protected function afterFork(): void + { + ForkReset::runAll(); + } + + // ------------------------------------------------------------------------- + // Control surface (CLI / web) + // ------------------------------------------------------------------------- + + /** + * Runs the process in the foreground, registering it in the store so + * {@see status()} / {@see stop()} reach it from another terminal. + */ + public static function start(): void + { + static::ensureNotRunning(); + + /** @var static $self */ + $self = Container::getInstance()->make(static::class); + $self->boot(); + } + + /** + * Launches the process detached in the background and returns its PID. + * + * @param string|null $output '/dev/null' (default) or a file path for the child's stdio. + */ + final public static function dispatch(?string $output = '/dev/null'): int + { + static::ensureNotRunning(); + + return new Thread( + new ProcessRunnable(static::class), + 'process', + new \ReflectionClass(static::class)->getShortName(), + )->start(outputTarget: $output, detached: true); + } + + /** + * Refuses to launch a second instance of the same class. + * + * @throws ProcessAlreadyRunningException If one is already running. + */ + protected static function ensureNotRunning(): void + { + $info = static::status(); + if ($info !== null) { + throw new ProcessAlreadyRunningException( + static::class . " is already running [PID {$info->pid}] (since {$info->getStartedAt()})." + ); + } + } + + /** + * Current status, or null when the process is not running. + * + * @param bool $usage Attach live resource usage (CPU/memory via `ps`). + */ + final public static function status(bool $usage = false): ?ProcessStatus + { + try { + $store = static::store(); + $key = static::key(); + /** @var ?ProcessStatus $status */ + $status = $store->read($key); + if (!$status) { + return null; + } + // Liveness via getpgid, not kill(pid, 0): getpgid needs no permission, + // so a status check from another user (web as `winter`, process as + // root) cannot mistake a live process for a dead one. And status() is a + // pure read — it never deletes: a read must have no side effect, or any + // caller who can query could evict a running process's record. A stale + // record left by a crash is harmless (this returns null) and is + // overwritten by the next start(). + if (posix_getpgid($status->pid) === false) { + return null; + } + if ($usage) { + $status->usage = ResourceUsage::ofPid($status->pid); + } + return $status; + } catch (\Throwable) { + return null; + } + } + + /** + * Sends a graceful stop signal (SIGTERM). Returns false when nothing is running. + */ + final public static function stop(): bool + { + $status = static::status(); + if (!$status) { + return false; + } + return posix_kill($status->pid, SIGTERM); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Foreground entry point: takes the singleton lock, registers the status + * record, runs the body, and guarantees the record and lock are released on + * every exit path (graceful, forced or fatal). + */ + private function boot(): void + { + // The status check in start()/dispatch() catches the common case; this + // exclusive lock closes the race between two near-simultaneous launches. + // A flock is released automatically when the process dies, so it is never + // left stale — unlike a PID file. + if (!$this->acquireLock()) { + LoggerFactory::getLogger(static::class)->notice( + static::class . ' is already running; not starting a second instance.' + ); + return; + } + + $this->prepareWorker(); + $this->ownsRecord = true; + $this->writeStatus(); + + try { + $this->runBody(); + } finally { + static::store()->del(static::key()); + $this->releaseLock(); + } + } + + /** + * Worker entry point used by a Daemon supervisor: resets inherited resources, + * sets up the runtime and runs the body. It does not own the main store record + * (the supervisor does); instead it writes a per-slot heartbeat the supervisor + * reads for the fleet view. + * + * @param int|null $slot Stable fleet slot, or null for a bare worker. + * @param string|null $title OS process title to apply (the daemon's worker title). + * @param string|null $ownerClass Owning daemon class whose store holds the per-slot record. + * @internal Not for application code — calling it re-enters the engine. + * Protected (not public) so a daemon can boot an external + * {@see \Flytachi\Winter\Kernel\Process\Stereotype\Daemon::$workerClass} + * worker (a sibling Process) without exposing it to the outside. + */ + protected function runWorker(?int $slot = null, ?string $title = null, ?string $ownerClass = null): void + { + $this->workerSlot = $slot; + $this->titleOverride = $title; + $this->ownerClass = $ownerClass; + $this->afterFork(); + $this->prepareWorker(); + $this->runBody(); + } + + /** + * Wires up the running process — PID, logger, title, the runtime engine and a + * fatal backstop for {@see onShutdown()} — then writes an initial heartbeat + * for a daemon worker. Shared by the foreground path and a supervised worker. + */ + private function prepareWorker(): void + { + $this->pid = getmypid(); + $this->logger = LoggerFactory::getLogger(static::class); + $this->startedAt = time(); + $this->state = ProcessState::RUNNING; + $this->applyProcessTitle(); + + // Fatal backstop for onShutdown; the explicit calls below cover normal paths. + register_shutdown_function(fn() => $this->invokeShutdown()); + + $this->engine = Engines::common($this->concurrency, $this->grace); + + // A daemon worker writes its initial heartbeat at once, so the supervisor + // sees it promote from STARTING to RUNNING without waiting a full tick. + if ($this->workerSlot !== null) { + $this->writeStatus(); + } + } + + /** + * Runs the body inside the runtime engine with the signal map wired to the + * hooks (SIGTERM/SIGINT → stop, SIGHUP → reload, SIGUSR1/2 → user), and + * guarantees {@see onShutdown()} runs once however the body ends. + */ + private function runBody(): void + { + try { + $this->engine->enter( + fn() => $this->run(), + [ + SIGTERM => fn() => $this->onStopSignal(fn() => $this->onTerminate()), + SIGINT => fn() => $this->onStopSignal(fn() => $this->onInterrupt()), + SIGHUP => fn() => $this->onReload(), + SIGUSR1 => fn() => $this->onUser1(), + SIGUSR2 => fn() => $this->onUser2(), + ], + fn() => $this->invokeShutdown(), + fn() => $this->flushStatus(), + ); + } finally { + $this->invokeShutdown(); + } + } + + /** + * First stop signal begins a graceful stop and runs the hook; further stop + * signals are ignored (force is the grace timer or an external SIGKILL). + */ + private function onStopSignal(callable $hook): void + { + if ($this->stopping) { + return; + } + $this->stopping = true; + $this->state = ProcessState::STOPPING; + $this->requestStop(); + $this->writeStatus(); + $hook(); + } + + /** + * Runs {@see onShutdown()} exactly once, on whichever exit path reaches it. + */ + private function invokeShutdown(): void + { + if ($this->shutdownDone) { + return; + } + $this->shutdownDone = true; + try { + $this->onShutdown(); + } catch (\Throwable $e) { + $this->logger->error('onShutdown() failed: ' . $e->getMessage()); + } + } + + /** + * Sets the OS process title shown in `ps` / `htop`, so the process is easy to + * find and signal. No-op where `cli_set_process_title` is unavailable. Internal + * — override {@see buildProcessTitle()} / {@see titleName()} to customise it. + */ + private function applyProcessTitle(): void + { + if (!function_exists('cli_set_process_title')) { + return; + } + // A daemon injects the worker title (e.g. `winter-daemon: X worker#2`); + // a bare process builds its own. + @cli_set_process_title($this->titleOverride ?? $this->buildProcessTitle()); + } + + /** + * The display name for this process, without the runtime prefix: the explicit + * {@see $processTitle} when set, otherwise the class short name. + */ + protected function titleName(): string + { + return $this->processTitle ?? new \ReflectionClass(static::class)->getShortName(); + } + + /** + * The full OS process title. Overridable so a {@see Daemon} can label its + * master and numbered workers distinctly (e.g. `winter-daemon: X worker#2`). + */ + protected function buildProcessTitle(): string + { + return 'winter-process: ' . $this->titleName(); + } + + // ------------------------------------------------------------------------- + // Status store — in-memory authoritative, throttled + deduped write + // ------------------------------------------------------------------------- + + /** + * Called on the heartbeat (~1s): persists the record only when the activity + * actually changed, so a per-message BUSY/IDLE flip never storms the disk. + * Routes to the bare-process record or the daemon-worker per-slot record. + */ + private function flushStatus(): void + { + // A daemon worker beats every tick (monotonic liveness for the watchdog), + // regardless of whether its activity changed. A bare process dedups by + // activity to avoid disk storms — it has no watchdog. + if ($this->workerSlot !== null) { + $this->writeWorkerRecord(); + return; + } + if ($this->activity() === $this->writtenActivity) { + return; + } + $this->writeStatus(); + } + + /** + * Persists the status record now: a daemon worker writes its per-slot + * heartbeat, a bare foreground process writes (and owns) its own record. + */ + private function writeStatus(): void + { + // A daemon worker reports to its per-slot heartbeat record in the owner + // daemon's store; a bare foreground process owns its own record. + if ($this->workerSlot !== null) { + $this->writeWorkerRecord(); + return; + } + if (!$this->ownsRecord) { + return; + } + $activity = $this->activity(); + try { + static::store()->write(static::key(), new ProcessStatus( + pid: $this->pid, + className: static::class, + state: $this->state, + activity: $activity, + startedAt: $this->startedAt, + concurrency: $this->concurrency, + )); + } catch (\Throwable $e) { + $this->logger->warning('Status write failed: ' . $e->getMessage()); + return; + } + $this->writtenActivity = $activity; + } + + /** + * Writes the per-slot heartbeat a daemon worker reports to the supervisor. + * Keyed by the owner daemon's class + slot, so the supervisor aggregates all + * workers into its {@see \Flytachi\Winter\Kernel\Process\Daemon\DaemonStatus}. + */ + private function writeWorkerRecord(): void + { + $activity = $this->activity(); + $this->lastHeartbeatAt = microtime(true); + try { + $key = hash('xxh64', $this->ownerClass . '#' . $this->workerSlot); + (new ProcessStore($this->ownerClass))->main()->write($key, new ProcessStatus( + pid: $this->pid, + className: static::class, + state: $this->state, + activity: $activity, + startedAt: $this->startedAt, + concurrency: $this->concurrency, + heartbeatAt: time(), + )); + } catch (\Throwable $e) { + $this->logger->warning('Worker status write failed: ' . $e->getMessage()); + return; + } + $this->writtenActivity = $activity; + } + + /** + * Stable store key for this process class (its status record's address). + */ + final protected static function key(): string + { + return hash('xxh64', static::class); + } + + /** + * The runnable store — one status record per class — shared by the CLI and + * the web layer so both read the same source of truth. + */ + final protected static function store(): FileStorage + { + return (new ProcessStore(static::class))->main(); + } +} diff --git a/src/Process/ThreadDaemon.php b/src/Process/ThreadDaemon.php deleted file mode 100644 index 49f6caa..0000000 --- a/src/Process/ThreadDaemon.php +++ /dev/null @@ -1,131 +0,0 @@ -prepareSignalHandler(); - self::store()->main()->write(static::hashName(), new TDStatus( - pid: $this->pid, - className: static::class, - condition: TCondition::STARTED, - startedAt: time(), - streamRps: $this->streamRps, - info: [] - )); - } - - final protected function resolutionEnd(): void - { - self::store()->main()->del(static::hashName()); - } - - /** - * @throws DaemonException - */ - final public static function dispatch(mixed $data = null): int - { - $info = static::status(); - if ($info) { - throw new DaemonException( - "Daemon already exist [PID:{$info->status->pid}] ({$info->status->getStartedAt()})", - HttpCode::LOCKED->value - ); - } else { - return parent::dispatch($data); - } - } - - final protected function streaming(callable $complianceCallable, ?callable $negationCallable = null): void - { - while (true) { - if (static::forkQty() < $this->streamRps) { - $complianceCallable(); - } else { - if ($negationCallable !== null) { - $negationCallable(); - } - } - usleep((int) ($this->streamRps < 1000 ? ceil(1_000_000 / $this->streamRps) : 1000)); - pcntl_signal_dispatch(); - } - } - - final public static function status(bool $showStats = false): ?TDInfo - { - try { - $key = static::hashName(); - /** @var ?TDStatus $status */ - $status = self::store()->main()->read($key); - if (!$status) { - return null; - } - - if (!posix_getpgid($status->pid)) { - self::store()->main()->del($key); - return null; - } - - return new TDInfo( - status: $status, - stats: $showStats ? TStats::ofPid($status->pid) : null - ); - } catch (FileStorageException) { - return null; - } - } - - /** - * @throws DaemonException - */ - final public static function stop(): bool - { - $info = static::status(); - if ($info) { - return Signal::interrupt($info->status->pid); - } else { - throw new DaemonException('Daemon has not started', HttpCode::LOCKED->value); - } - } -} diff --git a/src/Process/ThreadJob.php b/src/Process/ThreadJob.php deleted file mode 100644 index 36cc73b..0000000 --- a/src/Process/ThreadJob.php +++ /dev/null @@ -1,32 +0,0 @@ -prepareSignalHandler(); - } - - final protected function resolutionEnd(): void - { - } - - final public static function dispatch(mixed $data = null): int - { - return parent::dispatch($data); - } -} diff --git a/src/Process/ThreadProcess.php b/src/Process/ThreadProcess.php deleted file mode 100644 index e8c5403..0000000 --- a/src/Process/ThreadProcess.php +++ /dev/null @@ -1,34 +0,0 @@ -prepareSignalHandler(); - } - - final protected function resolutionEnd(): void - { - } - - final public static function dispatch(mixed $data = null): int - { - return parent::dispatch($data); - } -} diff --git a/src/Process/Traits/ThreadDaemonFork.php b/src/Process/Traits/ThreadDaemonFork.php deleted file mode 100644 index 17e0862..0000000 --- a/src/Process/Traits/ThreadDaemonFork.php +++ /dev/null @@ -1,190 +0,0 @@ - $childrenPids Children process ids */ - protected array $childrenPids = []; - private bool $iAmChild = false; - - final protected function fork(callable $function): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('fork'); - try { - $function(); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - final protected function forkAnonymous(mixed $data = null): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('anonymous'); - try { - $this->anonymousResolution($data); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - protected function forkStart(string $tag): void - { - $this->iAmChild = true; - $this->logger = LoggerFactory::getLogger(static::class); - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - $parentTitle = cli_get_process_title(); - $title = str_replace( - $this->exNamespace, - ($this->exNamespace . '(fork)'), - $parentTitle - ); - $title = str_replace($this->exTag, $tag, $title); - cli_set_process_title($title); - } - $this->preparationForkBefore($this->pid); - } - - protected function forkEnd(): void - { - $this->preparationForkAfter($this->pid); - } - - public function anonymousResolution(mixed $data = null): void - { - $this->logger->info("-forkAnonymous- running"); - } - - final public function wait(int $pid, ?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - - final public function waitAll(?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - foreach (static::forkList() as $pid) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - } -} diff --git a/src/Process/Traits/ThreadDaemonHandler.php b/src/Process/Traits/ThreadDaemonHandler.php deleted file mode 100644 index 9f00692..0000000 --- a/src/Process/Traits/ThreadDaemonHandler.php +++ /dev/null @@ -1,86 +0,0 @@ -iAmChild) { - foreach (static::forkList() as $childPid) { - posix_kill($childPid, SIGINT); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asInterrupt(); - } else { - $this->preparationForkAfter($this->pid); - $this->asChildInterrupt(); - } - exit(); - } - - private function signTermination(): never - { - if (!$this->iAmChild) { - foreach (static::forkList() as $childPid) { - posix_kill($childPid, SIGTERM); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asTermination(); - } else { - $this->preparationForkAfter($this->pid); - $this->asChildTermination(); - } - exit(1); - } - - private function signClose(): never - { - if (!$this->iAmChild) { - foreach (static::forkList() as $childPid) { - posix_kill($childPid, SIGHUP); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asClose(); - } else { - $this->preparationForkAfter($this->pid); - $this->asChildClose(); - } - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } - - protected function asChildInterrupt(): void - { - $this->logger->notice("INTERRUPTED CHILD"); - } - - protected function asChildTermination(): void - { - $this->logger->warning("TERMINATION CHILD"); - } - - protected function asChildClose(): void - { - $this->logger->notice("CLOSE CHILD"); - } -} diff --git a/src/Process/Traits/ThreadDaemonStatement.php b/src/Process/Traits/ThreadDaemonStatement.php deleted file mode 100644 index f0d7854..0000000 --- a/src/Process/Traits/ThreadDaemonStatement.php +++ /dev/null @@ -1,139 +0,0 @@ -threads()->keys(); - return count($keys); - } - - /** - * @throws FileStorageException - */ - final public static function forkList(): array - { - $keys = static::store()->threads()->keys(); - foreach ($keys as $key => $path) { - $keys[$key] = (int) trim($path, '_'); - } - return $keys; - } - - /** - * @return TInfo[] - * @throws FileStorageException - */ - final public static function forkListInfo(bool $showStats = false): array - { - $store = static::store()->threads(); - $keys = $store->keys(); - foreach ($keys as $key => $path) { - $pid = (int) trim($path, '_'); - $keys[$key] = new TInfo( - status: $store->read($path), - stats: $showStats ? TStats::ofPid($pid) : null - ); - } - return $keys; - } - - /** - * @throws FileStorageException - */ - final public static function forkInfo(int $forkPid, bool $showStats = false): ?TInfo - { - $store = static::store()->threads(); - $status = $store->read("_{$forkPid}_"); - if (!$status) { - return null; - } - - return new TInfo( - status: $status, - stats: $showStats ? TStats::ofPid($forkPid) : null - ); - } - - final public static function forkSetCondition(int $threadPid, TCondition $newCondition): void - { - $store = static::store()->threads(); - /** @var TStatus $status */ - $status = $store->read("_{$threadPid}_"); - $status->condition = $newCondition; - $store->write("_{$threadPid}_", $status); - } - - final protected function setCondition(TCondition $newCondition): void - { - $store = static::store()->main(); - $key = static::hashName(); - /** @var TDStatus $status */ - $status = $store->read($key); - $status->condition = $newCondition; - $store->write($key, $status); - } - - final protected function setInfo(array $newInfo): void - { - $store = static::store()->main(); - $key = static::hashName(); - /** @var TDStatus $status */ - $status = $store->read($key); - $status->info = $newInfo; - $store->write($key, $status); - } - - final protected function prepare(int $streamRps = 0): void - { - $store = static::store()->main(); - $key = static::hashName(); - /** @var TDStatus $status */ - $status = $store->read($key); - $status->condition = TCondition::PREPARATION; - $store->write($key, $status); - - $status->streamRps = $streamRps; - $this->streamRps = $streamRps; - $store->write($key, $status); - $this->preparation(); - - /** @var TDStatus $status */ - $status = $store->read($key); - $status->condition = TCondition::ACTIVE; - $store->write($key, $status); - } - - protected function preparationForkBefore(int $forkPid): void - { - static::store()->threads() - ->write("_{$forkPid}_", new TStatus( - pid: $forkPid, - condition: TCondition::STARTED, - startedAt: time() - )); - } - - protected function preparationForkAfter(int $forkPid): void - { - static::store()->threads()->del("_{$forkPid}_"); - } -} diff --git a/src/Process/Traits/ThreadFork.php b/src/Process/Traits/ThreadFork.php deleted file mode 100644 index 79307fd..0000000 --- a/src/Process/Traits/ThreadFork.php +++ /dev/null @@ -1,188 +0,0 @@ - $childrenPids Children process ids */ - protected array $childrenPids = []; - private bool $iAmChild = false; - - final protected function fork(callable $function): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('fork'); - try { - $function(); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - final protected function forkAnonymous(mixed $data = null): int - { - try { - $pid = pcntl_fork(); - if ($pid != -1) { - if ($pid == 0) { - // Child process - try { - $this->pid = getmypid(); - $this->forkStart('anonymous'); - try { - $this->anonymousResolution($data); - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork logic (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } - } catch (\Throwable $exception) { - $this->logger->critical( - 'Process fork (anonymous) => ' . $exception->getMessage() - . (env('DEBUG', false) - ? "\n" . $exception->getTraceAsString() - : '' - ) - ); - } finally { - $this->forkEnd(); - exit(0); - } - } else { - // Parent process - if ($this->childrenPidSave) { - $this->childrenPids[] = $pid; - } - return $pid; - } - } else { - throw new RuntimeException("Unable to fork process."); - } - } catch (\Throwable $e) { - $this->logger->critical( - $e->getMessage() - . (env('DEBUG', false) - ? "\n" . $e->getTraceAsString() - : '' - ) - ); - return 0; - } - } - - protected function forkStart(string $tag): void - { - $this->iAmChild = true; - $this->logger = LoggerFactory::getLogger(static::class); - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - $parentTitle = cli_get_process_title(); - $title = str_replace( - $this->exNamespace, - ($this->exNamespace . '(fork)'), - $parentTitle - ); - $title = str_replace($this->exTag, $tag, $title); - cli_set_process_title($title); - } - } - - protected function forkEnd(): void - { - } - - public function anonymousResolution(mixed $data = null): void - { - $this->logger->info("-forkAnonymous- running"); - } - - final public function wait(int $pid, ?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - - final public function waitAll(?callable $callableEndChild = null): void - { - if ( - PHP_SAPI === 'cli' - && empty($_SERVER['REMOTE_ADDR']) - && function_exists('pcntl_signal') - ) { - foreach ($this->childrenPids as $pid) { - pcntl_waitpid($pid, $status); - if (!is_null($callableEndChild)) { - $callableEndChild($pid, $status); - } - pcntl_signal_dispatch(); - } - } - } -} diff --git a/src/Process/Traits/ThreadJobHandler.php b/src/Process/Traits/ThreadJobHandler.php deleted file mode 100644 index 60a2cee..0000000 --- a/src/Process/Traits/ThreadJobHandler.php +++ /dev/null @@ -1,44 +0,0 @@ -resolutionEnd(); - $this->asInterrupt(); - exit(); - } - - private function signTermination(): never - { - $this->resolutionEnd(); - $this->asTermination(); - exit(1); - } - - private function signClose(): never - { - $this->resolutionEnd(); - $this->asClose(); - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } -} diff --git a/src/Process/Traits/ThreadProcessHandler.php b/src/Process/Traits/ThreadProcessHandler.php deleted file mode 100644 index 90ada7b..0000000 --- a/src/Process/Traits/ThreadProcessHandler.php +++ /dev/null @@ -1,83 +0,0 @@ -iAmChild) { - foreach ($this->childrenPids as $childPid) { - posix_kill($childPid, SIGINT); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asInterrupt(); - } else { - $this->asChildInterrupt(); - } - exit(); - } - - private function signTermination(): never - { - if (!$this->iAmChild) { - foreach ($this->childrenPids as $childPid) { - posix_kill($childPid, SIGTERM); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asTermination(); - } else { - $this->asChildTermination(); - } - exit(1); - } - - private function signClose(): never - { - if (!$this->iAmChild) { - foreach ($this->childrenPids as $childPid) { - posix_kill($childPid, SIGHUP); - pcntl_waitpid($childPid, $status); - } - $this->resolutionEnd(); - $this->asClose(); - } else { - $this->asChildClose(); - } - exit(1); - } - - protected function asInterrupt(): void - { - $this->logger->notice("INTERRUPTED"); - } - - protected function asTermination(): void - { - $this->logger->warning("TERMINATION"); - } - - protected function asClose(): void - { - $this->logger->notice("CLOSE"); - } - - protected function asChildInterrupt(): void - { - $this->logger->notice("INTERRUPTED CHILD"); - } - - protected function asChildTermination(): void - { - $this->logger->warning("TERMINATION CHILD"); - } - - protected function asChildClose(): void - { - $this->logger->notice("CLOSE CHILD"); - } -} diff --git a/src/Process/Traits/ThreadSignalHandler.php b/src/Process/Traits/ThreadSignalHandler.php deleted file mode 100644 index a00cacf..0000000 --- a/src/Process/Traits/ThreadSignalHandler.php +++ /dev/null @@ -1,28 +0,0 @@ -signClose(); - }); - pcntl_signal(SIGINT, function () { - $this->signInterrupt(); - }); - pcntl_signal(SIGTERM, function () { - $this->signTermination(); - }); - } - } -} diff --git a/src/Route/Annotation/AbstractMapping.php b/src/Route/Annotation/AbstractMapping.php index d668faa..96f3258 100644 --- a/src/Route/Annotation/AbstractMapping.php +++ b/src/Route/Annotation/AbstractMapping.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; abstract class AbstractMapping { diff --git a/src/Route/Annotation/CrossOrigin.php b/src/Route/Annotation/CrossOrigin.php index b262648..a8e0db1 100644 --- a/src/Route/Annotation/CrossOrigin.php +++ b/src/Route/Annotation/CrossOrigin.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; @@ -23,7 +23,7 @@ * public function stats(): ResponseEntity { ... } */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] -readonly class CrossOrigin +final readonly class CrossOrigin { /** * @param string[] $origins Allowed origins. Empty = '*'. diff --git a/src/Route/Annotation/DeleteMapping.php b/src/Route/Annotation/DeleteMapping.php index d9918b4..a37a3b0 100644 --- a/src/Route/Annotation/DeleteMapping.php +++ b/src/Route/Annotation/DeleteMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class DeleteMapping extends AbstractMapping +final class DeleteMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/GetMapping.php b/src/Route/Annotation/GetMapping.php index 49033ce..6753bb1 100644 --- a/src/Route/Annotation/GetMapping.php +++ b/src/Route/Annotation/GetMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class GetMapping extends AbstractMapping +final class GetMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/PatchMapping.php b/src/Route/Annotation/PatchMapping.php index 57beff7..d887423 100644 --- a/src/Route/Annotation/PatchMapping.php +++ b/src/Route/Annotation/PatchMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class PatchMapping extends AbstractMapping +final class PatchMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/PostMapping.php b/src/Route/Annotation/PostMapping.php index 1da3425..7a41083 100644 --- a/src/Route/Annotation/PostMapping.php +++ b/src/Route/Annotation/PostMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class PostMapping extends AbstractMapping +final class PostMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/PutMapping.php b/src/Route/Annotation/PutMapping.php index 85ac9ac..3f3b114 100644 --- a/src/Route/Annotation/PutMapping.php +++ b/src/Route/Annotation/PutMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class PutMapping extends AbstractMapping +final class PutMapping extends AbstractMapping { public function getMethod(): string { diff --git a/src/Route/Annotation/RequestMapping.php b/src/Route/Annotation/RequestMapping.php index d48201d..8aaca83 100644 --- a/src/Route/Annotation/RequestMapping.php +++ b/src/Route/Annotation/RequestMapping.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route\Annotation; +namespace Flytachi\Winter\Kernel\Route\Annotation; use Attribute; /** Class-level route prefix or a method-level route with no specific HTTP method. */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] -class RequestMapping extends AbstractMapping +final class RequestMapping extends AbstractMapping { } diff --git a/src/Route/Annotation/Timeout.php b/src/Route/Annotation/Timeout.php new file mode 100644 index 0000000..59a4be3 --- /dev/null +++ b/src/Route/Annotation/Timeout.php @@ -0,0 +1,60 @@ +getAttributes(Middleware::class, ReflectionAttribute::IS_INSTANCEOF) ); $classCors = $this->collectCrossOrigin($ref->getAttributes(CrossOrigin::class)); + $classTimeout = $this->collectTimeout($ref->getAttributes(Timeout::class)); foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->name === '__construct') { @@ -66,12 +68,15 @@ public function collect(string $class, ReflectionClass $ref): void $middlewares = array_merge($classMiddlewares, $methodMiddlewares); $cors = $this->collectCrossOrigin($method->getAttributes(CrossOrigin::class)) ?? $classCors; + // The method's own #[Timeout] wins over the controller's; neither + // present leaves the global deadline in force (null). + $timeout = $this->collectTimeout($method->getAttributes(Timeout::class)) ?? $classTimeout; if ($httpMethod !== null) { - $this->router->add($httpMethod, $url, $handler, $middlewares, $cors); + $this->router->add($httpMethod, $url, $handler, $middlewares, $cors, $timeout); } else { foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $m) { - $this->router->add($m, $url, $handler, $middlewares, $cors); + $this->router->add($m, $url, $handler, $middlewares, $cors, $timeout); } } } @@ -88,6 +93,25 @@ private function collectMiddlewares(array $attrs): array return $result; } + /** + * The route's own deadline in seconds, or null when it carries no #[Timeout]. + * + * Resolved here, at scan time, so it lands in the compiled route table and the + * mapping cache — nothing is reflected per request. + * + * @param ReflectionAttribute[] $attrs + */ + private function collectTimeout(array $attrs): ?int + { + if (empty($attrs)) { + return null; + } + /** @var Timeout $inst */ + $inst = $attrs[0]->newInstance(); + + return max(0, $inst->seconds); + } + /** @param ReflectionAttribute[] $attrs */ private function collectCrossOrigin(array $attrs): ?array { diff --git a/src/Route/DevWatcher.php b/src/Route/DevWatcher.php new file mode 100644 index 0000000..1344701 --- /dev/null +++ b/src/Route/DevWatcher.php @@ -0,0 +1,305 @@ +reload()`) is deliberate: `boot()` and the router + * scan run in the master before workers fork, so a plain worker reload would keep the + * old controller/service classes cached in the master. Re-exec'ing the process image + * is the only reliable way to reflect changes to master-loaded code. + * + * Usage (from WinterApplication::serveHttp, dev path): + * $dev = new DevWatcher([Kernel::$pathRoot]); + * $dev->attach($server, $onWorkerStart); + * $server->on('request', $dev->wrap($handler)); + * $server->start(); + * if ($dev->reloadRequested()) { $dev->reexec(); } + */ +final class DevWatcher +{ + private int $workerId = 0; + private int $baseline = 0; + + /** @var array Watched file path => last mtime. */ + private array $snapshot = []; + private bool $reloadRequested = false; + private ?int $timerId = null; + private bool $stopping = false; + private readonly bool $color; + + /** + * @param list $watchPaths Directories scanned for `.php` changes. + * @param float $interval Poll interval in seconds. + * @param list $exclude Directory names skipped during the scan. + */ + public function __construct( + private readonly array $watchPaths, + private readonly float $interval = 1.0, + private readonly array $exclude = ['vendor', 'storage', '.git', 'node_modules'], + ) { + $this->color = self::wantsColor(); + } + + /** Colour the dev output using the same LOG_COLOR contract as the logger. */ + private static function wantsColor(): bool + { + return match (strtolower((string) (env('LOG_COLOR', 'auto')))) { + 'always' => true, + 'never' => false, + default => defined('STDOUT') && stream_isatty(STDOUT), + }; + } + + /** Wraps text in an ANSI colour when colour is on; plain text otherwise. */ + private function paint(string $code, string $text): string + { + return $this->color ? "\033[{$code}m{$text}\033[0m" : $text; + } + + /** + * Registers the workerStart memory baseline (composing an optional extra + * callback) and the master-side file-watch timer. + * + * @param callable|null $onWorkerStart function(Server $server, int $workerId): void + */ + public function attach(Server $server, ?callable $onWorkerStart = null): void + { + $server->on('workerStart', function (Server $server, int $workerId) use ($onWorkerStart): void { + $this->workerId = $workerId; + $this->baseline = memory_get_usage(false); + echo $this->paint('32', '●') . ' ' . $this->paint('36', '[dev]') + . ' worker ' . $this->workerId + . $this->paint('90', ' · baseline ') . $this->format($this->baseline) . "\n"; + if ($onWorkerStart !== null) { + $onWorkerStart($server, $workerId); + } + }); + + // The file watcher lives in the master reactor. On a change it stops the + // server; serve() then re-exec's the process (see reexec()). + $server->on('start', function (Server $server): void { + $this->snapshot = $this->scan(); + $this->timerId = Timer::tick((int) ($this->interval * 1000), function () use ($server): void { + if ($this->stopping) { + return; + } + $current = $this->scan(); + if ($current === $this->snapshot) { + return; + } + + // Validate changed files before restarting: a mid-edit syntax error + // would kill the re-exec'd boot with no recovery. Keep the current + // (working) server up and report the error; the fix triggers the reload. + $invalid = $this->firstSyntaxError($this->changedPhpFiles($this->snapshot, $current)); + if ($invalid !== null) { + [$file, $error] = $invalid; + echo "\n" . $this->paint('31', '✗ [dev]') . ' syntax error in ' + . $this->paint('1;31', basename($file)) + . $this->paint('90', ' — keeping current server') . "\n" + . ' ' . $this->paint('90', $this->errorLine($error)) . "\n"; + // Acknowledge this change so the same break isn't re-linted every + // tick; the next edit (the fix) is a fresh change → re-checked. + $this->snapshot = $current; + return; + } + + $changed = $this->firstChange($this->snapshot, $current); + echo "\n" . $this->paint('33', '↻ [dev]') . ' change' + . ($changed !== null ? $this->paint('90', ' · ') . $this->paint('1;33', $changed) : '') + . $this->paint('90', ' — restarting…') . "\n"; + $this->reloadRequested = true; + if ($this->timerId !== null) { + Timer::clear($this->timerId); + $this->timerId = null; + } + $server->shutdown(); + }); + }); + + // Stop the watch cleanly on shutdown: flag the stop and clear the poll + // timer so no filesystem scan runs during reactor teardown. Otherwise the + // (hooked) scan is left as a sleeping coroutine and Swoole force-kills the + // worker after its exit timeout ("all coroutines are asleep - deadlock"). + $server->on('beforeShutdown', function (Server $server): void { + $this->stopping = true; + if ($this->timerId !== null) { + Timer::clear($this->timerId); + $this->timerId = null; + } + }); + } + + public function wrap(callable $handler): callable + { + return function (Request $request, Response $response) use ($handler): void { + $before = memory_get_usage(false); + + $handler($request, $response); + + $after = memory_get_usage(false); + + echo $this->paint('36', '[dev]') . ' worker ' . $this->workerId + . $this->paint('90', ' · before ') . $this->format($before) + . $this->paint('90', ' · after ') . $this->format($after) + . $this->paint('90', ' · Δ ') . $this->formatDelta($after - $before) + . $this->paint('90', ' · growth ') . $this->formatDelta($after - $this->baseline) + . $this->paint('90', ' · peak ') . $this->format(memory_get_peak_usage(false)) . "\n"; + }; + } + + /** True when a watched file changed and the server was stopped for a restart. */ + public function reloadRequested(): bool + { + return $this->reloadRequested; + } + + /** + * Replaces the current process image with a fresh `php run dev`. + * Call only after `$server->start()` has returned (workers already stopped). + */ + public function reexec(): never + { + $argv = $_SERVER['argv'] ?? []; + if (function_exists('pcntl_exec') && $argv !== []) { + pcntl_exec(PHP_BINARY, $argv); + } + // pcntl_exec only returns on failure (or is unavailable): fall back to a + // non-zero exit so an external supervisor can restart the process. + echo "[dev] cannot re-exec (ext-pcntl unavailable) — exiting for a supervisor restart.\n"; + exit(1); + } + + /** @return array path => mtime for every watched `.php` file. */ + private function scan(): array + { + $files = []; + foreach ($this->watchPaths as $path) { + if (!is_dir($path)) { + continue; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + function (\SplFileInfo $file): bool { + if ($file->isDir()) { + return !in_array($file->getFilename(), $this->exclude, true); + } + return $file->getExtension() === 'php'; + } + ) + ); + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + $files[$file->getPathname()] = (int) $file->getMTime(); + } + } + return $files; + } + + /** Name of the first added/changed/removed file, for the console notice. */ + private function firstChange(array $old, array $new): ?string + { + foreach ($new as $path => $mtime) { + if (!isset($old[$path]) || $old[$path] !== $mtime) { + return basename($path); + } + } + foreach ($old as $path => $mtime) { + if (!isset($new[$path])) { + return basename($path) . ' (removed)'; + } + } + return null; + } + + /** + * Added or modified files still on disk (removals can't be linted and are a + * valid reason to reload). + * + * @return list + */ + private function changedPhpFiles(array $old, array $new): array + { + $files = []; + foreach ($new as $path => $mtime) { + if ((!isset($old[$path]) || $old[$path] !== $mtime) && is_file($path)) { + $files[] = $path; + } + } + return $files; + } + + /** + * `php -l` each file; returns [path, output] of the first that fails to parse, + * or null when all are valid (or validation is unavailable, so the reload just + * proceeds as before). + * + * @param list $files + * @return array{0: string, 1: string}|null + */ + private function firstSyntaxError(array $files): ?array + { + if (!function_exists('exec')) { + return null; + } + foreach ($files as $file) { + $out = []; + $code = 0; + exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($file) . ' 2>&1', $out, $code); + if ($code !== 0) { + return [$file, implode("\n", $out)]; + } + } + return null; + } + + /** The "Parse error: ..." line from `php -l` output, for a compact notice. */ + private function errorLine(string $output): string + { + foreach (explode("\n", $output) as $line) { + if (stripos($line, 'error') !== false) { + return trim($line); + } + } + return trim(strtok($output, "\n") ?: $output); + } + + private function format(int $bytes): string + { + if ($bytes >= 1024 * 1024) { + return round($bytes / 1024 / 1024, 2) . ' MB'; + } + if ($bytes >= 1024) { + return round($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } + + private function formatDelta(int $bytes): string + { + $sign = $bytes >= 0 ? '+' : '-'; + $abs = abs($bytes); + if ($abs >= 1024 * 1024) { + return $sign . round($abs / 1024 / 1024, 2) . ' MB'; + } + if ($abs >= 1024) { + return $sign . round($abs / 1024, 2) . ' KB'; + } + return $sign . $abs . ' B'; + } +} diff --git a/src/Route/Dispatcher.php b/src/Route/Dispatcher.php index 4fbf3fa..936a449 100644 --- a/src/Route/Dispatcher.php +++ b/src/Route/Dispatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; /** * Fast dispatcher — O(1) static lookup + grouped-regex dynamic matching. @@ -10,7 +10,7 @@ * Routes sharing the same URI regex (e.g. GET /users/{id} and DELETE /users/{id}) * are merged into one URI group so alternation branches never shadow each other. */ -class Dispatcher +final class Dispatcher { private const int CHUNK_SIZE = 30; diff --git a/src/Route/MemoryWatcher.php b/src/Route/MemoryWatcher.php deleted file mode 100644 index 35346de..0000000 --- a/src/Route/MemoryWatcher.php +++ /dev/null @@ -1,97 +0,0 @@ -attach($server); - * $server->on('request', $watcher->wrap($handler)); - * - * Usage (with custom workerStart logic): - * $watcher->attach($server, function (Server $server, int $workerId): void { - * // your onWorkerStart code here - * }); - */ -class MemoryWatcher -{ - private int $workerId = 0; - private int $baseline = 0; - - /** - * Registers the workerStart handler. - * - * @param Server $server Swoole HTTP server - * @param callable|null $onWorkerStart Optional extra callback — called after MemoryWatcher - * initialises, inside the same workerStart event. - * Signature: function(Server $server, int $workerId): void - */ - public function attach(Server $server, ?callable $onWorkerStart = null): void - { - $server->on('workerStart', function (Server $server, int $workerId) use ($onWorkerStart): void { - $this->workerId = $workerId; - $this->baseline = memory_get_usage(false); - echo sprintf( - "[Worker %d] START | Baseline: %s\n", - $this->workerId, - $this->format($this->baseline) - ); - if ($onWorkerStart !== null) { - $onWorkerStart($server, $workerId); - } - }); - } - - public function wrap(callable $handler): callable - { - return function (Request $request, Response $response) use ($handler): void { - $before = memory_get_usage(false); - - $handler($request, $response); - - $after = memory_get_usage(false); - - echo sprintf( - "[Worker %d] REQUEST => (before: %s, after: %s, delta: %s, growth: %s, peak: %s)\n", - $this->workerId, - $this->format($before), - $this->format($after), - $this->formatDelta($after - $before), - $this->formatDelta($after - $this->baseline), - $this->format(memory_get_peak_usage(false)) - ); - }; - } - - private function format(int $bytes): string - { - if ($bytes >= 1024 * 1024) { - return round($bytes / 1024 / 1024, 2) . ' MB'; - } - if ($bytes >= 1024) { - return round($bytes / 1024, 2) . ' KB'; - } - return $bytes . ' B'; - } - - private function formatDelta(int $bytes): string - { - $sign = $bytes >= 0 ? '+' : '-'; - $abs = abs($bytes); - if ($abs >= 1024 * 1024) { - return $sign . round($abs / 1024 / 1024, 2) . ' MB'; - } - if ($abs >= 1024) { - return $sign . round($abs / 1024, 2) . ' KB'; - } - return $sign . $abs . ' B'; - } -} diff --git a/src/Route/RequestWatchdog.php b/src/Route/RequestWatchdog.php new file mode 100644 index 0000000..acbe18f --- /dev/null +++ b/src/Route/RequestWatchdog.php @@ -0,0 +1,241 @@ + deadline. */ + private static array $deadlines = []; + + /** Requests already cancelled, whose result must not be sent: cid => true. */ + private static array $expired = []; + + /** Swoole timer id of this worker's sweep, or null when not armed. */ + private static ?int $timerId = null; + + /** Default deadline in seconds for requests that do not carry their own; 0 = off. */ + private static float $default = 0.0; + + private function __construct() + { + } + + /** + * Arms the sweep for this worker. Call once from `workerStart`; a no-op when the + * timeout is disabled, so an application that wants none runs no timer at all. + */ + public static function enable(float $seconds): void + { + self::$default = max(0.0, $seconds); + + if (self::$timerId !== null || self::$default <= 0.0 || !extension_loaded('swoole')) { + return; + } + + $interval = self::sweepInterval(self::$default); + self::$timerId = Timer::tick((int) ($interval * 1000), static fn() => self::sweep()); + } + + /** Disarms the sweep and forgets everything — for worker shutdown and for tests. */ + public static function disable(): void + { + if (self::$timerId !== null && extension_loaded('swoole')) { + Timer::clear(self::$timerId); + } + self::$timerId = null; + self::$default = 0.0; + self::$deadlines = []; + self::$expired = []; + } + + /** + * Registers the current request and returns its coroutine id, or null when there is + * no deadline to enforce (timeout disabled, or not running in a coroutine). + * + * The caller must {@see release()} the id when the request ends — via `defer`, so it + * happens however the request finishes. + */ + public static function register(?float $seconds = null, float $elapsed = 0.0): ?int + { + $seconds ??= self::$default; + if ($seconds <= 0.0 || !Runtime::isSwooleCoroutine()) { + return null; + } + + $cid = Coroutine::getCid(); + // `$elapsed` is spent budget, not a start time: the deadline runs on the monotonic + // clock, and the caller measures the wait on the wall clock Swoole stamps arrivals + // with. Subtracting keeps the two apart. A request that used its whole budget + // queueing gets a deadline in the past and is cancelled by the next sweep. + self::$deadlines[$cid] = self::now() + $seconds - max(0.0, $elapsed); + + return $cid; + } + + /** + * Replaces the deadline of a registered request — for a route whose own + * {@see \Flytachi\Winter\Kernel\Route\Annotation\Timeout} differs from the global + * one, which is only known after the route has been matched. + */ + public static function extend(?int $cid, float $seconds): void + { + if ($cid === null) { + return; + } + if ($seconds <= 0.0) { + unset(self::$deadlines[$cid]); // the route opts out of the deadline + return; + } + + self::$deadlines[$cid] = self::now() + $seconds; + } + + /** Whether this request was cancelled by the watchdog, so its result must be dropped. */ + public static function hasExpired(?int $cid): bool + { + return $cid !== null && isset(self::$expired[$cid]); + } + + /** + * The same question about the request running right now. + * + * Error handling deep in the pipeline has no reference to the id — it only has the + * exception — so it asks about the coroutine it is already in. + */ + public static function isCurrentExpired(): bool + { + if (self::$expired === [] || !Runtime::isSwooleCoroutine()) { + return false; + } + + return isset(self::$expired[Coroutine::getCid()]); + } + + /** Forgets a finished request. Safe to call for an id that was never registered. */ + public static function release(?int $cid): void + { + if ($cid === null) { + return; + } + unset(self::$deadlines[$cid], self::$expired[$cid]); + } + + /** In-flight requests currently being watched — for diagnostics. */ + public static function watching(): int + { + return count(self::$deadlines); + } + + /** + * One pass over the registry: cancel everything past its deadline. + * + * Cancels again on every pass, not only the first, because cancellation is not + * sticky — see the class docblock. + */ + private static function sweep(): void + { + if (self::$deadlines === []) { + return; + } + $now = self::now(); + + foreach (self::$deadlines as $cid => $deadline) { + if ($now < $deadline) { + continue; + } + if (!Coroutine::exists($cid)) { + // Finished between its deadline and this pass. Its own defer normally + // clears the entry; dropping it here as well costs nothing and keeps the + // registry from growing for the worker's whole life if a defer is ever + // missed. Unsetting during foreach is safe — the loop walks a copy. + self::release($cid); + continue; + } + + self::$expired[$cid] = true; + Coroutine::cancel($cid, true); + } + } + + /** + * How often to sweep for a given deadline — the accuracy of the deadline itself. + * + * A quarter of the deadline, clamped: never rarer than {@see MAX_INTERVAL}, so a + * long timeout is still measured to 100 ms, and never more often than + * {@see MIN_INTERVAL}, so a sub-second one does not spin the reactor. + */ + private static function sweepInterval(float $seconds): float + { + return min(self::MAX_INTERVAL, max(self::MIN_INTERVAL, $seconds / 4)); + } + + private static function now(): float + { + return hrtime(true) / 1e9; + } +} diff --git a/src/Route/Route.php b/src/Route/Route.php index d2bc508..4a371d3 100644 --- a/src/Route/Route.php +++ b/src/Route/Route.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; -readonly class Route +final readonly class Route { public string $regex; /** @var list */ diff --git a/src/Route/RouteResult.php b/src/Route/RouteResult.php index c58a3b8..569ea7e 100644 --- a/src/Route/RouteResult.php +++ b/src/Route/RouteResult.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; -class RouteResult +final class RouteResult { public const NOT_FOUND = 0; public const FOUND = 1; diff --git a/src/Route/Router.php b/src/Route/Router.php index e2eca2e..847ace5 100644 --- a/src/Route/Router.php +++ b/src/Route/Router.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Route; +namespace Flytachi\Winter\Kernel\Route; use Flytachi\Winter\Base\Exception\DebugDumpException; use Flytachi\Winter\Base\Exception\ExceptionLogLevel; @@ -10,30 +10,30 @@ use Flytachi\Winter\DI\ReflectionCache; use Flytachi\Winter\Base\Runtime; use Flytachi\Winter\DI\Container; -use Flytachi\Winter\DI\Scanner; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Header; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Response\Collector\ExceptionCollector; -use Flytachi\Winter\K2\Localization\Locale; -use Flytachi\Winter\K2\Http\Response\ExceptionWrapper; -use Flytachi\Winter\K2\Http\Response\RenderContext; -use Flytachi\Winter\K2\Http\Response\ResponseEntity; -use Flytachi\Winter\K2\Http\Response\ResponseException; -use Flytachi\Winter\K2\Http\Response\Sendable; -use Flytachi\Winter\K2\Http\Cors; -use Flytachi\Winter\K2\Http\Health\Health; -use Flytachi\Winter\K2\Http\Health\HealthIndicatorInterface; -use Flytachi\Winter\K2\Plugin; -use Flytachi\Winter\K2\Route\Collector\MappingCollector; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Core\ClassScanner; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Header; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Response\Collector\ExceptionCollector; +use Flytachi\Winter\Kernel\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Response\ExceptionWrapper; +use Flytachi\Winter\Kernel\Http\Response\ResponseEntity; +use Flytachi\Winter\Kernel\Http\Response\ResponseException; +use Flytachi\Winter\Kernel\Http\Response\Sendable; +use Flytachi\Winter\Kernel\Http\Cors; +use Flytachi\Winter\Kernel\Http\Health\Health; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicatorInterface; +use Flytachi\Winter\Kernel\Http\Health\Status; +use Flytachi\Winter\Kernel\Plugin; +use Flytachi\Winter\Kernel\Route\Collector\MappingCollector; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; use Flytachi\Winter\Base\HttpCode; /** - * K2 Router — dual-mode (Swoole + FPM), Spring Boot-style. + * Router — dual-mode (Swoole + FPM), Spring Boot-style. * * ── Route registration (manual) ───────────────────────────────────────────── * $router->get('/users', [UserController::class, 'index']); @@ -51,13 +51,14 @@ * // Per-route override: #[CrossOrigin] attribute on controller class or method * * ── Static files ───────────────────────────────────────────────────────────── - * $router->static(Kernel::$pathPublic); + * Not the router's job. Swoole serves them itself, in C, before PHP is reached — + * declare the directory with {@see \Flytachi\Winter\Kernel\App\Config\ServerSettings::staticPath()}. * * ── Dispatch ───────────────────────────────────────────────────────────────── * $router->handle(new SwooleRequest($req), new SwooleResponse($res)); * $router->handle(new FpmRequest(), new FpmResponse()); */ -class Router +final class Router { /** @var array> [METHOD][path] => handler */ private array $staticRoutes = []; @@ -67,23 +68,6 @@ class Router private ?Dispatcher $dispatcher = null; - private ?string $publicDir = null; - - // ── Static file serving ─────────────────────────────────────────────────── - - /** - * Serve static files from $publicDir for GET requests that match an existing file. - * Required for Swoole — unlike FPM+nginx, Swoole does not serve files natively. - * - * Example: - * $router->static(__DIR__ . '/public'); - */ - public function static(string $publicDir): static - { - $this->publicDir = rtrim($publicDir, '/\\'); - return $this; - } - // ── Route registration ──────────────────────────────────────────────────── /** @@ -101,19 +85,22 @@ public function static(string $publicDir): static * maxAge:int, * vary:string[] * }|null $cors + * @param int|null $timeout Per-route deadline in seconds from #[Timeout]; 0 opts + * the route out, null leaves the global deadline in force. */ public function add( string $method, string $path, mixed $handler, array $middlewares = [], - ?array $cors = null + ?array $cors = null, + ?int $timeout = null ): static { $this->dispatcher = null; $method = strtoupper($method); - if ($middlewares !== [] || $cors !== null) { + if ($middlewares !== [] || $cors !== null || $timeout !== null) { $stored = ['__handler' => $handler]; if ($middlewares !== []) { $stored['__middlewares'] = $middlewares; @@ -121,6 +108,9 @@ public function add( if ($cors !== null) { $stored['__cors'] = $cors; } + if ($timeout !== null) { + $stored['__timeout'] = $timeout; + } } else { $stored = $handler; } @@ -182,7 +172,7 @@ public static function fromScan(string $rootDir, array $exclude = []): static $mappingCollector = new MappingCollector($router); $exceptionCollector = new ExceptionCollector(); - Scanner::run($rootDir) + ClassScanner::scanner($rootDir) ->exclude($exclude) ->collect($mappingCollector) ->collect($exceptionCollector) @@ -191,7 +181,7 @@ public static function fromScan(string $rootDir, array $exclude = []): static foreach (Plugin::getPlugins() as $prefix => $path) { $pluginSrc = $path . '/src'; if (is_dir($pluginSrc)) { - Scanner::run($pluginSrc) + ClassScanner::scanner($pluginSrc) ->collect(new MappingCollector($router, $prefix)) ->execute(); } @@ -210,7 +200,7 @@ public static function fromScan(string $rootDir, array $exclude = []): static /** Add attribute-scanned routes from $rootDir to this Router instance. */ public function scan(string $rootDir, array $exclude = []): static { - Scanner::run($rootDir) + ClassScanner::scanner($rootDir) ->exclude($exclude) ->collect(new MappingCollector($this)) ->execute(); @@ -369,13 +359,41 @@ private function registerHealth(string $indicatorClass, ?string $middlewareClass throw new ResponseException('Actuator endpoint not found', HttpCode::NOT_FOUND); } - return ResponseEntity::ok($indicator->{$method}()); + $body = $indicator->{$method}(); + + return ResponseEntity::status(self::healthCode($method, $body))->body($body); }; $this->add('GET', '/actuator', $handler, $middlewares); $this->add('GET', '/actuator/{method}', $handler, $middlewares); } + /** + * The response code carrying the health verdict: `down` → 503, everything else → 200. + * + * Without this the endpoint answered 200 while reporting `status: down` inside, so + * every consumer that reads the code rather than the body — a container health check, + * a k8s liveness/readiness probe, a load balancer — saw a dead application as healthy. + * + * `degraded` deliberately stays 200: it means working worse, not not working, and a + * probe that pulls the instance out of rotation over it would turn a partial outage + * into a full one. + * + * Only `health` reports a status; `info`, `metrics` and the rest are plain reads. + */ + private static function healthCode(string $method, mixed $body): HttpCode + { + if ($method !== 'health' || !is_array($body)) { + return HttpCode::OK; + } + $status = $body['status'] ?? null; + $status = $status instanceof Status ? $status->value : $status; + + return is_string($status) && strtolower($status) === Status::Down->value + ? HttpCode::SERVICE_UNAVAILABLE + : HttpCode::OK; + } + // ── Dispatch ────────────────────────────────────────────────────────────── /** @@ -401,16 +419,15 @@ public function dispatch(string $method, string $uri): RouteResult * 1. Header::init() — snapshot request headers into the static bag * 2. Locale::initFromRequest() — detect Accept-Language / locale cookie * 3. Swoole context — stamp start time, method, uri in coroutine ctx - * 4. Static file check — short-circuit for existing files (GET only) - * 5. Global CORS headers — applied before dispatch (covers 404 / 500 too) - * 6. OPTIONS preflight — returns 204 before handler invocation - * 7. Route dispatch — O(1) static map → chunked regex dynamic scan - * 8. Per-route #[CrossOrigin] — overrides global CORS if present - * 9. Middleware before() — run in declaration order - * 10. Controller method — resolved via ReflectionCache + ParameterResolver - * 11. Middleware after() — run in reverse order - * 12. Response serialise — Sendable::send() or ResponseEntity::ok()->send() - * 13. Error handling — ExceptionWrapper maps Throwable → HTTP response + * 4. Global CORS headers — applied before dispatch (covers 404 / 500 too) + * 5. OPTIONS preflight — returns 204 before handler invocation + * 6. Route dispatch — O(1) static map → chunked regex dynamic scan + * 7. Per-route #[CrossOrigin] — overrides global CORS if present + * 8. Middleware before() — run in declaration order + * 9. Controller method — resolved via ReflectionCache + ParameterResolver + * 10. Middleware after() — run in reverse order + * 11. Response serialise — Sendable::send() or ResponseEntity::ok()->send() + * 12. Error handling — ExceptionWrapper maps Throwable → HTTP response */ public function handle(HttpRequest $request, HttpResponse $response): void { @@ -424,15 +441,18 @@ public function handle(HttpRequest $request, HttpResponse $response): void $ctx['__request_uri'] = $request->getUri(); } - // static - files (js,css,media) - if (Runtime::isSwoole() && $this->publicDir !== null && strtoupper($request->getMethod()) === 'GET') { - $uri = $request->getUri(); - $path = ($pos = strpos($uri, '?')) !== false ? substr($uri, 0, $pos) : $uri; - $file = $this->publicDir . $path; - if (is_file($file)) { - $this->serveStaticFile($file, $response); - return; - } + // Watched from here, with the global deadline; a route carrying its own + // #[Timeout] adjusts it below, once dispatch has said which route this is. + // Released via defer so it happens however the request ends — including the + // cancellation the watchdog itself raises. + // + // The time already spent queueing counts against the deadline. Under + // worker_max_concurrency a request's coroutine is not created until the worker + // lets it through, so without this a request that waited three seconds would + // start a fresh thirty — while the client has been waiting the whole time. + $watched = RequestWatchdog::register(elapsed: self::waitedInQueue($request)); + if ($watched !== null) { + \Swoole\Coroutine::defer(static fn() => RequestWatchdog::release($watched)); } try { @@ -442,7 +462,6 @@ public function handle(HttpRequest $request, HttpResponse $response): void LoggerFactory::getLogger(self::class)->debug( $request->getClientIp() . " -- $method " . $request->getUri() ); - RenderContext::setRoutes($this->getRoutesSummary()); } // ── Global CORS applied eagerly (covers 404, 405, and errors too) ─ @@ -466,6 +485,14 @@ public function handle(HttpRequest $request, HttpResponse $response): void } } + // ── Per-route #[Timeout] overrides the global deadline ──────────── + if ($result->status === RouteResult::FOUND) { + $routeTimeout = $this->extractRouteTimeout($result->handler); + if ($routeTimeout !== null) { + RequestWatchdog::extend($watched, (float) $routeTimeout); + } + } + match ($result->status) { RouteResult::FOUND => $this->invoke($result->handler, $request, $response, $result->params), RouteResult::METHOD_NOT_ALLOWED => throw new ResponseException( @@ -509,9 +536,6 @@ private function invoke(mixed $stored, HttpRequest $req, HttpResponse $res, arra $object = Container::getInstance()->make($class); $refMethod = ReflectionCache::method($class, $methodName); $args = ParameterResolver::resolve($refMethod, $req, $res, $params); - if (env('DEBUG', false)) { - RenderContext::setMeta($class, $methodName); - } $result = $refMethod->invokeArgs($object, $args); } else { $result = ($handler)($req, $res, $params); @@ -522,6 +546,13 @@ private function invoke(mixed $stored, HttpRequest $req, HttpResponse $res, arra $result = $mw->after($result); } + // A handler that swallowed the watchdog's cancellation arrives here having + // completed no I/O — every wait was cut short — so its result was built from + // queries that never ran. Answer the deadline instead of that. + if (RequestWatchdog::isCurrentExpired()) { + throw new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT); + } + // ── Serialize return value ──────────────────────────────────────── if ($result instanceof Sendable) { $result->send($res, $req); @@ -535,6 +566,12 @@ private function invoke(mixed $stored, HttpRequest $req, HttpResponse $res, arra private function sendError(\Throwable $e, HttpResponse $res): void { + // Past the deadline, whatever surfaced is a consequence of the cancellation — + // typically Swoole\Coroutine\CanceledException, raised wherever the request + // happened to be waiting. On its own that is an empty message with code 0, which + // would be logged as a server fault and answered 500. Say what actually happened. + $e = $this->asTimeout($e); + if ($e instanceof DebugDumpException) { $res->status(200); $res->header('Content-Type', 'text/html; charset=utf-8'); @@ -703,21 +740,61 @@ private function extractRouteCors(mixed $stored): ?array return null; } - // ── Static file helper ──────────────────────────────────────────────────── + /** + * Extract the per-route deadline stored by {@see Collector\MappingCollector} under + * '__timeout' — the seconds from a route's own `#[Timeout]`, or null when it has + * none and the global deadline stands. `0` there means the route opts out. + */ + private function extractRouteTimeout(mixed $stored): ?int + { + if (is_array($stored) && array_key_exists('__timeout', $stored)) { + return $stored['__timeout']; + } + return null; + } - private function serveStaticFile(string $filePath, HttpResponse $response): void + /** + * Presents anything raised after the deadline as `504 Gateway Timeout`. + * + * Applied where the response is built rather than where the request is dispatched, + * because {@see invoke()} has a `catch` of its own: it answered the raw + * `CanceledException` — code 0, empty message, logged as a server fault and sent as + * 500 — before the outer handler ever saw it, and the outer 504 then arrived too late + * to change the response and only added a second log line. + * + * A `ResponseException` already carrying 504 is left alone, so re-wrapping cannot + * stack. Everything else on a request that did not time out passes through untouched: + * a request that timed out *and* had a real bug still reports the bug as the cause. + */ + private function asTimeout(\Throwable $e): \Throwable { - $content = file_get_contents($filePath); - if ($content === false) { - $response->status(500); - $response->end(''); - return; + if ($e instanceof ResponseException && $e->getCode() === HttpCode::GATEWAY_TIMEOUT->value) { + return $e; + } + if (!RequestWatchdog::isCurrentExpired()) { + return $e; } - $mime = mime_content_type($filePath) ?: 'application/octet-stream'; - $response->status(200); - $response->header('Content-Type', $mime); - $response->header('Cache-Control', 'public, max-age=86400'); - $response->end($content); + + return new ResponseException('Gateway Timeout', HttpCode::GATEWAY_TIMEOUT, $e); + } + + /** + * Seconds this request spent waiting to be picked up, before any of it ran. + * + * Swoole stamps `request_time_float` when the packet arrives, which is *before* + * `worker_max_concurrency` decides whether there is room to run it — verified with a + * limit of one and a 0.3-second handler: five simultaneous requests reported 0.000, + * 0.301, 0.603, 0.904 and 1.206 seconds of waiting. + * + * Returns 0.0 when the stamp is missing or nonsensical (a clock adjustment between + * arrival and now would otherwise charge the request for it), so the deadline then + * behaves exactly as it did before. + */ + private static function waitedInQueue(HttpRequest $request): float + { + $arrived = $request->getServerParam('request_time_float'); + + return is_numeric($arrived) ? max(0.0, microtime(true) - (float) $arrived) : 0.0; } // ── Debug helpers ───────────────────────────────────────────────────────── diff --git a/src/Schedule/ScheduleConfigException.php b/src/Schedule/ScheduleConfigException.php new file mode 100644 index 0000000..15b22d0 --- /dev/null +++ b/src/Schedule/ScheduleConfigException.php @@ -0,0 +1,14 @@ +getResult(); // ScheduledTask[] + */ +final class ScheduledCollector implements CollectorInterface +{ + /** @var ScheduledTask[] */ + private array $tasks = []; + + public function collect(string $class, ReflectionClass $ref): void + { + foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $attrs = $method->getAttributes(Scheduled::class); + if ($attrs === []) { + continue; + } + $this->assertCallable($ref, $method); + foreach ($attrs as $attr) { + /** @var Scheduled $scheduled */ + $scheduled = $attr->newInstance(); + $this->tasks[] = new ScheduledTask( + className: $class, + methodName: $method->getName(), + trigger: $this->trigger($scheduled, $class, $method->getName()), + initialDelay: max(0.0, $scheduled->initialDelay), + ); + } + } + } + + /** @return ScheduledTask[] */ + public function getResult(): array + { + return $this->tasks; + } + + /** + * Rejects a method the scheduler could never invoke: an abstract or + * non-instantiable class, a static method, or one that needs arguments. + */ + private function assertCallable(ReflectionClass $ref, ReflectionMethod $method): void + { + $where = $ref->getName() . '::' . $method->getName() . '()'; + if ($method->isStatic()) { + throw new ScheduleConfigException("#[Scheduled] {$where} must be a non-static method."); + } + if ($method->getNumberOfRequiredParameters() > 0) { + throw new ScheduleConfigException("#[Scheduled] {$where} must take no required arguments."); + } + if ($ref->isAbstract() || $ref->isInterface() || !$ref->isInstantiable()) { + throw new ScheduleConfigException( + "#[Scheduled] {$where} is on a non-instantiable class; it cannot be resolved." + ); + } + } + + /** + * Resolves the single trigger declared by the attribute, rejecting zero or + * more than one. + */ + private function trigger(Scheduled $scheduled, string $class, string $method): Trigger + { + $modes = []; + if ($scheduled->fixedDelay !== null) { + $modes[] = 'fixedDelay'; + } + if ($scheduled->fixedRate !== null) { + $modes[] = 'fixedRate'; + } + if ($scheduled->cron !== null) { + $modes[] = 'cron'; + } + + $where = $class . '::' . $method . '()'; + if ($modes === []) { + throw new ScheduleConfigException( + "#[Scheduled] {$where} sets no trigger; use fixedDelay, fixedRate or cron." + ); + } + if (count($modes) > 1) { + throw new ScheduleConfigException( + "#[Scheduled] {$where} sets more than one trigger (" . implode(', ', $modes) . '); use exactly one.' + ); + } + + if ($scheduled->fixedDelay !== null) { + $this->assertPositive($scheduled->fixedDelay, 'fixedDelay', $where); + return new FixedDelayTrigger($scheduled->fixedDelay); + } + if ($scheduled->fixedRate !== null) { + $this->assertPositive($scheduled->fixedRate, 'fixedRate', $where); + return new FixedRateTrigger($scheduled->fixedRate); + } + + if ($scheduled->initialDelay > 0.0) { + throw new ScheduleConfigException("#[Scheduled] {$where} initialDelay is not supported with cron."); + } + try { + return new CronTrigger((string) $scheduled->cron); + } catch (InvalidArgumentException $e) { + throw new ScheduleConfigException("#[Scheduled] {$where} " . $e->getMessage()); + } + } + + /** + * A period must be strictly positive — 0 or negative would busy-loop the task. + */ + private function assertPositive(float $value, string $name, string $where): void + { + if ($value <= 0.0) { + throw new ScheduleConfigException("#[Scheduled] {$where} {$name} must be greater than 0."); + } + } +} diff --git a/src/Schedule/ScheduledTask.php b/src/Schedule/ScheduledTask.php new file mode 100644 index 0000000..ed6c50b --- /dev/null +++ b/src/Schedule/ScheduledTask.php @@ -0,0 +1,51 @@ +className . '::' . $this->methodName; + } +} diff --git a/src/Schedule/Stereotype/Scheduler.php b/src/Schedule/Stereotype/Scheduler.php new file mode 100644 index 0000000..bc73c1d --- /dev/null +++ b/src/Schedule/Stereotype/Scheduler.php @@ -0,0 +1,202 @@ + In-flight run per task index; reaped when the future settles. */ + private array $running = []; + + /** + * Discovers the annotated methods and drives their triggers until stopped. + */ + public function run(): void + { + $this->tasks = $this->discover(); + $this->seed(microtime(true)); + + if ($this->tasks === []) { + $this->logger->warning('Scheduler: no #[Scheduled] methods found; idling.'); + } else { + $this->logger->info('Scheduler: ' . count($this->tasks) . ' scheduled task(s) registered.'); + } + + while ($this->isRunning()) { + $this->reap(); + $now = microtime(true); + foreach ($this->tasks as $index => $task) { + if (!$task->inFlight && $task->nextFireAt <= $now) { + $this->fire($index, $task); + } + } + $this->sleep($this->untilNext(microtime(true))); + } + } + + /** + * SIGHUP: re-scan the annotated methods so newly added or removed tasks take + * effect without a restart. Any in-flight runs are detached (they finish on + * their own and harmlessly); the fresh registry seeds from now. + */ + protected function onReload(): void + { + $this->tasks = $this->discover(); + $this->running = []; + $this->seed(microtime(true)); + $this->logger->info('Scheduler: reloaded, ' . count($this->tasks) . ' scheduled task(s).'); + } + + /** + * Dispatches one task: mark it in flight and spawn the run — resolve its class + * from the container and invoke the method, logging any failure so it is never + * fatal. Bean resolution and the method call are reported separately, so a + * class that cannot be autowired is distinguished from a method that threw. The + * run's completion is picked up later by {@see reap()}; the spawned closure + * deliberately does not touch task state, because under the fork runtime it + * executes in a separate process. + */ + private function fire(int $index, ScheduledTask $task): void + { + $task->inFlight = true; + $task->lastStartAt = microtime(true); + + $this->running[$index] = $this->spawn(function () use ($task): void { + try { + $bean = Container::getInstance()->make($task->className); + } catch (\Throwable $e) { + $this->logger->error( + 'Scheduled ' . $task->id() . ': cannot resolve ' . $task->className + . ' from the container — check its constructor and #[Autowired] dependencies (' + . $e->getMessage() . ').' + ); + return; + } + + try { + $bean->{$task->methodName}(); + } catch (\Throwable $e) { + $this->logger->error('Scheduled ' . $task->id() . ' threw: ' . $e->getMessage()); + } + }); + } + + /** + * Finalises every run whose future has settled: record its end, advance the + * next fire from the trigger, and release the in-flight hold. Reading + * completion from the parent (not the run) keeps the state correct under the + * fork runtime, where the run executes in a child process. + */ + private function reap(): void + { + foreach ($this->running as $index => $future) { + if (!$future->isDone()) { + continue; + } + $task = $this->tasks[$index] ?? null; + if ($task !== null) { + $task->lastEndAt = microtime(true); + $task->runs++; + $task->nextFireAt = $task->trigger->nextFireTime( + microtime(true), + $task->lastStartAt, + $task->lastEndAt, + ); + $task->inFlight = false; + } + unset($this->running[$index]); + } + } + + /** + * Scans the project and plugins for {@see \Flytachi\Winter\Kernel\Schedule\Scheduled} methods. + * + * Override to source tasks another way — e.g. from a database of cron rows — + * instead of (or in addition to) annotation scanning. + * + * @return ScheduledTask[] + */ + protected function discover(): array + { + $collector = new ScheduledCollector(); + ClassScanner::scan($collector); + return $collector->getResult(); + } + + /** + * Seeds each task's first fire time from its trigger — boot plus the initial + * delay for a period trigger, the next matching instant for a cron trigger. + */ + private function seed(float $now): void + { + foreach ($this->tasks as $task) { + $task->nextFireAt = $task->trigger->firstFireTime($now, $task->initialDelay); + } + } + + /** + * How long to pause before the next pass: until the soonest not-in-flight + * task is due, clamped to [{@see MIN_SLEEP}, {@see MAX_SLEEP}]. When every task + * is in flight (or there are none) it idles a full {@see MAX_SLEEP} — but while + * any run is in flight the wait is capped to {@see REAP_POLL} so its completion, + * and the task's next fire, are picked up promptly rather than a whole second later. + */ + private function untilNext(float $now): float + { + $soonest = null; + foreach ($this->tasks as $task) { + if ($task->inFlight) { + continue; + } + $soonest = $soonest === null ? $task->nextFireAt : min($soonest, $task->nextFireAt); + } + $wait = $soonest === null + ? self::MAX_SLEEP + : max(self::MIN_SLEEP, min($soonest - $now, self::MAX_SLEEP)); + + if ($this->running !== []) { + $wait = min($wait, self::REAP_POLL); + } + return $wait; + } +} diff --git a/src/Schedule/Trigger/CronTrigger.php b/src/Schedule/Trigger/CronTrigger.php new file mode 100644 index 0000000..3da7c09 --- /dev/null +++ b/src/Schedule/Trigger/CronTrigger.php @@ -0,0 +1,217 @@ + '0 0 1 1 *', + '@annually' => '0 0 1 1 *', + '@monthly' => '0 0 1 * *', + '@weekly' => '0 0 * * 0', + '@daily' => '0 0 * * *', + '@midnight' => '0 0 * * *', + '@hourly' => '0 * * * *', + ]; + + /** @var array */ + private array $minutes; + /** @var array */ + private array $hours; + /** @var array */ + private array $daysOfMonth; + /** @var array */ + private array $months; + /** @var array */ + private array $daysOfWeek; + private bool $domRestricted; + private bool $dowRestricted; + + /** + * @param string $expression A five-field cron expression or a supported macro. + * @throws InvalidArgumentException On a malformed expression. + */ + public function __construct(private readonly string $expression) + { + $normalized = self::MACROS[strtolower(trim($expression))] ?? trim($expression); + $fields = preg_split('/\s+/', $normalized) ?: []; + if (count($fields) !== 5) { + throw new InvalidArgumentException( + "cron '{$expression}' must have 5 fields (minute hour day month weekday) or be a macro." + ); + } + + [$min, $hour, $dom, $mon, $dow] = $fields; + $this->minutes = $this->parseField($min, 0, 59, $expression); + $this->hours = $this->parseField($hour, 0, 23, $expression); + $this->daysOfMonth = $this->parseField($dom, 1, 31, $expression); + $this->months = $this->parseField($mon, 1, 12, $expression); + $this->daysOfWeek = $this->normalizeWeekdays($this->parseField($dow, 0, 7, $expression)); + $this->domRestricted = trim($dom) !== '*'; + $this->dowRestricted = trim($dow) !== '*'; + } + + /** + * {@inheritDoc} + */ + public function firstFireTime(float $now, float $initialDelay): float + { + // Cron is clock-aligned; the initial delay does not apply. + return $this->nextFireTime($now, null, null); + } + + /** + * {@inheritDoc} + */ + public function nextFireTime(float $now, ?float $lastStartAt, ?float $lastEndAt): float + { + // Start at the next whole minute strictly after now, then walk the calendar + // jumping over whole non-matching months/days/hours so even a rare rule + // (e.g. Feb 29) resolves in a handful of steps. + $t = new DateTimeImmutable()->setTimestamp((int) floor($now)); + $t = $t->setTime((int) $t->format('G'), (int) $t->format('i'), 0)->modify('+1 minute'); + + for ($step = 0; $step < self::MAX_STEPS; $step++) { + // Jump over a whole non-matching month or day; step minute by minute + // inside a matching day (at most 1440 steps, so hour need not jump). + if (!isset($this->months[(int) $t->format('n')])) { + $t = $t->modify('first day of next month')->setTime(0, 0, 0); + continue; + } + if (!$this->dayMatches($t)) { + $t = $t->modify('+1 day')->setTime(0, 0, 0); + continue; + } + if (isset($this->hours[(int) $t->format('G')]) && isset($this->minutes[(int) $t->format('i')])) { + return (float) $t->getTimestamp(); + } + $t = $t->modify('+1 minute'); + } + + throw new InvalidArgumentException("cron '{$this->expression}' has no upcoming match."); + } + + /** + * {@inheritDoc} + */ + public function describe(): string + { + return 'cron ' . $this->expression; + } + + /** + * Whether the date's day satisfies the day-of-month / day-of-week rule: their + * union when both are restricted, otherwise whichever one constrains. + */ + private function dayMatches(DateTimeImmutable $t): bool + { + $domOk = isset($this->daysOfMonth[(int) $t->format('j')]); + $dowOk = isset($this->daysOfWeek[(int) $t->format('w')]); + + if ($this->domRestricted && $this->dowRestricted) { + return $domOk || $dowOk; + } + if ($this->domRestricted) { + return $domOk; + } + if ($this->dowRestricted) { + return $dowOk; + } + return true; + } + + /** + * Parses one cron field into the set of values it allows. + * + * @return array + */ + private function parseField(string $field, int $min, int $max, string $expression): array + { + $allowed = []; + foreach (explode(',', trim($field)) as $part) { + $step = 1; + $range = $part; + if (str_contains($part, '/')) { + [$range, $stepStr] = explode('/', $part, 2); + if (!ctype_digit($stepStr) || (int) $stepStr < 1) { + throw new InvalidArgumentException("cron '{$expression}' has an invalid step in '{$part}'."); + } + $step = (int) $stepStr; + } + + if ($range === '*') { + $lo = $min; + $hi = $max; + } elseif (str_contains($range, '-')) { + [$a, $b] = explode('-', $range, 2); + $lo = $this->intOrFail($a, $part, $expression); + $hi = $this->intOrFail($b, $part, $expression); + } else { + $lo = $hi = $this->intOrFail($range, $part, $expression); + } + + if ($lo < $min || $hi > $max || $lo > $hi) { + throw new InvalidArgumentException( + "cron '{$expression}' field '{$part}' is out of range [{$min}-{$max}]." + ); + } + for ($v = $lo; $v <= $hi; $v += $step) { + $allowed[$v] = true; + } + } + return $allowed; + } + + /** + * Folds weekday 7 (an accepted alias for Sunday) onto 0. + * + * @param array $days + * @return array + */ + private function normalizeWeekdays(array $days): array + { + if (isset($days[7])) { + unset($days[7]); + $days[0] = true; + } + return $days; + } + + private function intOrFail(string $value, string $part, string $expression): int + { + if (!ctype_digit($value)) { + throw new InvalidArgumentException("cron '{$expression}' field '{$part}' is not numeric."); + } + return (int) $value; + } +} diff --git a/src/Schedule/Trigger/FixedDelayTrigger.php b/src/Schedule/Trigger/FixedDelayTrigger.php new file mode 100644 index 0000000..575f939 --- /dev/null +++ b/src/Schedule/Trigger/FixedDelayTrigger.php @@ -0,0 +1,44 @@ +delay; + } + + /** + * {@inheritDoc} + */ + public function describe(): string + { + return 'fixedDelay ' . $this->delay . 's'; + } +} diff --git a/src/Schedule/Trigger/FixedRateTrigger.php b/src/Schedule/Trigger/FixedRateTrigger.php new file mode 100644 index 0000000..f409a6a --- /dev/null +++ b/src/Schedule/Trigger/FixedRateTrigger.php @@ -0,0 +1,50 @@ +rate); + } + + /** + * {@inheritDoc} + */ + public function describe(): string + { + return 'fixedRate ' . $this->rate . 's'; + } +} diff --git a/src/Schedule/Trigger/Trigger.php b/src/Schedule/Trigger/Trigger.php new file mode 100644 index 0000000..ae30090 --- /dev/null +++ b/src/Schedule/Trigger/Trigger.php @@ -0,0 +1,44 @@ +getSql('option') !== null) { - throw new DataTableNetException( - 'Repository already has a SELECT clause defined. ' . - 'The paginator requires the SELECT clause to be empty.' - ); - } - if ($repo->getSql('where') !== null) { - throw new DataTableNetException( - 'Repository already has a WHERE clause defined. ' . - 'The paginator builds its own filter and expects no predefined WHERE conditions.' - ); - } - if ($repo->getSql('order') !== null) { - throw new DataTableNetException( - 'Repository already has an ORDER BY clause defined. ' . - 'The paginator applies its own ordering logic and requires ORDER BY to be unset.' - ); - } - - try { - $repo->select($request->selection()); - $repo->where($headQueryBuilder); - $repo->orderBy($request->order()); - $repo->limit($request->length, $request->start); - - if ($accurateCounts) { - $recordsTotal = self::countRecords($repo); - } - - $repo->cleanCache('where'); - $repo->cleanCache('binds'); - $repo->where(Qb::and( - $headQueryBuilder ?: Qb::empty(), - $request->filter() - )); - - $recordsFiltered = self::countRecords($repo); - - return new DataTableNetResponse( - $request->draw, - $recordsTotal ?? $recordsFiltered, - $recordsFiltered, - $repo->findAll() - ); - } catch (\Throwable $throwable) { - if ((int) $throwable->getCode() === 42703) { - $message = $throwable->getMessage(); - - if (preg_match('/column "(.*?)" does not exist/i', $message, $matches)) { - $invalidColumn = $matches[1]; - $userMessage = "Invalid column reference: `{$invalidColumn}` does not exist in the table"; - } else { - $userMessage = "Invalid column name used in request. Please check your field mappings"; - } - - throw new DataTableNetException($userMessage, previous: $throwable); - } - throw $throwable; - } - } - - private static function countRecords(RepositoryInterface $repo): int - { - $sql = self::prepareCountSql($repo->buildSql()); - $stmt = new CDOStatement($repo->db()->prepare($sql)); - - if ($repo->getSql('binds')) { - $method = method_exists($stmt, 'bindTypedValue') ? 'bindTypedValue' : 'bindValue'; - foreach ($repo->getSql('binds') as $bind) { - $stmt->{$method}($bind->getName(), $bind->getValue()); - } - } - - $stmt->getStmt()->execute(); - return (int) $stmt->getStmt()->fetchColumn(); - } - - private static function prepareCountSql(string $sql): string - { - $sql = preg_replace('/\s+LIMIT\s+\d+/i', '', $sql); - $sql = preg_replace('/\s+OFFSET\s+\d+/i', '', $sql); - $sql = preg_replace('/\s+FOR\s+UPDATE/i', '', $sql); - return 'SELECT COUNT(*) FROM (' . $sql . ') AS tmp'; - } -} diff --git a/src/Unit/DataTableNet/DataTableNetException.php b/src/Unit/DataTableNet/DataTableNetException.php deleted file mode 100644 index 6a44234..0000000 --- a/src/Unit/DataTableNet/DataTableNetException.php +++ /dev/null @@ -1,15 +0,0 @@ -search = match (true) { - $search === null => new DTNetSearch(), - default => new DTNetSearch($search['value'] ?? '', $search['regex'] ?? false), - }; - - if (!empty($order)) { - foreach ($order as $orderItem) { - $this->order[] = new DTNetOrder($orderItem['column'], $orderItem['dir']); - } - } - - $this->columns = new DTNetColumns($columns ?? []); - } - - public function rules(): void - { - $this->validate('draw', ['positive']); - $this->validate('start', ['numeric']); - $this->validate('length', ['positive']); - } - - /** - * Validates that all requested columns are explicitly allowed. - * - * @param string[] $allowed List of permitted column `data` keys. - * @throws RequestException If any column is not in the allowed list. - */ - public function allowColumns(array $allowed): void - { - foreach ($this->columns->items as $column) { - if (!in_array($column->data, $allowed, true)) { - throw new RequestException("Column '{$column->data}' is not allowed"); - } - } - } - - /** - * Overrides column identifiers for use in the SQL SELECT clause. - * - * @param array $resetNames Associative array of `data => name`. - */ - final public function overrideSelection(array $resetNames = []): void - { - foreach ($this->columns->items as $item) { - if (isset($resetNames[$item->data])) { - $item->name = $resetNames[$item->data]; - } - } - } - - /** - * Sets a custom filtering callback for global and per-column search. - * - * @param callable|null $callback function(DTNetColumn $column, string $value): ?Qb - */ - final public function overrideFilter(?callable $callback): void - { - $this->filterCallback = $callback; - } - - /** - * Sets a fallback ORDER BY string used when no sorting is specified. - * - * @param string|null $defaultContext Example: "id DESC, created_at ASC" - */ - final public function overrideOrder(?string $defaultContext = null): void - { - $this->defaultOrder = $defaultContext; - } - - /** - * Generates a comma-separated list of column names for a SQL SELECT clause. - */ - final public function selection(): string - { - $naming = array_map( - function (DTNetColumn $item): string { - if (!empty($item->name) && $item->name !== $item->data) { - return "{$item->name} AS {$item->data}"; - } - return $item->name ?: $item->data; - }, - $this->columns->items - ); - - return implode(', ', $naming); - } - - /** - * Builds the SQL WHERE clause based on global and column-specific filters. - */ - final public function filter(): Qb - { - $andConditions = []; - - $callback = $this->filterCallback ?? function (DTNetColumn $column, string $value): ?Qb { - $field = $column->name ?: $column->data; - return Qb::like($field, "%{$value}%"); - }; - - foreach ($this->columns->items as $column) { - $value = trim($column->search->value ?? ''); - if ($value !== '' && $column->searchable) { - $cond = $callback($column, $value); - if ($cond !== null) { - $andConditions[] = $cond; - } - } - } - - $global = trim($this->search->value ?? ''); - if ($global !== '') { - $orConditions = []; - - foreach ($this->columns->items as $column) { - if ($column->searchable) { - $cond = $callback($column, $global); - if ($cond !== null) { - $orConditions[] = $cond; - } - } - } - - if (!empty($orConditions)) { - $andConditions[] = Qb::clip(Qb::or(...$orConditions)); - } - } - - return empty($andConditions) ? Qb::empty() : Qb::and(...$andConditions); - } - - /** - * Builds a SQL ORDER BY expression from the current ordering configuration. - */ - final public function order(): string - { - $orderClauses = []; - - foreach ($this->order as $orderItem) { - $column = $this->columns->items[$orderItem->column] ?? null; - if (!$column || !$column->orderable) { - continue; - } - - $field = $column->name ?: $column->data; - $direction = strtolower($orderItem->dir) === 'desc' ? 'DESC' : 'ASC'; - $orderClauses[] = "{$field} {$direction}"; - } - - if (empty($orderClauses) && $this->defaultOrder !== null) { - return $this->defaultOrder; - } - - return implode(', ', $orderClauses); - } -} diff --git a/src/Unit/DataTableNet/DataTableNetResponse.php b/src/Unit/DataTableNet/DataTableNetResponse.php deleted file mode 100644 index be1181d..0000000 --- a/src/Unit/DataTableNet/DataTableNetResponse.php +++ /dev/null @@ -1,27 +0,0 @@ - */ - public function toArray(): array - { - return [ - 'draw' => $this->draw, - 'recordsTotal' => $this->recordsTotal, - 'recordsFiltered' => $this->recordsFiltered, - 'data' => $this->data, - ]; - } -} diff --git a/src/Unit/DataTableNet/Entity/DTNetColumn.php b/src/Unit/DataTableNet/Entity/DTNetColumn.php deleted file mode 100644 index 646cfad..0000000 --- a/src/Unit/DataTableNet/Entity/DTNetColumn.php +++ /dev/null @@ -1,31 +0,0 @@ -items[] = DTNetColumn::fromArray($column); - } - } - } -} diff --git a/src/Unit/DataTableNet/Entity/DTNetOrder.php b/src/Unit/DataTableNet/Entity/DTNetOrder.php deleted file mode 100644 index 7b43f33..0000000 --- a/src/Unit/DataTableNet/Entity/DTNetOrder.php +++ /dev/null @@ -1,14 +0,0 @@ -return(); // blocks until the task finishes - * ``` - * - * --- - * ### Example 2: Fire-and-forget - * - * ``` - * $future = Operation::async(function () { - * sendWelcomeEmail($userId); - * }); - * - * return new Response('OK'); // $future goes out of scope → result discarded - * ``` - * @template TResult - * - * @see Operation::async() - * @see OpResult - */ -readonly class Future -{ - private OpResult $result; - - /** - * Creates a new Future and immediately starts the background thread. - * - * @param string $id The unique operation ID generated by {@see OperationRunnable}. - * @param Thread $thread The thread that will execute the task. - */ - public function __construct( - private string $id, - private Thread $thread - ) { - Operation::store()->write($this->id, 'pending'); - $thread->start(); - } - - public function join(): void - { - $this->thread->join(); - usleep(1_000); - } - - /** - * Blocks until the operation completes and returns its result. - * - * @return OpResult - * @throws Error If the result is not available after 3 retries. - */ - public function get(): OpResult - { - if (!isset($this->result)) { - $this->join(); - - for ($i = 0; $i < 3; $i++) { - $opResult = Operation::store()->read($this->id); - if ($opResult) { - $this->result = $opResult; - Operation::store()->del($this->id); - break; - } - if ($i == 2) { - Error::throw('Operation result not found'); - } - usleep(1_000); - } - } - - return $this->result; - } - - /** - * Awaits the operation and returns the raw result value. - * - * @param bool $isThrow Whether to re-throw any exception caught in the child process. - * @return TResult - * @throws \Throwable If $isThrow is true and the background task threw an exception. - */ - public function return(bool $isThrow = true): mixed - { - $opResult = $this->get(); - if ($isThrow && $opResult->getThrowable()) { - throw $opResult->getThrowable(); - } - return $opResult->getResult(); - } - - public function __destruct() - { - Operation::store()->del($this->id); - } -} diff --git a/src/Unit/Operation/OpResult.php b/src/Unit/Operation/OpResult.php deleted file mode 100644 index e473235..0000000 --- a/src/Unit/Operation/OpResult.php +++ /dev/null @@ -1,56 +0,0 @@ -get(); - * - * if ($opResult->getThrowable() !== null) { - * // task failed - * echo $opResult->getThrowable()->getMessage(); - * } else { - * $value = $opResult->getResult(); - * } - * ``` - * @template TResult - * - * @see Future::get() - * @see OperationRunnable::run() - */ -readonly class OpResult -{ - /** - * @param TResult $result The value returned by the callback. - * @param \Throwable|null $throwable The exception thrown by the callback, or null on success. - */ - public function __construct( - private mixed $result, - private ?\Throwable $throwable - ) { - } - - /** @return TResult */ - public function getResult(): mixed - { - return $this->result; - } - - public function getThrowable(): ?\Throwable - { - return $this->throwable; - } -} diff --git a/src/Unit/Operation/Operation.php b/src/Unit/Operation/Operation.php deleted file mode 100644 index d288f1e..0000000 --- a/src/Unit/Operation/Operation.php +++ /dev/null @@ -1,70 +0,0 @@ - heavyJob())->return(); - * - * // Fire-and-forget - * Operation::async(fn() => sendEmail($to, $subject, $body)); - * ``` - * - * @see Future - * @see OperationRunnable - */ -final class Operation -{ - private function __construct() - { - } - - /** - * Returns the shared volatile store used for inter-process result passing. - * - * @return FileStorage The shared operation store instance. - */ - public static function store(): FileStorage - { - return Kernel::volatile('operations'); - } - - /** - * Dispatches a callable to run asynchronously in a background process. - * - * @template TResult - * - * @param callable(): TResult $callback Any PHP callable: closure, named function, or invokable object. - * @return Future A handle to the running background operation. - */ - public static function async(callable $callback): Future - { - $runnable = new OperationRunnable($callback); - - $thread = new Thread( - $runnable, - 'operation', - $runnable->getName() - ); - - return new Future($runnable->getId(), $thread); - } -} diff --git a/src/Unit/Operation/OperationRunnable.php b/src/Unit/Operation/OperationRunnable.php deleted file mode 100644 index 4e524af..0000000 --- a/src/Unit/Operation/OperationRunnable.php +++ /dev/null @@ -1,93 +0,0 @@ -id = 'op_' . Algorithm::random(16); - $this->callback = \Closure::fromCallable($callback); - $this->name = $this->callableName(); - } - - public function getId(): string - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function run(array $args): void - { - try { - $result = ($this->callback)(); - } catch (\Throwable $throwable) { - } finally { - $pending = Operation::store()->read($this->id); - if ($pending === 'pending') { - Operation::store()->write( - $this->id, - new OpResult( - $result ?? null, - $throwable ?? null - ) - ); - } - } - } - - private function callableName(): string - { - $reflection = new \ReflectionFunction($this->callback); - - if ($reflection->isClosure()) { - $class = $reflection->getClosureScopeClass(); - if ($class) { - return '[closure] in ' . $class->getName(); - } - return '[closure]'; - } - - $name = $reflection->getName(); - if (str_contains($name, '{closure}')) { - return '[closure]'; - } - - return '[function] ' . $name; - } -} diff --git a/src/Unit/Pagination/CursorDirection.php b/src/Unit/Pagination/CursorDirection.php index 4e5c55b..6f551fe 100644 --- a/src/Unit/Pagination/CursorDirection.php +++ b/src/Unit/Pagination/CursorDirection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; /** * Navigation direction encoded into a cursor token by {@see CursorToken}. diff --git a/src/Unit/Pagination/CursorKey.php b/src/Unit/Pagination/CursorKey.php index 4724167..2273eab 100644 --- a/src/Unit/Pagination/CursorKey.php +++ b/src/Unit/Pagination/CursorKey.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use InvalidArgumentException; diff --git a/src/Unit/Pagination/CursorToken.php b/src/Unit/Pagination/CursorToken.php index 7283cb7..c3fb123 100644 --- a/src/Unit/Pagination/CursorToken.php +++ b/src/Unit/Pagination/CursorToken.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use JsonException; @@ -78,6 +78,18 @@ public static function decode(string $token, string $expectedSignature): array ); } + // The token is client-supplied and JSON allows arrays and objects, while a + // cursor position is always scalar. Rejecting them here keeps a forged cursor a + // 400 through InvalidCursorException; left through, it reaches the query builder + // and surfaces as an uncaught TypeError — a 500 for bad input. + foreach ($payload['v'] as $value) { + if ($value !== null && !is_scalar($value)) { + throw new InvalidCursorException( + 'Cursor holds a non-scalar position value of type ' . get_debug_type($value) . '.' + ); + } + } + $direction = CursorDirection::tryFrom($payload['d']); if ($direction === null) { throw new InvalidCursorException("Cursor has unknown direction '{$payload['d']}'."); diff --git a/src/Unit/Pagination/InvalidCursorException.php b/src/Unit/Pagination/InvalidCursorException.php index b681446..0b40c73 100644 --- a/src/Unit/Pagination/InvalidCursorException.php +++ b/src/Unit/Pagination/InvalidCursorException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use RuntimeException; diff --git a/src/Unit/Pagination/PaginationMeta.php b/src/Unit/Pagination/PaginationMeta.php index 05a2c1e..8767577 100644 --- a/src/Unit/Pagination/PaginationMeta.php +++ b/src/Unit/Pagination/PaginationMeta.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use JsonSerializable; diff --git a/src/Unit/Pagination/PaginationMetaCursor.php b/src/Unit/Pagination/PaginationMetaCursor.php index e5f7503..e50066e 100644 --- a/src/Unit/Pagination/PaginationMetaCursor.php +++ b/src/Unit/Pagination/PaginationMetaCursor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use JsonSerializable; diff --git a/src/Unit/Pagination/PaginationResult.php b/src/Unit/Pagination/PaginationResult.php index a1bd21f..caba484 100644 --- a/src/Unit/Pagination/PaginationResult.php +++ b/src/Unit/Pagination/PaginationResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use JsonSerializable; diff --git a/src/Unit/Pagination/Paginator.php b/src/Unit/Pagination/Paginator.php index ec249c3..a264b08 100644 --- a/src/Unit/Pagination/Paginator.php +++ b/src/Unit/Pagination/Paginator.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use Flytachi\Winter\Cdo\Connection\CDOStatement; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryInterface; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; use LogicException; use ValueError; diff --git a/src/Unit/Pagination/Sort.php b/src/Unit/Pagination/Sort.php index f79da15..ccc3405 100644 --- a/src/Unit/Pagination/Sort.php +++ b/src/Unit/Pagination/Sort.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; /** * Sort direction for ordered queries — used by {@see CursorKey} and forwarded diff --git a/src/Unit/Pagination/WrapMeta.php b/src/Unit/Pagination/WrapMeta.php index 372c3c9..a032bfa 100644 --- a/src/Unit/Pagination/WrapMeta.php +++ b/src/Unit/Pagination/WrapMeta.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use JsonSerializable; @@ -10,7 +10,7 @@ * Page-centric pagination metadata. * * Carried by {@see WrapResult} when produced by - * {@see \Flytachi\Winter\K2\Unit\Wrapper::paginator()}. Unlike + * {@see \Flytachi\Winter\Kernel\Unit\Wrapper::paginator()}. Unlike * {@see PaginationMeta} (which is offset-centric), `WrapMeta` exposes the * page-oriented fields a classical numbered-page UI expects — `current`, * `pages`, plus `previous` / `next` for prev/next links. diff --git a/src/Unit/Pagination/WrapResult.php b/src/Unit/Pagination/WrapResult.php index dee5227..281d20e 100644 --- a/src/Unit/Pagination/WrapResult.php +++ b/src/Unit/Pagination/WrapResult.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit\Pagination; +namespace Flytachi\Winter\Kernel\Unit\Pagination; use JsonSerializable; /** * Page-centric pagination response container — meta plus page data. * - * Returned by {@see \Flytachi\Winter\K2\Unit\Wrapper::paginator()}. + * Returned by {@see \Flytachi\Winter\Kernel\Unit\Wrapper::paginator()}. * Implements {@see JsonSerializable} so `json_encode($result)` produces an * API-ready payload: * ``` diff --git a/src/Unit/Wrapper.php b/src/Unit/Wrapper.php index 2c9af78..9621511 100644 --- a/src/Unit/Wrapper.php +++ b/src/Unit/Wrapper.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Unit; +namespace Flytachi\Winter\Kernel\Unit; -use Flytachi\Winter\K2\Ppa\Entity\RepositoryViewInterface; -use Flytachi\Winter\K2\Unit\Pagination\Paginator; -use Flytachi\Winter\K2\Unit\Pagination\WrapMeta; -use Flytachi\Winter\K2\Unit\Pagination\WrapResult; +use Flytachi\Winter\Kernel\Ppa\Entity\RepositoryViewInterface; +use Flytachi\Winter\Kernel\Unit\Pagination\Paginator; +use Flytachi\Winter\Kernel\Unit\Pagination\WrapMeta; +use Flytachi\Winter\Kernel\Unit\Pagination\WrapResult; use ValueError; /** diff --git a/src/WinterApplication.php b/src/WinterApplication.php new file mode 100644 index 0000000..fb4a3bc --- /dev/null +++ b/src/WinterApplication.php @@ -0,0 +1,762 @@ +> */ + private static array $webConfigurers = []; + + /** Returns the concrete application class name set during boot. */ + public static function getAppClass(): string + { + return self::$appClass; + } + + // ── Hooks (override in your App class) ──────────────────────────────────── + + /** + * Initialise the kernel — paths, .env, logging, timezone. Runs before the + * scan (it decides where the scan looks), so it cannot be a discovered class. + * + * The default derives the project root from the App class's own file; override + * only for non-standard layouts. + */ + protected static function configure(ApplicationArguments $args): void + { + Kernel::init(pathRoot: static::rootPath()); + } + + // ── Entry ───────────────────────────────────────────────────────────────── + + /** + * The application's `main()` — the single front door. Typically the App class + * just forwards to {@see run()}: + * ``` + * public static function main(array $argv): never { static::run($argv); } + * ``` + * + * @param array $argv Raw $argv (script name in [0]). + */ + public static function main(array $argv): never + { + static::run($argv); + } + + /** + * The background-launch entry — the child side of {@see Process::dispatch()}. + * + * Detaching a process cannot be a fork: the parent may be a Swoole worker whose + * reactor must not be duplicated, so the launcher spawns a **fresh PHP process** + * running `vendor/bin/wKernelRunner`, which lands here. The application has to be + * booted again from scratch — a new process shares nothing — before the staged + * payload can run, otherwise the dispatched class has no container, no logging + * and no configuration. + * + * So this is {@see run()} without the dispatch: the same {@see bootstrap()}, + * then the thread payload instead of the console. The launcher's own options + * (`--namespace`, `--name`, `--tag`, `--debug`, `--detach`, `--shmkey`) are read + * straight from the command line by `getopt()`; `--detach` makes the runner + * daemonise itself. + * + * @param array $argv Raw $argv (script name in [0]). + */ + final public static function executor(array $argv): never + { + static::bootstrap(ApplicationArguments::parse($argv)); + + exit(AdaptiveRunner::adaptive()->execute( + getopt('', ['namespace::', 'name::', 'tag::', 'debug', 'detach', 'shmkey::']) + )); + } + + /** + * Locates the application class the project's bootstrap file declared, so a + * generic entry point (the thread runner) can reach {@see executor()} without + * knowing the project's naming. + * + * Call it only after the bootstrap file has been required: it looks at what is + * actually declared, and a project declares exactly one application class. + * + * @return class-string + */ + public static function discoverAppClass(): string + { + $found = array_values(array_filter( + get_declared_classes(), + static fn(string $class): bool => is_subclass_of($class, self::class), + )); + + return match (count($found)) { + 1 => $found[0], + 0 => throw new ApplicationConfigException( + 'No application class found. The bootstrap file must declare a class ' + . 'extending ' . self::class . '.' + ), + default => throw new ApplicationConfigException( + 'Several application classes are declared (' . implode(', ', $found) + . '); the bootstrap file must declare exactly one.' + ), + }; + } + + /** + * Boots the application once, then dispatches: + * - `call run` / `call run dev` → bring the app up ({@see serve()}); + * - bare `call` or any other verb → the console dispatcher (bare → help). + * + * @param array $argv Raw $argv (script name in [0]). + */ + final public static function run(array $argv = []): never + { + self::$bootStartedAt = hrtime(true); + $args = ApplicationArguments::parse($argv); + static::bootstrap($args); + + if ($args->command() === 'run') { + $watch = $args->sub() === 'dev' || $args->flag('w') || $args->has('watcher'); + static::serve($watch, $args); + } + + // Bare `call` and every other verb are console commands (bare → Help); they + // run after the same boot. + LoggerFactory::setDefaultChannel('sys'); + new Core($args->raw())->run(); + exit(0); + } + + // ── Boot ────────────────────────────────────────────────────────────────── + + /** + * The boot sequence: kernel init → one project scan (DI + beans + configurers) + * → apply discovered configuration (logging, CORS, imports). Every entry runs + * this first. + */ + protected static function bootstrap(ApplicationArguments $args): void + { + self::$appClass = static::class; + static::configure($args); + + $c = Container::init(); + self::$container = $c; + $debug = (bool) env('DEBUG', false); + + $config = new ConfigurationCollector($c); + $webCollector = new ImplementorCollector(WebConfigurer::class); + $logCollector = new ImplementorCollector(LoggingConfigurer::class); + $actuatorCollector = new ImplementorCollector(HealthContributor::class); + $scopeGraph = new ScopeGraphCollector(); + + // #[Async] proxying is opt-in, like Spring's @EnableAsync: the collector is + // created and wired only when #[EnableAsync] is present. Without it, classes + // carrying #[Async] are not proxied and their methods run synchronously. It + // must run after DICollector (which rebinds a class to itself), so it is + // collected last. + $async = static::hasAttribute(EnableAsync::class) + ? new AsyncCollector( + $c, + ProxyFactory::forKernel($debug), + $debug ? null : Kernel::$pathStorageVolatile . '/async.php', + ) + : null; + + $scan = ClassScanner::scanner( + rootDir: Kernel::$pathRoot, + cache: $debug ? null : Kernel::$pathStorageVolatile . '/di.php', + ) + ->collect(new DICollector($c)) + ->collect($config) + ->collect($webCollector) + ->collect($logCollector) + ->collect($actuatorCollector) + ->collect($scopeGraph); + + if ($async !== null) { + $scan->collect($async); + } + $scan->execute(); + + // Before anything is resolved: a #[Singleton] holding a #[Request] bean would + // freeze the first request's instance for the worker's lifetime, and say nothing. + $scopeGraph->assertNoFrozenRequestScope(); + + $async?->flush(); + + // Default contextual logger: an injected LoggerInterface is named after the + // class it is injected into. + $c->contextual( + LoggerInterface::class, + static fn(Container $c, ?string $consumer) => LoggerFactory::getLogger($consumer ?? 'app'), + ); + + static::applyLogging($c, $logCollector->getResult()); + static::applyCors($c, $webCollector->getResult()); + static::applyActuator($actuatorCollector->getResult()); + static::applyImports(); + } + + /** + * @param list<\ReflectionClass> $configurers + */ + private static function applyLogging(Container $c, array $configurers): void + { + if ($configurers === []) { + return; + } + $registry = new ChannelRegistry(); + foreach ($configurers as $ref) { + /** @var LoggingConfigurer $configurer */ + $configurer = $c->make($ref->getName()); + $configurer->configureChannels($registry); + } + $registry->apply(); + } + + /** + * @param list<\ReflectionClass> $configurers + */ + private static function applyCors(Container $c, array $configurers): void + { + $registry = new CorsRegistry(); + $classes = []; + foreach ($configurers as $ref) { + $classes[] = $ref->getName(); + /** @var WebConfigurer $configurer */ + $configurer = $c->make($ref->getName()); + $configurer->configureCors($registry); + } + self::$webConfigurers = $classes; + + if ($registry->isTouched()) { + $registry->apply(); + } + } + + private static function applyImports(): void + { + foreach (new \ReflectionClass(static::class)->getAttributes(Import::class) as $attribute) { + /** @var Import $import */ + $import = $attribute->newInstance(); + Plugin::registry($import->package, $import->prefix, $import->required); + } + } + + /** + * Enables the actuator when the App class carries {@see EnableActuator}: registers + * the discovered {@see HealthContributor} classes and hands the indicator + + * optional guard middleware to {@see Health}, which {@see Router::fromScan()} then + * wires into the `/actuator/*` routes. No attribute → actuator stays off. + * + * @param list<\ReflectionClass> $contributors + */ + private static function applyActuator(array $contributors): void + { + $attributes = new \ReflectionClass(static::class)->getAttributes(EnableActuator::class); + if ($attributes === []) { + return; + } + + /** @var EnableActuator $actuator */ + $actuator = $attributes[0]->newInstance(); + Health::setContributors(array_map( + static fn(\ReflectionClass $ref): string => $ref->getName(), + $contributors, + )); + Health::configure($actuator->indicator ?? HealthIndicator::class, $actuator->middleware); + } + + // ── Serve ───────────────────────────────────────────────────────────────── + + /** + * Brings the application up and blocks until shutdown. With a + * {@see Component::http()} declared: one Swoole HTTP server plus every other + * component supervised via `addProcess`. Without it: headless. + * + * @param bool $watch Attach the DevWatcher (memory + hot-reload) — development. + */ + final public static function serve(bool $watch, ApplicationArguments $args): never + { + $components = static::resolveComponents(); + if ($components === []) { + throw new ApplicationConfigException( + 'No components declared on ' . static::class . ': add at least one ' + . '#[EnableWeb], #[EnableProcess], #[EnableDaemon] or #[EnableScheduler].' + ); + } + + /** @var ?Component $http */ + $http = null; + /** @var list $sockets */ + $sockets = []; + /** @var list $companions */ + $companions = []; + + foreach ($components as $component) { + match ($component->kind) { + ComponentKind::Http => $http = $component, + ComponentKind::WebSocket => $sockets[] = $component, + default => $companions[] = $component, + }; + } + + if ($sockets !== []) { + throw new ApplicationConfigException( + 'WebSocket components are not hosted by the bundled runtime yet.' + ); + } + + $logger = LoggerFactory::getLogger(static::class); + + if ($http !== null) { + static::serveHttp($companions, $watch, $args, $logger); + } + + static::serveHeadless($companions, $args, $logger); + } + + /** + * Web bundle: the Http component becomes the Swoole server; every other + * component is attached with addProcess so the master supervises it. + * + * @param list $companions + */ + private static function serveHttp( + array $companions, + bool $watch, + ApplicationArguments $args, + LoggerInterface $logger, + ): never { + if (!extension_loaded('swoole')) { + throw new ApplicationConfigException( + '`call run` with a web tier needs ext-swoole (pecl install swoole).' + ); + } + + $settings = static::buildServerSettings($args); + $host = $settings->getHost(); + $port = $settings->getPort(); + + $router = Router::fromScan(Kernel::$pathRoot); + + \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + Runtime::boot(RuntimeMode::Swoole); + + $server = new \Swoole\Http\Server($host, $port); + $server->set($settings->toArray()); + + // Says the arithmetic out loud before any worker exists: the memory limit is per + // worker and shared by every coroutine in it, so what the box must hold is + // worker_num × limit plus opcache. Warns, never refuses — over-committing is a + // legitimate choice and this is a worst-case estimate. + WorkerMemory::check( + $settings->getMemoryLimit(), + (int) ($settings->toArray()['worker_num'] ?? 1), + LoggerFactory::getLogger('sys'), + ); + + $names = []; + foreach ($companions as $companion) { + $class = (string) $companion->class; + $names[] = self::shortName($class); + $server->addProcess(new \Swoole\Process( + static function () use ($class): void { + // Reset runtime hooks (flags = 0) so the child is a clean plain + // process, exactly like a standalone launch. + \Swoole\Runtime::enableCoroutine(0); + LoggerFactory::setContextStorage(new ProcessContext()); + LoggerFactory::setDefaultChannel('sys'); + ForkReset::runAll(); + $class::start(); + } + )); + } + + $trimThreshold = $settings->getMemoryTrimThreshold(); + $handler = static function ( + \Swoole\Http\Request $req, + \Swoole\Http\Response $res + ) use ($router, $trimThreshold): void { + $request = new SwooleRequest($req); + $isHead = strtoupper($request->getMethod()) === 'HEAD'; + $router->handle($request, new SwooleResponse($res, $isHead)); + + // After the response, not before it: giving memory back can take tens of + // milliseconds when there is a lot of it, and the client should not wait for + // that. A request that reserved nothing unusual pays 5 µs to find out. + WorkerMemory::trimIfIdle($trimThreshold); + }; + + // Request workers log on 'http' with per-request coroutine isolation, and are + // marked eligible to publish their connection-pool utilisation for + // `call db pool` — the publisher itself only starts if a pool is ever opened. + $memoryLimit = $settings->getMemoryLimit(); + $requestTimeout = $settings->getRequestTimeout(); + $workerStart = static function ( + \Swoole\Http\Server $server, + int $workerId + ) use ($memoryLimit, $requestTimeout): void { + // Per worker, because the limit is a property of this process — and a no-op + // when nothing was configured, so PHP's own value stands. + WorkerMemory::apply($memoryLimit); + // One sweep per worker rather than a timer per request; a no-op at 0, so an + // application that wants no deadline runs no timer at all. + RequestWatchdog::enable($requestTimeout); + LoggerFactory::setContextStorage(new CoroutineContext()); + LoggerFactory::setDefaultChannel('http'); + PoolTelemetry::enable($workerId); + }; + + // A worker cannot leave while its reactor still holds a repeating timer, so a + // shutdown would hang until Swoole force-kills it ("worker exit timeout"). + // `workerExit` fires exactly while the reactor is trying to drain, which is + // where those timers have to be released. + $workerExit = static function (\Swoole\Http\Server $server, int $workerId): void { + RequestWatchdog::disable(); + PoolTelemetry::stop($workerId); + PpaConnectionPool::shutdown(); + }; + + $dev = $watch ? new DevWatcher([Kernel::$pathRoot]) : null; + if ($dev !== null) { + $dev->attach($server, $workerStart); + $server->on('request', $dev->wrap($handler)); + } else { + $server->on('workerStart', $workerStart); + $server->on('request', $handler); + } + $server->on('workerExit', $workerExit); + + if (Banner::isEnabled($args)) { + $rows = static::bannerRows($companions, $host, $port); + // What the profile resolved to, because a limit nobody can see is a limit + // nobody thinks of when a request starts queueing or a connection is refused. + $rows[] = ['profile', self::profileSummary($settings)]; + Banner::print($rows, self::elapsedMs()); + } + + $logger->info(sprintf( + 'Application up: http://%s:%d%s%s', + $host, + $port, + $names === [] ? '' : ' + [' . implode(', ', $names) . ']', + $watch ? ' (dev/watch)' : '' + )); + + $server->start(); + + if ($dev !== null && $dev->reloadRequested()) { + $dev->reexec(); + } + + exit(0); + } + + /** + * No web tier: run the background components directly — one in the foreground, + * several under a small pcntl supervisor that forwards the stop signal. + * + * @param list $companions + */ + private static function serveHeadless( + array $companions, + ApplicationArguments $args, + LoggerInterface $logger, + ): never { + if ($companions === []) { + throw new ApplicationConfigException( + 'Nothing to run: no components declared. Add at least one ' + . '#[EnableWeb]/#[EnableProcess]/#[EnableDaemon]/#[EnableScheduler].' + ); + } + + if (Banner::isEnabled($args)) { + Banner::print(static::bannerRows($companions, null, null), self::elapsedMs()); + } + + if (count($companions) === 1) { + $class = (string) $companions[0]->class; + $logger->info('Application up (headless): ' . self::shortName($class)); + $class::start(); + exit(0); + } + + if (!function_exists('pcntl_fork')) { + throw new ApplicationConfigException( + 'Running several headless components needs ext-pcntl.' + ); + } + + $children = []; + foreach ($companions as $companion) { + $class = (string) $companion->class; + $pid = pcntl_fork(); + if ($pid === -1) { + throw new \RuntimeException("WinterApplication: fork failed for {$class}."); + } + if ($pid === 0) { + if (extension_loaded('swoole')) { + \Swoole\Runtime::enableCoroutine(0); + } + LoggerFactory::setContextStorage(new ProcessContext()); + LoggerFactory::setDefaultChannel('sys'); + ForkReset::runAll(); + $class::start(); + exit(0); + } + $children[$pid] = self::shortName($class); + } + + $forward = static function (int $signo) use (&$children): void { + foreach (array_keys($children) as $pid) { + @posix_kill($pid, $signo); + } + }; + pcntl_async_signals(true); + pcntl_signal(SIGTERM, $forward); + pcntl_signal(SIGINT, $forward); + + $logger->info('Application up (headless): [' . implode(', ', $children) . ']'); + + while ($children !== []) { + $pid = pcntl_waitpid(-1, $status); + if ($pid > 0) { + unset($children[$pid]); + } + } + + exit(0); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + /** + * Builds the server settings: bind address + Swoole options. The default bind + * policy is `--host`/`--port` (fallback `0.0.0.0:8000`); base Swoole options come + * from .env; every discovered {@see WebConfigurer} may then tune both, re-deriving + * host/port from any source (env, a custom flag, a literal) via the handle. + */ + private static function buildServerSettings(ApplicationArguments $args): ServerSettings + { + $settings = ServerSettings::fromEnv( + $args->option('host', '0.0.0.0') ?? '0.0.0.0', + $args->int('port', 8000), + ); + $c = self::$container; + if ($c !== null) { + foreach (self::$webConfigurers as $class) { + /** @var WebConfigurer $configurer */ + $configurer = $c->make($class); + $configurer->configureServer($settings, $args); + } + } + return $settings; + } + + /** + * Builds the component manifest from the App class's #[Enable*] attributes. + * Each attribute maps to one {@see Component}; {@see EnableAsync} is not here — + * it is a boot toggle read in {@see bootstrap()}, not a component. + * + * @return list + */ + private static function resolveComponents(): array + { + $ref = new \ReflectionClass(static::class); + $components = []; + + if ($ref->getAttributes(EnableWeb::class) !== []) { + $components[] = Component::http(); + } + $scheduler = $ref->getAttributes(EnableScheduler::class); + if ($scheduler !== []) { + $components[] = Component::scheduler($scheduler[0]->newInstance()->class); + } + foreach ($ref->getAttributes(EnableProcess::class) as $attribute) { + $components[] = Component::process($attribute->newInstance()->class); + } + foreach ($ref->getAttributes(EnableDaemon::class) as $attribute) { + $components[] = Component::daemon($attribute->newInstance()->class); + } + + return $components; + } + + /** + * True if the App class carries the given attribute. + * + * @param class-string $attribute + */ + private static function hasAttribute(string $attribute): bool + { + return new \ReflectionClass(static::class)->getAttributes($attribute) !== []; + } + + /** + * Builds the startup-banner rows from the live manifest: the web endpoint (when + * hosting one), each companion, and the async toggle. Only what is actually + * running appears. + * + * @param list $companions + * @return list + */ + /** + * The profile and the numbers it resolved to, for the startup banner. + * + * `Profile::Stress` prints a warning instead of numbers: it removes the caps and the + * periodic work that would distort a measurement, which is right for a benchmark and + * wrong for anything else, and a banner is the last place it can still be noticed. + */ + private static function profileSummary(ServerSettings $settings): string + { + $profile = $settings->getProfile(); + if (!$profile->guards()) { + return $profile->value . ' — guards off, benchmarks only'; + } + + return sprintf( + '%s · %d in flight · %d connections · recycle at %s', + $profile->value, + $settings->getMaxConcurrency(), + $settings->getMaxConnections(), + number_format($settings->getMaxRequest()), + ); + } + + private static function bannerRows(array $companions, ?string $host, ?int $port): array + { + $rows = []; + if ($host !== null) { + $rows[] = ['web', sprintf('http://%s:%d', $host, $port)]; + } + foreach ($companions as $companion) { + $rows[] = $companion->kind === ComponentKind::Scheduler + ? ['scheduler', 'enabled'] + : [self::componentLabel($companion->kind), self::shortName((string) $companion->class)]; + } + if (static::hasAttribute(EnableAsync::class)) { + $rows[] = ['async', 'enabled']; + } + + return $rows; + } + + private static function componentLabel(ComponentKind $kind): string + { + return match ($kind) { + ComponentKind::Daemon => 'daemon', + default => 'process', + }; + } + + /** Milliseconds from boot start to now (0.0 before {@see run()} sets the mark). */ + private static function elapsedMs(): float + { + return self::$bootStartedAt > 0 + ? (hrtime(true) - self::$bootStartedAt) / 1e6 + : 0.0; + } + + protected static function rootPath(): string + { + return dirname((string) new \ReflectionClass(static::class)->getFileName()); + } + + private static function shortName(string $class): string + { + return new \ReflectionClass($class)->getShortName(); + } +} diff --git a/tests/App/ApplicationArgumentsTest.php b/tests/App/ApplicationArgumentsTest.php new file mode 100644 index 0000000..c73d3d4 --- /dev/null +++ b/tests/App/ApplicationArgumentsTest.php @@ -0,0 +1,57 @@ +command()); + self::assertSame('dev', $args->sub()); + } + + public function test_bare_invocation_has_no_command(): void + { + $args = ApplicationArguments::parse(['call']); + self::assertNull($args->command()); + self::assertNull($args->sub()); + } + + public function test_long_options_with_and_without_value(): void + { + $args = ApplicationArguments::parse(['call', 'run', '--port=8080', '--watcher']); + self::assertSame('8080', $args->option('port')); + self::assertSame(8080, $args->int('port', 8000)); + self::assertTrue($args->has('watcher')); + self::assertNull($args->option('watcher')); // present, no value + self::assertSame('0.0.0.0', $args->option('host', '0.0.0.0')); + } + + public function test_int_falls_back_when_absent_or_non_numeric(): void + { + $args = ApplicationArguments::parse(['call', 'run', '--port=abc']); + self::assertSame(8000, $args->int('port', 8000)); + self::assertSame(9000, $args->int('missing', 9000)); + } + + public function test_short_flags_expand(): void + { + $args = ApplicationArguments::parse(['call', 'run', '-w']); + self::assertTrue($args->flag('w')); + self::assertFalse($args->flag('x')); + } + + public function test_raw_is_preserved_for_console_handoff(): void + { + $argv = ['call', 'make', '-c', 'UserController']; + $args = ApplicationArguments::parse($argv); + self::assertSame($argv, $args->raw()); + self::assertSame('make', $args->command()); + } +} diff --git a/tests/App/ConfigurationCollectorTest.php b/tests/App/ConfigurationCollectorTest.php new file mode 100644 index 0000000..7b6c94b --- /dev/null +++ b/tests/App/ConfigurationCollectorTest.php @@ -0,0 +1,127 @@ +collect( + BeansFixtureConfig::class, + new ReflectionClass(BeansFixtureConfig::class), + ); + } + + public function test_singleton_bean_keyed_by_return_type(): void + { + $c = Container::init(); + $this->collect($c); + + $a = $c->make(CacheContract::class); + $b = $c->make(CacheContract::class); + + self::assertInstanceOf(RedisCacheFixture::class, $a); + self::assertSame($a, $b, 'a #[Bean] defaults to singleton scope'); + } + + public function test_transient_bean_returns_fresh_instances(): void + { + $c = Container::init(); + $this->collect($c); + + $q1 = $c->make(QueryBuilderFixture::class); + $q2 = $c->make(QueryBuilderFixture::class); + + self::assertNotSame($q1, $q2, 'Scope::Transient yields a new instance each resolve'); + } + + public function test_value_parameter_uses_default_when_env_unset(): void + { + unset($_ENV['FIXTURE_CACHE_URL']); + $c = Container::init(); + $this->collect($c); + + $cache = $c->make(CacheContract::class); + self::assertSame('default-url', $cache->url); + } + + public function test_value_parameter_reads_env(): void + { + $_ENV['FIXTURE_CACHE_URL'] = 'redis://from-env'; + try { + $c = Container::init(); + $this->collect($c); // re-register clears the cached singleton + $cache = $c->make(CacheContract::class); + self::assertSame('redis://from-env', $cache->url); + } finally { + unset($_ENV['FIXTURE_CACHE_URL']); + } + } + + public function test_explicit_name_binding(): void + { + $c = Container::init(); + $this->collect($c); + + $primary = $c->make('primary.cache'); + self::assertInstanceOf(RedisCacheFixture::class, $primary); + self::assertSame('primary', $primary->url); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +interface CacheContract +{ +} + +final class RedisCacheFixture implements CacheContract +{ + public function __construct(public string $url) + { + } +} + +final class QueryBuilderFixture +{ + private static int $counter = 0; + public int $id; + + public function __construct() + { + $this->id = ++self::$counter; + } +} + +#[Configuration] +final class BeansFixtureConfig +{ + #[Bean] + public function cache(#[Value('FIXTURE_CACHE_URL', 'default-url')] string $url): CacheContract + { + return new RedisCacheFixture($url); + } + + #[Bean(scope: Scope::Transient)] + public function query(): QueryBuilderFixture + { + return new QueryBuilderFixture(); + } + + #[Bean(name: 'primary.cache')] + public function primaryCache(): RedisCacheFixture + { + return new RedisCacheFixture('primary'); + } +} diff --git a/tests/App/EnableManifestTest.php b/tests/App/EnableManifestTest.php new file mode 100644 index 0000000..191cc08 --- /dev/null +++ b/tests/App/EnableManifestTest.php @@ -0,0 +1,119 @@ + $app + * @return list + */ + private function resolve(string $app): array + { + return new ReflectionMethod($app, 'resolveComponents')->invoke(null); + } + + /** + * @param class-string $app + * @param class-string $attribute + */ + private function hasAttr(string $app, string $attribute): bool + { + return new ReflectionMethod($app, 'hasAttribute')->invoke(null, $attribute); + } + + public function test_full_manifest_maps_every_attribute_in_order(): void + { + $c = $this->resolve(FullApp::class); + + self::assertCount(5, $c); + self::assertSame(ComponentKind::Http, $c[0]->kind); + self::assertSame(ComponentKind::Scheduler, $c[1]->kind); + self::assertSame(Scheduler::class, $c[1]->class); + self::assertSame(ComponentKind::Process, $c[2]->kind); + self::assertSame(ComponentKind::Process, $c[3]->kind); + self::assertSame(ComponentKind::Daemon, $c[4]->kind); + } + + public function test_repeatable_process_preserves_declaration_order(): void + { + $c = $this->resolve(FullApp::class); + + self::assertSame('Main\\Proc\\A', $c[2]->class); + self::assertSame('Main\\Proc\\B', $c[3]->class); + self::assertSame('Main\\Daemon\\E', $c[4]->class); + } + + public function test_scheduler_uses_declared_class(): void + { + $c = $this->resolve(CustomSchedulerApp::class); + + self::assertCount(1, $c); + self::assertSame(ComponentKind::Scheduler, $c[0]->kind); + self::assertSame('Custom\\Sched', $c[0]->class); + } + + public function test_headless_app_has_no_http(): void + { + $c = $this->resolve(HeadlessApp::class); + + self::assertCount(1, $c); + self::assertSame(ComponentKind::Process, $c[0]->kind); + self::assertSame('Main\\Proc\\Only', $c[0]->class); + } + + public function test_empty_app_yields_empty_manifest(): void + { + self::assertSame([], $this->resolve(EmptyApp::class)); + } + + public function test_enable_async_is_detected(): void + { + self::assertTrue($this->hasAttr(FullApp::class, EnableAsync::class)); + self::assertFalse($this->hasAttr(HeadlessApp::class, EnableAsync::class)); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +#[EnableWeb] +#[EnableAsync] +#[EnableScheduler] +#[EnableProcess('Main\\Proc\\A')] +#[EnableProcess('Main\\Proc\\B')] +#[EnableDaemon('Main\\Daemon\\E')] +final class FullApp extends WinterApplication +{ +} + +#[EnableScheduler('Custom\\Sched')] +final class CustomSchedulerApp extends WinterApplication +{ +} + +#[EnableProcess('Main\\Proc\\Only')] +final class HeadlessApp extends WinterApplication +{ +} + +final class EmptyApp extends WinterApplication +{ +} diff --git a/tests/App/ServerSettingsTest.php b/tests/App/ServerSettingsTest.php new file mode 100644 index 0000000..4e0c29e --- /dev/null +++ b/tests/App/ServerSettingsTest.php @@ -0,0 +1,73 @@ +getHost()); + self::assertSame(8000, $s->getPort()); + } + + public function test_seeded_bind_address(): void + { + $s = ServerSettings::fromEnv('127.0.0.1', 9000); + + self::assertSame('127.0.0.1', $s->getHost()); + self::assertSame(9000, $s->getPort()); + } + + public function test_setters_override_seed(): void + { + $s = ServerSettings::fromEnv('0.0.0.0', 8000)->host('10.0.0.1')->port(1234); + + self::assertSame('10.0.0.1', $s->getHost()); + self::assertSame(1234, $s->getPort()); + } + + public function test_host_and_port_are_not_swoole_options(): void + { + $s = ServerSettings::fromEnv('1.2.3.4', 80); + $options = $s->toArray(); + + self::assertArrayNotHasKey('host', $options); + self::assertArrayNotHasKey('port', $options); + } + + public function test_env_seeds_tuning_options(): void + { + $_ENV['SERVER_WORKERS'] = '4'; + try { + $s = ServerSettings::fromEnv(); + self::assertSame(4, $s->toArray()['worker_num']); + } finally { + unset($_ENV['SERVER_WORKERS']); + } + } + + public function test_fluent_options(): void + { + $s = ServerSettings::fromEnv() + ->workers(8) + ->maxRequest(5000) + ->set('ssl_cert_file', '/etc/ssl/app.pem'); + $options = $s->toArray(); + + self::assertSame(8, $options['worker_num']); + self::assertSame(5000, $options['max_request']); + self::assertSame('/etc/ssl/app.pem', $options['ssl_cert_file']); + } +} diff --git a/tests/App/StaticPathTest.php b/tests/App/StaticPathTest.php new file mode 100644 index 0000000..6f281b6 --- /dev/null +++ b/tests/App/StaticPathTest.php @@ -0,0 +1,123 @@ +originalRoot = $prop->isInitialized() ? $prop->getValue() : null; + + $this->root = sys_get_temp_dir() . '/wk_static_cfg_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->root . '/resources/static', 0777, true); + KernelConfig::$pathRoot = $this->root; + } + + protected function tearDown(): void + { + @rmdir($this->root . '/resources/static'); + @rmdir($this->root . '/resources'); + @rmdir($this->root); + + if ($this->originalRoot !== null) { + KernelConfig::$pathRoot = $this->originalRoot; + } + } + + private function settings(): ServerSettings + { + return ServerSettings::fromEnv(); + } + + public function test_static_is_off_until_asked_for(): void + { + $options = $this->settings()->toArray(); + + self::assertArrayNotHasKey('document_root', $options); + self::assertArrayNotHasKey('enable_static_handler', $options, 'an API-only service serves no files'); + } + + public function test_a_relative_path_resolves_against_the_project_root(): void + { + $options = $this->settings()->staticPath('resources/static')->toArray(); + + self::assertSame($this->root . '/resources/static', $options['document_root']); + self::assertTrue($options['enable_static_handler']); + } + + public function test_an_absolute_path_is_used_as_is(): void + { + $options = $this->settings()->staticPath($this->root . '/resources/static')->toArray(); + + self::assertSame($this->root . '/resources/static', $options['document_root']); + } + + public function test_trailing_slashes_are_trimmed(): void + { + $options = $this->settings()->staticPath('resources/static/')->toArray(); + + self::assertSame($this->root . '/resources/static', $options['document_root']); + } + + public function test_it_does_not_impose_a_prefix_filter(): void + { + $options = $this->settings()->staticPath('resources/static')->toArray(); + + self::assertArrayNotHasKey( + 'static_handler_locations', + $options, + 'the directory is the URL root; narrowing it is a tuning knob, set() covers it', + ); + } + + public function test_the_prefix_filter_remains_reachable_through_set(): void + { + $options = $this->settings() + ->staticPath('resources/static') + ->set('static_handler_locations', ['/assets']) + ->toArray(); + + self::assertSame(['/assets'], $options['static_handler_locations']); + self::assertSame($this->root . '/resources/static', $options['document_root']); + } + + public function test_a_missing_directory_fails_the_boot(): void + { + $this->expectException(ApplicationConfigException::class); + $this->expectExceptionMessage('Static directory does not exist'); + + $this->settings()->staticPath('resources/typo'); + } + + public function test_it_keeps_other_options_intact(): void + { + $options = $this->settings() + ->workers(4) + ->staticPath('resources/static') + ->maxRequest(1000) + ->toArray(); + + self::assertSame(4, $options['worker_num']); + self::assertSame(1000, $options['max_request']); + self::assertSame($this->root . '/resources/static', $options['document_root']); + } +} diff --git a/tests/App/WorkerMemoryTest.php b/tests/App/WorkerMemoryTest.php new file mode 100644 index 0000000..53bb7c1 --- /dev/null +++ b/tests/App/WorkerMemoryTest.php @@ -0,0 +1,707 @@ +invoke(null, $value); + } + + // ── Parsing PHP's shorthand ──────────────────────────────────────────────── + + public function test_php_memory_shorthand_is_understood(): void + { + self::assertSame(256 * 1024 ** 2, $this->toBytes('256M')); + self::assertSame(1024 ** 3, $this->toBytes('1G')); + self::assertSame(512 * 1024, $this->toBytes('512K')); + self::assertSame(1000, $this->toBytes('1000'), 'a bare number is bytes'); + self::assertSame(256 * 1024 ** 2, $this->toBytes(' 256m '), 'case and spacing are PHP-tolerant'); + } + + public function test_unlimited_is_distinguished_from_unparseable(): void + { + self::assertSame(-1, $this->toBytes('-1'), 'unlimited'); + self::assertSame(0, $this->toBytes('nonsense'), 'unparseable — PHP reports it better than we can'); + self::assertSame(0, $this->toBytes('')); + } + + // ── The one configuration that is worse than a limit ─────────────────────── + + /** + * Unlimited does not remove the ceiling, it moves it into the kernel: PHP never + * stops the process, so the OOM killer does — with no shutdown functions, no log + * entry, and the whole container when the server is PID 1. + */ + public function test_unlimited_is_warned_about(): void + { + $logger = $this->check('-1', 4); + + self::assertCount(1, $logger->warnings()); + self::assertStringContainsString('OOM killer', $logger->warnings()[0]); + } + + public function test_a_real_limit_is_not_warned_about(): void + { + self::assertSame([], $this->check('64M', 1)->warnings()); + } + + public function test_an_unparseable_limit_is_left_to_php(): void + { + self::assertSame([], $this->check('not-a-size', 4)->warnings(), 'no second opinion'); + } + + // ── The arithmetic ───────────────────────────────────────────────────────── + + /** The case this exists for: four workers at 512M cannot live in a 1 GiB box. */ + public function test_a_fleet_larger_than_the_box_is_reported(): void + { + $warnings = $this->check('512M', 4, boxBytes: self::GIB)->warnings(); + + self::assertCount(1, $warnings); + self::assertStringContainsString('4 worker(s) × 512 MiB', $warnings[0]); + self::assertStringContainsString('2 GiB', $warnings[0], 'the product is spelled out'); + self::assertStringContainsString('1 GiB', $warnings[0], 'against what the box has'); + } + + public function test_a_fleet_that_fits_is_silent(): void + { + self::assertSame([], $this->check('128M', 4, boxBytes: 4 * self::GIB)->warnings()); + } + + /** The same limit is fine alone and over-committed multiplied — that is the point. */ + public function test_the_worker_count_is_what_turns_a_fit_into_an_over_commit(): void + { + self::assertSame([], $this->check('512M', 1, boxBytes: self::GIB)->warnings()); + self::assertCount(1, $this->check('512M', 8, boxBytes: self::GIB)->warnings()); + } + + /** + * Worst-case estimate, so it warns and never throws: every worker peaking together + * is rare, and over-committing is a legitimate choice. Refusing would break working + * deployments over a guess — unlike the DI scope check, where the condition is + * certainly wrong. + */ + public function test_over_commit_never_throws(): void + { + $this->check('4G', 128, boxBytes: 256 * 1024 ** 2); + + $this->addToAssertionCount(1); // reaching this line is the assertion + } + + public function test_nothing_is_reported_when_the_box_has_no_limit(): void + { + // An unlimited (or unreadable) ceiling cannot be judged, and silence is the + // honest answer — no cgroup limit is set on the test machine either. + self::assertSame([], $this->check('4G', 128)->warnings()); + } + + // ── Applying it ──────────────────────────────────────────────────────────── + + public function test_apply_sets_the_ini_for_this_process(): void + { + $original = ini_get('memory_limit'); + + try { + WorkerMemory::apply('321M'); + self::assertSame('321M', ini_get('memory_limit')); + } finally { + ini_set('memory_limit', $original); + } + } + + /** + * Configure nothing and the framework must not touch the ini at all. + * + * Reaching `ini_set()` with an empty value does not change the limit — it fails and + * leaves it alone — but it does raise `Failed to set memory limit to 0 bytes`, and a + * PHP warning on every worker start reads like a bug. So what is asserted is the + * silence, not just the value. + */ + public function test_apply_without_a_limit_touches_nothing_and_says_nothing(): void + { + $original = ini_get('memory_limit'); + $diagnostics = []; + set_error_handler(static function (int $no, string $message) use (&$diagnostics): bool { + $diagnostics[] = $message; + return true; + }); + + try { + WorkerMemory::apply(null); + WorkerMemory::apply(''); + } finally { + restore_error_handler(); + } + + self::assertSame($original, ini_get('memory_limit')); + self::assertSame([], $diagnostics, 'the ini was never touched'); + } + + // ── Handing idle memory back ─────────────────────────────────────────────── + + /** + * Leaves the allocator holding roughly 30 MB it no longer needs — the shape of a + * request that built a large result, at a size the 128M test limit tolerates. The + * mechanism does not care about the magnitude; the live case was 258 MB. + */ + private function buildAndDropALargeResult(): void + { + $many = []; + for ($i = 0; $i < 60_000; $i++) { + $many[] = (object) ['a' => "value {$i}", 'b' => $i]; + } + unset($many); + } + + /** + * PHP frees memory to its own allocator, not to the kernel: many small objects live + * in chunks that are kept for reuse, so a worker that once built a large result goes + * on holding that memory for the rest of its life. Measured on a live worker — a + * request building 100 000 entities left `top` reading 114 MB against 6 MB in use. + */ + public function test_a_large_reserve_is_handed_back(): void + { + $this->buildAndDropALargeResult(); + + $reserveBefore = memory_get_usage(true) - memory_get_usage(); + self::assertGreaterThan( + 8 * 1024 ** 2, + $reserveBefore, + 'the fixture must actually leave a reserve, or the test proves nothing', + ); + + $freed = WorkerMemory::trimIfIdle(8 * 1024 ** 2); + + self::assertGreaterThan(0, $freed); + self::assertLessThan( + $reserveBefore, + memory_get_usage(true) - memory_get_usage(), + 'the reserve must actually shrink', + ); + } + + /** An ordinary worker carries a few megabytes of reserve and must be left alone. */ + public function test_a_small_reserve_is_left_alone(): void + { + WorkerMemory::trimIfIdle(1024); // clear whatever this process holds + + self::assertSame(0, WorkerMemory::trimIfIdle(512 * 1024 ** 2), 'nothing near half a gig'); + } + + /** + * The threshold has to be the deciding factor, not a formality: with a reserve worth + * releasing but a threshold above it, nothing may happen. Otherwise every request + * would pay the release — 80 ms when there is a lot to give back. + */ + public function test_a_reserve_below_the_threshold_is_kept(): void + { + $this->buildAndDropALargeResult(); + + $reserve = memory_get_usage(true) - memory_get_usage(); + self::assertGreaterThan(8 * 1024 ** 2, $reserve, 'there is something to release'); + + self::assertSame( + 0, + WorkerMemory::trimIfIdle(512 * 1024 ** 2), + '…but the threshold says it is not worth it', + ); + + WorkerMemory::trimIfIdle(8 * 1024 ** 2); // tidy up for the tests that follow + } + + /** + * Why the decision is made on the reserve and not on the process peak. + * + * A worker in the middle of a large request has a **high peak and almost no + * reserve** — the memory is in use, none of it is idle. Once the request ends the + * reserve is exactly what stayed behind. The peak, by contrast, never decreases: it + * says "something large happened here once" for the rest of the worker's life and + * cannot tell whether anything is being held *now*. + * + * Note what this test does and does not prove. The two triggers differ in **cost**, + * not in outcome: with memory in use, releasing would find nothing to release either + * way, so both return 0. What is provable — and what this asserts — is that the + * reserve tracks what is idle while the peak does not. + */ + public function test_the_reserve_falls_again_while_the_peak_never_does(): void + { + WorkerMemory::trimIfIdle(1024); + + $held = []; + for ($i = 0; $i < 60_000; $i++) { + $held[] = (object) ['a' => "value {$i}", 'b' => $i]; + } + + self::assertGreaterThan(8 * 1024 ** 2, memory_get_peak_usage(), 'the peak is high…'); + self::assertLessThan(8 * 1024 ** 2, WorkerMemory::idleReserve(), '…while nothing is idle'); + self::assertSame(0, WorkerMemory::trimIfIdle(8 * 1024 ** 2), 'a busy worker is left alone'); + + unset($held); + + self::assertGreaterThan( + 8 * 1024 ** 2, + WorkerMemory::idleReserve(), + 'the request ended and its memory is now idle — this is what the peak cannot say', + ); + + WorkerMemory::trimIfIdle(8 * 1024 ** 2); + + self::assertLessThan( + 8 * 1024 ** 2, + WorkerMemory::idleReserve(), + 'and the reserve falls again, which is why one release is enough', + ); + self::assertGreaterThan( + 8 * 1024 ** 2, + memory_get_peak_usage(), + 'while the peak still reads high, and would go on triggering forever', + ); + } + + /** + * Releasing closes the gap, so the next check finds nothing to do. That is what keeps + * a busy worker from paying the cost on every request — unlike the process peak, + * which never decreases and would trip forever after one heavy request. + */ + public function test_the_trigger_is_self_correcting(): void + { + $this->buildAndDropALargeResult(); + + self::assertGreaterThan(0, WorkerMemory::trimIfIdle(8 * 1024 ** 2), 'first call releases'); + self::assertSame(0, WorkerMemory::trimIfIdle(8 * 1024 ** 2), 'second finds nothing'); + } + + public function test_a_zero_threshold_disables_it(): void + { + $this->buildAndDropALargeResult(); + + self::assertSame(0, WorkerMemory::trimIfIdle(0), 'opted out, whatever is held'); + + WorkerMemory::trimIfIdle(8 * 1024 ** 2); // tidy up for the tests that follow + } + + // ── Wiring through ServerSettings ────────────────────────────────────────── + + public function test_the_setting_is_absent_until_asked_for(): void + { + self::assertNull(ServerSettings::fromEnv()->getMemoryLimit()); + } + + public function test_the_setting_round_trips(): void + { + self::assertSame('256M', ServerSettings::fromEnv()->memoryLimit('256M')->getMemoryLimit()); + } + + public function test_the_env_shorthand_seeds_it(): void + { + $original = $_ENV['SERVER_MEMORY_LIMIT'] ?? null; + $_ENV['SERVER_MEMORY_LIMIT'] = '512M'; + + try { + self::assertSame('512M', ServerSettings::fromEnv()->getMemoryLimit()); + } finally { + if ($original === null) { + unset($_ENV['SERVER_MEMORY_LIMIT']); + } else { + $_ENV['SERVER_MEMORY_LIMIT'] = $original; + } + } + } + + /** + * It is a PHP ini, not a Swoole option — and Swoole answers an option it does not + * know with `unsupported option` on every start, as it does for + * `max_request_execution_time`. + */ + public function test_it_never_reaches_the_swoole_options(): void + { + $options = ServerSettings::fromEnv() + ->workers(4) + ->memoryLimit('256M') + ->memoryTrimThreshold('64M') + ->toArray(); + + self::assertArrayHasKey('worker_num', $options); + self::assertArrayNotHasKey('memory_limit', $options); + self::assertArrayNotHasKey('memoryLimit', $options); + self::assertArrayNotHasKey('memoryTrimThreshold', $options); + } + + /** + * Worker recycling is on by default, and has to be: Swoole leaks 56 bytes every time + * a coroutine suspends — measured to the byte — and an HTTP request always suspends. + * At 2 000 req/s that is ~385 MB an hour, so a worker that never recycles dies of it. + */ + public function test_worker_recycling_is_configured_by_default(): void + { + $options = self::settings('256M')->toArray(); + + self::assertSame(298_261, $options['max_request'], 'a worker must not live forever'); + self::assertSame(29_826, $options['max_request_grace'], 'and they must not all recycle at once'); + } + + /** + * Replacement costs the same whatever the profile — it kills the requests the worker + * was serving — while what it guards against, a per-request leak, has nothing to do + * with how large a request is. Tying it to the profile made the most cautious one + * replace four times as often as the least, and drop clients accordingly: measured on + * a live stand, 6 805 connections lost against 48. + */ + public function test_recycling_does_not_vary_with_the_profile(): void + { + $of = static fn(Profile $p): int => self::settings('256M')->profile($p)->getMaxRequest(); + + self::assertSame($of(Profile::Balance), $of(Profile::Stable)); + self::assertSame($of(Profile::Balance), $of(Profile::Performance)); + self::assertSame(0, $of(Profile::Stress), 'the bench profile never replaces its worker'); + } + + /** + * Swoole **adds** the grace to the limit — verified live: at `max_request = 20, + * grace = 15` worker instances served 30, 27, 34 and 26 requests, while at `grace = 0` + * every one served exactly 20. So the default spread is 100 000 – 110 000. + */ + public function test_recycling_can_be_overridden(): void + { + $options = ServerSettings::fromEnv()->maxRequest(500)->maxRequestGrace(50)->toArray(); + + self::assertSame(500, $options['max_request']); + self::assertSame(50, $options['max_request_grace']); + } + + public function test_the_env_shorthand_overrides_the_recycling_default(): void + { + $original = $_ENV['SERVER_MAX_REQUEST'] ?? null; + $_ENV['SERVER_MAX_REQUEST'] = '250000'; + + try { + self::assertSame(250_000, ServerSettings::fromEnv()->toArray()['max_request']); + } finally { + if ($original === null) { + unset($_ENV['SERVER_MAX_REQUEST']); + } else { + $_ENV['SERVER_MAX_REQUEST'] = $original; + } + } + } + + /** + * Swoole's own limit is 2 MB — measured, 2 000 KB passes and 2 048 KB comes back 413 + * — which is tight for anything accepting a document, and invisible when it bites. + * 8 MB matches PHP's `post_max_size`, the number a PHP developer expects. + */ + public function test_the_request_size_limit_is_raised_from_swooles_2mb(): void + { + self::assertSame( + 8 * 1024 ** 2, + ServerSettings::fromEnv()->toArray()['package_max_length'], + ); + self::assertSame( + 32 * 1024 ** 2, + ServerSettings::fromEnv()->maxRequestSize(32 * 1024 ** 2)->toArray()['package_max_length'], + ); + } + + /** + * The idle timeout stays off, because Swoole applies it to requests that are still + * running: it measures the time since the client last *sent* something, and a client + * waiting for a slow response sends nothing. Measured — with the timeout at 2 seconds + * a 5-second request was cut at 2.07 s with no response and no log line; at 8 seconds + * the same request returned normally. + * + * A default here would therefore be a silent ceiling on request duration, overruling + * #[Timeout] on the routes that legitimately take longer. What it would buy is small: + * the connection table costs about 160 KB between 1 024 and 1 000 000 connections. + */ + public function test_no_idle_timeout_is_imposed_on_running_requests(): void + { + self::assertArrayNotHasKey('heartbeat_idle_time', ServerSettings::fromEnv()->toArray()); + self::assertSame( + 600, + ServerSettings::fromEnv()->idleConnectionTimeout(600)->toArray()['heartbeat_idle_time'], + ); + } + + /** + * Connections are not requests. A keep-alive connection serves many in turn, and idle + * ones hold none at all — measured: `max_connection = 2` did not slow six concurrent + * requests, while `worker_max_concurrency = 2` doubled their time by queueing. So this + * bounds file descriptors, not work, and Swoole's own 100 000 is left alone: lowering + * it only refuses clients earlier, and memory runs out first. + */ + public function test_the_connection_ceiling_is_derived_rather_than_left_to_swoole(): void + { + // Swoole's own 100 000 is no limit at all: at the measured 68 KB apiece it would + // take 6.8 GB to reach, and the worker dies at ~1 900 with the default 128M. + self::assertSame(1792, self::settings('256M')->toArray()['max_connection']); + self::assertSame( + 5000, + self::settings('256M')->maxConnections(5000)->toArray()['max_connection'], + ); + } + + /** + * Every in-flight request is budgeted at the measured floor (78 KB) plus what the + * profile grants it, plus one idle connection (68 KB) for the keep-alive client that + * is connected and not currently asking. + */ + public function test_each_profile_derives_its_own_concurrency(): void + { + $expected = [ + // profile, 256M, 128M + [Profile::Stable, 373, 174], + [Profile::Balance, 896, 418], + [Profile::Performance, 1170, 546], + ]; + + foreach ($expected as [$profile, $at256, $at128]) { + self::assertSame( + $at256, + self::settings('256M')->profile($profile)->getMaxConcurrency(), + $profile->value . ' at 256M', + ); + self::assertSame( + $at128, + self::settings('128M')->profile($profile)->getMaxConcurrency(), + $profile->value . ' at 128M', + ); + } + } + + /** Connections are the working ones plus an idle one apiece. */ + public function test_connections_leave_room_for_idle_keep_alive_clients(): void + { + $settings = self::settings('256M')->profile(Profile::Balance); + + self::assertSame($settings->getMaxConcurrency() * 2, $settings->getMaxConnections()); + } + + /** + * A socket is a file descriptor, so `ulimit -n` bounds connections independently of + * memory — and is the tighter ceiling on a stingy host. Asked for more than it allows, + * Swoole prints `max_connection is exceed the maximum value, it's reset to N` and uses + * N, so a derived number above it would be a number the framework reports and Swoole + * ignores. + */ + public function test_derived_connections_never_exceed_the_descriptor_limit(): void + { + $available = 240 * 1024 ** 2; + + self::assertSame(1792, Profile::Balance->connections($available, PHP_INT_MAX)); + self::assertSame(1024, Profile::Balance->connections($available, 1024), 'clamped by descriptors'); + self::assertSame(1, Profile::Balance->connections($available, 0), 'never zero, which means "no cap"'); + } + + /** A request in flight holds a socket, so the descriptor ceiling binds concurrency too. */ + public function test_concurrency_never_exceeds_the_connections_that_carry_it(): void + { + $settings = self::settings('256M')->maxConnections(100); + + self::assertSame(100, $settings->getMaxConnections()); + self::assertSame(100, $settings->getMaxConcurrency(), 'cannot work more requests than sockets'); + } + + /** + * An explicit value above the limit is left alone. Swoole's warning is then the only + * thing telling the operator to raise `ulimit -n`, and swallowing it would leave them + * wondering why a number they set is not the number in force. + */ + public function test_an_explicit_connection_ceiling_is_left_for_swoole_to_complain_about(): void + { + self::assertSame(999_999, self::settings('256M')->maxConnections(999_999)->getMaxConnections()); + } + + /** Balance is what an application that says nothing gets. */ + public function test_balance_is_the_default_profile(): void + { + self::assertSame(Profile::Balance, ServerSettings::fromEnv()->getProfile()); + self::assertSame( + self::settings('256M')->profile(Profile::Balance)->getMaxConcurrency(), + self::settings('256M')->getMaxConcurrency(), + ); + } + + /** The bench profile caps nothing, so it contributes no key and Swoole's own stands. */ + public function test_stress_removes_the_caps_entirely(): void + { + $options = self::settings('256M')->profile(Profile::Stress)->toArray(); + + foreach (['worker_max_concurrency', 'max_connection', 'max_request', 'max_request_grace'] as $key) { + self::assertArrayNotHasKey($key, $options, "{$key} must be left to Swoole under stress"); + } + self::assertSame(0, self::settings('256M')->profile(Profile::Stress)->getMemoryTrimThreshold()); + } + + /** + * Resolved on read, so the order of calls in a WebConfigurer cannot matter — deriving + * in fromEnv() would freeze the ini's value before ->memoryLimit() is reached. + */ + public function test_raising_the_memory_limit_raises_everything_derived_from_it(): void + { + $settings = self::settings('256M'); + $before = $settings->getMaxConcurrency(); + + $settings->memoryLimit('512M'); + + self::assertSame(1853, $settings->getMaxConcurrency()); + self::assertGreaterThan($before, $settings->getMaxConcurrency()); + self::assertSame(1853, $settings->toArray()['worker_max_concurrency']); + self::assertSame(3706, $settings->toArray()['max_connection']); + } + + /** An explicit value wins over the profile, whichever way it was given. */ + public function test_a_configured_value_is_not_second_guessed(): void + { + self::assertSame( + 10000, + self::settings('128M')->maxConcurrency(10000)->getMaxConcurrency(), + ); + + $original = $_ENV['SERVER_MAX_CONCURRENCY'] ?? null; + $_ENV['SERVER_MAX_CONCURRENCY'] = '7777'; + try { + self::assertSame(7777, ServerSettings::fromEnv()->getMaxConcurrency()); + } finally { + if ($original === null) { + unset($_ENV['SERVER_MAX_CONCURRENCY']); + } else { + $_ENV['SERVER_MAX_CONCURRENCY'] = $original; + } + } + } + + /** The profile can be chosen by an operator without touching code. */ + public function test_the_profile_can_come_from_the_environment(): void + { + $original = $_ENV['SERVER_PROFILE'] ?? null; + + try { + $_ENV['SERVER_PROFILE'] = 'performance'; + self::assertSame(Profile::Performance, ServerSettings::fromEnv()->getProfile()); + + $_ENV['SERVER_PROFILE'] = ' STRESS '; + self::assertSame(Profile::Stress, ServerSettings::fromEnv()->getProfile()); + + $_ENV['SERVER_PROFILE'] = 'nonsense'; + self::assertSame(Profile::Balance, ServerSettings::fromEnv()->getProfile(), 'an unknown name is not fatal'); + } finally { + if ($original === null) { + unset($_ENV['SERVER_PROFILE']); + } else { + $_ENV['SERVER_PROFILE'] = $original; + } + } + } + + /** + * With no limit to derive from there is still a ceiling. And it never derives zero, + * which Swoole stores as given and reads as "no limit" — verified — so a tiny budget + * would become an unlimited one. + */ + public function test_an_unreadable_memory_limit_still_yields_a_ceiling(): void + { + self::assertSame(418, self::settings('-1')->getMaxConcurrency(), 'falls back to a 128M budget'); + self::assertSame(418, self::settings('lots')->getMaxConcurrency()); + self::assertSame(1, self::settings('1K')->getMaxConcurrency(), 'never zero, which would mean no limit'); + } + + /** The threshold scales with the limit: what counts as "unused" depends on how much there is. */ + public function test_the_trim_threshold_follows_the_profile(): void + { + $of = static fn(Profile $p): int => self::settings('256M')->profile($p)->getMemoryTrimThreshold(); + + self::assertSame(32 * 1024 ** 2, self::settings('256M')->getMemoryTrimThreshold()); + self::assertSame(16 * 1024 ** 2, $of(Profile::Stable)); + self::assertSame(64 * 1024 ** 2, $of(Profile::Performance)); + } + + public function test_the_trim_threshold_is_configurable(): void + { + self::assertSame(64 * 1024 ** 2, self::settings('256M')->memoryTrimThreshold('64M')->getMemoryTrimThreshold()); + + $original = $_ENV['SERVER_MEMORY_TRIM'] ?? null; + $_ENV['SERVER_MEMORY_TRIM'] = '128M'; + + try { + self::assertSame(128 * 1024 ** 2, ServerSettings::fromEnv()->getMemoryTrimThreshold()); + } finally { + if ($original === null) { + unset($_ENV['SERVER_MEMORY_TRIM']); + } else { + $_ENV['SERVER_MEMORY_TRIM'] = $original; + } + } + } + + /** + * Settings with a known worker baseline, so the derived numbers are exact rather than + * a function of whatever the test process happens to hold. Sixteen megabytes is what a + * booted application carries; the framework measures it for real at startup. + */ + private static function settings(string $limit): ServerSettings + { + $settings = ServerSettings::fromEnv()->memoryLimit($limit); + new \ReflectionProperty(ServerSettings::class, 'baselineBytes') + ->setValue($settings, 16 * 1024 ** 2); + + return $settings; + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** A PSR-3 logger that keeps the warnings, so a test can read what was said. */ +final class RecordingLogger extends AbstractLogger +{ + /** @var list */ + private array $lines = []; + + public function log($level, Stringable|string $message, array $context = []): void + { + $this->lines[] = [$level, (string) $message]; + } + + /** @return list */ + public function warnings(): array + { + return array_values(array_map( + static fn(array $line): string => $line[1], + array_filter($this->lines, static fn(array $line): bool => $line[0] === 'warning'), + )); + } +} diff --git a/tests/Architecture/ExtensionSurfaceTest.php b/tests/Architecture/ExtensionSurfaceTest.php new file mode 100644 index 0000000..afc8e15 --- /dev/null +++ b/tests/Architecture/ExtensionSurfaceTest.php @@ -0,0 +1,143 @@ + + */ + private const array OPEN = [ + 'Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler' + => 'stereotype: #[EnableScheduler(MyScheduler::class)] extends it', + 'Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase' + => 'stereotype: #[AdviceException] handlers extend it', + 'Flytachi\Winter\Kernel\Process\ProcessStatus' + => 'extended by DaemonStatus inside the kernel', + 'Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\DateTime' + => 'extended by Date, Time and Timestamp inside PPA', + 'Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\FloatType' + => 'extended by Double and Decimal inside PPA', + 'Flytachi\Winter\Kernel\Http\Health\HealthIndicator' + => 'replaceable through #[EnableActuator(indicator: ...)]', + 'Flytachi\Winter\Kernel\Process\Daemon\ScalingPolicy' + => 'policy object, documented as non-final so it can be refined', + 'Flytachi\Winter\Kernel\Process\Daemon\RestartPolicy' + => 'policy object, documented as non-final so it can be refined', + ]; + + public function test_every_concrete_class_is_final_or_listed_as_open(): void + { + $unlisted = []; + + foreach ($this->classesInSrc() as $fqcn) { + $reflection = new ReflectionClass($fqcn); + + if ($reflection->isInterface() || $reflection->isEnum() || $reflection->isTrait()) { + continue; + } + if ($reflection->isAbstract() || $reflection->isFinal()) { + continue; + } + if ($reflection->implementsInterface(Throwable::class)) { + continue; + } + if (array_key_exists($fqcn, self::OPEN)) { + continue; + } + + $unlisted[] = $fqcn; + } + + sort($unlisted); + + self::assertSame( + [], + $unlisted, + 'These classes are open for extension by accident. Mark each final, or add it ' + . 'to self::OPEN with the reason it must stay open.', + ); + } + + /** + * A list entry outliving its class is worse than no list: it reads as a decision + * someone made, while guarding nothing. + */ + public function test_the_open_list_has_no_stale_entries(): void + { + $stale = array_values(array_filter( + array_keys(self::OPEN), + static fn (string $fqcn): bool => !class_exists($fqcn), + )); + + self::assertSame([], $stale, 'These entries name classes that no longer exist.'); + } + + /** + * The listed classes must actually be open, or the entry is a leftover that hides a + * class someone has since closed. + */ + public function test_every_listed_class_is_really_open(): void + { + $closed = []; + + foreach (array_keys(self::OPEN) as $fqcn) { + if (class_exists($fqcn) && new ReflectionClass($fqcn)->isFinal()) { + $closed[] = $fqcn; + } + } + + self::assertSame([], $closed, 'These are final already — drop them from self::OPEN.'); + } + + /** @return list */ + private function classesInSrc(): array + { + $root = dirname(__DIR__, 2) . '/src'; + $classes = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen($root) + 1, -4); + $fqcn = 'Flytachi\Winter\Kernel\\' . str_replace('/', '\\', $relative); + + if (class_exists($fqcn) || interface_exists($fqcn) || trait_exists($fqcn) || enum_exists($fqcn)) { + $classes[] = $fqcn; + } + } + + return $classes; + } +} diff --git a/tests/Architecture/StereotypeLayoutTest.php b/tests/Architecture/StereotypeLayoutTest.php new file mode 100644 index 0000000..d449463 --- /dev/null +++ b/tests/Architecture/StereotypeLayoutTest.php @@ -0,0 +1,76 @@ + */ + public static function stereotypes(): array + { + return [ + 'controller' => ['Flytachi\Winter\Kernel\Http\Stereotype\Controller'], + 'controller interface' => ['Flytachi\Winter\Kernel\Http\Stereotype\ControllerInterface'], + 'middleware' => ['Flytachi\Winter\Kernel\Http\Stereotype\Middleware'], + 'exception response' => ['Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase'], + 'process' => ['Flytachi\Winter\Kernel\Process\Stereotype\Process'], + 'daemon' => ['Flytachi\Winter\Kernel\Process\Stereotype\Daemon'], + 'scheduler' => ['Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler'], + 'cmd custom' => ['Flytachi\Winter\Console\Stereotype\CmdCustom'], + 'cmd custom interface' => ['Flytachi\Winter\Console\Stereotype\CmdCustomInterface'], + ]; + } + + #[DataProvider('stereotypes')] + public function test_the_stereotype_is_addressable(string $fqcn): void + { + self::assertTrue( + class_exists($fqcn) || interface_exists($fqcn), + "{$fqcn} must exist — an application extends it, so its address is public API.", + ); + } + + /** + * Every Stereotype directory belongs to a layer — there is no orphan one at the root. + * + * The root directory existed for a single empty `Service` base class, a transliteration + * of Spring's `@Service` **annotation** into inheritance. PHP allows one parent, so + * extending it spent that slot on nothing: the container resolves by class name and + * lifetime comes from `#[Singleton]`, so no part of the kernel ever read the base + * class. Removing it leaves the rule without an exception. + */ + public function test_no_stereotype_directory_sits_outside_a_layer(): void + { + self::assertDirectoryDoesNotExist( + dirname(__DIR__, 2) . '/src/Stereotype', + 'A Stereotype directory belongs to its layer; a root one has no layer to speak for.', + ); + } + + /** + * The machinery a stereotype relies on is not itself a stereotype, so it must not + * follow the extension point into the Stereotype directory. + */ + public function test_the_middleware_contract_stays_out_of_the_stereotype_directory(): void + { + self::assertTrue( + interface_exists('Flytachi\Winter\Kernel\Http\Middleware\MiddlewareInterface'), + 'MiddlewareInterface is a contract to implement, not a base class to extend.', + ); + self::assertFalse( + interface_exists('Flytachi\Winter\Kernel\Http\Stereotype\MiddlewareInterface'), + ); + } +} diff --git a/tests/Collector/ScopeGraphCollectorTest.php b/tests/Collector/ScopeGraphCollectorTest.php new file mode 100644 index 0000000..4c10335 --- /dev/null +++ b/tests/Collector/ScopeGraphCollectorTest.php @@ -0,0 +1,179 @@ +collect($class, new ReflectionClass($class)); + } + + return $collector; + } + + public function test_a_singleton_holding_a_request_bean_is_refused(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgDirectHolder::class); + + $this->expectException(ScopeConflictException::class); + $graph->assertNoFrozenRequestScope(); + } + + /** + * The transitive case is the one that surprises people: every link looks correct on + * its own, and only the chain is wrong. + */ + public function test_it_follows_the_chain_through_an_innocent_middleman(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgPlainService::class, SgIndirectHolder::class); + + $this->expectException(ScopeConflictException::class); + $graph->assertNoFrozenRequestScope(); + } + + /** + * The message has to name the middle of the chain, since that is where the mistake + * hides — pointing only at the endpoints leaves the reader to find the link. + */ + public function test_the_message_names_the_whole_path(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgPlainService::class, SgIndirectHolder::class); + + try { + $graph->assertNoFrozenRequestScope(); + self::fail('Expected the boot to be refused.'); + } catch (ScopeConflictException $e) { + self::assertStringContainsString(SgIndirectHolder::class, $e->getMessage()); + self::assertStringContainsString(SgPlainService::class, $e->getMessage(), 'The middle link.'); + self::assertStringContainsString(SgAuthContext::class, $e->getMessage()); + self::assertStringContainsString('service', $e->getMessage(), 'The property name.'); + } + } + + public function test_an_explicit_inject_target_counts_too(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgNamedHolder::class); + + $this->expectException(ScopeConflictException::class); + $graph->assertNoFrozenRequestScope(); + } + + public function test_a_singleton_with_only_shareable_dependencies_passes(): void + { + $graph = $this->graphOf(SgPlainRepository::class, SgHarmless::class); + + $graph->assertNoFrozenRequestScope(); + + $this->addToAssertionCount(1); + } + + /** + * A request-scoped bean held by something that does not outlive the request is the + * correct pattern, and must not be flagged. + */ + public function test_a_transient_holder_is_fine(): void + { + $graph = $this->graphOf(SgAuthContext::class, SgPlainService::class); + + $graph->assertNoFrozenRequestScope(); + + $this->addToAssertionCount(1); + } + + /** + * A dependency cycle must not hang the walk — it is a separate problem the container + * reports on its own, and this check has no business turning it into an infinite loop. + */ + public function test_a_dependency_cycle_terminates(): void + { + $graph = $this->graphOf(SgCycleA::class, SgCycleB::class); + + $graph->assertNoFrozenRequestScope(); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/Concurrent/Async/AsyncContractTest.php b/tests/Concurrent/Async/AsyncContractTest.php new file mode 100644 index 0000000..b79b056 --- /dev/null +++ b/tests/Concurrent/Async/AsyncContractTest.php @@ -0,0 +1,207 @@ +originalVolatile = $prop->isInitialized() ? $prop->getValue() : null; + + $this->volatile = sys_get_temp_dir() . '/wk_async_' . getmypid() . '_' . bin2hex(random_bytes(4)); + KernelConfig::$pathStorageVolatile = $this->volatile; + } + + protected function tearDown(): void + { + foreach (glob($this->volatile . '/*/*') ?: [] as $file) { + @unlink($file); + } + foreach (glob($this->volatile . '/*') ?: [] as $dir) { + is_dir($dir) ? @rmdir($dir) : @unlink($dir); + } + @rmdir($this->volatile); + + if ($this->originalVolatile !== null) { + KernelConfig::$pathStorageVolatile = $this->originalVolatile; + } + } + + private function proxyFor(string $class): string + { + return ProxyFactory::forKernel(true)->proxyFor(new ReflectionClass($class)); + } + + // ── Accepted ─────────────────────────────────────────────────────────────── + + public function test_a_valid_class_is_proxied(): void + { + $proxy = $this->proxyFor(AsyncFixture::class); + + self::assertTrue(is_subclass_of($proxy, AsyncFixture::class), 'the proxy extends the original'); + self::assertSame(AsyncFixture::class, $proxy::proxyTarget()); + } + + public function test_only_annotated_methods_are_overridden(): void + { + $proxy = new ReflectionClass($this->proxyFor(AsyncFixture::class)); + + self::assertTrue($proxy->hasMethod('fire')); + self::assertSame($proxy->getName(), $proxy->getMethod('fire')->getDeclaringClass()->getName()); + self::assertNotSame( + $proxy->getName(), + $proxy->getMethod('plain')->getDeclaringClass()->getName(), + 'a method without #[Async] must stay the original', + ); + } + + public function test_a_protected_method_is_allowed(): void + { + // Self-calls still go through the proxy, so protected is legitimate — the + // contract is "not private", not "public only". + $proxy = new ReflectionClass($this->proxyFor(ProtectedAsyncFixture::class)); + + self::assertSame($proxy->getName(), $proxy->getMethod('step')->getDeclaringClass()->getName()); + } + + // ── Rejected ─────────────────────────────────────────────────────────────── + + /** @return list */ + public static function violations(): array + { + return [ + 'final class' => [FinalClassFixture::class, 'final'], + 'static method' => [StaticMethodFixture::class, 'static'], + 'final method' => [FinalMethodFixture::class, 'final'], + 'private method' => [PrivateMethodFixture::class, 'private'], + 'bad return type' => [BadReturnFixture::class, 'return type'], + 'no return type' => [NoReturnTypeFixture::class, 'return type'], + 'by-reference arg' => [ByRefFixture::class, 'by reference'], + ]; + } + + /** @param class-string $class */ + #[DataProvider('violations')] + public function test_a_violation_is_reported_at_generation(string $class, string $needle): void + { + $this->expectException(AsyncException::class); + $this->expectExceptionMessageMatches('/' . preg_quote($needle, '/') . '/i'); + + $this->proxyFor($class); + } +} + +// ── Fixtures ──────────────────────────────────────────────────────────────────── + +class AsyncFixture +{ + #[Async] + public function fire(string $to): void + { + } + + #[Async] + public function compute(int $a, int $b): Future + { + return CompletableFuture::completedFuture($a + $b); + } + + public function plain(int $x): int + { + return $x * 2; + } +} + +class ProtectedAsyncFixture +{ + #[Async] + protected function step(): void + { + } +} + +final class FinalClassFixture +{ + #[Async] + public function go(): void + { + } +} + +class StaticMethodFixture +{ + #[Async] + public static function go(): void + { + } +} + +class FinalMethodFixture +{ + #[Async] + final public function go(): void + { + } +} + +class PrivateMethodFixture +{ + public function trigger(): void + { + $this->go(); + } + + #[Async] + private function go(): void + { + } +} + +class BadReturnFixture +{ + #[Async] + public function go(): int + { + return 1; + } +} + +class NoReturnTypeFixture +{ + #[Async] + public function go() + { + } +} + +class ByRefFixture +{ + #[Async] + public function go(array &$rows): void + { + } +} diff --git a/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php b/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php new file mode 100644 index 0000000..c098287 --- /dev/null +++ b/tests/Concurrent/Executor/FixedExecutorConcurrencyTest.php @@ -0,0 +1,113 @@ +submit(function () use ($ex, &$peak): bool { + $peak = max($peak, $ex->activeCount()); + \Swoole\Coroutine::sleep(0.02); + return true; + }); + } + foreach ($futures as $future) { + $future->get(); + } + }); + + self::assertGreaterThan(0, $peak); + self::assertLessThanOrEqual(2, $peak); + } + + public function test_abort_policy_throws_when_saturated(): void + { + $thrown = false; + \Swoole\Coroutine\run(function () use (&$thrown): void { + // N=1, queue=1 → capacity for 2 in flight; the 3rd is rejected. + $ex = new FixedExecutorService(1, 1, RejectPolicy::ABORT); + $gate = new \Swoole\Coroutine\Channel(1); + $block = static function () use ($gate): void { + $gate->pop(); // hold the slot until released + }; + $ex->submit($block); // occupies the single slot + $ex->submit($block); // occupies the single queue slot + try { + $ex->submit($block); // saturated + } catch (RejectedExecutionException) { + $thrown = true; + } + $gate->push(true); + $gate->push(true); + }); + + self::assertTrue($thrown, 'a saturated ABORT pool must reject'); + } + + public function test_discard_policy_returns_a_cancelled_future(): void + { + $cancelled = false; + \Swoole\Coroutine\run(function () use (&$cancelled): void { + $ex = new FixedExecutorService(1, 1, RejectPolicy::DISCARD); + $gate = new \Swoole\Coroutine\Channel(1); + $block = static function () use ($gate): void { + $gate->pop(); + }; + $ex->submit($block); + $ex->submit($block); + $dropped = $ex->submit($block); // saturated → discarded + $cancelled = $dropped->isCancelled(); + $gate->push(true); + $gate->push(true); + }); + + self::assertTrue($cancelled, 'a discarded task must come back cancelled'); + } + + public function test_caller_runs_policy_executes_inline(): void + { + $ran = false; + \Swoole\Coroutine\run(function () use (&$ran): void { + $ex = new FixedExecutorService(1, 1, RejectPolicy::CALLER_RUNS); + $gate = new \Swoole\Coroutine\Channel(1); + $block = static function () use ($gate): void { + $gate->pop(); + }; + $ex->submit($block); + $ex->submit($block); + $ex->submit(static function () use (&$ran): void { + $ran = true; // runs inline, right here + }); + $gate->push(true); + $gate->push(true); + }); + + self::assertTrue($ran, 'CALLER_RUNS must execute the task inline'); + } +} diff --git a/tests/Concurrent/Executor/FixedExecutorServiceTest.php b/tests/Concurrent/Executor/FixedExecutorServiceTest.php new file mode 100644 index 0000000..8ceb83c --- /dev/null +++ b/tests/Concurrent/Executor/FixedExecutorServiceTest.php @@ -0,0 +1,104 @@ +concurrency()); + } + + public function test_constructor_validates_arguments(): void + { + $this->expectException(InvalidArgumentException::class); + new FixedExecutorService(0); + } + + public function test_negative_queue_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + new FixedExecutorService(1, -1); + } + + public function test_remaining_capacity_is_unbounded_by_default(): void + { + self::assertSame(PHP_INT_MAX, new FixedExecutorService(3)->remainingCapacity()); + } + + public function test_remaining_capacity_reflects_a_bounded_queue(): void + { + // N + Q available when idle. + self::assertSame(3 + 5, new FixedExecutorService(3, 5)->remainingCapacity()); + } + + public function test_idle_gauges_are_zero(): void + { + $ex = new FixedExecutorService(4); + self::assertSame(0, $ex->activeCount()); + self::assertSame(0, $ex->queuedCount()); + } + + public function test_submit_runs_the_task_and_returns_its_value(): void + { + $ex = new FixedExecutorService(2); + self::assertSame(42, $ex->submit(static fn(): int => 42)->get()); + } + + public function test_submit_forwards_arguments(): void + { + $ex = new FixedExecutorService(2); + self::assertSame(42, $ex->submit(static fn(int $x): int => $x + 1, 41)->get()); + } + + public function test_execute_runs_on_drain(): void + { + $ex = new FixedExecutorService(2); + $box = new ArrayObject(['n' => 0]); + + $ex->execute(static function () use ($box): void { + $box['n']++; + }); + $ex->awaitTermination(); + + self::assertSame(1, $box['n']); + } + + public function test_shutdown_refuses_new_tasks(): void + { + $ex = new FixedExecutorService(2); + $ex->shutdown(); + + self::assertTrue($ex->isShutdown()); + $this->expectException(RejectedExecutionException::class); + $ex->submit(static fn(): int => 1); + } + + public function test_reject_policy_does_not_reject_when_queue_is_unbounded(): void + { + // Sanity: with the default unbounded queue, nothing is ever rejected even + // under the sequential backend — the value comes straight back. + $ex = new FixedExecutorService(1, 0, RejectPolicy::ABORT); + self::assertSame(7, $ex->submit(static fn(): int => 7)->get()); + } +} diff --git a/tests/Concurrent/RejectPolicyTest.php b/tests/Concurrent/RejectPolicyTest.php new file mode 100644 index 0000000..8889f91 --- /dev/null +++ b/tests/Concurrent/RejectPolicyTest.php @@ -0,0 +1,20 @@ + $p->name, RejectPolicy::cases()), + ); + } +} diff --git a/tests/Configuration/CorsTest.php b/tests/Configuration/CorsTest.php index 0752f67..f48ce1b 100644 --- a/tests/Configuration/CorsTest.php +++ b/tests/Configuration/CorsTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Http\Cors; +use Flytachi\Winter\Kernel\Http\Cors; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Configuration/HealthTest.php b/tests/Configuration/HealthTest.php index 0bb8b6e..940fd6e 100644 --- a/tests/Configuration/HealthTest.php +++ b/tests/Configuration/HealthTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Http\Health\Health; -use Flytachi\Winter\K2\Http\Health\HealthIndicator; -use Flytachi\Winter\K2\Http\Health\HealthIndicatorInterface; -use Flytachi\Winter\K2\Stereotype\Middleware; +use Flytachi\Winter\Kernel\Http\Health\Health; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicator; +use Flytachi\Winter\Kernel\Http\Health\HealthIndicatorInterface; +use Flytachi\Winter\Kernel\Http\Stereotype\Middleware; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Configuration/KernelConfigTest.php b/tests/Configuration/KernelConfigTest.php index a6d9521..7404dde 100644 --- a/tests/Configuration/KernelConfigTest.php +++ b/tests/Configuration/KernelConfigTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Core\KernelConfig; +use Flytachi\Winter\Kernel\Core\KernelConfig; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -52,7 +52,7 @@ public function test_init_does_not_create_volatile_directory(): void KernelConfig::init( pathRoot: $this->tmpDir, pathStorage: $this->tmpDir . '/storage', - // isTmpVolatile defaults to true on KernelConfig, but the K2\Kernel + // isTmpVolatile defaults to true on KernelConfig, but the Kernel // wrapper passes false. We explicitly pass false here to mimic the // production-FPM scenario where the bug bit hardest. isTmpVolatile: false, @@ -84,13 +84,39 @@ public function test_init_resolves_default_paths_under_root(): void self::assertSame($this->tmpDir, KernelConfig::$pathRoot); self::assertSame($this->tmpDir . '/.env', KernelConfig::$pathEnv); - self::assertSame($this->tmpDir . '/public', KernelConfig::$pathPublic); + self::assertSame($this->tmpDir . '/resources', KernelConfig::$pathResource); self::assertSame($this->tmpDir . '/storage', KernelConfig::$pathStorage); self::assertSame($this->tmpDir . '/storage/logs', KernelConfig::$pathStorageLog); self::assertSame($this->tmpDir . '/storage/cache', KernelConfig::$pathStorageCache); self::assertSame($this->tmpDir . '/storage/runnable', KernelConfig::$pathStorageRunnable); } + /** + * `$pathResource` used to be derived under `if ($pathStorageLog === null)`, so + * passing a log path alone left it unassigned and init died on the typed property. + */ + public function test_an_explicit_log_path_does_not_break_resource_resolution(): void + { + KernelConfig::init( + pathRoot: $this->tmpDir, + pathStorageLog: $this->tmpDir . '/var/log', + ); + + self::assertSame($this->tmpDir . '/resources', KernelConfig::$pathResource); + self::assertSame($this->tmpDir . '/var/log', KernelConfig::$pathStorageLog); + } + + /** The mirror case: an explicit resource path used to be silently overwritten. */ + public function test_an_explicit_resource_path_is_kept(): void + { + KernelConfig::init( + pathRoot: $this->tmpDir, + pathResource: $this->tmpDir . '/app/views', + ); + + self::assertSame($this->tmpDir . '/app/views', KernelConfig::$pathResource); + } + public function test_volatile_path_uses_temp_dir_when_isTmpVolatile_is_true(): void { KernelConfig::init(pathRoot: $this->tmpDir, isTmpVolatile: true); diff --git a/tests/Configuration/KernelStoreTest.php b/tests/Configuration/KernelStoreTest.php index b825bfc..caebf45 100644 --- a/tests/Configuration/KernelStoreTest.php +++ b/tests/Configuration/KernelStoreTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; use Flytachi\FileStore\FileStorage; -use Flytachi\Winter\K2\Core\KernelStore; -use Flytachi\Winter\K2\Kernel; +use Flytachi\Winter\Kernel\Core\KernelStore; +use Flytachi\Winter\Kernel\Kernel; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -23,7 +23,7 @@ protected function setUp(): void mkdir($this->tmpDir . '/volatile'); // KernelConfig holds path state in static properties — point them at our tempdir. - $cfg = new ReflectionClass(\Flytachi\Winter\K2\Core\KernelConfig::class); + $cfg = new ReflectionClass(\Flytachi\Winter\Kernel\Core\KernelConfig::class); $cfg->getProperty('pathStorageCache')->setValue(null, $this->tmpDir . '/cache'); $cfg->getProperty('pathStorageRunnable')->setValue(null, $this->tmpDir . '/runnable'); $cfg->getProperty('pathStorageVolatile')->setValue(null, $this->tmpDir . '/volatile'); diff --git a/tests/Configuration/PluginTest.php b/tests/Configuration/PluginTest.php index 271f5db..8995b37 100644 --- a/tests/Configuration/PluginTest.php +++ b/tests/Configuration/PluginTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Configuration; +namespace Flytachi\Winter\Kernel\Tests\Configuration; -use Flytachi\Winter\K2\Exception\Error; -use Flytachi\Winter\K2\Plugin; +use Flytachi\Winter\Kernel\Exception\Error; +use Flytachi\Winter\Kernel\Plugin; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/ConnectionPool/CloseOutsideCoroutineTest.php b/tests/ConnectionPool/CloseOutsideCoroutineTest.php new file mode 100644 index 0000000..58c122c --- /dev/null +++ b/tests/ConnectionPool/CloseOutsideCoroutineTest.php @@ -0,0 +1,133 @@ +pop(0.001) + * PpaConnectionPool::shutdown() + * WinterApplication::{closure:serveHttp()} ← workerExit + * + * It only fires when the pool still holds an idle connection, which is why an + * application that never queries never sees it — and why a real one always does. + * + * Draining is skipped there rather than made to work: the process is terminating, so the + * kernel closes the sockets either way, and every database treats a dropped client as + * routine. What must not be skipped is the timer. + */ +final class CloseOutsideCoroutineTest extends TestCase +{ + protected function setUp(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('ConnectionPool needs Swoole.'); + } + } + + /** + * A leaked repeating timer would hang PHP's own shutdown (the reactor waits for it), + * taking the whole suite with it. The assertions run first, so this only decides how + * a failure is reported, not whether it is caught. + */ + protected function tearDown(): void + { + Timer::clearAll(); + } + + /** Fills the pool inside a coroutine, then hands it back for the caller to close. */ + private function pooledWithIdleConnection(MockFactory $factory): ConnectionPool + { + $pool = null; + + \Swoole\Coroutine\run(static function () use ($factory, &$pool): void { + $pool = new ConnectionPool($factory, new PoolPolicy(maximumPoolSize: 4)); + $pool->release($pool->borrow()); + }); + + return $pool; + } + + public function test_closing_a_populated_pool_outside_a_coroutine_does_not_throw(): void + { + $factory = new MockFactory(); + $pool = $this->pooledWithIdleConnection($factory); + + self::assertSame(1, $pool->stats()['idle'], 'The pool must hold something to drain.'); + + $pool->close(); + + $this->addToAssertionCount(1); + } + + /** + * The timer is the whole reason `workerExit` calls this, so it has to be gone even + * when the drain is skipped. + */ + public function test_the_housekeeper_is_released_even_when_the_drain_is_skipped(): void + { + $pool = new ConnectionPool( + new MockFactory(), + new PoolPolicy(maximumPoolSize: 4, housekeepingInterval: 1.0, keepaliveTime: 1.0), + ); + + // Armed the way the first borrow arms it. It cannot be armed inside a coroutine + // here: a repeating timer keeps the reactor alive, so `Coroutine\run()` would + // never return — which is the same property that makes clearing it the whole + // point of closing on worker exit. + new ReflectionMethod(ConnectionPool::class, 'ensureHousekeeper')->invoke($pool); + + $timer = new ReflectionProperty(ConnectionPool::class, 'timerId'); + $timerId = $timer->getValue($pool); + self::assertNotNull($timerId, 'A housekeeping timer is expected.'); + + $pool->close(); + + self::assertNull($timer->getValue($pool)); + self::assertFalse(Timer::exists($timerId), 'The Swoole timer itself must be gone.'); + } + + /** + * Inside a coroutine the drain still happens — skipping it is the exit path's + * concession, not the pool's normal behaviour. + */ + public function test_inside_a_coroutine_the_connections_are_still_closed(): void + { + $factory = new MockFactory(); + + \Swoole\Coroutine\run(static function () use ($factory): void { + $pool = new ConnectionPool($factory, new PoolPolicy(maximumPoolSize: 4)); + $pool->release($pool->borrow()); + $pool->release($pool->borrow()); + $pool->close(); + }); + + self::assertSame($factory->created, $factory->closed, 'Every connection made was closed.'); + } + + public function test_closing_an_empty_pool_outside_a_coroutine_is_harmless(): void + { + $factory = new MockFactory(); + $pool = new ConnectionPool($factory, new PoolPolicy(maximumPoolSize: 4)); + + $pool->close(); + $pool->close(); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/ConnectionPool/ConnectionPoolTest.php b/tests/ConnectionPool/ConnectionPoolTest.php new file mode 100644 index 0000000..05110a9 --- /dev/null +++ b/tests/ConnectionPool/ConnectionPoolTest.php @@ -0,0 +1,219 @@ +borrow(); + $out = ['isEntry' => $e instanceof PoolEntry, 'created' => $f->created, 'validated' => $f->validated]; + }); + + self::assertTrue($out['isEntry']); + self::assertSame(1, $out['created']); + self::assertSame(0, $out['validated'], 'a freshly opened connection is not probed'); + } + + public function test_reuses_idle_connection_within_bypass_window(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(aliveBypassWindow: 0.5)); + $a = $pool->borrow(); + $pool->release($a); + $b = $pool->borrow(); // immediate → idle < window → no probe + $out = ['same' => $a === $b, 'created' => $f->created, 'validated' => $f->validated]; + }); + + self::assertTrue($out['same'], 'idle connection is reused'); + self::assertSame(1, $out['created']); + self::assertSame(0, $out['validated'], 'hot connection skips the probe'); + } + + public function test_probes_connection_idle_beyond_bypass_window(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + $a = $pool->borrow(); + $pool->release($a); + $time = 1002.0; // idle 2s > 0.5 window + $b = $pool->borrow(); // → probe (alive) → reuse + $out = ['same' => $a === $b, 'validated' => $f->validated, 'created' => $f->created]; + }); + + self::assertTrue($out['same']); + self::assertSame(1, $out['validated'], 'idle-beyond-window connection is probed'); + self::assertSame(1, $out['created']); + } + + public function test_retires_dead_connection_and_opens_fresh(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + $a = $pool->borrow(); + $pool->release($a); + $time = 1002.0; + $f->alive = false; // the pooled connection has died + $b = $pool->borrow(); // probe fails → retire $a → open fresh + $out = ['same' => $a === $b, 'created' => $f->created, 'closed' => $f->closed]; + }); + + self::assertFalse($out['same'], 'a fresh connection replaces the dead one'); + self::assertSame(2, $out['created']); + self::assertSame(1, $out['closed'], 'the dead connection was closed'); + } + + public function test_retires_connection_past_max_lifetime(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maxLifetime: 10.0, aliveBypassWindow: 0.5, maxLifetimeJitter: 0.0), + static function () use (&$time): float { + return $time; + }, + ); + $a = $pool->borrow(); // expires at 1010 + $pool->release($a); + $time = 1011.0; // past maxLifetime + $b = $pool->borrow(); // expired → retire → fresh + $out = ['same' => $a === $b, 'created' => $f->created]; + }); + + self::assertFalse($out['same']); + self::assertSame(2, $out['created']); + } + + public function test_exhaustion_throws_after_connection_timeout(): void + { + $f = new MockFactory(); + $caught = false; + $created = 0; + \Swoole\Coroutine\run(function () use ($f, &$caught, &$created): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 2, connectionTimeout: 0.05)); + $pool->borrow(); + $pool->borrow(); // pool full (2/2), both held + try { + $pool->borrow(); // no release → waits 0.05s → exhausted + } catch (PoolException) { + $caught = true; + } + $created = $f->created; + }); + + self::assertTrue($caught, 'a full pool fails fast after connectionTimeout'); + self::assertSame(2, $created); + } + + public function test_connect_failure_throws(): void + { + $f = new MockFactory(); + $f->failCreate = true; + $caught = false; + \Swoole\Coroutine\run(function () use ($f, &$caught): void { + $pool = new ConnectionPool($f); + try { + $pool->borrow(); + } catch (PoolException) { + $caught = true; + } + }); + + self::assertTrue($caught); + self::assertSame(0, $f->created); + } + + public function test_max_lifetime_jitter_within_bounds(): void + { + $f = new MockFactory(); + $expiry = null; + \Swoole\Coroutine\run(function () use ($f, &$expiry): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maxLifetime: 100.0, maxLifetimeJitter: 0.1), + static fn(): float => 1000.0, + ); + $expiry = $pool->borrow()->expiresAt; + }); + + self::assertNotNull($expiry); + self::assertGreaterThanOrEqual(1000.0 + 90.0, $expiry, 'within -10% jitter'); + self::assertLessThanOrEqual(1000.0 + 110.0, $expiry, 'within +10% jitter'); + } + + public function test_evict_retires_the_connection_and_frees_its_slot(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 1)); + $a = $pool->borrow(); + + $pool->evict($a); // died in use — retire instead of returning it + + $b = $pool->borrow(); // the freed slot allows a fresh connection + $out = ['same' => $a === $b, 'created' => $f->created, 'closed' => $f->closed]; + }); + + self::assertFalse($out['same'], 'an evicted connection is never handed out again'); + self::assertSame(2, $out['created']); + self::assertSame(1, $out['closed']); + } + + public function test_stats_track_total_idle_active(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 5)); + $a = $pool->borrow(); + $pool->borrow(); + $out['held'] = $pool->stats(); + $pool->release($a); + $out['oneBack'] = $pool->stats(); + }); + + self::assertSame(['total' => 2, 'idle' => 0, 'active' => 2, 'maximum' => 5], $out['held']); + self::assertSame(['total' => 2, 'idle' => 1, 'active' => 1, 'maximum' => 5], $out['oneBack']); + } +} diff --git a/tests/ConnectionPool/HousekeeperTest.php b/tests/ConnectionPool/HousekeeperTest.php new file mode 100644 index 0000000..1dd4b74 --- /dev/null +++ b/tests/ConnectionPool/HousekeeperTest.php @@ -0,0 +1,222 @@ +close()` before the coroutine ends: the first `borrow()` + * with maintenance enabled arms a real `Swoole\Timer::tick`, which keeps the reactor + * alive — `close()` clears it so `Swoole\Coroutine\run` can return. + */ +final class HousekeeperTest extends TestCase +{ + protected function setUp(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('ConnectionPool needs a Swoole coroutine context.'); + } + } + + private static function maintain(ConnectionPool $pool): void + { + (new ReflectionMethod($pool, 'maintain'))->invoke($pool); + } + + public function test_keepalive_retires_dead_idle_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(keepaliveTime: 10.0), + static function () use (&$time): float { + return $time; + }, + ); + $pool->release($pool->borrow()); // idle, lastUsedAt = 1000 + $time = 1011.0; // idle 11s >= keepaliveTime + $f->alive = false; // died while idle + self::maintain($pool); + $out = ['validated' => $f->validated, 'closed' => $f->closed, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(1, $out['validated'], 'a long-idle connection is probed'); + self::assertSame(1, $out['closed'], 'the dead connection is retired'); + self::assertSame(0, $out['stats']['total']); + } + + public function test_keepalive_keeps_live_idle_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(keepaliveTime: 10.0), static function () use (&$time): float { + return $time; + }); + $pool->release($pool->borrow()); + $time = 1011.0; + self::maintain($pool); + $out = ['validated' => $f->validated, 'closed' => $f->closed, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(1, $out['validated']); + self::assertSame(0, $out['closed'], 'a live connection survives'); + self::assertSame(1, $out['stats']['idle']); + } + + public function test_keepalive_skips_hot_idle_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(keepaliveTime: 10.0), static function () use (&$time): float { + return $time; + }); + $pool->release($pool->borrow()); + $time = 1005.0; // idle 5s < keepaliveTime + self::maintain($pool); + $out = ['validated' => $f->validated, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(0, $out['validated'], 'a recently-used connection is not probed'); + self::assertSame(1, $out['stats']['idle']); + } + + public function test_idle_timeout_shrinks_toward_minimum_idle(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maximumPoolSize: 5, idleTimeout: 10.0, minimumIdle: 1), + static function () use (&$time): float { + return $time; + }, + ); + $a = $pool->borrow(); + $b = $pool->borrow(); + $c = $pool->borrow(); // total = 3 + $pool->release($a); + $pool->release($b); + $pool->release($c); // idle = 3, all lastUsedAt = 1000 + $time = 1011.0; // idle 11s >= idleTimeout + self::maintain($pool); + $out = ['closed' => $f->closed, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(2, $out['closed'], 'shrinks 3 → minimumIdle 1'); + self::assertSame(1, $out['stats']['total']); + self::assertSame(1, $out['stats']['idle']); + } + + public function test_minimum_idle_tops_up_from_empty(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(maximumPoolSize: 5, minimumIdle: 2)); + self::maintain($pool); // nothing idle → open the warm floor + $out = ['created' => $f->created, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(2, $out['created']); + self::assertSame(2, $out['stats']['idle']); + self::assertSame(2, $out['stats']['total']); + } + + public function test_maintain_retires_expired_connection(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maxLifetime: 10.0, maxLifetimeJitter: 0.0, keepaliveTime: 5.0), + static function () use (&$time): float { + return $time; + }, + ); + $pool->release($pool->borrow()); // expires at 1010 + $time = 1011.0; // past maxLifetime + self::maintain($pool); + $out = ['closed' => $f->closed, 'validated' => $f->validated, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(1, $out['closed']); + self::assertSame(0, $out['validated'], 'expired connection is retired without a probe'); + self::assertSame(0, $out['stats']['total']); + } + + public function test_abandon_clears_the_timer_without_closing_sockets(): void + { + $f = new MockFactory(); + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$out): void { + $pool = new ConnectionPool($f, new PoolPolicy(keepaliveTime: 10.0)); + $pool->release($pool->borrow()); // arms the housekeeper, 1 idle connection + $armed = (new \ReflectionProperty(ConnectionPool::class, 'timerId'))->getValue($pool) !== null; + + $pool->abandon(); + + $out = [ + 'armedBefore' => $armed, + 'armedAfter' => (new \ReflectionProperty(ConnectionPool::class, 'timerId'))->getValue($pool) !== null, + 'closed' => $f->closed, + ]; + }); + + self::assertTrue($out['armedBefore'], 'the housekeeper arms on first borrow'); + self::assertFalse($out['armedAfter'], 'abandon() disarms it — an orphaned pool must not keep maintaining'); + self::assertSame(0, $out['closed'], 'inherited sockets are forgotten, never closed (fork safety)'); + } + + public function test_maintain_leaves_borrowed_connections_untouched(): void + { + $f = new MockFactory(); + $time = 1000.0; + $out = []; + \Swoole\Coroutine\run(function () use ($f, &$time, &$out): void { + $pool = new ConnectionPool( + $f, + new PoolPolicy(maximumPoolSize: 5, idleTimeout: 1.0, keepaliveTime: 1.0), + static function () use (&$time): float { + return $time; + }, + ); + $pool->borrow(); + $pool->borrow(); // both held — not in the idle channel + $time = 1010.0; + self::maintain($pool); + $out = ['closed' => $f->closed, 'validated' => $f->validated, 'stats' => $pool->stats()]; + $pool->close(); + }); + + self::assertSame(0, $out['closed'], 'in-use connections are never maintained'); + self::assertSame(0, $out['validated']); + self::assertSame(2, $out['stats']['total']); + } +} diff --git a/tests/ConnectionPool/MockFactory.php b/tests/ConnectionPool/MockFactory.php new file mode 100644 index 0000000..dfacbf1 --- /dev/null +++ b/tests/ConnectionPool/MockFactory.php @@ -0,0 +1,42 @@ +failCreate) { + throw new \RuntimeException('connect refused'); + } + ++$this->created; + return (object) ['id' => $this->created]; + } + + public function validate(object $connection): bool + { + ++$this->validated; + return $this->alive; + } + + public function close(object $connection): void + { + ++$this->closed; + } +} diff --git a/tests/ConnectionPool/PoolPolicyTest.php b/tests/ConnectionPool/PoolPolicyTest.php new file mode 100644 index 0000000..9d8d009 --- /dev/null +++ b/tests/ConnectionPool/PoolPolicyTest.php @@ -0,0 +1,30 @@ +maximumPoolSize); + self::assertSame(15.0, $p->connectionTimeout); + self::assertSame(1800.0, $p->maxLifetime); + self::assertSame(0.5, $p->aliveBypassWindow); + self::assertSame(0.1, $p->maxLifetimeJitter); + } + + public function test_overrides(): void + { + $p = new PoolPolicy(maximumPoolSize: 20, maxLifetime: 0.0); + + self::assertSame(20, $p->maximumPoolSize); + self::assertSame(0.0, $p->maxLifetime, 'maxLifetime 0 disables rotation'); + } +} diff --git a/tests/ConnectionPool/SingleConnectionTest.php b/tests/ConnectionPool/SingleConnectionTest.php new file mode 100644 index 0000000..34fb2cc --- /dev/null +++ b/tests/ConnectionPool/SingleConnectionTest.php @@ -0,0 +1,142 @@ +get(); + + self::assertSame(1, $f->created); + self::assertSame(0, $f->validated, 'a freshly opened connection is not probed'); + self::assertSame(1, $r->id); + } + + public function test_reuses_connection_within_bypass_window(): void + { + $f = new MockFactory(); + $c = new SingleConnection($f, new PoolPolicy(aliveBypassWindow: 0.5)); + + $a = $c->get(); + $b = $c->get(); // immediate → idle < window → no probe + + self::assertSame($a, $b); + self::assertSame(1, $f->created); + self::assertSame(0, $f->validated, 'hot connection skips the probe'); + } + + public function test_probes_connection_idle_beyond_bypass_window(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + + $a = $c->get(); + $time = 1002.0; // idle 2s > 0.5 window + $b = $c->get(); // → probe (alive) → reuse + + self::assertSame($a, $b); + self::assertSame(1, $f->validated, 'idle-beyond-window connection is probed'); + self::assertSame(1, $f->created); + } + + public function test_retires_dead_connection_and_reopens(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection($f, new PoolPolicy(aliveBypassWindow: 0.5), static function () use (&$time): float { + return $time; + }); + + $a = $c->get(); + $time = 1002.0; + $f->alive = false; // the connection has died + $b = $c->get(); // probe fails → retire → reopen + + self::assertNotSame($a, $b); + self::assertSame(2, $f->created); + self::assertSame(1, $f->closed, 'the dead connection was closed'); + } + + public function test_retires_connection_past_max_lifetime(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection( + $f, + new PoolPolicy(maxLifetime: 10.0, aliveBypassWindow: 0.5), + static function () use (&$time): float { + return $time; + }, + ); + + $a = $c->get(); // expires at 1010 + $time = 1011.0; // past maxLifetime + $b = $c->get(); // expired → retire → reopen + + self::assertNotSame($a, $b); + self::assertSame(2, $f->created); + } + + public function test_maxlifetime_zero_disables_rotation(): void + { + $f = new MockFactory(); + $time = 1000.0; + $c = new SingleConnection( + $f, + new PoolPolicy(maxLifetime: 0.0, aliveBypassWindow: 0.5), + static function () use (&$time): float { + return $time; + }, + ); + + $a = $c->get(); + $time = 999_999.0; // ancient, but rotation is off + $b = $c->get(); // idle → probe (alive) → reuse, never rotated + + self::assertSame($a, $b); + self::assertSame(1, $f->created); + } + + public function test_connect_failure_throws(): void + { + $f = new MockFactory(); + $f->failCreate = true; + $c = new SingleConnection($f); + + $this->expectException(PoolException::class); + $c->get(); + } + + public function test_evict_forces_reopen_on_next_get(): void + { + $f = new MockFactory(); + $c = new SingleConnection($f); + + $a = $c->get(); + $c->evict(); + $b = $c->get(); + + self::assertNotSame($a, $b); + self::assertSame(2, $f->created); + self::assertSame(1, $f->closed); + } +} diff --git a/tests/Console/CommandSurfaceTest.php b/tests/Console/CommandSurfaceTest.php new file mode 100644 index 0000000..d01c389 --- /dev/null +++ b/tests/Console/CommandSurfaceTest.php @@ -0,0 +1,124 @@ +commands() as $fqcn) { + if (!new ReflectionClass($fqcn)->isFinal()) { + $open[] = $fqcn; + } + } + + sort($open); + + self::assertSame([], $open, 'Built-in commands must be final — they are not extension points.'); + } + + /** + * The names that collide with a stereotype are the reason this test exists, so they + * are asserted by name: a future command reusing a stereotype's short name would + * reintroduce the ambiguity even while every command is final. + */ + public function test_the_commands_shadowing_a_stereotype_stay_closed(): void + { + foreach (['Process', 'Daemon'] as $shadowed) { + $command = 'Flytachi\Winter\Console\Command\\' . $shadowed; + $stereotype = 'Flytachi\Winter\Kernel\Process\Stereotype\\' . $shadowed; + + self::assertTrue(class_exists($command), "{$command} is expected to exist."); + self::assertTrue(class_exists($stereotype), "{$stereotype} is expected to exist."); + self::assertTrue( + new ReflectionClass($command)->isFinal(), + "{$command} shares a short name with {$stereotype}; leaving it open makes " + . 'both appear in `extends` completion.', + ); + } + } + + /** + * The rest of the console is closed too. + * + * Only Command/ was the reported problem, but leaving the surrounding classes open + * would let the next one drift back in unnoticed. The single extension point here is + * CmdCustom, which is abstract and therefore never counted. + */ + public function test_the_rest_of_the_console_is_closed(): void + { + $open = []; + + foreach ($this->consoleClasses() as $fqcn) { + $reflection = new ReflectionClass($fqcn); + + if ($reflection->isInterface() || $reflection->isEnum() || $reflection->isTrait()) { + continue; + } + if ($reflection->isAbstract() || $reflection->isFinal()) { + continue; + } + + $open[] = $fqcn; + } + + sort($open); + + self::assertSame([], $open, 'The CLI is wiring, not API — close these or make them abstract.'); + } + + /** @return list */ + private function commands(): array + { + $classes = []; + + foreach (glob(dirname(__DIR__, 2) . '/console/Command/*.php') ?: [] as $file) { + $fqcn = 'Flytachi\Winter\Console\Command\\' . basename($file, '.php'); + + if (class_exists($fqcn)) { + $classes[] = $fqcn; + } + } + + return $classes; + } + + /** @return list */ + private function consoleClasses(): array + { + $root = dirname(__DIR__, 2) . '/console'; + $classes = []; + + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)) as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen($root) + 1, -4); + $fqcn = 'Flytachi\Winter\Console\\' . str_replace('/', '\\', $relative); + + if (class_exists($fqcn) || interface_exists($fqcn) || trait_exists($fqcn) || enum_exists($fqcn)) { + $classes[] = $fqcn; + } + } + + return $classes; + } +} diff --git a/tests/Console/MakeTemplateTest.php b/tests/Console/MakeTemplateTest.php new file mode 100644 index 0000000..02fcad0 --- /dev/null +++ b/tests/Console/MakeTemplateTest.php @@ -0,0 +1,73 @@ + */ + public static function templates(): array + { + $root = dirname(__DIR__, 2) . '/console/Template'; + $cases = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) { + if (!$file->isFile() || in_array($file->getFilename(), self::PARKED, true)) { + continue; + } + + // Only PHP templates carry imports; Docker, shell and completion files do not. + $contents = (string) file_get_contents($file->getPathname()); + if (!str_starts_with($contents, 'getFilename()] = [$file->getPathname()]; + } + + ksort($cases); + + return $cases; + } + + #[DataProvider('templates')] + public function test_every_imported_class_exists(string $path): void + { + preg_match_all('/^use ([^;]+);/m', (string) file_get_contents($path), $matches); + + // A template may legitimately import nothing — a DTO is a plain class, and the + // PhpStorm meta file is not a class at all. + $this->addToAssertionCount(1); + + foreach ($matches[1] as $import) { + $fqcn = trim(explode(' as ', $import)[0]); + + self::assertTrue( + class_exists($fqcn) || interface_exists($fqcn) || trait_exists($fqcn) || enum_exists($fqcn), + sprintf('%s imports %s, which does not exist.', basename($path), $fqcn), + ); + } + } +} diff --git a/tests/Core/ClassScannerExclusionTest.php b/tests/Core/ClassScannerExclusionTest.php new file mode 100644 index 0000000..3a9584d --- /dev/null +++ b/tests/Core/ClassScannerExclusionTest.php @@ -0,0 +1,79 @@ +root = sys_get_temp_dir() . '/wk-scan-' . getmypid(); + @mkdir($this->root . '/storage/volatile', 0777, true); + Kernel::init(pathRoot: $this->root, isTmpVolatile: false); + } + + protected function tearDown(): void + { + @unlink($this->root . '/storage/volatile/di.php'); + @rmdir($this->root . '/storage/volatile'); + @rmdir($this->root . '/storage'); + @rmdir($this->root); + } + + public function test_the_storage_directory_is_excluded(): void + { + self::assertContains( + rtrim(Kernel::$pathStorage, '/\\'), + $this->exclusionsOf(ClassScanner::scanner($this->root)), + 'Storage holds the DI cache and generated proxies — walking it is self-referential.', + ); + } + + /** + * Views are PHP files that are not classes. The scanner reads every `.php` file + * looking for a class declaration and `require_once`s what it finds, so a template + * declaring a helper class would be **executed** at boot — echoing into the output + * and running whatever else sits at its top level. Excluding the directory is the + * only place that distinction can be made, since the scanner cannot see it. + */ + public function test_the_resource_directory_is_excluded(): void + { + self::assertContains( + rtrim(Kernel::$pathResource, '/\\'), + $this->exclusionsOf(ClassScanner::scanner($this->root)), + 'resources/views holds templates — reading, let alone requiring them, is wrong.', + ); + } + + public function test_vendor_stays_excluded_as_well(): void + { + self::assertContains( + $this->root . '/vendor', + $this->exclusionsOf(ClassScanner::scanner($this->root)), + 'Scanner excludes vendor itself; adding our own must not replace that.', + ); + } + + /** @return list */ + private function exclusionsOf(Scanner $scanner): array + { + return array_values(new ReflectionProperty(Scanner::class, 'exclude')->getValue($scanner)); + } +} diff --git a/tests/Core/RequestLocalTest.php b/tests/Core/RequestLocalTest.php new file mode 100644 index 0000000..1e3ab59 --- /dev/null +++ b/tests/Core/RequestLocalTest.php @@ -0,0 +1,137 @@ +requireSwoole(); + $seen = []; + + \Swoole\Coroutine\run(static function () use (&$seen): void { + \Swoole\Coroutine::create(static function () use (&$seen): void { + RequestLocal::set('tz', 'Asia/Tashkent'); + \Swoole\Coroutine::sleep(0.05); // wait on I/O + $seen['A'] = RequestLocal::get('tz'); // resume + }); + \Swoole\Coroutine::create(static function () use (&$seen): void { + \Swoole\Coroutine::sleep(0.01); // lands while A waits + RequestLocal::set('tz', 'Europe/London'); + $seen['B'] = RequestLocal::get('tz'); + }); + }); + + self::assertSame('Asia/Tashkent', $seen['A'], 'request A must not read B\'s value'); + self::assertSame('Europe/London', $seen['B']); + } + + public function test_a_coroutine_starts_without_the_previous_ones_values(): void + { + $this->requireSwoole(); + $second = 'unset'; + + \Swoole\Coroutine\run(static function () use (&$second): void { + \Swoole\Coroutine::create(static function (): void { + RequestLocal::set('leftover', 'first request'); + }); + \Swoole\Coroutine::create(static function () use (&$second): void { + $second = RequestLocal::get('leftover', 'clean'); + }); + }); + + self::assertSame('clean', $second, 'a finished request leaves nothing behind'); + } + + /** The static fallback must not leak into the coroutine path, or vice versa. */ + public function test_the_two_runtimes_keep_separate_stores(): void + { + $this->requireSwoole(); + RequestLocal::set('where', 'outside'); + $inside = 'unset'; + + \Swoole\Coroutine\run(static function () use (&$inside): void { + $inside = RequestLocal::get('where', 'nothing here'); + }); + + self::assertSame('nothing here', $inside); + self::assertSame('outside', RequestLocal::get('where'), 'the outer store is untouched'); + } + + private function requireSwoole(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('Coroutine isolation needs Swoole.'); + } + } +} diff --git a/tests/Http/ActuatorTest.php b/tests/Http/ActuatorTest.php new file mode 100644 index 0000000..c94829a --- /dev/null +++ b/tests/Http/ActuatorTest.php @@ -0,0 +1,172 @@ +setValue(null, null); + new ReflectionProperty(Health::class, 'contributors')->setValue(null, []); + Health::setRootDir(''); + Container::init(); + } + + // ── HealthStatus value object ───────────────────────────────────────────── + + public function test_status_factories(): void + { + self::assertSame(Status::Up, HealthStatus::up()->status()); + self::assertSame(Status::Degraded, HealthStatus::degraded()->status()); + self::assertSame(Status::Down, HealthStatus::down()->status()); + } + + public function test_to_array_carries_status_and_details(): void + { + self::assertSame( + ['status' => 'up', 'details' => ['latency_ms' => 3]], + HealthStatus::up()->withDetail('latency_ms', 3)->toArray(), + ); + self::assertSame( + ['status' => 'down', 'details' => []], + HealthStatus::down()->toArray(), + ); + } + + // ── Contributor discovery / aggregation ─────────────────────────────────── + + public function test_contributor_is_merged_under_its_name(): void + { + Health::setContributors([UpContributor::class]); + + $components = (new HealthIndicator())->health()['components']; + + self::assertSame(['status' => 'up', 'details' => ['x' => 1]], $components['up-check']); + } + + public function test_down_contributor_forces_overall_down(): void + { + Health::setContributors([DownContributor::class]); + + $report = (new HealthIndicator())->health(); + + self::assertSame('down', $report['components']['down-check']['status']); + self::assertSame('down', $report['status']); + } + + public function test_contributor_may_override_a_builtin_component(): void + { + Health::setContributors([DbOverrideContributor::class]); + + $components = (new HealthIndicator())->health()['components']; + + self::assertSame(['status' => 'up', 'details' => ['driver' => 'fake']], $components['db']); + } + + public function test_no_contributors_keeps_builtin_components_only(): void + { + $components = (new HealthIndicator())->health()['components']; + + self::assertArrayHasKey('disk', $components); + self::assertArrayNotHasKey('up-check', $components); + } + + // ── #[EnableActuator] wiring ────────────────────────────────────────────── + + public function test_no_attribute_leaves_actuator_off(): void + { + new ReflectionMethod(PlainActuatorApp::class, 'applyActuator')->invoke(null, []); + + self::assertNull(Health::getConfig()); + } + + public function test_attribute_enables_actuator_with_guard_and_contributors(): void + { + new ReflectionMethod(GuardedActuatorApp::class, 'applyActuator') + ->invoke(null, [new ReflectionClass(UpContributor::class)]); + + self::assertSame( + ['indicator' => HealthIndicator::class, 'middleware' => 'Acme\\Guard'], + Health::getConfig(), + ); + self::assertSame([UpContributor::class], Health::getContributors()); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +final class UpContributor implements HealthContributor +{ + public function name(): string + { + return 'up-check'; + } + + public function check(): HealthStatus + { + return HealthStatus::up()->withDetail('x', 1); + } +} + +final class DownContributor implements HealthContributor +{ + public function name(): string + { + return 'down-check'; + } + + public function check(): HealthStatus + { + return HealthStatus::down(); + } +} + +final class DbOverrideContributor implements HealthContributor +{ + public function name(): string + { + return 'db'; + } + + public function check(): HealthStatus + { + return HealthStatus::up()->withDetail('driver', 'fake'); + } +} + +final class PlainActuatorApp extends WinterApplication +{ + public static function main(array $a): never + { + exit(0); + } +} + +#[EnableActuator(middleware: 'Acme\\Guard')] +final class GuardedActuatorApp extends WinterApplication +{ + public static function main(array $a): never + { + exit(0); + } +} diff --git a/tests/Http/Fixtures/OriginProbeRequest.php b/tests/Http/Fixtures/OriginProbeRequest.php new file mode 100644 index 0000000..c7a5f61 --- /dev/null +++ b/tests/Http/Fixtures/OriginProbeRequest.php @@ -0,0 +1,103 @@ + $headers */ + public function __construct( + private readonly string $scheme = 'http', + private readonly string $host = 'localhost', + private readonly int $port = 80, + private readonly string $baseUrl = 'http://localhost', + private readonly array $headers = [], + ) { + } + + public function getScheme(): string + { + $this->originCalls++; + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getHeader(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + public function getClientIp(): string + { + return '127.0.0.1'; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getUri(): string + { + return '/'; + } + + public function getQueryParams(): array + { + return []; + } + + public function getParsedBody(): array + { + return []; + } + + public function getRawBody(): string + { + return ''; + } + + public function getUploadedFiles(): array + { + return []; + } + + public function getServerParam(string $key): ?string + { + return null; + } + + public function getClientTimezone(): ?string + { + return null; + } +} diff --git a/tests/Http/HeaderNormalizeTest.php b/tests/Http/HeaderNormalizeTest.php new file mode 100644 index 0000000..27dc7d9 --- /dev/null +++ b/tests/Http/HeaderNormalizeTest.php @@ -0,0 +1,95 @@ + */ + public static function keys(): array + { + return [ + 'lower case' => ['content-type', 'Content-Type'], + 'upper case' => ['CONTENT-TYPE', 'Content-Type'], + 'mixed case' => ['CoNtEnT-TyPe', 'Content-Type'], + 'already normal' => ['Content-Type', 'Content-Type'], + 'single word' => ['host', 'Host'], + 'three parts' => ['x-forwarded-for', 'X-Forwarded-For'], + 'no separator' => ['authorization', 'Authorization'], + ]; + } + + #[DataProvider('keys')] + public function test_a_key_normalises_to_title_case(string $raw, string $expected): void + { + self::assertSame($expected, self::normalize($raw)); + } + + public function test_repeated_normalisation_is_stable(): void + { + for ($i = 0; $i < 3; $i++) { + self::assertSame('Accept-Language', self::normalize('accept-language')); + } + } + + /** + * The safety property: a flood of distinct names must not grow the memo past its cap, + * and every one of them must still normalise correctly. + */ + public function test_a_flood_of_distinct_names_stays_bounded_and_correct(): void + { + $limit = new \ReflectionClassConstant(Header::class, 'NORMALIZE_MEMO_LIMIT')->getValue(); + + for ($i = 0; $i < $limit * 4; $i++) { + self::assertSame("X-Flood-{$i}", self::normalize("x-flood-{$i}")); + } + + self::assertLessThanOrEqual( + $limit, + self::memoSize(), + 'The memo must stop growing at its cap — its keys are client-controlled.', + ); + } + + /** + * Reading a header goes through the same normalisation, so the cap must not change + * what a lookup finds. + */ + public function test_lookup_still_works_for_a_key_beyond_the_cap(): void + { + Header::init(new OriginProbeRequest('http', 'localhost', 80, 'http://localhost', [ + 'x-late-header' => 'value', + ])); + + self::assertSame('value', Header::get('X-Late-Header')); + self::assertSame('value', Header::get('x-late-header')); + } + + private static function normalize(string $key): string + { + return new ReflectionMethod(Header::class, 'normalizeKey')->invoke(null, $key); + } + + private static function memoSize(): int + { + $statics = new ReflectionMethod(Header::class, 'normalizeKey')->getStaticVariables(); + + return count($statics['memo'] ?? []); + } +} diff --git a/tests/Http/HeaderOriginTest.php b/tests/Http/HeaderOriginTest.php new file mode 100644 index 0000000..edc5107 --- /dev/null +++ b/tests/Http/HeaderOriginTest.php @@ -0,0 +1,115 @@ +setValue(null, []); + new ReflectionProperty(Header::class, 'origin')->setValue(null, []); + new ReflectionProperty(Header::class, 'request')->setValue(null, null); + } + + public function test_the_origin_is_reported_as_the_request_states_it(): void + { + Header::init(new OriginProbeRequest('https', 'example.test', 8443, 'https://example.test:8443')); + + self::assertSame('https', Header::getScheme()); + self::assertSame('example.test', Header::getHost()); + self::assertSame(8443, Header::getPort()); + self::assertSame('https://example.test:8443', Header::getBaseUrl()); + } + + /** + * The point of the change: a request that never reads the origin never computes it. + */ + public function test_a_request_that_never_asks_pays_nothing(): void + { + $request = new OriginProbeRequest(); + + Header::init($request); + Header::get('Content-Type'); + Header::getIpAddress(); + + self::assertSame( + 0, + $request->originCalls, + 'Nothing read the origin, so the request should not have been asked for it.', + ); + } + + /** + * Reading repeatedly must not recompute: the four getters together used to cost + * six calls into the request, because getBaseUrl() re-derives scheme, host and port. + */ + public function test_the_origin_is_computed_once_however_often_it_is_read(): void + { + $request = new OriginProbeRequest(); + Header::init($request); + + for ($i = 0; $i < 5; $i++) { + Header::getScheme(); + Header::getHost(); + Header::getPort(); + Header::getBaseUrl(); + } + + self::assertSame(1, $request->originCalls, 'The origin should be derived exactly once.'); + } + + /** + * A later request must not inherit the previous one's origin — the failure a + * per-request cache introduces when it is never invalidated. + */ + public function test_a_later_request_does_not_inherit_the_previous_origin(): void + { + Header::init(new OriginProbeRequest('http', 'first.test', 80, 'http://first.test')); + self::assertSame('http://first.test', Header::getBaseUrl()); + + Header::init(new OriginProbeRequest('https', 'second.test', 8443, 'https://second.test:8443')); + self::assertSame('http://second.test:8443', str_replace('https', 'http', Header::getBaseUrl() ?? '')); + self::assertSame(8443, Header::getPort()); + self::assertSame('second.test', Header::getHost()); + } + + /** + * The raw `Host` header keeps its port; the origin's host is stripped. The two + * live side by side and must not be confused for one another. + */ + public function test_the_raw_host_header_survives_beside_the_stripped_origin(): void + { + Header::init(new OriginProbeRequest('http', 'example.test', 8080, 'http://example.test:8080', [ + 'Host' => 'example.test:8080', + ])); + + self::assertSame('example.test:8080', Header::get('Host')); + self::assertSame('example.test', Header::getHost()); + } +} diff --git a/tests/Http/Request/FpmRequestBaseUrlTest.php b/tests/Http/Request/FpmRequestBaseUrlTest.php index ee0aee2..024b390 100644 --- a/tests/Http/Request/FpmRequestBaseUrlTest.php +++ b/tests/Http/Request/FpmRequestBaseUrlTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Adapter\FpmRequest; +use Flytachi\Winter\Kernel\Http\Adapter\FpmRequest; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Http/Request/HeaderOriginTest.php b/tests/Http/Request/HeaderOriginTest.php index e559bb9..ea9c47b 100644 --- a/tests/Http/Request/HeaderOriginTest.php +++ b/tests/Http/Request/HeaderOriginTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Adapter\FpmRequest; -use Flytachi\Winter\K2\Http\Header; +use Flytachi\Winter\Kernel\Http\Adapter\FpmRequest; +use Flytachi\Winter\Kernel\Http\Header; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Http/Request/K1ValidationTraitTest.php b/tests/Http/Request/K1ValidationTraitTest.php index efbd82b..5d799e1 100644 --- a/tests/Http/Request/K1ValidationTraitTest.php +++ b/tests/Http/Request/K1ValidationTraitTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Request\K1ValidationTrait; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Http\Request\K1ValidationTrait; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Localization\Locale; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Http/Request/ListOfTest.php b/tests/Http/Request/ListOfTest.php index 23ab282..4c266f9 100644 --- a/tests/Http/Request/ListOfTest.php +++ b/tests/Http/Request/ListOfTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Validation\ListOf; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/MixedTypeBindingTest.php b/tests/Http/Request/MixedTypeBindingTest.php new file mode 100644 index 0000000..6cb23d9 --- /dev/null +++ b/tests/Http/Request/MixedTypeBindingTest.php @@ -0,0 +1,150 @@ +response = $this->createStub(HttpResponse::class); + } + + private function resolve(string $method, string $rawBody): array + { + $request = $this->createStub(HttpRequest::class); + $request->method('getRawBody')->willReturn($rawBody); + $request->method('getQueryParams')->willReturn([]); + + return ParameterResolver::resolve( + new ReflectionMethod(MixedTypeFixture::class, $method), + $request, + $this->response, + [], + ); + } + + public function test_a_mixed_property_accepts_an_array(): void + { + $args = $this->resolve('filter', '{"field":"status","value":["draft","sent"]}'); + + self::assertSame(['draft', 'sent'], $args[0]->value); + } + + public function test_a_mixed_property_accepts_a_nested_object(): void + { + $args = $this->resolve('filter', '{"field":"range","value":{"from":1,"to":9}}'); + + self::assertSame(['from' => 1, 'to' => 9], $args[0]->value); + } + + /** + * `mixed` accepts every kind and converts none of them: the value must arrive with + * the type JSON gave it. Casting here would be a guess — the whole point of the + * declaration is that the shape is decided elsewhere. + * + * @return array + */ + public static function everyKind(): array + { + return [ + 'string' => ['"john"', 'john'], + 'int' => ['42', 42], + 'negative int' => ['-7', -7], + 'float' => ['3.14', 3.14], + 'bool true' => ['true', true], + 'bool false' => ['false', false], + 'null' => ['null', null], + 'empty string' => ['""', ''], + 'zero' => ['0', 0], + 'numeric string stays a string' => ['"42"', '42'], + 'list' => ['[1,2]', [1, 2]], + 'nested object' => ['{"a":1}', ['a' => 1]], + 'empty list' => ['[]', []], + ]; + } + + #[DataProvider('everyKind')] + public function test_a_mixed_property_takes_every_kind_unchanged(string $json, mixed $expected): void + { + $args = $this->resolve('filter', '{"field":"f","value":' . $json . '}'); + + self::assertSame($expected, $args[0]->value); + } + + public function test_an_iterable_property_accepts_an_array(): void + { + $args = $this->resolve('iterableDto', '{"items":[1,2,3]}'); + + self::assertSame([1, 2, 3], $args[0]->items); + } + + /** + * The guard must keep doing its job: an array reaching a scalar is still a client + * error, and reporting it beats casting the value to the string "Array". + * + * Hydrating a DTO collects such refusals into a validation report rather than + * failing on the first one, so the caller sees every bad field at once — hence + * ValidationException here and RequestException on a bare parameter. + */ + public function test_an_array_reaching_a_string_is_still_refused(): void + { + $this->expectException(ValidationException::class); + + $this->resolve('filter', '{"field":["a","b"],"value":null}'); + } +} diff --git a/tests/Http/Request/PathVariableTest.php b/tests/Http/Request/PathVariableTest.php index ded57cd..c8170dd 100644 --- a/tests/Http/Request/PathVariableTest.php +++ b/tests/Http/Request/PathVariableTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestBodyTest.php b/tests/Http/Request/RequestBodyTest.php index a151634..e1caac6 100644 --- a/tests/Http/Request/RequestBodyTest.php +++ b/tests/Http/Request/RequestBodyTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestBody; -use Flytachi\Winter\K2\Http\Request\Validation\Constraint; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestBody; +use Flytachi\Winter\Kernel\Http\Request\Validation\Constraint; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -375,7 +375,7 @@ public function test_variadic_multiple_items_hydrated(): void public function test_variadic_non_array_json_throws(): void { - $this->expectException(\Flytachi\Winter\K2\Http\Request\RequestException::class); + $this->expectException(\Flytachi\Winter\Kernel\Http\Request\RequestException::class); $this->resolve('asVariadic', $this->makeRequest('{"title":"X","amount":1}')); } diff --git a/tests/Http/Request/RequestFileTest.php b/tests/Http/Request/RequestFileTest.php index e508442..0fca644 100644 --- a/tests/Http/Request/RequestFileTest.php +++ b/tests/Http/Request/RequestFileTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestFile; -use Flytachi\Winter\K2\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestFile; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestFormTest.php b/tests/Http/Request/RequestFormTest.php index 720d8f7..589427b 100644 --- a/tests/Http/Request/RequestFormTest.php +++ b/tests/Http/Request/RequestFormTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestForm; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestForm; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestHeaderTest.php b/tests/Http/Request/RequestHeaderTest.php index 6f99f32..f5b7bc6 100644 --- a/tests/Http/Request/RequestHeaderTest.php +++ b/tests/Http/Request/RequestHeaderTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestHeader; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestHeader; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestJsonTest.php b/tests/Http/Request/RequestJsonTest.php index ce5b7aa..7d71fc1 100644 --- a/tests/Http/Request/RequestJsonTest.php +++ b/tests/Http/Request/RequestJsonTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson; -use Flytachi\Winter\K2\Http\Request\Validation\In; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson; +use Flytachi\Winter\Kernel\Http\Request\Validation\In; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -290,7 +290,7 @@ public function test_variadic_multiple_items_hydrated(): void public function test_variadic_non_array_json_throws(): void { - $this->expectException(\Flytachi\Winter\K2\Http\Request\RequestException::class); + $this->expectException(\Flytachi\Winter\Kernel\Http\Request\RequestException::class); $this->resolve('asVariadic', '{"name":"Widget","qty":1}'); } diff --git a/tests/Http/Request/RequestParamTest.php b/tests/Http/Request/RequestParamTest.php index 0f50685..5c6c669 100644 --- a/tests/Http/Request/RequestParamTest.php +++ b/tests/Http/Request/RequestParamTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestParam; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Localization\Locale; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Localization\Locale; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestQueryTest.php b/tests/Http/Request/RequestQueryTest.php index f281a5d..9d0401c 100644 --- a/tests/Http/Request/RequestQueryTest.php +++ b/tests/Http/Request/RequestQueryTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestQuery; -use Flytachi\Winter\K2\Http\Request\RequestException; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestQuery; +use Flytachi\Winter\Kernel\Http\Request\RequestException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/RequestXmlTest.php b/tests/Http/Request/RequestXmlTest.php index 501d624..0921947 100644 --- a/tests/Http/Request/RequestXmlTest.php +++ b/tests/Http/Request/RequestXmlTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; - -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\ParameterResolver; -use Flytachi\Winter\K2\Http\Request\Annotation\RequestXml; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Valid; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; -use Flytachi\Winter\K2\Http\Request\RequestException; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; + +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\ParameterResolver; +use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestXml; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Valid; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\RequestException; use PHPUnit\Framework\TestCase; use ReflectionMethod; diff --git a/tests/Http/Request/ServerParamTypeTest.php b/tests/Http/Request/ServerParamTypeTest.php new file mode 100644 index 0000000..07b9e9b --- /dev/null +++ b/tests/Http/Request/ServerParamTypeTest.php @@ -0,0 +1,79 @@ +server = [ + 'request_method' => 'GET', + 'server_port' => 9501, + 'remote_port' => 54321, + 'request_time' => 1_754_500_000, + 'request_time_float' => 1_754_500_000.123456, + ]; + $request = new SwooleRequest($raw); + + self::assertSame('GET', $request->getServerParam('request_method')); + self::assertSame(9501, $request->getServerParam('server_port')); + self::assertSame(54321, $request->getServerParam('remote_port')); + self::assertSame(1_754_500_000, $request->getServerParam('request_time')); + self::assertSame(1_754_500_000.123456, $request->getServerParam('request_time_float')); + self::assertNull($request->getServerParam('absent')); + } + + public function test_fpm_numeric_server_values_are_returned_not_thrown(): void + { + $original = $_SERVER; + $_SERVER['REQUEST_TIME'] = 1_754_500_000; + $_SERVER['REQUEST_TIME_FLOAT'] = 1_754_500_000.123456; + $_SERVER['SERVER_PORT'] = 8080; + + try { + $request = new FpmRequest(); + + self::assertSame(1_754_500_000, $request->getServerParam('REQUEST_TIME')); + self::assertSame(1_754_500_000.123456, $request->getServerParam('REQUEST_TIME_FLOAT')); + self::assertSame(8080, $request->getServerParam('SERVER_PORT')); + } finally { + $_SERVER = $original; + } + } + + /** + * The float must survive intact, because the request deadline is computed from it: + * casting through a string would round it at PHP's 14-digit `precision`. + */ + public function test_the_arrival_stamp_keeps_its_precision(): void + { + $raw = new \Swoole\Http\Request(); + $raw->server = ['request_time_float' => 1_754_500_000.123456]; + + $value = new SwooleRequest($raw)->getServerParam('request_time_float'); + + self::assertIsFloat($value); + self::assertSame(0.0, $value - 1_754_500_000.123456); + } +} diff --git a/tests/Http/Request/SwooleRequestBaseUrlTest.php b/tests/Http/Request/SwooleRequestBaseUrlTest.php index a879309..9f609b6 100644 --- a/tests/Http/Request/SwooleRequestBaseUrlTest.php +++ b/tests/Http/Request/SwooleRequestBaseUrlTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request; +namespace Flytachi\Winter\Kernel\Tests\Http\Request; -use Flytachi\Winter\K2\Http\Adapter\SwooleRequest; +use Flytachi\Winter\Kernel\Http\Adapter\SwooleRequest; use PHPUnit\Framework\TestCase; use Swoole\Http\Request; diff --git a/tests/Http/Request/Validation/AssertTest.php b/tests/Http/Request/Validation/AssertTest.php index 5de5136..3bc1aa4 100644 --- a/tests/Http/Request/Validation/AssertTest.php +++ b/tests/Http/Request/Validation/AssertTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Assert; +use Flytachi\Winter\Kernel\Http\Request\Validation\Assert; use PHPUnit\Framework\TestCase; function assert_must_be_even(mixed $value, string $field): ?string diff --git a/tests/Http/Request/Validation/CustomMessageTest.php b/tests/Http/Request/Validation/CustomMessageTest.php index 1f998bf..dea3cae 100644 --- a/tests/Http/Request/Validation/CustomMessageTest.php +++ b/tests/Http/Request/Validation/CustomMessageTest.php @@ -2,32 +2,32 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Constraint; -use Flytachi\Winter\K2\Http\Request\Validation\Date; -use Flytachi\Winter\K2\Http\Request\Validation\Datetime; -use Flytachi\Winter\K2\Http\Request\Validation\Digits; -use Flytachi\Winter\K2\Http\Request\Validation\Email; -use Flytachi\Winter\K2\Http\Request\Validation\In; -use Flytachi\Winter\K2\Http\Request\Validation\Ip; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv4; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv6; -use Flytachi\Winter\K2\Http\Request\Validation\Max; -use Flytachi\Winter\K2\Http\Request\Validation\Min; -use Flytachi\Winter\K2\Http\Request\Validation\Msisdn; -use Flytachi\Winter\K2\Http\Request\Validation\Negative; -use Flytachi\Winter\K2\Http\Request\Validation\NegativeOrZero; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; -use Flytachi\Winter\K2\Http\Request\Validation\Phone; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; -use Flytachi\Winter\K2\Http\Request\Validation\PositiveOrZero; -use Flytachi\Winter\K2\Http\Request\Validation\Regex; -use Flytachi\Winter\K2\Http\Request\Validation\Required; -use Flytachi\Winter\K2\Http\Request\Validation\Size; -use Flytachi\Winter\K2\Http\Request\Validation\Time; -use Flytachi\Winter\K2\Http\Request\Validation\Url; -use Flytachi\Winter\K2\Http\Request\Validation\Uuid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Constraint; +use Flytachi\Winter\Kernel\Http\Request\Validation\Date; +use Flytachi\Winter\Kernel\Http\Request\Validation\Datetime; +use Flytachi\Winter\Kernel\Http\Request\Validation\Digits; +use Flytachi\Winter\Kernel\Http\Request\Validation\Email; +use Flytachi\Winter\Kernel\Http\Request\Validation\In; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ip; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv4; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv6; +use Flytachi\Winter\Kernel\Http\Request\Validation\Max; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\Msisdn; +use Flytachi\Winter\Kernel\Http\Request\Validation\Negative; +use Flytachi\Winter\Kernel\Http\Request\Validation\NegativeOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\Phone; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\PositiveOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\Regex; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Time; +use Flytachi\Winter\Kernel\Http\Request\Validation\Url; +use Flytachi\Winter\Kernel\Http\Request\Validation\Uuid; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Http/Request/Validation/DateTimeTest.php b/tests/Http/Request/Validation/DateTimeTest.php index 3bbaa2d..76b4012 100644 --- a/tests/Http/Request/Validation/DateTimeTest.php +++ b/tests/Http/Request/Validation/DateTimeTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Date; -use Flytachi\Winter\K2\Http\Request\Validation\Datetime; -use Flytachi\Winter\K2\Http\Request\Validation\Time; +use Flytachi\Winter\Kernel\Http\Request\Validation\Date; +use Flytachi\Winter\Kernel\Http\Request\Validation\Datetime; +use Flytachi\Winter\Kernel\Http\Request\Validation\Time; use PHPUnit\Framework\TestCase; class DateTimeTest extends TestCase diff --git a/tests/Http/Request/Validation/DigitsTest.php b/tests/Http/Request/Validation/DigitsTest.php index ea67ad2..e79d921 100644 --- a/tests/Http/Request/Validation/DigitsTest.php +++ b/tests/Http/Request/Validation/DigitsTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Digits; +use Flytachi\Winter\Kernel\Http\Request\Validation\Digits; use PHPUnit\Framework\TestCase; class DigitsTest extends TestCase diff --git a/tests/Http/Request/Validation/EmailTest.php b/tests/Http/Request/Validation/EmailTest.php index 346eb44..d90837f 100644 --- a/tests/Http/Request/Validation/EmailTest.php +++ b/tests/Http/Request/Validation/EmailTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Email; +use Flytachi\Winter\Kernel\Http\Request\Validation\Email; use PHPUnit\Framework\TestCase; class EmailTest extends TestCase diff --git a/tests/Http/Request/Validation/InTest.php b/tests/Http/Request/Validation/InTest.php index 1a5ebed..74c878a 100644 --- a/tests/Http/Request/Validation/InTest.php +++ b/tests/Http/Request/Validation/InTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\In; +use Flytachi\Winter\Kernel\Http\Request\Validation\In; use PHPUnit\Framework\TestCase; class InTest extends TestCase diff --git a/tests/Http/Request/Validation/IpTest.php b/tests/Http/Request/Validation/IpTest.php index fcc3f6e..430ca70 100644 --- a/tests/Http/Request/Validation/IpTest.php +++ b/tests/Http/Request/Validation/IpTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Ip; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv4; -use Flytachi\Winter\K2\Http\Request\Validation\Ipv6; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ip; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv4; +use Flytachi\Winter\Kernel\Http\Request\Validation\Ipv6; use PHPUnit\Framework\TestCase; class IpTest extends TestCase diff --git a/tests/Http/Request/Validation/MaxTest.php b/tests/Http/Request/Validation/MaxTest.php index 917fbe3..8d48d11 100644 --- a/tests/Http/Request/Validation/MaxTest.php +++ b/tests/Http/Request/Validation/MaxTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Max; +use Flytachi\Winter\Kernel\Http\Request\Validation\Max; use PHPUnit\Framework\TestCase; class MaxTest extends TestCase diff --git a/tests/Http/Request/Validation/MinTest.php b/tests/Http/Request/Validation/MinTest.php index 3065b8f..53ca7cf 100644 --- a/tests/Http/Request/Validation/MinTest.php +++ b/tests/Http/Request/Validation/MinTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Min; +use Flytachi\Winter\Kernel\Http\Request\Validation\Min; use PHPUnit\Framework\TestCase; class MinTest extends TestCase diff --git a/tests/Http/Request/Validation/MsisdnPhoneTest.php b/tests/Http/Request/Validation/MsisdnPhoneTest.php index 8a62029..a0c372e 100644 --- a/tests/Http/Request/Validation/MsisdnPhoneTest.php +++ b/tests/Http/Request/Validation/MsisdnPhoneTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Msisdn; -use Flytachi\Winter\K2\Http\Request\Validation\Phone; +use Flytachi\Winter\Kernel\Http\Request\Validation\Msisdn; +use Flytachi\Winter\Kernel\Http\Request\Validation\Phone; use PHPUnit\Framework\TestCase; class MsisdnPhoneTest extends TestCase diff --git a/tests/Http/Request/Validation/NegativeOrZeroTest.php b/tests/Http/Request/Validation/NegativeOrZeroTest.php index c7b5806..d8b3681 100644 --- a/tests/Http/Request/Validation/NegativeOrZeroTest.php +++ b/tests/Http/Request/Validation/NegativeOrZeroTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\NegativeOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\NegativeOrZero; use PHPUnit\Framework\TestCase; class NegativeOrZeroTest extends TestCase diff --git a/tests/Http/Request/Validation/NegativeTest.php b/tests/Http/Request/Validation/NegativeTest.php index 59e9710..a09e10f 100644 --- a/tests/Http/Request/Validation/NegativeTest.php +++ b/tests/Http/Request/Validation/NegativeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Negative; +use Flytachi\Winter\Kernel\Http\Request\Validation\Negative; use PHPUnit\Framework\TestCase; class NegativeTest extends TestCase diff --git a/tests/Http/Request/Validation/NotBlankTest.php b/tests/Http/Request/Validation/NotBlankTest.php index 8f84d35..dfd2054 100644 --- a/tests/Http/Request/Validation/NotBlankTest.php +++ b/tests/Http/Request/Validation/NotBlankTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\NotBlank; +use Flytachi\Winter\Kernel\Http\Request\Validation\NotBlank; use PHPUnit\Framework\TestCase; class NotBlankTest extends TestCase diff --git a/tests/Http/Request/Validation/PositiveOrZeroTest.php b/tests/Http/Request/Validation/PositiveOrZeroTest.php index 69892e5..631bf52 100644 --- a/tests/Http/Request/Validation/PositiveOrZeroTest.php +++ b/tests/Http/Request/Validation/PositiveOrZeroTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\PositiveOrZero; +use Flytachi\Winter\Kernel\Http\Request\Validation\PositiveOrZero; use PHPUnit\Framework\TestCase; class PositiveOrZeroTest extends TestCase diff --git a/tests/Http/Request/Validation/PositiveTest.php b/tests/Http/Request/Validation/PositiveTest.php index 62b712e..fd7de27 100644 --- a/tests/Http/Request/Validation/PositiveTest.php +++ b/tests/Http/Request/Validation/PositiveTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Positive; +use Flytachi\Winter\Kernel\Http\Request\Validation\Positive; use PHPUnit\Framework\TestCase; class PositiveTest extends TestCase diff --git a/tests/Http/Request/Validation/RegexTest.php b/tests/Http/Request/Validation/RegexTest.php index bc5cb75..89bbfd3 100644 --- a/tests/Http/Request/Validation/RegexTest.php +++ b/tests/Http/Request/Validation/RegexTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Regex; +use Flytachi\Winter\Kernel\Http\Request\Validation\Regex; use PHPUnit\Framework\TestCase; class RegexTest extends TestCase diff --git a/tests/Http/Request/Validation/RequiredTest.php b/tests/Http/Request/Validation/RequiredTest.php index f918100..eb545f7 100644 --- a/tests/Http/Request/Validation/RequiredTest.php +++ b/tests/Http/Request/Validation/RequiredTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Required; +use Flytachi\Winter\Kernel\Http\Request\Validation\Required; use PHPUnit\Framework\TestCase; class RequiredTest extends TestCase diff --git a/tests/Http/Request/Validation/SizeTest.php b/tests/Http/Request/Validation/SizeTest.php index 6ce6917..6672756 100644 --- a/tests/Http/Request/Validation/SizeTest.php +++ b/tests/Http/Request/Validation/SizeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Size; +use Flytachi\Winter\Kernel\Http\Request\Validation\Size; use PHPUnit\Framework\TestCase; class SizeTest extends TestCase diff --git a/tests/Http/Request/Validation/UrlTest.php b/tests/Http/Request/Validation/UrlTest.php index 40c1149..e9283c5 100644 --- a/tests/Http/Request/Validation/UrlTest.php +++ b/tests/Http/Request/Validation/UrlTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Url; +use Flytachi\Winter\Kernel\Http\Request\Validation\Url; use PHPUnit\Framework\TestCase; class UrlTest extends TestCase diff --git a/tests/Http/Request/Validation/UuidTest.php b/tests/Http/Request/Validation/UuidTest.php index 7212ce7..fe04720 100644 --- a/tests/Http/Request/Validation/UuidTest.php +++ b/tests/Http/Request/Validation/UuidTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\Uuid; +use Flytachi\Winter\Kernel\Http\Request\Validation\Uuid; use PHPUnit\Framework\TestCase; class UuidTest extends TestCase diff --git a/tests/Http/Request/Validation/ValidationExceptionTest.php b/tests/Http/Request/Validation/ValidationExceptionTest.php index cda3403..af140e6 100644 --- a/tests/Http/Request/Validation/ValidationExceptionTest.php +++ b/tests/Http/Request/Validation/ValidationExceptionTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Request\Validation; +namespace Flytachi\Winter\Kernel\Tests\Http\Request\Validation; -use Flytachi\Winter\K2\Http\Request\Validation\ValidationException; +use Flytachi\Winter\Kernel\Http\Request\Validation\ValidationException; use PHPUnit\Framework\TestCase; class ValidationExceptionTest extends TestCase @@ -44,6 +44,6 @@ public function testNestedPathErrors(): void public function testIsInstanceOfResponseException(): void { $ex = new ValidationException([]); - self::assertInstanceOf(\Flytachi\Winter\K2\Http\Response\ResponseException::class, $ex); + self::assertInstanceOf(\Flytachi\Winter\Kernel\Http\Response\ResponseException::class, $ex); } } diff --git a/tests/Http/Response/FpmResponseSendfileTest.php b/tests/Http/Response/FpmResponseSendfileTest.php index 2aaffab..7a946a2 100644 --- a/tests/Http/Response/FpmResponseSendfileTest.php +++ b/tests/Http/Response/FpmResponseSendfileTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Response; +namespace Flytachi\Winter\Kernel\Tests\Http\Response; -use Flytachi\Winter\K2\Http\Adapter\FpmResponse; +use Flytachi\Winter\Kernel\Http\Adapter\FpmResponse; use PHPUnit\Framework\TestCase; final class FpmResponseSendfileTest extends TestCase diff --git a/tests/Http/Response/ResponseStreamFileTest.php b/tests/Http/Response/ResponseStreamFileTest.php index 7673533..0c0fa77 100644 --- a/tests/Http/Response/ResponseStreamFileTest.php +++ b/tests/Http/Response/ResponseStreamFileTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Http\Response; +namespace Flytachi\Winter\Kernel\Tests\Http\Response; use Flytachi\Winter\Base\HttpCode; -use Flytachi\Winter\K2\Http\Contracts\HttpRequest; -use Flytachi\Winter\K2\Http\Contracts\HttpResponse; -use Flytachi\Winter\K2\Http\Response\ResponseStreamFile; +use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest; +use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse; +use Flytachi\Winter\Kernel\Http\Response\ResponseStreamFile; use PHPUnit\Framework\TestCase; // ── Spy HttpResponse — records everything send() does ───────────────────────── diff --git a/tests/Http/Response/ResponseViewPathTest.php b/tests/Http/Response/ResponseViewPathTest.php new file mode 100644 index 0000000..96b5a50 --- /dev/null +++ b/tests/Http/Response/ResponseViewPathTest.php @@ -0,0 +1,108 @@ +originalRoot = $prop->isInitialized() ? $prop->getValue() : null; + + $this->root = sys_get_temp_dir() . '/wk_views_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->root . '/views/layouts', 0777, true); + @mkdir($this->root . '/views/user', 0777, true); + + file_put_contents($this->root . '/views/user/profile.php', '

profile

'); + file_put_contents($this->root . '/views/layouts/main.php', ''); + // A stray file at the resource root must NOT be reachable any more. + file_put_contents($this->root . '/legacy.php', '

legacy

'); + + KernelConfig::$pathResource = $this->root; + ResponseView::setBasePath(''); + } + + protected function tearDown(): void + { + foreach (['/views/user/profile.php', '/views/layouts/main.php', '/legacy.php'] as $file) { + @unlink($this->root . $file); + } + foreach (['/views/layouts', '/views/user', '/views'] as $dir) { + @rmdir($this->root . $dir); + } + @rmdir($this->root); + + ResponseView::setBasePath(''); + if ($this->originalRoot !== null) { + KernelConfig::$pathResource = $this->originalRoot; + } + } + + public function test_views_resolve_under_resources_views_by_default(): void + { + ResponseView::view('user/profile'); + + self::assertSame($this->root . '/views', ResponseView::getBasePath()); + } + + public function test_a_layout_and_a_page_share_the_same_root(): void + { + ResponseView::render('layouts/main', 'user/profile'); + + self::assertSame($this->root . '/views', ResponseView::getBasePath()); + } + + public function test_files_directly_under_resources_are_no_longer_views(): void + { + self::assertFileExists($this->root . '/legacy.php', 'the file is there; only the root moved'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View resource not found'); + + ResponseView::view('legacy'); + } + + public function test_a_missing_view_fails_loudly(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View resource not found'); + + ResponseView::view('user/nope'); + } + + public function test_a_missing_layout_fails_loudly(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View template not found'); + + ResponseView::render('layouts/nope', 'user/profile'); + } + + public function test_an_explicit_base_path_still_wins(): void + { + // The escape hatch for a project that keeps views somewhere else. + ResponseView::setBasePath($this->root); + + ResponseView::view('legacy'); + + self::assertSame($this->root, ResponseView::getBasePath()); + } +} diff --git a/tests/Integration/Cli/DbMigrateTestCase.php b/tests/Integration/Cli/DbMigrateTestCase.php index f985d44..95bbb5d 100644 --- a/tests/Integration/Cli/DbMigrateTestCase.php +++ b/tests/Integration/Cli/DbMigrateTestCase.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use Flytachi\Winter\Console\Command\Db; -use Flytachi\Winter\K2\Kernel; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Kernel; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; /** * End-to-end test of the `Db` console command's migrate flow. diff --git a/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php b/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php index a8778cb..6796a08 100644 --- a/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php +++ b/tests/Integration/Cli/Fixtures/Mariadb/MigrMariadbRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\Mariadb; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\Mariadb; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; -use Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\MigrEntity; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MariadbTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\MigrEntity; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MariadbTestDbConfig; final class MigrMariadbRepo extends RepositoryView { diff --git a/tests/Integration/Cli/Fixtures/MigrEntity.php b/tests/Integration/Cli/Fixtures/MigrEntity.php index 9e09335..3c54f64 100644 --- a/tests/Integration/Cli/Fixtures/MigrEntity.php +++ b/tests/Integration/Cli/Fixtures/MigrEntity.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Entity\Table; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Unique; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Boolean; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Entity\Table; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Unique; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Boolean; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; /** * Shared entity for Phase C.5 (Cli `db migrate` E2E tests). diff --git a/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php b/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php index 6a6c833..c810b27 100644 --- a/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php +++ b/tests/Integration/Cli/Fixtures/Mysql/MigrMysqlRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\Mysql; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\Mysql; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; -use Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\MigrEntity; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MysqlTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\MigrEntity; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MysqlTestDbConfig; final class MigrMysqlRepo extends RepositoryView { diff --git a/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php b/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php index c968f4f..f265c61 100644 --- a/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php +++ b/tests/Integration/Cli/Fixtures/Pg/MigrPgRepo.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\Pg; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\Pg; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; -use Flytachi\Winter\K2\Tests\Integration\Cli\Fixtures\MigrEntity; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\PgTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Tests\Integration\Cli\Fixtures\MigrEntity; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\PgTestDbConfig; final class MigrPgRepo extends RepositoryView { diff --git a/tests/Integration/Cli/MariadbDbMigrateTest.php b/tests/Integration/Cli/MariadbDbMigrateTest.php index e59ee91..62fe81a 100644 --- a/tests/Integration/Cli/MariadbDbMigrateTest.php +++ b/tests/Integration/Cli/MariadbDbMigrateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Cli/MysqlDbMigrateTest.php b/tests/Integration/Cli/MysqlDbMigrateTest.php index a6f2351..69a44af 100644 --- a/tests/Integration/Cli/MysqlDbMigrateTest.php +++ b/tests/Integration/Cli/MysqlDbMigrateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Cli/PgDbMigrateTest.php b/tests/Integration/Cli/PgDbMigrateTest.php index 5d8ea78..9367252 100644 --- a/tests/Integration/Cli/PgDbMigrateTest.php +++ b/tests/Integration/Cli/PgDbMigrateTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Cli; +namespace Flytachi\Winter\Kernel\Tests\Integration\Cli; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Crud/CrudIntegrationTestCase.php b/tests/Integration/Crud/CrudIntegrationTestCase.php index 23805e6..a6dd247 100644 --- a/tests/Integration/Crud/CrudIntegrationTestCase.php +++ b/tests/Integration/Crud/CrudIntegrationTestCase.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; /** - * CRUD test bodies — insert / insertGroup / update / delete / upsert. + * CRUD test bodies — insert / insertBatch / update / delete / upsert. * * Schema/lifecycle are owned by {@see ProductsTableTestCase}. Tests start * with an empty `products` table (setUp truncates) and insert rows with @@ -61,7 +61,7 @@ public function test_insert_omits_null_columns(): void public function test_insert_group_creates_all_rows(): void { - $this->repo()->insertGroup( + $this->repo()->insertBatch( ['id' => 1, 'name' => 'a', 'price' => 1.0], ['id' => 2, 'name' => 'b', 'price' => 2.0], ['id' => 3, 'name' => 'c', 'price' => 3.0], @@ -74,7 +74,7 @@ public function test_insert_group_creates_all_rows(): void public function test_update_changes_matched_rows_only(): void { - $this->repo()->insertGroup( + $this->repo()->insertBatch( ['id' => 1, 'name' => 'old', 'price' => 1.0], ['id' => 2, 'name' => 'keep', 'price' => 2.0], ); @@ -91,7 +91,7 @@ public function test_update_changes_matched_rows_only(): void public function test_delete_removes_only_matched_rows(): void { - $this->repo()->insertGroup( + $this->repo()->insertBatch( ['id' => 1, 'name' => 'a', 'price' => 1.0], ['id' => 2, 'name' => 'b', 'price' => 2.0], ['id' => 3, 'name' => 'c', 'price' => 3.0], diff --git a/tests/Integration/Crud/MariadbCrudTest.php b/tests/Integration/Crud/MariadbCrudTest.php index c710628..545e241 100644 --- a/tests/Integration/Crud/MariadbCrudTest.php +++ b/tests/Integration/Crud/MariadbCrudTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMariadbRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMariadbRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Crud/MysqlCrudTest.php b/tests/Integration/Crud/MysqlCrudTest.php index acb9245..1cd47a4 100644 --- a/tests/Integration/Crud/MysqlCrudTest.php +++ b/tests/Integration/Crud/MysqlCrudTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMysqlRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMysqlRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Crud/PgCrudTest.php b/tests/Integration/Crud/PgCrudTest.php index 9875583..8f4e66a 100644 --- a/tests/Integration/Crud/PgCrudTest.php +++ b/tests/Integration/Crud/PgCrudTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductPgRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductPgRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Crud/ProductsTableTestCase.php b/tests/Integration/Crud/ProductsTableTestCase.php index 8196478..2355f10 100644 --- a/tests/Integration/Crud/ProductsTableTestCase.php +++ b/tests/Integration/Crud/ProductsTableTestCase.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Crud; +namespace Flytachi\Winter\Kernel\Tests\Integration\Crud; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; /** * Shared infrastructure for any integration test that operates on the @@ -14,7 +14,7 @@ * * Concrete behaviour test bodies live in subclasses: * - {@see CrudIntegrationTestCase} — insert / update / delete / upsert - * - {@see \Flytachi\Winter\K2\Tests\Integration\View\ViewIntegrationTestCase} + * - {@see \Flytachi\Winter\Kernel\Tests\Integration\View\ViewIntegrationTestCase} * — find / findAll / count / exists / *OrThrow * * Schema uses an explicit (non-auto-increment) integer PK; tests insert diff --git a/tests/Integration/Fixtures/IntegrationTestCase.php b/tests/Integration/Fixtures/IntegrationTestCase.php index 1c8e4b1..ea216c3 100644 --- a/tests/Integration/Fixtures/IntegrationTestCase.php +++ b/tests/Integration/Fixtures/IntegrationTestCase.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; use PHPUnit\Framework\TestCase; use ReflectionClass; diff --git a/tests/Integration/Fixtures/MariadbTestDbConfig.php b/tests/Integration/Fixtures/MariadbTestDbConfig.php index 16bd843..0af9c7c 100644 --- a/tests/Integration/Fixtures/MariadbTestDbConfig.php +++ b/tests/Integration/Fixtures/MariadbTestDbConfig.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; use Flytachi\Winter\Cdo\Config\MySqlDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; /** * MariaDB configuration — separate from MysqlTestDbConfig only so the pool diff --git a/tests/Integration/Fixtures/MysqlTestDbConfig.php b/tests/Integration/Fixtures/MysqlTestDbConfig.php index bfaace1..7741a12 100644 --- a/tests/Integration/Fixtures/MysqlTestDbConfig.php +++ b/tests/Integration/Fixtures/MysqlTestDbConfig.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; use Flytachi\Winter\Cdo\Config\MySqlDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; /** * MySQL configuration driven by env vars. diff --git a/tests/Integration/Fixtures/PgTestDbConfig.php b/tests/Integration/Fixtures/PgTestDbConfig.php index fc45616..a7c1c5a 100644 --- a/tests/Integration/Fixtures/PgTestDbConfig.php +++ b/tests/Integration/Fixtures/PgTestDbConfig.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; use Flytachi\Winter\Cdo\Config\PgDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; /** * PostgreSQL configuration driven entirely by env vars. diff --git a/tests/Integration/Fixtures/ProductEntity.php b/tests/Integration/Fixtures/ProductEntity.php index 58546b7..deeead2 100644 --- a/tests/Integration/Fixtures/ProductEntity.php +++ b/tests/Integration/Fixtures/ProductEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; /** * Test entity used by all CRUD integration tests. Three primitive types diff --git a/tests/Integration/Fixtures/ProductMariadbRepo.php b/tests/Integration/Fixtures/ProductMariadbRepo.php index 87c4412..83616d4 100644 --- a/tests/Integration/Fixtures/ProductMariadbRepo.php +++ b/tests/Integration/Fixtures/ProductMariadbRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class ProductMariadbRepo extends Repository { diff --git a/tests/Integration/Fixtures/ProductMysqlRepo.php b/tests/Integration/Fixtures/ProductMysqlRepo.php index 26449c8..f8c0e5a 100644 --- a/tests/Integration/Fixtures/ProductMysqlRepo.php +++ b/tests/Integration/Fixtures/ProductMysqlRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class ProductMysqlRepo extends Repository { diff --git a/tests/Integration/Fixtures/ProductPgRepo.php b/tests/Integration/Fixtures/ProductPgRepo.php index c56b748..1df9bb5 100644 --- a/tests/Integration/Fixtures/ProductPgRepo.php +++ b/tests/Integration/Fixtures/ProductPgRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class ProductPgRepo extends Repository { diff --git a/tests/Integration/Fixtures/SpecimenEntity.php b/tests/Integration/Fixtures/SpecimenEntity.php index dab318f..4fd03a9 100644 --- a/tests/Integration/Fixtures/SpecimenEntity.php +++ b/tests/Integration/Fixtures/SpecimenEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; /** * Typed entity used by the Types integration tests. diff --git a/tests/Integration/Fixtures/SpecimenMariadbRepo.php b/tests/Integration/Fixtures/SpecimenMariadbRepo.php index 8f8734e..edfe9bd 100644 --- a/tests/Integration/Fixtures/SpecimenMariadbRepo.php +++ b/tests/Integration/Fixtures/SpecimenMariadbRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class SpecimenMariadbRepo extends Repository { diff --git a/tests/Integration/Fixtures/SpecimenMysqlRepo.php b/tests/Integration/Fixtures/SpecimenMysqlRepo.php index 3ec7001..d25980f 100644 --- a/tests/Integration/Fixtures/SpecimenMysqlRepo.php +++ b/tests/Integration/Fixtures/SpecimenMysqlRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class SpecimenMysqlRepo extends Repository { diff --git a/tests/Integration/Fixtures/SpecimenPgRepo.php b/tests/Integration/Fixtures/SpecimenPgRepo.php index da6f46d..0bf968f 100644 --- a/tests/Integration/Fixtures/SpecimenPgRepo.php +++ b/tests/Integration/Fixtures/SpecimenPgRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Integration\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; final class SpecimenPgRepo extends Repository { diff --git a/tests/Integration/Migration/MariadbMigrationE2ETest.php b/tests/Integration/Migration/MariadbMigrationE2ETest.php index 68474eb..cf84335 100644 --- a/tests/Integration/Migration/MariadbMigrationE2ETest.php +++ b/tests/Integration/Migration/MariadbMigrationE2ETest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Migration; +namespace Flytachi\Winter\Kernel\Tests\Integration\Migration; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/Migration/MysqlMigrationE2ETest.php b/tests/Integration/Migration/MysqlMigrationE2ETest.php index 4e1ecc4..fc00a85 100644 --- a/tests/Integration/Migration/MysqlMigrationE2ETest.php +++ b/tests/Integration/Migration/MysqlMigrationE2ETest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Migration; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +namespace Flytachi\Winter\Kernel\Tests\Integration\Migration; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Migration/PgMigrationE2ETest.php b/tests/Integration/Migration/PgMigrationE2ETest.php index 4afceb7..e09a92c 100644 --- a/tests/Integration/Migration/PgMigrationE2ETest.php +++ b/tests/Integration/Migration/PgMigrationE2ETest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Migration; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +namespace Flytachi\Winter\Kernel\Tests\Integration\Migration; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php b/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php index 53d6192..1a73e94 100644 --- a/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php +++ b/tests/Integration/Pool/CdoMariadbSegfaultDiagnosticTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MariadbTestDbConfig; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MariadbTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('cdo-diagnostic')] diff --git a/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php b/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php index f95ddf1..bca72e1 100644 --- a/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php +++ b/tests/Integration/Pool/CdoMysqlSegfaultDiagnosticTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MysqlTestDbConfig; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MysqlTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('cdo-diagnostic')] diff --git a/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php b/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php index 457b92f..7bfe0e1 100644 --- a/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php +++ b/tests/Integration/Pool/CdoSegfaultDiagnosticTestCase.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunInSeparateProcess; diff --git a/tests/Integration/Pool/MariadbPoolConnectionTest.php b/tests/Integration/Pool/MariadbPoolConnectionTest.php index 29e470e..ce6ab07 100644 --- a/tests/Integration/Pool/MariadbPoolConnectionTest.php +++ b/tests/Integration/Pool/MariadbPoolConnectionTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MariadbTestDbConfig; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MariadbTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('pool')] diff --git a/tests/Integration/Pool/MysqlPoolConnectionTest.php b/tests/Integration/Pool/MysqlPoolConnectionTest.php index eae548e..fe3d076 100644 --- a/tests/Integration/Pool/MysqlPoolConnectionTest.php +++ b/tests/Integration/Pool/MysqlPoolConnectionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\MysqlTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\MysqlTestDbConfig; use PHPUnit\Framework\Attributes\Group; #[Group('pool')] diff --git a/tests/Integration/Pool/PgPoolConnectionTest.php b/tests/Integration/Pool/PgPoolConnectionTest.php index 68843fc..4270107 100644 --- a/tests/Integration/Pool/PgPoolConnectionTest.php +++ b/tests/Integration/Pool/PgPoolConnectionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Pool; +namespace Flytachi\Winter\Kernel\Tests\Integration\Pool; -use Flytachi\Winter\K2\Ppa\Pool\PpaConnectionPool; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\PgTestDbConfig; +use Flytachi\Winter\Kernel\Ppa\Pool\PpaConnectionPool; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\PgTestDbConfig; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php b/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php index 65830af..9ee68ed 100644 --- a/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php +++ b/tests/Integration/Smoke/MariadbConnectivitySmokeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Smoke; +namespace Flytachi\Winter\Kernel\Tests\Integration\Smoke; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; /** diff --git a/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php b/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php index b8b1479..d6c1b0e 100644 --- a/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php +++ b/tests/Integration/Smoke/MysqlConnectivitySmokeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Smoke; +namespace Flytachi\Winter\Kernel\Tests\Integration\Smoke; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Smoke/PgConnectivitySmokeTest.php b/tests/Integration/Smoke/PgConnectivitySmokeTest.php index 92b8ae7..a9437ff 100644 --- a/tests/Integration/Smoke/PgConnectivitySmokeTest.php +++ b/tests/Integration/Smoke/PgConnectivitySmokeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Smoke; +namespace Flytachi\Winter\Kernel\Tests\Integration\Smoke; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/MariadbTypesTest.php b/tests/Integration/Types/MariadbTypesTest.php index 1c27272..c45c3c6 100644 --- a/tests/Integration/Types/MariadbTypesTest.php +++ b/tests/Integration/Types/MariadbTypesTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenMariadbRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenMariadbRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/MysqlTypesTest.php b/tests/Integration/Types/MysqlTypesTest.php index 3afc07d..adf1b3c 100644 --- a/tests/Integration/Types/MysqlTypesTest.php +++ b/tests/Integration/Types/MysqlTypesTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenMysqlRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenMysqlRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/PgTypesTest.php b/tests/Integration/Types/PgTypesTest.php index c78ccb8..a6732d6 100644 --- a/tests/Integration/Types/PgTypesTest.php +++ b/tests/Integration/Types/PgTypesTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenPgRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenPgRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/Types/TypesIntegrationTestCase.php b/tests/Integration/Types/TypesIntegrationTestCase.php index 213d486..2b9294e 100644 --- a/tests/Integration/Types/TypesIntegrationTestCase.php +++ b/tests/Integration/Types/TypesIntegrationTestCase.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\Types; +namespace Flytachi\Winter\Kernel\Tests\Integration\Types; -use Flytachi\Winter\K2\Ppa\Stereotype\Repository; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\IntegrationTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\SpecimenEntity; +use Flytachi\Winter\Kernel\Ppa\Stereotype\Repository; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\IntegrationTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\SpecimenEntity; /** * Verifies the framework's Mapping layer end-to-end across pgsql / mysql / mariadb: diff --git a/tests/Integration/View/MariadbViewTest.php b/tests/Integration/View/MariadbViewTest.php index e6e0117..31fbc6c 100644 --- a/tests/Integration/View/MariadbViewTest.php +++ b/tests/Integration/View/MariadbViewTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMariadbRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMariadbRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/View/MysqlViewTest.php b/tests/Integration/View/MysqlViewTest.php index 66ba008..252e43e 100644 --- a/tests/Integration/View/MysqlViewTest.php +++ b/tests/Integration/View/MysqlViewTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductMysqlRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductMysqlRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/View/PgViewTest.php b/tests/Integration/View/PgViewTest.php index fbef01f..96c45e7 100644 --- a/tests/Integration/View/PgViewTest.php +++ b/tests/Integration/View/PgViewTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductPgRepo; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductPgRepo; use PHPUnit\Framework\Attributes\Group; #[Group('integration')] diff --git a/tests/Integration/View/ViewIntegrationTestCase.php b/tests/Integration/View/ViewIntegrationTestCase.php index 3039f00..9ffeb50 100644 --- a/tests/Integration/View/ViewIntegrationTestCase.php +++ b/tests/Integration/View/ViewIntegrationTestCase.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Integration\View; +namespace Flytachi\Winter\Kernel\Tests\Integration\View; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Ppa\Entity\EntityException; -use Flytachi\Winter\K2\Tests\Integration\Crud\ProductsTableTestCase; -use Flytachi\Winter\K2\Tests\Integration\Fixtures\ProductEntity; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityException; +use Flytachi\Winter\Kernel\Tests\Integration\Crud\ProductsTableTestCase; +use Flytachi\Winter\Kernel\Tests\Integration\Fixtures\ProductEntity; /** * Shared base for read-side integration tests across pgsql / mysql / mariadb. @@ -22,7 +22,7 @@ * 3 | gamma | NULL * 4 | delta | 4.00 * - * Tests exercise the full {@see \Flytachi\Winter\K2\Ppa\Repository\RepositoryViewTrait} + * Tests exercise the full {@see \Flytachi\Winter\Kernel\Ppa\Repository\RepositoryViewTrait} * surface — find / findAll / findColumn / count / exists / static finders * / rawFetch / hydration. */ diff --git a/tests/Localization/LanguageNegotiatorTest.php b/tests/Localization/LanguageNegotiatorTest.php index 4943637..933cee7 100644 --- a/tests/Localization/LanguageNegotiatorTest.php +++ b/tests/Localization/LanguageNegotiatorTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Localization; +namespace Flytachi\Winter\Kernel\Tests\Localization; -use Flytachi\Winter\K2\Localization\LanguageNegotiator; +use Flytachi\Winter\Kernel\Localization\LanguageNegotiator; use PHPUnit\Framework\TestCase; final class LanguageNegotiatorTest extends TestCase diff --git a/tests/Localization/LocaleServiceTest.php b/tests/Localization/LocaleServiceTest.php index 2cb0e27..32cfe7e 100644 --- a/tests/Localization/LocaleServiceTest.php +++ b/tests/Localization/LocaleServiceTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Localization; +namespace Flytachi\Winter\Kernel\Tests\Localization; -use Flytachi\Winter\K2\Localization\LocaleService; +use Flytachi\Winter\Kernel\Localization\LocaleService; use PHPUnit\Framework\TestCase; final class LocaleServiceTest extends TestCase diff --git a/tests/Localization/LocaleTest.php b/tests/Localization/LocaleTest.php index a6ca4fa..c32605c 100644 --- a/tests/Localization/LocaleTest.php +++ b/tests/Localization/LocaleTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Localization; +namespace Flytachi\Winter\Kernel\Tests\Localization; -use Flytachi\Winter\K2\Localization\Locale; -use Flytachi\Winter\K2\Localization\LocaleService; +use Flytachi\Winter\Kernel\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\LocaleService; use PHPUnit\Framework\TestCase; final class LocaleTest extends TestCase diff --git a/tests/Localization/TimezoneTest.php b/tests/Localization/TimezoneTest.php new file mode 100644 index 0000000..08f70f4 --- /dev/null +++ b/tests/Localization/TimezoneTest.php @@ -0,0 +1,134 @@ +originalEnv = $_ENV['TIME_ZONE'] ?? null; + RequestLocal::clear(); + } + + protected function tearDown(): void + { + if ($this->originalEnv === null) { + unset($_ENV['TIME_ZONE']); + } else { + $_ENV['TIME_ZONE'] = $this->originalEnv; + } + RequestLocal::clear(); + } + + public function test_the_stored_zone_is_returned(): void + { + Timezone::set('Asia/Tashkent'); + + self::assertSame('Asia/Tashkent', Timezone::current()); + self::assertTrue(Timezone::isSet()); + } + + public function test_without_a_stored_zone_the_environment_answers(): void + { + $_ENV['TIME_ZONE'] = 'Europe/Berlin'; + + self::assertSame('Europe/Berlin', Timezone::current()); + self::assertFalse(Timezone::isSet(), 'a default is not the request\'s own zone'); + } + + public function test_with_no_environment_either_it_falls_back_to_utc(): void + { + unset($_ENV['TIME_ZONE']); + + self::assertSame('UTC', Timezone::current()); + } + + public function test_reset_returns_to_the_environment_default(): void + { + $_ENV['TIME_ZONE'] = 'Europe/Berlin'; + Timezone::set('Asia/Tashkent'); + + Timezone::reset(); + + self::assertSame('Europe/Berlin', Timezone::current()); + } + + /** + * The failure that motivated the class. Request A must read its own zone after an + * I/O wait, not the zone of a request that arrived while it waited. + */ + public function test_a_concurrent_request_cannot_overwrite_the_zone(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('Coroutine isolation needs Swoole.'); + } + $seen = []; + + \Swoole\Coroutine\run(static function () use (&$seen): void { + \Swoole\Coroutine::create(static function () use (&$seen): void { + Timezone::set('Asia/Tashkent'); + \Swoole\Coroutine::sleep(0.05); + $seen['A'] = Timezone::current(); + }); + \Swoole\Coroutine::create(static function () use (&$seen): void { + \Swoole\Coroutine::sleep(0.01); + Timezone::set('Europe/London'); + $seen['B'] = Timezone::current(); + }); + }); + + self::assertSame('Asia/Tashkent', $seen['A']); + self::assertSame('Europe/London', $seen['B']); + } + + /** + * PHP's own global is what this replaces — and the contrast is the point: the same + * two coroutines mixed up their zones through `date_default_timezone_set()`. + */ + public function test_the_php_global_is_still_shared_which_is_why_this_exists(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('Coroutine isolation needs Swoole.'); + } + $original = date_default_timezone_get(); + $seen = []; + + \Swoole\Coroutine\run(static function () use (&$seen): void { + \Swoole\Coroutine::create(static function () use (&$seen): void { + date_default_timezone_set('Asia/Tashkent'); + \Swoole\Coroutine::sleep(0.05); + $seen['A'] = date_default_timezone_get(); + }); + \Swoole\Coroutine::create(static function (): void { + \Swoole\Coroutine::sleep(0.01); + date_default_timezone_set('Europe/London'); + }); + }); + + date_default_timezone_set($original); + + self::assertSame( + 'Europe/London', + $seen['A'], + 'if this ever fails, PHP made its default timezone coroutine-local and ' + . 'Timezone could delegate to it', + ); + } +} diff --git a/tests/Ppa/DeclarationItemTest.php b/tests/Ppa/DeclarationItemTest.php index 987ad8d..7d39b4e 100644 --- a/tests/Ppa/DeclarationItemTest.php +++ b/tests/Ppa/DeclarationItemTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa; - -use Flytachi\Winter\K2\Ppa\DeclarationItem; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Extension as ExtensionStructure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Ppa\Fixtures\StubDbConfig; +namespace Flytachi\Winter\Kernel\Tests\Ppa; + +use Flytachi\Winter\Kernel\Ppa\DeclarationItem; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Extension as ExtensionStructure; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Ppa\Fixtures\StubDbConfig; use PHPUnit\Framework\TestCase; // ── Fixture configs ────────────────────────────────────────────────────────── diff --git a/tests/Ppa/DeclarationTest.php b/tests/Ppa/DeclarationTest.php index 88540bb..9f84a61 100644 --- a/tests/Ppa/DeclarationTest.php +++ b/tests/Ppa/DeclarationTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa; +namespace Flytachi\Winter\Kernel\Tests\Ppa; -use Flytachi\Winter\K2\Ppa\Declaration; -use Flytachi\Winter\K2\Ppa\DeclarationItem; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; -use Flytachi\Winter\K2\Tests\Ppa\Fixtures\StubDbConfig; +use Flytachi\Winter\Kernel\Ppa\Declaration; +use Flytachi\Winter\Kernel\Ppa\DeclarationItem; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; +use Flytachi\Winter\Kernel\Tests\Ppa\Fixtures\StubDbConfig; use PHPUnit\Framework\TestCase; final class DeclConfigAlpha extends StubDbConfig diff --git a/tests/Ppa/Fixtures/StubDbConfig.php b/tests/Ppa/Fixtures/StubDbConfig.php index eff27fc..6e634aa 100644 --- a/tests/Ppa/Fixtures/StubDbConfig.php +++ b/tests/Ppa/Fixtures/StubDbConfig.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Fixtures; use Flytachi\Winter\Cdo\Config\Common\DbConfigInterface; use Flytachi\Winter\Cdo\Connection\CDO; diff --git a/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php b/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php index ddff8be..f3b3e39 100644 --- a/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php +++ b/tests/Ppa/Mapping/Attributes/Additive/AdditiveAttributesTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Additive; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Additive; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; use PHPUnit\Framework\TestCase; final class AdditiveAttributesTest extends TestCase diff --git a/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php b/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php index 3cf3dbd..7baa9a3 100644 --- a/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php +++ b/tests/Ppa/Mapping/Attributes/Config/ExtensionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Extension; use PHPUnit\Framework\TestCase; use ReflectionAttribute; use ReflectionClass; diff --git a/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php b/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php index 1c93207..204b2de 100644 --- a/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php +++ b/tests/Ppa/Mapping/Attributes/Config/MigratableTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Config; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Config; use Attribute; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\AttributeDbConfig; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Config\Migratable; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\AttributeDbConfig; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Config\Migratable; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; use PHPUnit\Framework\TestCase; use ReflectionAttribute; use ReflectionClass; diff --git a/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php b/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php index 3af0b6b..47a3c15 100644 --- a/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php +++ b/tests/Ppa/Mapping/Attributes/Constraint/ConstraintAttributesTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Constraint; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\Check; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\CheckEnum; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\ForeignRepo; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\RepositoryMappingInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey as ForeignKeyStructure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Constraint; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\Check; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\CheckEnum; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\ForeignRepo; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\RepositoryMappingInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey as ForeignKeyStructure; use PHPUnit\Framework\TestCase; // ── Enums used by CheckEnum tests ──────────────────────────────────────────── diff --git a/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php b/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php index 204d524..c504471 100644 --- a/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php +++ b/tests/Ppa/Mapping/Attributes/Hybrid/HybridTypesTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Hybrid; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Hybrid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\BigId; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\SmallId; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\UuidPk; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\BigInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\SmallInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Uuid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\BigId; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\SmallId; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\UuidPk; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\BigInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\SmallInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Uuid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; use PHPUnit\Framework\TestCase; final class HybridTypesTest extends TestCase diff --git a/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php b/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php index 7d5be6a..11d5592 100644 --- a/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php +++ b/tests/Ppa/Mapping/Attributes/Idx/IdxAttributesTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Idx; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Primary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Unique; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index as IndexStructure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Idx; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Primary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Unique; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index as IndexStructure; use PHPUnit\Framework\TestCase; final class IdxAttributesTest extends TestCase diff --git a/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php b/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php index 9d59456..17c3c44 100644 --- a/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php +++ b/tests/Ppa/Mapping/Attributes/Primal/PrimalTypesTest.php @@ -2,29 +2,29 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Primal; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\AttributeDbType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\BigInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Binary; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Blob; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Boolean; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Char; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Date; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\DateTime; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Decimal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Double; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\FloatType; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Json; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\SmallInteger; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Text; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\TextArray; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Time; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Timestamp; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Type; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Uuid; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Primal; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\AttributeDbType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\BigInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Binary; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Blob; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Boolean; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Char; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Date; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\DateTime; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Decimal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Double; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\FloatType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Json; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\SmallInteger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Text; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\TextArray; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Time; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Timestamp; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Type; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Uuid; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php b/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php index f16de55..952388f 100644 --- a/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php +++ b/tests/Ppa/Mapping/Attributes/Sub/AutoIncrementTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Attributes\Sub; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Attributes\Sub; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Sub\AutoIncrement; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Sub\AutoIncrement; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/ColumnMappingTest.php b/tests/Ppa/Mapping/ColumnMappingTest.php index 818e3d6..bb995ec 100644 --- a/tests/Ppa/Mapping/ColumnMappingTest.php +++ b/tests/Ppa/Mapping/ColumnMappingTest.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping; - -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\DefaultVal; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Additive\NullableIs; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\Check; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Constraint\ForeignKey as ForeignKeyAttr; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Hybrid\Id; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Index as IndexAttr; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Idx\Unique; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Integer; -use Flytachi\Winter\K2\Ppa\Mapping\Attributes\Primal\Varchar; -use Flytachi\Winter\K2\Ppa\Mapping\ColumnMapping; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\DefaultVal; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Additive\NullableIs; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\Check; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Constraint\ForeignKey as ForeignKeyAttr; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Hybrid\Id; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Index as IndexAttr; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Idx\Unique; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Integer; +use Flytachi\Winter\Kernel\Ppa\Mapping\Attributes\Primal\Varchar; +use Flytachi\Winter\Kernel\Ppa\Mapping\ColumnMapping; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; use PHPUnit\Framework\TestCase; use ReflectionProperty; diff --git a/tests/Ppa/Mapping/Constants/FKActionTest.php b/tests/Ppa/Mapping/Constants/FKActionTest.php index b02669e..20f794f 100644 --- a/tests/Ppa/Mapping/Constants/FKActionTest.php +++ b/tests/Ppa/Mapping/Constants/FKActionTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; use PHPUnit\Framework\TestCase; final class FKActionTest extends TestCase diff --git a/tests/Ppa/Mapping/Constants/IndexMethodTest.php b/tests/Ppa/Mapping/Constants/IndexMethodTest.php index c13cd76..04409b2 100644 --- a/tests/Ppa/Mapping/Constants/IndexMethodTest.php +++ b/tests/Ppa/Mapping/Constants/IndexMethodTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; use PHPUnit\Framework\TestCase; final class IndexMethodTest extends TestCase diff --git a/tests/Ppa/Mapping/Constants/IndexTypeTest.php b/tests/Ppa/Mapping/Constants/IndexTypeTest.php index c2d1faa..84c7232 100644 --- a/tests/Ppa/Mapping/Constants/IndexTypeTest.php +++ b/tests/Ppa/Mapping/Constants/IndexTypeTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; use PHPUnit\Framework\TestCase; final class IndexTypeTest extends TestCase diff --git a/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php b/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php index d40d58e..618579c 100644 --- a/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php +++ b/tests/Ppa/Mapping/Constants/MigratablePriorityTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Constants; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Constants; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\MigratablePriority; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\MigratablePriority; use PHPUnit\Framework\TestCase; final class MigratablePriorityTest extends TestCase diff --git a/tests/Ppa/Mapping/SqliteDdlTest.php b/tests/Ppa/Mapping/SqliteDdlTest.php new file mode 100644 index 0000000..9132b24 --- /dev/null +++ b/tests/Ppa/Mapping/SqliteDdlTest.php @@ -0,0 +1,150 @@ +getProperties() as $property) { + $map->push($property); + } + + $sql = (array) new Table('products', $map->getColumns())->toSql($dialect); + + return array_values(array_filter(array_map('trim', explode(';', implode(";\n", $sql))))); + } + + private function migrated(): PDO + { + $db = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + foreach ($this->ddl() as $statement) { + $db->exec($statement); + } + + return $db; + } + + public function test_the_generated_schema_is_accepted(): void + { + // Any syntax the generator got wrong fails right here. + $this->migrated(); + + $this->addToAssertionCount(1); + } + + public function test_the_identity_column_assigns_ids(): void + { + $db = $this->migrated(); + + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 1.5, 2.5)"); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('B', 2.5, 3.5)"); + + self::assertSame( + ['1', '2'], + array_map('strval', $db->query('SELECT id FROM products ORDER BY id')->fetchAll(PDO::FETCH_COLUMN)), + 'the column must be a rowid alias, which requires the type to be exactly INTEGER', + ); + } + + public function test_the_identity_column_is_declared_as_plain_integer(): void + { + $create = implode("\n", $this->ddl()); + + self::assertMatchesRegularExpression('/\bid\s+INTEGER\b/', $create); + self::assertStringNotContainsString('AUTO_INCREMENT', $create, 'that is MySQL syntax'); + self::assertStringNotContainsString('IDENTITY', $create, 'that is PostgreSQL syntax'); + } + + public function test_defaults_and_nullability_survive(): void + { + $db = $this->migrated(); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 1.5, 2.5)"); + + $row = $db->query('SELECT stock, active, description FROM products')->fetch(PDO::FETCH_ASSOC); + + self::assertSame(0, (int) $row['stock']); + self::assertSame(1, (int) $row['active']); + self::assertNull($row['description']); + } + + public function test_a_unique_index_is_enforced(): void + { + $db = $this->migrated(); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 1.5, 2.5)"); + + $this->expectException(\PDOException::class); + $db->exec("INSERT INTO products (sku, price, ratio) VALUES ('A', 9.9, 9.9)"); + } + + public function test_floating_types_no_longer_break_generation(): void + { + // Decimal, Double and Float used to raise UnhandledMatchError for any dialect + // beyond mysql/pgsql, which stopped the migration before it reached the database. + $create = implode("\n", $this->ddl()); + + self::assertStringContainsString('NUMERIC(12, 2)', $create); + self::assertStringContainsString('REAL', $create); + } +} + +/** Entity under test — kept next to the test since only this file uses it. */ +final class SqliteProduct +{ + #[Id] + public int $id; + + #[P\Varchar(120)] + #[Unique] + public string $sku; + + #[P\Text] + #[NullableIs] + public ?string $description; + + #[P\Decimal(12, 2)] + public float $price; + + #[P\Double] + public float $ratio; + + #[P\Integer] + #[DefaultVal('0')] + #[Index] + public int $stock; + + #[P\Boolean] + #[DefaultVal('TRUE')] + public bool $active; +} diff --git a/tests/Ppa/Mapping/Structure/CheckConstraintTest.php b/tests/Ppa/Mapping/Structure/CheckConstraintTest.php index 95a7403..dc89e53 100644 --- a/tests/Ppa/Mapping/Structure/CheckConstraintTest.php +++ b/tests/Ppa/Mapping/Structure/CheckConstraintTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\TestCase; final class CheckConstraintTest extends TestCase diff --git a/tests/Ppa/Mapping/Structure/ColumnTest.php b/tests/Ppa/Mapping/Structure/ColumnTest.php index 4605a9d..6706e8a 100644 --- a/tests/Ppa/Mapping/Structure/ColumnTest.php +++ b/tests/Ppa/Mapping/Structure/ColumnTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/ExtensionTest.php b/tests/Ppa/Mapping/Structure/ExtensionTest.php index 6ff66eb..50a6ff7 100644 --- a/tests/Ppa/Mapping/Structure/ExtensionTest.php +++ b/tests/Ppa/Mapping/Structure/ExtensionTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Extension; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Extension; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/ForeignKeyTest.php b/tests/Ppa/Mapping/Structure/ForeignKeyTest.php index e54862c..c12623a 100644 --- a/tests/Ppa/Mapping/Structure/ForeignKeyTest.php +++ b/tests/Ppa/Mapping/Structure/ForeignKeyTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\FKAction; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\FKAction; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/IndexTest.php b/tests/Ppa/Mapping/Structure/IndexTest.php index e1a53f8..a29c7dd 100644 --- a/tests/Ppa/Mapping/Structure/IndexTest.php +++ b/tests/Ppa/Mapping/Structure/IndexTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexMethod; -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexMethod; +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -227,8 +227,37 @@ public function test_pg_and_mysql_share_same_hash_suffix_on_long_names(): void public function test_unsupported_dialect_throws(): void { + // sqlite used to stand in for "unsupported" here; it is a supported dialect now, + // so the guard is proven with one the mapper genuinely does not know. $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported dialect: sqlite'); - (new Index(columns: ['id']))->toSql('users', 'sqlite'); + $this->expectExceptionMessage('Unsupported dialect: oci'); + (new Index(columns: ['id']))->toSql('users', 'oci'); + } + + // ── SQLite ─────────────────────────────────────────────────────────────── + + public function test_sqlite_index_has_no_using_clause(): void + { + // SQLite has a single index implementation, so USING would be a syntax error. + $sql = (new Index(columns: ['email'], type: IndexType::UNIQUE))->toSql('users', 'sqlite'); + + self::assertStringContainsString('CREATE UNIQUE INDEX', $sql); + self::assertStringContainsString('ON users (email)', $sql); + self::assertStringNotContainsString('USING', $sql); + } + + public function test_sqlite_primary_key_is_a_table_constraint(): void + { + $sql = (new Index(columns: ['id'], type: IndexType::PRIMARY))->toSql('users', 'sqlite'); + + self::assertSame('PRIMARY KEY (id)', $sql); + } + + public function test_sqlite_supports_a_partial_index(): void + { + $sql = (new Index(columns: ['email'], type: IndexType::INDEX, where: 'deleted_at IS NULL')) + ->toSql('users', 'sqlite'); + + self::assertStringContainsString('WHERE deleted_at IS NULL', $sql); } } diff --git a/tests/Ppa/Mapping/Structure/NameValidatorTest.php b/tests/Ppa/Mapping/Structure/NameValidatorTest.php index 7023fbe..cb4454e 100644 --- a/tests/Ppa/Mapping/Structure/NameValidatorTest.php +++ b/tests/Ppa/Mapping/Structure/NameValidatorTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\NameValidator; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\NameValidator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/StoredProcedureTest.php b/tests/Ppa/Mapping/Structure/StoredProcedureTest.php index 036c23a..2ca9eab 100644 --- a/tests/Ppa/Mapping/Structure/StoredProcedureTest.php +++ b/tests/Ppa/Mapping/Structure/StoredProcedureTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StoredProcedure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StoredProcedure; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/TableTest.php b/tests/Ppa/Mapping/Structure/TableTest.php index bb78abb..58a96fa 100644 --- a/tests/Ppa/Mapping/Structure/TableTest.php +++ b/tests/Ppa/Mapping/Structure/TableTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; - -use Flytachi\Winter\K2\Ppa\Mapping\Constants\IndexType; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\CheckConstraint; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Column; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\ForeignKey; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Index; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Table; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; + +use Flytachi\Winter\Kernel\Ppa\Mapping\Constants\IndexType; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\CheckConstraint; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Column; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\ForeignKey; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Index; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Table; use PHPUnit\Framework\TestCase; final class TableTest extends TestCase diff --git a/tests/Ppa/Mapping/Structure/TriggerTest.php b/tests/Ppa/Mapping/Structure/TriggerTest.php index e29212f..ceeac69 100644 --- a/tests/Ppa/Mapping/Structure/TriggerTest.php +++ b/tests/Ppa/Mapping/Structure/TriggerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\Trigger; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\Trigger; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Ppa/Mapping/Structure/ViewTest.php b/tests/Ppa/Mapping/Structure/ViewTest.php index ee2284a..a2860e2 100644 --- a/tests/Ppa/Mapping/Structure/ViewTest.php +++ b/tests/Ppa/Mapping/Structure/ViewTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Mapping\Structure; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Mapping\Structure; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\StructureInterface; -use Flytachi\Winter\K2\Ppa\Mapping\Structure\View; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\StructureInterface; +use Flytachi\Winter\Kernel\Ppa\Mapping\Structure\View; use PHPUnit\Framework\TestCase; final class ViewTest extends TestCase diff --git a/tests/Ppa/Pool/ConnectionLossTest.php b/tests/Ppa/Pool/ConnectionLossTest.php new file mode 100644 index 0000000..cb3b5b9 --- /dev/null +++ b/tests/Ppa/Pool/ConnectionLossTest.php @@ -0,0 +1,122 @@ +errorInfo = [$sqlState, $driverCode, $message]; + return $e; + } + + /** CDO always wraps the original PDOException as `previous`. */ + private static function wrapped(PDOException $inner): CDOException + { + return new CDOException($inner->getMessage(), previous: $inner); + } + + public function test_sqlstate_class_08_is_a_lost_connection(): void + { + foreach (['08000', '08001', '08003', '08004', '08006', '08007', '08S01'] as $state) { + self::assertTrue( + ConnectionLoss::isLost(self::pdo($state)), + "SQLSTATE {$state} is a connection exception", + ); + } + } + + public function test_postgres_server_shutdown_states_are_a_lost_connection(): void + { + foreach (['57P01', '57P02', '57P03'] as $state) { + self::assertTrue(ConnectionLoss::isLost(self::pdo($state)), "SQLSTATE {$state} terminates the connection"); + } + } + + public function test_mysql_gone_away_driver_codes_are_a_lost_connection(): void + { + // MySQL reports these as HY000 plus a driver code. + self::assertTrue(ConnectionLoss::isLost(self::pdo('HY000', 2006, 'MySQL server has gone away'))); + self::assertTrue(ConnectionLoss::isLost(self::pdo('HY000', 2013, 'Lost connection during query'))); + } + + public function test_query_errors_leave_the_connection_alone(): void + { + // A healthy server rejecting a bad query — evicting here would churn the pool. + self::assertFalse(ConnectionLoss::isLost(self::pdo('23505', 7, 'duplicate key')), 'constraint violation'); + self::assertFalse(ConnectionLoss::isLost(self::pdo('42601', 7, 'syntax error')), 'syntax error'); + self::assertFalse(ConnectionLoss::isLost(self::pdo('23503', 7, 'foreign key')), 'foreign key violation'); + self::assertFalse(ConnectionLoss::isLost(self::pdo('HY000', 1213, 'deadlock')), 'deadlock — connection lives'); + } + + public function test_it_unwraps_the_cdo_exception_chain(): void + { + self::assertTrue( + ConnectionLoss::isLost(self::wrapped(self::pdo('08006'))), + 'CDO wraps the PDOException, so the cause chain must be walked', + ); + self::assertFalse(ConnectionLoss::isLost(self::wrapped(self::pdo('23505')))); + } + + public function test_postgres_reports_a_killed_connection_as_undecided(): void + { + // Verified against a live PostgreSQL: killing the backend yields exactly this, + // NOT SQLSTATE 08006 — with the socket gone there is no result object to take a + // SQLSTATE from, so PDO falls back to HY000 with libpq's generic code 7. + $error = self::pdo('HY000', 7, 'FATAL: terminating connection due to administrator command'); + + self::assertFalse(ConnectionLoss::isLost($error), 'the code alone cannot decide it'); + self::assertTrue(ConnectionLoss::isUndecided($error), 'so a live probe has to'); + } + + public function test_a_decided_sqlstate_is_never_probed(): void + { + // Driver code 7 again — identical to the killed connection above; only the + // SQLSTATE tells them apart, which is why the class must be trusted over it. + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('42P01', 7, 'syntax error'))); + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('23505', 7, 'duplicate key'))); + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('25P02', 7, 'transaction is aborted'))); + } + + public function test_a_connection_already_decided_lost_is_not_undecided(): void + { + // MariaDB: HY000 too, but the driver code settles it — no probe needed. + self::assertTrue(ConnectionLoss::isLost(self::pdo('HY000', 2006, 'gone away'))); + self::assertFalse(ConnectionLoss::isUndecided(self::pdo('HY000', 2006, 'gone away'))); + } + + public function test_undecided_survives_the_cdo_wrapper(): void + { + self::assertTrue(ConnectionLoss::isUndecided(self::wrapped(self::pdo('HY000', 7, 'terminating')))); + } + + public function test_non_database_failures_are_not_a_lost_connection(): void + { + self::assertFalse(ConnectionLoss::isLost(new RuntimeException('something else'))); + } + + public function test_it_falls_back_to_the_code_when_error_info_is_absent(): void + { + // PDO raises connect-time failures with no errorInfo and SQLSTATE in the code. + $error = new PDOException('could not connect'); + (new \ReflectionProperty(\Exception::class, 'code'))->setValue($error, '08006'); + + self::assertTrue(ConnectionLoss::isLost($error)); + } +} diff --git a/tests/Ppa/Pool/PoolTelemetryTest.php b/tests/Ppa/Pool/PoolTelemetryTest.php new file mode 100644 index 0000000..dac1be0 --- /dev/null +++ b/tests/Ppa/Pool/PoolTelemetryTest.php @@ -0,0 +1,293 @@ +originalEnv = $_ENV['PPA_POOL_TELEMETRY'] ?? null; + + $prop = new ReflectionProperty(KernelConfig::class, 'pathStorageRunnable'); + $this->originalPath = $prop->isInitialized() ? $prop->getValue() : null; + + $this->storage = sys_get_temp_dir() . '/wk_pool_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->storage, 0777, true); + KernelConfig::$pathStorageRunnable = $this->storage; + + // Kernel caches FileStorage by name against the path it was first built with. + $this->clearRunnableCache(); + PoolTelemetry::forget(); + } + + protected function tearDown(): void + { + // An armed repeating timer would keep the reactor — and PHP's own shutdown — + // from ever draining, hanging the suite. Assertions have already run. + if (extension_loaded('swoole')) { + \Swoole\Timer::clearAll(); + } + PoolTelemetry::forget(); + + if ($this->originalEnv === null) { + unset($_ENV['PPA_POOL_TELEMETRY']); + } else { + $_ENV['PPA_POOL_TELEMETRY'] = $this->originalEnv; + } + + if ($this->originalPath !== null) { + KernelConfig::$pathStorageRunnable = $this->originalPath; + } + $this->clearRunnableCache(); + + foreach (glob($this->storage . '/*/*') ?: [] as $file) { + @unlink($file); + } + foreach (glob($this->storage . '/*') ?: [] as $dir) { + @rmdir($dir); + } + @rmdir($this->storage); + } + + private function clearRunnableCache(): void + { + (new ReflectionProperty(KernelStore::class, 'runnable'))->setValue(null, []); + } + + /** Writes a record exactly as a worker publishes it. */ + private function publishRecord(int $worker, array $pools, int $ttl = 60): void + { + Kernel::runnable('ppa.pool', false)->write( + 'worker.' . $worker, + ['worker' => $worker, 'at' => time(), 'pools' => $pools], + time() + $ttl, + ); + } + + // ── interval knob ────────────────────────────────────────────────────────── + + public function test_interval_defaults_to_five_seconds(): void + { + unset($_ENV['PPA_POOL_TELEMETRY']); + + self::assertSame(5.0, PoolTelemetry::interval()); + } + + public function test_interval_zero_disables_telemetry(): void + { + $_ENV['PPA_POOL_TELEMETRY'] = '0'; + + self::assertSame(0.0, PoolTelemetry::interval(), '0 turns publishing off entirely'); + } + + public function test_interval_is_floored_at_one_second(): void + { + $_ENV['PPA_POOL_TELEMETRY'] = '0.2'; + + self::assertSame(1.0, PoolTelemetry::interval(), 'telemetry is not a heartbeat'); + } + + public function test_interval_honours_an_explicit_value(): void + { + $_ENV['PPA_POOL_TELEMETRY'] = '30'; + + self::assertSame(30.0, PoolTelemetry::interval()); + } + + // ── store round-trip ─────────────────────────────────────────────────────── + + public function test_snapshot_reads_every_worker_record_in_order(): void + { + $this->publishRecord(2, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]]); + $this->publishRecord(0, ['App\\Config\\MainDb' => ['total' => 3, 'idle' => 2, 'active' => 1, 'maximum' => 5]]); + + $snapshot = PoolTelemetry::snapshot(); + + self::assertCount(2, $snapshot); + self::assertSame([0, 2], array_column($snapshot, 'worker'), 'records come back ordered by worker'); + } + + public function test_snapshot_skips_expired_records_of_dead_workers(): void + { + $this->publishRecord(0, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]]); + $this->publishRecord(1, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]], -1); + + $snapshot = PoolTelemetry::snapshot(); + + self::assertSame([0], array_column($snapshot, 'worker'), 'a worker that stopped refreshing simply expires'); + } + + public function test_aggregate_sums_each_config_across_workers(): void + { + $this->publishRecord(0, [ + 'App\\Config\\MainDb' => ['total' => 5, 'idle' => 3, 'active' => 2, 'maximum' => 10], + ]); + $this->publishRecord(1, [ + 'App\\Config\\MainDb' => ['total' => 4, 'idle' => 1, 'active' => 3, 'maximum' => 10], + 'App\\Config\\OtherDb' => ['total' => 2, 'idle' => 2, 'active' => 0, 'maximum' => 5], + ]); + + $aggregate = PoolTelemetry::aggregate(); + + self::assertSame( + ['total' => 9, 'idle' => 4, 'active' => 5, 'maximum' => 20, 'workers' => 2, 'saturated' => 0], + $aggregate['App\\Config\\MainDb'], + 'the fleet view a single actuator response cannot give', + ); + self::assertSame( + ['total' => 2, 'idle' => 2, 'active' => 0, 'maximum' => 5, 'workers' => 1, 'saturated' => 0], + $aggregate['App\\Config\\OtherDb'], + ); + } + + public function test_aggregate_flags_a_single_saturated_worker(): void + { + // Worker 1 is fully handed out; the fleet sum (12 of 20) still looks roomy. + $this->publishRecord(0, [ + 'App\\Config\\MainDb' => ['total' => 5, 'idle' => 3, 'active' => 2, 'maximum' => 10], + ]); + $this->publishRecord(1, [ + 'App\\Config\\MainDb' => ['total' => 10, 'idle' => 0, 'active' => 10, 'maximum' => 10], + ]); + + $stat = PoolTelemetry::aggregate()['App\\Config\\MainDb']; + + self::assertSame(12, $stat['active']); + self::assertSame(20, $stat['maximum']); + self::assertSame( + 1, + $stat['saturated'], + 'a borrow queues on its own worker, so summed slack must not hide a stalled worker', + ); + } + + public function test_snapshot_is_empty_when_nothing_published(): void + { + self::assertSame([], PoolTelemetry::snapshot()); + self::assertSame([], PoolTelemetry::aggregate()); + } + + public function test_publish_writes_nothing_when_the_worker_holds_no_pool(): void + { + // PpaConnectionPool has no pools in this process, so there is nothing to report. + (new ReflectionMethod(PoolTelemetry::class, 'publish'))->invoke(null, 0, 60); + + self::assertSame([], PoolTelemetry::snapshot(), 'an app that never touches PPA leaves no records'); + } + + // ── Lifecycle: nothing is paid for until a pool exists ────────────────────── + + private function timerId(): ?int + { + return new ReflectionProperty(PoolTelemetry::class, 'timerId')->getValue(); + } + + /** The store directory is created by the mere act of asking for the store. */ + private function storeDirExists(): bool + { + return is_dir($this->storage . '/ppa.pool'); + } + + public function test_enable_arms_no_timer_on_its_own(): void + { + $this->requireSwoole(); + + PoolTelemetry::enable(0); + + self::assertNull($this->timerId(), 'a worker with no datasource must not run a publisher'); + } + + public function test_the_publisher_starts_when_a_pool_appears(): void + { + $this->requireSwoole(); + PoolTelemetry::enable(0); + $armed = null; + + // Inside a coroutine, where a worker really opens its first pool. The timer is + // released before the closure ends: a live repeating timer holds the reactor + // open and `Coroutine\run()` would never return. + \Swoole\Coroutine\run(function () use (&$armed): void { + PoolTelemetry::arm(); + $armed = $this->timerId(); + PoolTelemetry::stop(0); + }); + + self::assertNotNull($armed, 'the first pool is the first thing worth reporting'); + } + + /** A daemon worker or a CLI process opens pools too, and publishes for nobody. */ + public function test_arming_without_an_eligible_worker_does_nothing(): void + { + $this->requireSwoole(); + + PoolTelemetry::arm(); + + self::assertNull($this->timerId()); + } + + public function test_a_forked_child_does_not_inherit_the_publishing_identity(): void + { + $this->requireSwoole(); + PoolTelemetry::enable(0); + + PoolTelemetry::forget(); + PoolTelemetry::arm(); + + self::assertNull($this->timerId(), 'the child would otherwise overwrite its parent record'); + } + + /** + * The regression this pair of fixes is really about: a worker that never published + * must not reach for the store on the way out. Asking for it creates the directory, + * and an empty `runnable/ppa.pool/` reads as "this application uses PPA". + */ + public function test_a_worker_that_never_published_leaves_no_directory_behind(): void + { + $this->requireSwoole(); + PoolTelemetry::enable(0); + + \Swoole\Coroutine\run(static function (): void { + PoolTelemetry::arm(); + PoolTelemetry::stop(0); + }); + + self::assertNull($this->timerId(), 'the timer is released either way'); + self::assertFalse($this->storeDirExists(), 'nothing was written, so nothing is asked for'); + } + + public function test_stop_drops_the_record_of_a_worker_that_did_publish(): void + { + $this->publishRecord(0, ['App\\Config\\MainDb' => ['total' => 1, 'idle' => 1, 'active' => 0, 'maximum' => 5]]); + new ReflectionProperty(PoolTelemetry::class, 'published')->setValue(null, true); + + PoolTelemetry::stop(0); + + self::assertSame([], PoolTelemetry::snapshot(), 'a worker leaving takes its record with it'); + } + + private function requireSwoole(): void + { + if (!extension_loaded('swoole')) { + self::markTestSkipped('The publisher is a Swoole timer.'); + } + } +} diff --git a/tests/Ppa/Pool/PoolTimezoneTest.php b/tests/Ppa/Pool/PoolTimezoneTest.php new file mode 100644 index 0000000..de0e56c --- /dev/null +++ b/tests/Ppa/Pool/PoolTimezoneTest.php @@ -0,0 +1,180 @@ +originalEnv = $_ENV['TIME_ZONE'] ?? null; + RequestLocal::clear(); + new ReflectionProperty(PpaConnectionPool::class, 'appliedTimezone')->setValue(null, null); + } + + protected function tearDown(): void + { + if ($this->originalEnv === null) { + unset($_ENV['TIME_ZONE']); + } else { + $_ENV['TIME_ZONE'] = $this->originalEnv; + } + RequestLocal::clear(); + new ReflectionProperty(PpaConnectionPool::class, 'appliedTimezone')->setValue(null, null); + } + + /** Calls the private `syncTimezone($config, $cdo)` the way `coroutineDb()` does. */ + private function sync(object $config, RecordingCdo $cdo): void + { + new ReflectionMethod(PpaConnectionPool::class, 'syncTimezone')->invoke(null, $config, $cdo); + } + + private function cdo(): RecordingCdo + { + return new ReflectionClass(RecordingCdo::class)->newInstanceWithoutConstructor(); + } + + public function test_the_session_gets_the_requests_zone(): void + { + Timezone::set('Asia/Tashkent'); + $cdo = $this->cdo(); + + $this->sync(new \stdClass(), $cdo); + + self::assertSame(['Asia/Tashkent'], $cdo->applied); + } + + public function test_without_a_request_zone_the_environment_default_is_used(): void + { + $_ENV['TIME_ZONE'] = 'Europe/Berlin'; + $cdo = $this->cdo(); + + $this->sync(new \stdClass(), $cdo); + + self::assertSame(['Europe/Berlin'], $cdo->applied); + } + + /** The engine global is not the source — that is the whole point of the change. */ + public function test_the_php_global_is_ignored(): void + { + $original = date_default_timezone_get(); + date_default_timezone_set('America/New_York'); // another request's leftover + $_ENV['TIME_ZONE'] = 'UTC'; + $cdo = $this->cdo(); + + try { + $this->sync(new \stdClass(), $cdo); + } finally { + date_default_timezone_set($original); + } + + self::assertSame(['UTC'], $cdo->applied, 'the global must not reach the session'); + } + + public function test_the_same_zone_is_not_sent_twice_to_one_connection(): void + { + Timezone::set('Asia/Tashkent'); + $config = new \stdClass(); + $cdo = $this->cdo(); + + $this->sync($config, $cdo); + $this->sync($config, $cdo); + $this->sync($config, $cdo); + + self::assertSame(['Asia/Tashkent'], $cdo->applied, 'one command, not three'); + } + + /** + * The case the memo must not swallow: the connection comes back from the pool while + * a different user holds it. + */ + public function test_a_changed_zone_is_applied_again(): void + { + $config = new \stdClass(); + $cdo = $this->cdo(); + + Timezone::set('Asia/Tashkent'); + $this->sync($config, $cdo); + Timezone::set('Europe/London'); + $this->sync($config, $cdo); + + self::assertSame(['Asia/Tashkent', 'Europe/London'], $cdo->applied); + } + + /** The memo is per connection: one connection's state says nothing about another's. */ + public function test_each_connection_is_tracked_separately(): void + { + Timezone::set('Asia/Tashkent'); + $first = $this->cdo(); + $second = $this->cdo(); + + $this->sync($configA = new \stdClass(), $first); + $this->sync($configB = new \stdClass(), $second); + + self::assertSame(['Asia/Tashkent'], $first->applied); + self::assertSame(['Asia/Tashkent'], $second->applied, 'a fresh connection needs its own SET'); + self::assertNotSame($configA, $configB); + } + + public function test_a_driverless_connection_is_left_alone(): void + { + Timezone::set('Asia/Tashkent'); + $cdo = $this->cdo(); + $cdo->driver = ''; + + $this->sync(new \stdClass(), $cdo); + + self::assertSame([], $cdo->applied); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** + * A CDO that records the timezones asked of it instead of talking to a server. + * Built via `newInstanceWithoutConstructor()`, so no connection is ever opened. + */ +final class RecordingCdo extends CDO +{ + /** @var list */ + public array $applied = []; + public string $driver = 'pgsql'; + + public function getAttribute(int $attribute): mixed + { + return $this->driver; + } + + public function applyDatabaseTimezone(mixed $driver, string $tz): void + { + $this->applied[] = $tz; + } +} diff --git a/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php new file mode 100644 index 0000000..12b9bac --- /dev/null +++ b/tests/Ppa/Pool/PpaConnectionPoolStatsTest.php @@ -0,0 +1,116 @@ + $pools */ + private function withPools(array $pools, callable $body): void + { + $prop = new ReflectionProperty(PpaConnectionPool::class, 'pools'); + $original = $prop->getValue(); + try { + $prop->setValue(null, $pools); + $body(); + } finally { + $prop->setValue(null, $original); + } + } + + private static function fullPool(int $maximum): ConnectionPool + { + $pool = new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: $maximum)); + // Simulate every connection handed out (active == maximum, none idle). + (new ReflectionProperty(ConnectionPool::class, 'total'))->setValue($pool, $maximum); + return $pool; + } + + public function test_stats_keys_by_config_fqcn(): void + { + $pool = new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 7)); + $this->withPools([base64_encode('App\\Config\\MainDb') => $pool], function (): void { + self::assertSame( + ['App\\Config\\MainDb' => ['total' => 0, 'idle' => 0, 'active' => 0, 'maximum' => 7]], + PpaConnectionPool::stats(), + ); + }); + } + + /** Runs the `db` component with the scan skipped, so only the pool merge is exercised. */ + private static function dbComponent(): array + { + return (new ReflectionMethod(HealthIndicator::class, 'dbHealth')) + ->invoke(new HealthIndicator(), ''); + } + + public function test_db_component_nests_pool_utilisation(): void + { + $pool = new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 5)); + $this->withPools([base64_encode('App\\Config\\MainDb') => $pool], function (): void { + $component = self::dbComponent(); + + self::assertSame('up', $component['status']); + $entry = $component['details']['App\\Config\\MainDb']; + self::assertSame('up', $entry['status']); + self::assertSame( + ['total' => 0, 'idle' => 0, 'active' => 0, 'maximum' => 5], + $entry['pool'], + 'utilisation lives under the datasource it belongs to', + ); + }); + } + + public function test_saturated_pool_degrades_its_datasource(): void + { + $this->withPools( + [ + base64_encode('App\\Config\\MainDb') => self::fullPool(2), + base64_encode('App\\Config\\OtherDb') => new ConnectionPool(new MockFactory(), new PoolPolicy(maximumPoolSize: 5)), + ], + function (): void { + $component = self::dbComponent(); + + self::assertSame('degraded', $component['status'], 'a saturated pool degrades the db component'); + self::assertSame('degraded', $component['details']['App\\Config\\MainDb']['status']); + self::assertSame(2, $component['details']['App\\Config\\MainDb']['pool']['active']); + self::assertSame('up', $component['details']['App\\Config\\OtherDb']['status']); + }, + ); + } + + public function test_db_component_is_up_when_no_pools(): void + { + $this->withPools([], function (): void { + $component = self::dbComponent(); + + self::assertSame('up', $component['status']); + self::assertSame([], $component['details']); + }); + } +} diff --git a/tests/Ppa/Pool/PpaPoolTraitTest.php b/tests/Ppa/Pool/PpaPoolTraitTest.php new file mode 100644 index 0000000..cdaa113 --- /dev/null +++ b/tests/Ppa/Pool/PpaPoolTraitTest.php @@ -0,0 +1,47 @@ +getPoolMaxConnections()); + self::assertSame(3.0, $config->getPoolWaitTimeout()); + self::assertSame(0.0, $config->getKeepaliveTime(), 'housekeeping off by default'); + self::assertSame(0.0, $config->getIdleTimeout(), 'no shrink by default'); + self::assertSame(0, $config->getMinimumIdle(), 'fully lazy by default'); + } + + public function test_property_overrides(): void + { + $config = new class { + use PpaPoolTrait; + + public int $poolMaxConnections = 20; + public float $poolWaitTimeout = 5.0; + public float $keepaliveTime = 30.0; + public float $idleTimeout = 600.0; + public int $minimumIdle = 4; + }; + + self::assertSame(20, $config->getPoolMaxConnections()); + self::assertSame(5.0, $config->getPoolWaitTimeout()); + self::assertSame(30.0, $config->getKeepaliveTime()); + self::assertSame(600.0, $config->getIdleTimeout()); + self::assertSame(4, $config->getMinimumIdle()); + } +} diff --git a/tests/Ppa/Pool/ReportFailureTest.php b/tests/Ppa/Pool/ReportFailureTest.php new file mode 100644 index 0000000..e41a8ef --- /dev/null +++ b/tests/Ppa/Pool/ReportFailureTest.php @@ -0,0 +1,104 @@ +errorInfo = [$sqlState, null, 'boom']; + + return new CDOException('boom', previous: $pdo); + } + + private static function held(): BorrowedConnection + { + return new BorrowedConnection(new PoolEntry(new \stdClass(), 0.0, 0.0, null)); + } + + public function test_a_lost_connection_is_marked_dead_and_dropped_from_the_context(): void + { + $out = []; + \Swoole\Coroutine\run(static function () use (&$out): void { + $held = self::held(); + \Swoole\Coroutine::getContext()[self::ctxKey()] = $held; + + $evicted = PpaConnectionPool::reportFailure(self::CONFIG, self::failure('08006')); + + $out = [ + 'evicted' => $evicted, + 'dead' => $held->dead, + 'stillHeld' => isset(\Swoole\Coroutine::getContext()[self::ctxKey()]), + ]; + }); + + self::assertTrue($out['evicted']); + self::assertTrue($out['dead'], 'the defer must evict this connection instead of pooling it again'); + self::assertFalse($out['stillHeld'], 'the next query in this request borrows a fresh connection'); + } + + public function test_a_query_error_leaves_the_connection_pooled(): void + { + $out = []; + \Swoole\Coroutine\run(static function () use (&$out): void { + $held = self::held(); + \Swoole\Coroutine::getContext()[self::ctxKey()] = $held; + + // 23505 — unique violation: the server is healthy, the query was not. + $evicted = PpaConnectionPool::reportFailure(self::CONFIG, self::failure('23505')); + + $out = [ + 'evicted' => $evicted, + 'dead' => $held->dead, + 'stillHeld' => isset(\Swoole\Coroutine::getContext()[self::ctxKey()]), + ]; + }); + + self::assertFalse($out['evicted']); + self::assertFalse($out['dead'], 'a healthy connection must not be churned'); + self::assertTrue($out['stillHeld'], 'it stays borrowed for the rest of the request'); + } + + public function test_it_is_a_no_op_when_this_coroutine_holds_no_connection(): void + { + $evicted = true; + \Swoole\Coroutine\run(static function () use (&$evicted): void { + $evicted = PpaConnectionPool::reportFailure(self::CONFIG, self::failure('08006')); + }); + + self::assertFalse($evicted, 'nothing borrowed here — nothing to evict'); + } +} diff --git a/tests/Ppa/Repository/BindsAndCacheTest.php b/tests/Ppa/Repository/BindsAndCacheTest.php index 25dd842..21e9a1e 100644 --- a/tests/Ppa/Repository/BindsAndCacheTest.php +++ b/tests/Ppa/Repository/BindsAndCacheTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class BindsAndCacheTest extends TestCase diff --git a/tests/Ppa/Repository/BuildSqlOrderTest.php b/tests/Ppa/Repository/BuildSqlOrderTest.php index cc127fc..44d9539 100644 --- a/tests/Ppa/Repository/BuildSqlOrderTest.php +++ b/tests/Ppa/Repository/BuildSqlOrderTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Ppa/Repository/Fixtures/OrdersRepo.php b/tests/Ppa/Repository/Fixtures/OrdersRepo.php index 828d3bb..34c5602 100644 --- a/tests/Ppa/Repository/Fixtures/OrdersRepo.php +++ b/tests/Ppa/Repository/Fixtures/OrdersRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; final class OrdersRepo extends RepositoryView { diff --git a/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php b/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php index dc9b3fd..aa9f3ee 100644 --- a/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php +++ b/tests/Ppa/Repository/Fixtures/RepoTestDbConfig.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Tests\Ppa\Fixtures\StubDbConfig; +use Flytachi\Winter\Kernel\Tests\Ppa\Fixtures\StubDbConfig; /** * Shared no-op DbConfig used by every Repository test. PpaConnectionPool diff --git a/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php b/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php index 7051a45..291129b 100644 --- a/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php +++ b/tests/Ppa/Repository/Fixtures/SelectionMappedRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; final class SelectionMappedRepo extends RepositoryView { diff --git a/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php b/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php index b3e353c..681ee2f 100644 --- a/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php +++ b/tests/Ppa/Repository/Fixtures/TypedUsersRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; final class TypedUsersRepo extends RepositoryView { diff --git a/tests/Ppa/Repository/Fixtures/UserEntity.php b/tests/Ppa/Repository/Fixtures/UserEntity.php index b962253..076d585 100644 --- a/tests/Ppa/Repository/Fixtures/UserEntity.php +++ b/tests/Ppa/Repository/Fixtures/UserEntity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; /** * Typed entity — buildSql() with this entity class will emit column list diff --git a/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php b/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php index 1320aaa..f949c4d 100644 --- a/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php +++ b/tests/Ppa/Repository/Fixtures/UserWithSelectionEntity.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Entity\EntityInterface; +use Flytachi\Winter\Kernel\Ppa\Entity\EntityInterface; /** * Entity that overrides one column via the EntityInterface::selection() map. diff --git a/tests/Ppa/Repository/Fixtures/UsersRepo.php b/tests/Ppa/Repository/Fixtures/UsersRepo.php index a3949d1..9af93be 100644 --- a/tests/Ppa/Repository/Fixtures/UsersRepo.php +++ b/tests/Ppa/Repository/Fixtures/UsersRepo.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures; -use Flytachi\Winter\K2\Ppa\Stereotype\RepositoryView; +use Flytachi\Winter\Kernel\Ppa\Stereotype\RepositoryView; /** * Plain repo with no typed entity — buildSql() will emit `SELECT *`. diff --git a/tests/Ppa/Repository/GroupOrderLimitTest.php b/tests/Ppa/Repository/GroupOrderLimitTest.php index 9a616d0..ea3a8f2 100644 --- a/tests/Ppa/Repository/GroupOrderLimitTest.php +++ b/tests/Ppa/Repository/GroupOrderLimitTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Dotenv\Parser\Value; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; use TypeError; use ValueError; diff --git a/tests/Ppa/Repository/InsertBatchFlattenTest.php b/tests/Ppa/Repository/InsertBatchFlattenTest.php new file mode 100644 index 0000000..e127df7 --- /dev/null +++ b/tests/Ppa/Repository/InsertBatchFlattenTest.php @@ -0,0 +1,162 @@ + */ + private function flatten(mixed ...$args): array + { + $flatten = new ReflectionMethod(RepositoryCrudTrait::class, 'flatten'); + + return iterator_to_array($flatten->invoke(null, $args), false); + } + + // ── The rule ─────────────────────────────────────────────────────────────── + + public function test_objects_pass_through_one_by_one(): void + { + $a = new \stdClass(); + $b = new \stdClass(); + + self::assertSame([$a, $b], $this->flatten($a, $b)); + } + + /** The half that could not be guessed: an array is a row, not a collection. */ + public function test_an_array_is_one_row(): void + { + $row = ['name' => 'John', 'email' => 'j@x']; + + self::assertSame([$row], $this->flatten($row), 'an array must not be drained'); + } + + public function test_several_arrays_are_several_rows(): void + { + $first = ['name' => 'John']; + $second = ['name' => 'Jane']; + + self::assertSame([$first, $second], $this->flatten($first, $second)); + } + + public function test_a_generator_is_drained(): void + { + $stream = (static function (): Generator { + yield ['n' => 1]; + yield ['n' => 2]; + yield ['n' => 3]; + })(); + + self::assertSame([['n' => 1], ['n' => 2], ['n' => 3]], $this->flatten($stream)); + } + + public function test_any_traversable_is_drained(): void + { + self::assertSame( + [['n' => 1], ['n' => 2]], + $this->flatten(new ArrayIterator([['n' => 1], ['n' => 2]])), + ); + } + + public function test_the_forms_combine_in_one_call(): void + { + $entity = new \stdClass(); + $stream = (static function (): Generator { + yield ['n' => 1]; + yield ['n' => 2]; + })(); + + self::assertSame( + [$entity, ['n' => 1], ['n' => 2], ['extra' => true]], + $this->flatten($entity, $stream, ['extra' => true]), + ); + } + + public function test_two_streams_are_drained_in_order(): void + { + $first = (static function (): Generator { + yield 'a'; + yield 'b'; + })(); + $second = (static function (): Generator { + yield 'c'; + })(); + + self::assertSame(['a', 'b', 'c'], $this->flatten($first, $second)); + } + + public function test_no_arguments_yield_nothing(): void + { + self::assertSame([], $this->flatten()); + } + + public function test_an_empty_stream_contributes_nothing(): void + { + $empty = (static function (): Generator { + if (false) { + yield 1; + } + })(); + + self::assertSame([['kept' => true]], $this->flatten($empty, ['kept' => true])); + } + + // ── Laziness: the reason the shape exists ────────────────────────────────── + + /** Nothing may be pulled from the source until the driver asks for it. */ + public function test_the_source_is_untouched_until_iterated(): void + { + $produced = 0; + $stream = (static function () use (&$produced): Generator { + for ($i = 0; $i < 5; $i++) { + $produced++; + yield ['n' => $i]; + } + })(); + + $flatten = new ReflectionMethod(RepositoryCrudTrait::class, 'flatten'); + $rows = $flatten->invoke(null, [$stream]); + + self::assertSame(0, $produced, 'building the stream must not consume the source'); + + $rows->current(); + self::assertSame(1, $produced, 'exactly one row is produced to satisfy one read'); + } + + /** …and each row is produced exactly once, however many streams are involved. */ + public function test_each_row_is_produced_once(): void + { + $produced = 0; + $stream = (static function () use (&$produced): Generator { + for ($i = 0; $i < 50; $i++) { + $produced++; + yield ['n' => $i]; + } + })(); + + $rows = $this->flatten($stream, ['tail' => true]); + + self::assertCount(51, $rows); + self::assertSame(50, $produced); + } +} diff --git a/tests/Ppa/Repository/JoinBuilderTest.php b/tests/Ppa/Repository/JoinBuilderTest.php index a5dd138..6e312d9 100644 --- a/tests/Ppa/Repository/JoinBuilderTest.php +++ b/tests/Ppa/Repository/JoinBuilderTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class JoinBuilderTest extends TestCase diff --git a/tests/Ppa/Repository/PrepareSelectTest.php b/tests/Ppa/Repository/PrepareSelectTest.php index 0dd139c..410b265 100644 --- a/tests/Ppa/Repository/PrepareSelectTest.php +++ b/tests/Ppa/Repository/PrepareSelectTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\SelectionMappedRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\SelectionMappedRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; use PHPUnit\Framework\TestCase; final class PrepareSelectTest extends TestCase @@ -15,7 +15,7 @@ final class PrepareSelectTest extends TestCase public function test_stdClass_entity_emits_select_star(): void { // UsersRepo uses default stdClass entity → '*' in SELECT. - $r = \Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo::instance(); + $r = \Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo::instance(); self::assertStringStartsWith('SELECT * FROM ', $r->buildSql()); } diff --git a/tests/Ppa/Repository/SelectFromAsTest.php b/tests/Ppa/Repository/SelectFromAsTest.php index f5b6adc..cb5b889 100644 --- a/tests/Ppa/Repository/SelectFromAsTest.php +++ b/tests/Ppa/Repository/SelectFromAsTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; -use Flytachi\Winter\K2\Ppa\Repository\RepositoryException; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Ppa\Repository\RepositoryException; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\TypedUsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class SelectFromAsTest extends TestCase diff --git a/tests/Ppa/Repository/UnionCteTest.php b/tests/Ppa/Repository/UnionCteTest.php index bc823f6..1ead149 100644 --- a/tests/Ppa/Repository/UnionCteTest.php +++ b/tests/Ppa/Repository/UnionCteTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\OrdersRepo; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\OrdersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class UnionCteTest extends TestCase diff --git a/tests/Ppa/Repository/WhereBuilderTest.php b/tests/Ppa/Repository/WhereBuilderTest.php index 339f871..c6b9cc4 100644 --- a/tests/Ppa/Repository/WhereBuilderTest.php +++ b/tests/Ppa/Repository/WhereBuilderTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Ppa\Repository; +namespace Flytachi\Winter\Kernel\Tests\Ppa\Repository; use Flytachi\Winter\Cdo\CDOBind; use Flytachi\Winter\Cdo\Qb; -use Flytachi\Winter\K2\Tests\Ppa\Repository\Fixtures\UsersRepo; +use Flytachi\Winter\Kernel\Tests\Ppa\Repository\Fixtures\UsersRepo; use PHPUnit\Framework\TestCase; final class WhereBuilderTest extends TestCase diff --git a/tests/Process/ActivityTest.php b/tests/Process/ActivityTest.php new file mode 100644 index 0000000..36a7dc3 --- /dev/null +++ b/tests/Process/ActivityTest.php @@ -0,0 +1,33 @@ +getBackingType()?->getName()); + } + + public function test_cases_and_values(): void + { + self::assertSame('idle', Activity::IDLE->value); + self::assertSame('busy', Activity::BUSY->value); + } + + public function test_exactly_two_cases(): void + { + self::assertCount(2, Activity::cases()); + } + + public function test_from_value_round_trips(): void + { + self::assertSame(Activity::BUSY, Activity::from('busy')); + self::assertSame(Activity::IDLE, Activity::from('idle')); + } +} diff --git a/tests/Process/Daemon/DaemonStatusTest.php b/tests/Process/Daemon/DaemonStatusTest.php new file mode 100644 index 0000000..082030f --- /dev/null +++ b/tests/Process/Daemon/DaemonStatusTest.php @@ -0,0 +1,72 @@ +make()); + } + + public function test_daemon_fields(): void + { + $workers = [new WorkerStatus(0, 11, SlotState::RUNNING, Activity::BUSY, time(), 0)]; + $status = $this->make($workers, 7); + + self::assertSame(7, $status->restarts); + self::assertSame($workers, $status->workers); + } + + public function test_json_merges_base_and_daemon_fields(): void + { + $workers = [ + new WorkerStatus(0, 11, SlotState::RUNNING, Activity::BUSY, time(), 0), + new WorkerStatus(1, 12, SlotState::RETIRING, Activity::IDLE, time(), 1), + ]; + $json = $this->make($workers, 3)->jsonSerialize(); + + // base keys still present + self::assertArrayHasKey('pid', $json); + self::assertArrayHasKey('state', $json); + self::assertArrayHasKey('heartbeat_at', $json); + // daemon-only keys added + self::assertSame(3, $json['restarts']); + self::assertCount(2, $json['workers']); + self::assertSame($workers, $json['workers']); + } + + public function test_encodes_to_json_string_with_nested_workers(): void + { + $workers = [new WorkerStatus(0, 11, SlotState::RUNNING, Activity::BUSY, time(), 0)]; + $decoded = json_decode((string) json_encode($this->make($workers, 1)), true); + + self::assertSame(1, $decoded['restarts']); + self::assertSame('running', $decoded['workers'][0]['state']); + self::assertSame(11, $decoded['workers'][0]['pid']); + } +} diff --git a/tests/Process/Daemon/DaemonTest.php b/tests/Process/Daemon/DaemonTest.php new file mode 100644 index 0000000..ee56515 --- /dev/null +++ b/tests/Process/Daemon/DaemonTest.php @@ -0,0 +1,163 @@ +invoke($o, ...$args); + } + + // --- introspection accessors + clamping ------------------------------- + + // These accessors are internal (consumed by the SupervisesFleet trait), so + // they are reflected — clamping behaviour is still asserted directly. + + public function test_replicas_clamps_to_at_least_one(): void + { + self::assertSame(1, $this->priv(new DefaultDaemon(), 'replicas')); + self::assertSame(3, $this->priv(new InlineDaemon(), 'replicas')); + self::assertSame(1, $this->priv(new ClampDaemon(), 'replicas')); // configured 0 → 1 + } + + public function test_compute_desired_clamps_to_at_least_zero(): void + { + self::assertSame(1, $this->priv(new DefaultDaemon(), 'computeDesired')); // = replicas() + self::assertSame(4, $this->priv(new InlineDaemon(), 'computeDesired')); + self::assertSame(0, $this->priv(new ClampDaemon(), 'computeDesired')); // configured -3 → 0 + } + + public function test_grace_seconds_clamps_and_defaults_to_thirty(): void + { + self::assertSame(30.0, $this->priv(new DefaultDaemon(), 'graceSeconds')); // k8s-parity default + self::assertSame(5.0, $this->priv(new InlineDaemon(), 'graceSeconds')); + self::assertSame(0.0, $this->priv(new ClampDaemon(), 'graceSeconds')); // configured -5 → 0 + } + + public function test_liveness_timeout_clamps_and_defaults_to_off(): void + { + self::assertSame(0.0, $this->priv(new DefaultDaemon(), 'livenessTimeout')); // watchdog off + self::assertSame(7.0, $this->priv(new InlineDaemon(), 'livenessTimeout')); + self::assertSame(0.0, $this->priv(new ClampDaemon(), 'livenessTimeout')); // configured -1 → 0 + } + + // --- policies ---------------------------------------------------------- + + public function test_default_scaling_policy(): void + { + $p = $this->priv(new DefaultDaemon(), 'scalingPolicy'); + self::assertInstanceOf(ScalingPolicy::class, $p); + self::assertEquals(ScalingPolicy::default(), $p); + } + + public function test_overridden_scaling_policy(): void + { + $p = $this->priv(new InlineDaemon(), 'scalingPolicy'); + self::assertSame(2.0, $p->scaleInterval); + self::assertSame(2, $p->scaleStep); + } + + public function test_default_restart_policy(): void + { + $p = $this->priv(new DefaultDaemon(), 'restartPolicy'); + self::assertInstanceOf(RestartPolicy::class, $p); + self::assertSame(RestartMode::ON_FAILURE, $p->mode); + self::assertSame(0, $p->maxRestarts); + } + + public function test_overridden_restart_policy(): void + { + $p = $this->priv(new InlineDaemon(), 'restartPolicy'); + self::assertSame(RestartMode::ALWAYS, $p->mode); + self::assertSame(4, $p->maxRestarts); + self::assertSame(0.25, $p->backoff); + } + + // --- worker body resolution ------------------------------------------- + + public function test_defines_worker_run_true_when_overridden(): void + { + self::assertTrue($this->priv(new InlineDaemon(), 'definesWorkerRun')); + self::assertTrue($this->priv(new DefaultDaemon(), 'definesWorkerRun')); + } + + public function test_defines_worker_run_false_when_using_worker_class(): void + { + self::assertFalse($this->priv(new ExternalDaemon(), 'definesWorkerRun')); + } + + public function test_boot_worker_throws_when_no_body_configured(): void + { + $this->expectException(DaemonConfigException::class); + $this->priv(new BlankDaemon(), 'bootWorker', 0); + } + + public function test_run_delegates_to_worker_run_and_throws_when_undefined(): void + { + // BlankDaemon has no workerRun → the base default throws. + $this->expectException(DaemonConfigException::class); + (new BlankDaemon())->run(); + } + + public function test_run_delegates_to_defined_worker_run_without_throwing(): void + { + // InlineDaemon defines an empty workerRun → run() is a clean no-op. + (new InlineDaemon())->run(); + $this->addToAssertionCount(1); + } + + public function test_run_is_final(): void + { + self::assertTrue((new \ReflectionMethod(Daemon::class, 'run'))->isFinal()); + } + + // --- titles ------------------------------------------------------------ + + public function test_master_title(): void + { + self::assertSame('winter-daemon: DefaultDaemon master', $this->priv(new DefaultDaemon(), 'buildProcessTitle')); + } + + public function test_worker_title_is_one_based(): void + { + $d = new DefaultDaemon(); + self::assertSame('winter-daemon: DefaultDaemon worker#1', $this->priv($d, 'workerTitle', 0)); + self::assertSame('winter-daemon: DefaultDaemon worker#3', $this->priv($d, 'workerTitle', 2)); + } + + // --- hook fan-out ------------------------------------------------------ + + public function test_fire_hooks_invoke_the_protected_callbacks(): void + { + $d = new InlineDaemon(); + + $this->priv($d, 'fireWorkerStart', 0, 111); + $this->priv($d, 'fireWorkerStart', 1, 222); + $this->priv($d, 'fireWorkerExit', 1, 222, true); + $this->priv($d, 'fireScale', 2, 5); + $this->priv($d, 'fireTick'); + $this->priv($d, 'fireTick'); + $this->priv($d, 'fireReload'); + + self::assertSame([[0, 111], [1, 222]], $d->started); + self::assertSame([[1, 222, true]], $d->exited); + self::assertSame([[2, 5]], $d->scaled); + self::assertSame(2, $d->ticks); + self::assertSame(1, $d->reloads); + } +} diff --git a/tests/Process/Daemon/RestartModeTest.php b/tests/Process/Daemon/RestartModeTest.php new file mode 100644 index 0000000..c32472e --- /dev/null +++ b/tests/Process/Daemon/RestartModeTest.php @@ -0,0 +1,43 @@ +getBackingType()); + } + + public function test_exactly_three_cases(): void + { + self::assertCount(3, RestartMode::cases()); + self::assertSame(['ALWAYS', 'ON_FAILURE', 'NEVER'], array_map( + static fn(RestartMode $m) => $m->name, + RestartMode::cases() + )); + } + + public function test_always_restarts_on_any_exit(): void + { + self::assertTrue(RestartMode::ALWAYS->shouldRestart(true)); + self::assertTrue(RestartMode::ALWAYS->shouldRestart(false)); + } + + public function test_on_failure_restarts_only_on_crash(): void + { + self::assertTrue(RestartMode::ON_FAILURE->shouldRestart(true)); + self::assertFalse(RestartMode::ON_FAILURE->shouldRestart(false)); + } + + public function test_never_restarts(): void + { + self::assertFalse(RestartMode::NEVER->shouldRestart(true)); + self::assertFalse(RestartMode::NEVER->shouldRestart(false)); + } +} diff --git a/tests/Process/Daemon/RestartPolicyTest.php b/tests/Process/Daemon/RestartPolicyTest.php new file mode 100644 index 0000000..1fcc917 --- /dev/null +++ b/tests/Process/Daemon/RestartPolicyTest.php @@ -0,0 +1,56 @@ +mode); + self::assertSame(0, $p->maxRestarts); + self::assertSame(1.0, $p->backoff); + } + + public function test_constructor_defaults_match_default_factory(): void + { + $p = new RestartPolicy(); + self::assertSame(RestartMode::ON_FAILURE, $p->mode); + self::assertSame(0, $p->maxRestarts); + self::assertSame(1.0, $p->backoff); + } + + public function test_named_construction_and_field_types(): void + { + $p = new RestartPolicy(mode: RestartMode::ALWAYS, maxRestarts: 5, backoff: 2.5); + self::assertSame(RestartMode::ALWAYS, $p->mode); + self::assertSame(5, $p->maxRestarts); + self::assertSame(2.5, $p->backoff); + self::assertIsInt($p->maxRestarts); + self::assertIsFloat($p->backoff); + } + + public function test_should_restart_delegates_to_mode(): void + { + self::assertTrue((new RestartPolicy(mode: RestartMode::ALWAYS))->shouldRestart(false)); + self::assertTrue((new RestartPolicy(mode: RestartMode::ON_FAILURE))->shouldRestart(true)); + self::assertFalse((new RestartPolicy(mode: RestartMode::ON_FAILURE))->shouldRestart(false)); + self::assertFalse((new RestartPolicy(mode: RestartMode::NEVER))->shouldRestart(true)); + } + + public function test_is_readonly(): void + { + self::assertTrue((new \ReflectionClass(RestartPolicy::class))->isReadOnly()); + } + + public function test_is_not_final_so_named_profiles_can_subclass(): void + { + self::assertFalse((new \ReflectionClass(RestartPolicy::class))->isFinal()); + } +} diff --git a/tests/Process/Daemon/ScalingPolicyTest.php b/tests/Process/Daemon/ScalingPolicyTest.php new file mode 100644 index 0000000..28afda3 --- /dev/null +++ b/tests/Process/Daemon/ScalingPolicyTest.php @@ -0,0 +1,56 @@ +scaleInterval); + self::assertSame(0.0, $p->scaleUpDelay); + self::assertSame(60.0, $p->scaleDownStabilization); + self::assertSame(3.0, $p->cooldown); + self::assertSame(0, $p->scaleStep); + } + + public function test_constructor_defaults_match_default_factory(): void + { + $a = new ScalingPolicy(); + $b = ScalingPolicy::default(); + self::assertEquals($a, $b); + } + + public function test_named_construction_and_types(): void + { + $p = new ScalingPolicy( + scaleInterval: 0.5, + scaleUpDelay: 2.0, + scaleDownStabilization: 120.0, + cooldown: 10.0, + scaleStep: 4, + ); + self::assertSame(0.5, $p->scaleInterval); + self::assertSame(2.0, $p->scaleUpDelay); + self::assertSame(120.0, $p->scaleDownStabilization); + self::assertSame(10.0, $p->cooldown); + self::assertSame(4, $p->scaleStep); + self::assertIsInt($p->scaleStep); + self::assertIsFloat($p->scaleDownStabilization); + } + + public function test_is_readonly(): void + { + self::assertTrue((new \ReflectionClass(ScalingPolicy::class))->isReadOnly()); + } + + public function test_is_not_final_so_named_profiles_can_subclass(): void + { + self::assertFalse((new \ReflectionClass(ScalingPolicy::class))->isFinal()); + } +} diff --git a/tests/Process/Daemon/SlotStateTest.php b/tests/Process/Daemon/SlotStateTest.php new file mode 100644 index 0000000..7bee272 --- /dev/null +++ b/tests/Process/Daemon/SlotStateTest.php @@ -0,0 +1,67 @@ +getBackingType()?->getName()); + } + + public function test_all_cases_present(): void + { + $values = array_map(static fn(SlotState $s) => $s->value, SlotState::cases()); + self::assertSame( + ['empty', 'starting', 'running', 'retiring', 'killing', 'restarting', 'retired'], + $values + ); + } + + /** + * isCommitted = counts toward the fleet size reconcile drives to desired. + */ + public function test_is_committed_matrix(): void + { + self::assertTrue(SlotState::STARTING->isCommitted()); + self::assertTrue(SlotState::RUNNING->isCommitted()); + self::assertTrue(SlotState::RESTARTING->isCommitted()); + self::assertTrue(SlotState::RETIRED->isCommitted()); + + self::assertFalse(SlotState::EMPTY->isCommitted()); + self::assertFalse(SlotState::RETIRING->isCommitted()); + self::assertFalse(SlotState::KILLING->isCommitted()); + } + + /** + * isAlive = has a live OS process attached. + */ + public function test_is_alive_matrix(): void + { + self::assertTrue(SlotState::STARTING->isAlive()); + self::assertTrue(SlotState::RUNNING->isAlive()); + self::assertTrue(SlotState::RETIRING->isAlive()); + self::assertTrue(SlotState::KILLING->isAlive()); + + self::assertFalse(SlotState::EMPTY->isAlive()); + self::assertFalse(SlotState::RESTARTING->isAlive()); + self::assertFalse(SlotState::RETIRED->isAlive()); + } + + /** + * A committed-but-not-alive state (RESTARTING/RETIRED) is exactly what keeps + * reconcile from refilling a slot the restart path already owns. + */ + public function test_restarting_and_retired_are_committed_but_not_alive(): void + { + foreach ([SlotState::RESTARTING, SlotState::RETIRED] as $state) { + self::assertTrue($state->isCommitted(), $state->value); + self::assertFalse($state->isAlive(), $state->value); + } + } +} diff --git a/tests/Process/Daemon/SlotTest.php b/tests/Process/Daemon/SlotTest.php new file mode 100644 index 0000000..7f3fa98 --- /dev/null +++ b/tests/Process/Daemon/SlotTest.php @@ -0,0 +1,49 @@ +index); + self::assertSame(SlotState::EMPTY, $slot->state); + self::assertSame(0, $slot->pid); + self::assertTrue(is_infinite($slot->deadline)); + self::assertSame(0.0, $slot->restartAt); + self::assertSame(0, $slot->restarts); + self::assertSame(0, $slot->startedAt); + self::assertSame(Activity::IDLE, $slot->activity); + self::assertSame(0, $slot->heartbeatAt); + self::assertFalse($slot->killed); + } + + public function test_index_is_readonly(): void + { + $prop = new \ReflectionProperty(Slot::class, 'index'); + self::assertTrue($prop->isReadOnly()); + } + + public function test_state_is_mutable_in_place(): void + { + $slot = new Slot(0); + $slot->state = SlotState::RUNNING; + $slot->pid = 4242; + $slot->activity = Activity::BUSY; + $slot->killed = true; + + self::assertSame(SlotState::RUNNING, $slot->state); + self::assertSame(4242, $slot->pid); + self::assertSame(Activity::BUSY, $slot->activity); + self::assertTrue($slot->killed); + } +} diff --git a/tests/Process/Daemon/SupervisesFleetTest.php b/tests/Process/Daemon/SupervisesFleetTest.php new file mode 100644 index 0000000..34a4641 --- /dev/null +++ b/tests/Process/Daemon/SupervisesFleetTest.php @@ -0,0 +1,224 @@ +daemon = new StubDaemon(); + } + + private function call(string $method, mixed ...$args): mixed + { + return (new \ReflectionMethod($this->daemon, $method))->invoke($this->daemon, ...$args); + } + + /** @param array $slots */ + private function setSlots(array $slots): void + { + // The property is declared by the SupervisesFleet trait on Daemon, so it + // must be reflected via the declaring class, not the StubDaemon subclass. + (new \ReflectionProperty(Daemon::class, 'slots'))->setValue($this->daemon, $slots); + } + + /** @param list $history */ + private function setHistory(array $history): void + { + (new \ReflectionProperty(Daemon::class, 'desiredHistory'))->setValue($this->daemon, $history); + } + + private function slot(int $index, SlotState $state, Activity $activity = Activity::IDLE): Slot + { + $s = new Slot($index); + $s->state = $state; + $s->activity = $activity; + return $s; + } + + // --- back-off ---------------------------------------------------------- + + public function test_backoff_is_exponential(): void + { + self::assertSame(1.0, $this->call('backoff', 1.0, 1)); + self::assertSame(2.0, $this->call('backoff', 1.0, 2)); + self::assertSame(4.0, $this->call('backoff', 1.0, 3)); + self::assertSame(8.0, $this->call('backoff', 1.0, 4)); + self::assertSame(1.0, $this->call('backoff', 0.5, 2)); // 0.5 * 2^1 + } + + public function test_backoff_is_capped_at_thirty_seconds(): void + { + self::assertSame(30.0, $this->call('backoff', 1.0, 10)); // 512 → capped + } + + public function test_backoff_zero_for_no_failures_or_no_base(): void + { + self::assertSame(0.0, $this->call('backoff', 1.0, 0)); + self::assertSame(0.0, $this->call('backoff', 0.0, 3)); + } + + // --- windowExtreme (the stabilization core) --------------------------- + + public function test_window_extreme_max_over_window_excludes_aged_entries(): void + { + $now = microtime(true); + // An old high reading (100s ago) plus recent low readings. + $this->setHistory([[$now - 100.0, 8], [$now - 1.0, 4], [$now, 4]]); + + // 60s window: the old 8 is aged out → high-water is 4. + self::assertSame(4, $this->call('windowExtreme', $now - 60.0, true)); + // 200s window: the 8 is still in → high-water is 8 (no shrink yet). + self::assertSame(8, $this->call('windowExtreme', $now - 200.0, true)); + } + + public function test_window_extreme_min_over_window(): void + { + $now = microtime(true); + $this->setHistory([[$now - 5.0, 9], [$now - 1.0, 3], [$now, 6]]); + self::assertSame(3, $this->call('windowExtreme', $now - 10.0, false)); + } + + // --- effectiveDesired (asymmetric damping) ---------------------------- + + public function test_scale_up_reacts_to_the_raw_target(): void + { + $this->daemon->desired = 8; + // committed = 1, raw = 8 → grow to 8. + self::assertSame(8, $this->call('effectiveDesired', 1, ScalingPolicy::default())); + } + + public function test_stable_when_raw_equals_committed(): void + { + $this->daemon->desired = 5; + self::assertSame(5, $this->call('effectiveDesired', 5, ScalingPolicy::default())); + } + + public function test_scale_down_when_low_demand_is_the_only_reading(): void + { + $this->daemon->desired = 4; + // committed = 8, the only reading is 4 → shrink to 4. + self::assertSame(4, $this->call('effectiveDesired', 8, ScalingPolicy::default())); + } + + public function test_scale_down_is_held_off_while_a_recent_high_reading_stands(): void + { + $policy = new ScalingPolicy(scaleDownStabilization: 60.0); + + $this->daemon->desired = 8; + $this->call('effectiveDesired', 8, $policy); // records a high reading (8) + + // Demand drops to 4, but the high-water over the stabilization window is + // still 8, so the fleet does NOT shrink yet. + $this->daemon->desired = 4; + self::assertSame(8, $this->call('effectiveDesired', 8, $policy)); + } + + // --- victim selection (IDLE-first, then highest slot) ------------------ + + public function test_pick_victims_prefers_idle_then_highest_index(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING, Activity::BUSY), + $this->slot(1, SlotState::RUNNING, Activity::IDLE), + $this->slot(2, SlotState::RUNNING, Activity::IDLE), + $this->slot(3, SlotState::RUNNING, Activity::BUSY), + $this->slot(4, SlotState::RETIRING, Activity::IDLE), // not a candidate + $this->slot(5, SlotState::EMPTY), // not a candidate + ]); + + $indexes = array_map(static fn(Slot $s) => $s->index, $this->call('pickVictims', 2)); + // Two IDLE workers, highest index first. + self::assertSame([2, 1], $indexes); + } + + public function test_pick_victims_falls_back_to_busy_by_highest_index(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING, Activity::BUSY), + $this->slot(1, SlotState::RUNNING, Activity::IDLE), + $this->slot(2, SlotState::RUNNING, Activity::BUSY), + ]); + + $indexes = array_map(static fn(Slot $s) => $s->index, $this->call('pickVictims', 3)); + // IDLE #1 first, then BUSY by highest index (#2 then #0). + self::assertSame([1, 2, 0], $indexes); + } + + public function test_pick_victims_zero_or_no_candidates_returns_empty(): void + { + $this->setSlots([$this->slot(0, SlotState::RETIRING), $this->slot(1, SlotState::EMPTY)]); + self::assertSame([], $this->call('pickVictims', 2)); + + $this->setSlots([$this->slot(0, SlotState::RUNNING)]); + self::assertSame([], $this->call('pickVictims', 0)); + } + + // --- slot accounting --------------------------------------------------- + + public function test_committed_count_includes_starting_running_restarting_retired(): void + { + $this->setSlots([ + $this->slot(0, SlotState::STARTING), + $this->slot(1, SlotState::RUNNING), + $this->slot(2, SlotState::RESTARTING), + $this->slot(3, SlotState::RETIRED), + $this->slot(4, SlotState::RETIRING), // not committed + $this->slot(5, SlotState::KILLING), // not committed + $this->slot(6, SlotState::EMPTY), // not committed + ]); + + self::assertSame(4, $this->call('committedCount')); + } + + public function test_alive_count_includes_states_with_a_live_process(): void + { + $this->setSlots([ + $this->slot(0, SlotState::STARTING), + $this->slot(1, SlotState::RUNNING), + $this->slot(2, SlotState::RETIRING), + $this->slot(3, SlotState::KILLING), + $this->slot(4, SlotState::RESTARTING), // not alive + $this->slot(5, SlotState::RETIRED), // not alive + $this->slot(6, SlotState::EMPTY), // not alive + ]); + + self::assertSame(4, $this->call('aliveCount')); + } + + public function test_next_free_index_returns_lowest_empty_slot(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING), + $this->slot(1, SlotState::EMPTY), + $this->slot(2, SlotState::RUNNING), + ]); + self::assertSame(1, $this->call('nextFreeIndex')); + } + + public function test_next_free_index_appends_when_all_occupied(): void + { + $this->setSlots([ + $this->slot(0, SlotState::RUNNING), + $this->slot(1, SlotState::RUNNING), + ]); + self::assertSame(2, $this->call('nextFreeIndex')); + } +} diff --git a/tests/Process/Daemon/WorkerStatusTest.php b/tests/Process/Daemon/WorkerStatusTest.php new file mode 100644 index 0000000..dbb4e37 --- /dev/null +++ b/tests/Process/Daemon/WorkerStatusTest.php @@ -0,0 +1,61 @@ +slot); + self::assertSame(5555, $w->pid); + self::assertSame(SlotState::RUNNING, $w->state); + self::assertSame(Activity::BUSY, $w->activity); + self::assertSame(2, $w->restarts); + } + + public function test_json_shape_and_types(): void + { + $started = time() - 30; + $json = (new WorkerStatus(3, 4321, SlotState::RETIRING, Activity::IDLE, $started, 1))->jsonSerialize(); + + self::assertSame( + ['slot', 'pid', 'state', 'activity', 'started_at', 'uptime', 'restarts'], + array_keys($json) + ); + self::assertSame(3, $json['slot']); + self::assertSame(4321, $json['pid']); + self::assertSame('retiring', $json['state']); // SlotState->value + self::assertSame('idle', $json['activity']); // Activity->value + self::assertSame($started, $json['started_at']); + self::assertGreaterThanOrEqual(30, $json['uptime']); + self::assertSame(1, $json['restarts']); + } + + public function test_uptime_zero_when_not_started(): void + { + $json = (new WorkerStatus(0, 0, SlotState::RETIRED, Activity::IDLE, 0, 0))->jsonSerialize(); + self::assertSame(0, $json['uptime']); + } + + public function test_encodes_to_json_string(): void + { + $w = new WorkerStatus(0, 10, SlotState::STARTING, Activity::IDLE, time(), 0); + self::assertIsString(json_encode($w)); + } +} diff --git a/tests/Process/ExceptionsTest.php b/tests/Process/ExceptionsTest.php new file mode 100644 index 0000000..e65a2b8 --- /dev/null +++ b/tests/Process/ExceptionsTest.php @@ -0,0 +1,42 @@ +getMessage()); + } + + public function test_interrupted_is_a_runtime_exception(): void + { + self::assertInstanceOf(\RuntimeException::class, new InterruptedException()); + } + + public function test_daemon_config_is_a_runtime_exception(): void + { + self::assertInstanceOf(\RuntimeException::class, new DaemonConfigException('bad')); + } + + public function test_are_throwable_and_catchable(): void + { + try { + throw new DaemonConfigException('no body'); + } catch (DaemonConfigException $e) { + self::assertSame('no body', $e->getMessage()); + return; + } + + self::fail('exception was not caught'); + } +} diff --git a/tests/Process/Fixtures/AutoscaleLoopDaemon.php b/tests/Process/Fixtures/AutoscaleLoopDaemon.php new file mode 100644 index 0000000..85cb4ae --- /dev/null +++ b/tests/Process/Fixtures/AutoscaleLoopDaemon.php @@ -0,0 +1,52 @@ +bootAt === 0.0) { + $this->bootAt = microtime(true); + } + $elapsed = microtime(true) - $this->bootAt; + + if ($elapsed < 2.0) { + return 1; + } + if ($elapsed < 5.0) { + return 4; + } + return 2; + } + + protected function workerRun(): void + { + while ($this->isRunning()) { + $this->sleep(0.2); + } + } +} diff --git a/tests/Process/Fixtures/BlankDaemon.php b/tests/Process/Fixtures/BlankDaemon.php new file mode 100644 index 0000000..4682042 --- /dev/null +++ b/tests/Process/Fixtures/BlankDaemon.php @@ -0,0 +1,16 @@ +isRunning()) { + $this->markBusy(); + $this->sleep(0.15); + $this->markIdle(); + $this->sleep(0.15); + } + } +} diff --git a/tests/Process/Fixtures/ClampDaemon.php b/tests/Process/Fixtures/ClampDaemon.php new file mode 100644 index 0000000..28dcf27 --- /dev/null +++ b/tests/Process/Fixtures/ClampDaemon.php @@ -0,0 +1,27 @@ += 1, desired >= 0, grace/liveness >= 0). + */ +final class ClampDaemon extends Daemon +{ + protected int $replicas = 0; + protected float $grace = -5.0; + protected float $livenessTimeout = -1.0; + + protected function desiredReplicas(): int + { + return -3; + } + + protected function workerRun(): void + { + } +} diff --git a/tests/Process/Fixtures/CrashCapDaemon.php b/tests/Process/Fixtures/CrashCapDaemon.php new file mode 100644 index 0000000..e865cd1 --- /dev/null +++ b/tests/Process/Fixtures/CrashCapDaemon.php @@ -0,0 +1,29 @@ +sleep(0.4); + while (true) { + // wedged — blocks the reactor, no heartbeat lands + } + } +} diff --git a/tests/Process/Fixtures/InlineDaemon.php b/tests/Process/Fixtures/InlineDaemon.php new file mode 100644 index 0000000..5e9577e --- /dev/null +++ b/tests/Process/Fixtures/InlineDaemon.php @@ -0,0 +1,76 @@ + */ + public array $started = []; + /** @var list */ + public array $exited = []; + /** @var list */ + public array $scaled = []; + public int $ticks = 0; + public int $reloads = 0; + + protected function desiredReplicas(): int + { + return $this->desired; + } + + protected function scaling(): ScalingPolicy + { + return new ScalingPolicy(scaleInterval: 2.0, scaleDownStabilization: 30.0, scaleStep: 2); + } + + protected function restart(): RestartPolicy + { + return new RestartPolicy(mode: RestartMode::ALWAYS, maxRestarts: 4, backoff: 0.25); + } + + protected function workerRun(): void + { + } + + protected function onWorkerStart(int $slot, int $pid): void + { + $this->started[] = [$slot, $pid]; + } + + protected function onWorkerExit(int $slot, int $pid, bool $crashed): void + { + $this->exited[] = [$slot, $pid, $crashed]; + } + + protected function onScale(int $from, int $to): void + { + $this->scaled[] = [$from, $to]; + } + + protected function tick(): void + { + $this->ticks++; + } + + protected function onReload(): void + { + $this->reloads++; + } +} diff --git a/tests/Process/Fixtures/LoopDaemon.php b/tests/Process/Fixtures/LoopDaemon.php new file mode 100644 index 0000000..204cf7c --- /dev/null +++ b/tests/Process/Fixtures/LoopDaemon.php @@ -0,0 +1,31 @@ +isRunning()) { + $this->sleep(0.2); + } + } +} diff --git a/tests/Process/Fixtures/LoopWorker.php b/tests/Process/Fixtures/LoopWorker.php new file mode 100644 index 0000000..4669821 --- /dev/null +++ b/tests/Process/Fixtures/LoopWorker.php @@ -0,0 +1,22 @@ +isRunning()) { + $this->sleep(0.2); + } + } +} diff --git a/tests/Process/Fixtures/NeverCrashDaemon.php b/tests/Process/Fixtures/NeverCrashDaemon.php new file mode 100644 index 0000000..201b88d --- /dev/null +++ b/tests/Process/Fixtures/NeverCrashDaemon.php @@ -0,0 +1,29 @@ +mark('start'); + while ($this->isRunning()) { + $this->sleep(0.1); + } + } + + protected function onTerminate(): void + { + $this->mark('terminate'); + } + + protected function onInterrupt(): void + { + $this->mark('interrupt'); + } + + protected function onReload(): void + { + $this->mark('reload'); + } + + protected function onUser1(): void + { + $this->mark('user1'); + } + + protected function onUser2(): void + { + $this->mark('user2'); + } +} diff --git a/tests/Process/Fixtures/StubDaemon.php b/tests/Process/Fixtures/StubDaemon.php new file mode 100644 index 0000000..f67a2f3 --- /dev/null +++ b/tests/Process/Fixtures/StubDaemon.php @@ -0,0 +1,25 @@ +desired; + } + + protected function workerRun(): void + { + } +} diff --git a/tests/Process/Fixtures/StuckStopDaemon.php b/tests/Process/Fixtures/StuckStopDaemon.php new file mode 100644 index 0000000..196e925 --- /dev/null +++ b/tests/Process/Fixtures/StuckStopDaemon.php @@ -0,0 +1,25 @@ +addToAssertionCount(1); + } + + public function test_registered_handlers_run_in_order(): void + { + $order = []; + ForkReset::register(static function () use (&$order): void { + $order[] = 'a'; + }); + ForkReset::register(static function () use (&$order): void { + $order[] = 'b'; + }); + + ForkReset::runAll(); + + self::assertSame(['a', 'b'], $order); + } + + public function test_a_throwing_handler_does_not_block_the_others(): void + { + $ran = []; + ForkReset::register(static function () use (&$ran): void { + $ran[] = 'before'; + }); + ForkReset::register(static function (): void { + throw new \RuntimeException('boom'); + }); + ForkReset::register(static function () use (&$ran): void { + $ran[] = 'after'; + }); + + ForkReset::runAll(); + + self::assertSame(['before', 'after'], $ran); + } + + public function test_clear_removes_all_handlers(): void + { + $calls = 0; + ForkReset::register(static function () use (&$calls): void { + $calls++; + }); + + ForkReset::clear(); + ForkReset::runAll(); + + self::assertSame(0, $calls); + } +} diff --git a/tests/Process/Integration/DaemonIntegrationTest.php b/tests/Process/Integration/DaemonIntegrationTest.php new file mode 100644 index 0000000..ca6dd5b --- /dev/null +++ b/tests/Process/Integration/DaemonIntegrationTest.php @@ -0,0 +1,233 @@ + $class */ + private function daemonStatus(string $class): ?DaemonStatus + { + $s = $class::status(); + return $s instanceof DaemonStatus ? $s : null; + } + + public function test_forks_the_configured_replicas(): void + { + $sup = $this->fork(static fn() => LoopDaemon::start()); + + $ready = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(LoopDaemon::class); + return $st !== null + && count($st->workers) === 2 + && count(array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING)) === 2; + }); + self::assertTrue($ready, 'two workers should reach RUNNING'); + + $st = $this->daemonStatus(LoopDaemon::class); + self::assertNotNull($st); + self::assertSame(0, $st->restarts); + $pids = array_map(static fn($w) => $w->pid, $st->workers); + self::assertCount(2, array_unique($pids), 'workers have distinct PIDs'); + self::assertSame($sup, $st->pid, 'status PID is the supervisor'); + } + + public function test_supervises_an_external_worker_class(): void + { + // $workerClass path: the daemon forks a sibling Process and boots it via + // the protected, cross-instance runWorker() — regression guard for that. + $sup = $this->fork(static fn() => WorkerClassDaemon::start()); + + $ready = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(WorkerClassDaemon::class); + return $st !== null + && count(array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING)) === 2; + }); + self::assertTrue($ready, 'the external worker class is supervised into a running fleet'); + + $workerPids = array_map(static fn($w) => $w->pid, $this->daemonStatus(WorkerClassDaemon::class)->workers); + posix_kill($sup, SIGTERM); + + self::assertTrue($this->waitExit($sup), 'the fleet stops gracefully'); + foreach ($workerPids as $pid) { + self::assertFalse($this->isAlive($pid), "external worker {$pid} must not be orphaned"); + } + } + + public function test_graceful_stop_drains_the_whole_fleet_without_orphans(): void + { + $sup = $this->fork(static fn() => LoopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(LoopDaemon::class)) !== null && count($st->workers) === 2 + )); + + $workerPids = array_map(static fn($w) => $w->pid, $this->daemonStatus(LoopDaemon::class)->workers); + + posix_kill($sup, SIGTERM); + + self::assertTrue($this->waitExit($sup), 'supervisor exits gracefully'); + self::assertNull(LoopDaemon::status(), 'the store record is removed'); + foreach ($workerPids as $pid) { + self::assertFalse($this->isAlive($pid), "worker {$pid} must not be orphaned"); + } + } + + public function test_restarts_a_crashed_worker_in_the_same_slot(): void + { + $this->fork(static fn() => CrashLoopDaemon::start()); + + // Restarts accumulate while the same slot (#0) is reused. + $seen = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(CrashLoopDaemon::class); + return $st !== null && $st->restarts >= 2; + }, timeout: 10.0); + self::assertTrue($seen, 'the crashed worker is restarted repeatedly'); + + $st = $this->daemonStatus(CrashLoopDaemon::class); + self::assertNotNull($st); + foreach ($st->workers as $w) { + self::assertSame(0, $w->slot, 'restart reuses slot #0'); + } + } + + public function test_gives_up_after_max_restarts_and_self_terminates(): void + { + $sup = $this->fork(static fn() => CrashCapDaemon::start()); + + // maxRestarts = 2 → the supervisor stops itself with no signal from us. + self::assertTrue($this->waitExit($sup, 10.0), 'daemon self-terminates on FAILED'); + self::assertNull(CrashCapDaemon::status()); + } + + public function test_never_policy_retires_crashed_workers_without_restarting(): void + { + $this->fork(static fn() => NeverCrashDaemon::start()); + + $retired = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(NeverCrashDaemon::class); + return $st !== null + && count($st->workers) === 2 + && count(array_filter($st->workers, fn($w) => $w->state === SlotState::RETIRED)) === 2; + }, timeout: 8.0); + + self::assertTrue($retired, 'both crashed workers are retired terminally'); + $st = $this->daemonStatus(NeverCrashDaemon::class); + self::assertNotNull($st, 'the daemon keeps running (no self-terminate under NEVER)'); + self::assertSame(0, $st->restarts, 'NEVER never restarts — no storm'); + } + + public function test_watchdog_kills_and_restarts_a_hung_worker(): void + { + $started = microtime(true); + $sup = $this->fork(static fn() => HungLoopDaemon::start()); + + // The worker is alive-but-wedged; only the watchdog can end it. With + // livenessTimeout=2 and maxRestarts=2 it kills, restarts, then gives up — + // so it must take at least one liveness window, not die instantly. + self::assertTrue($this->waitExit($sup, 15.0), 'watchdog drives it to FAILED'); + self::assertGreaterThan(2.0, microtime(true) - $started, 'it waited for the liveness timeout'); + } + + public function test_autoscaler_scales_up_then_down(): void + { + $this->fork(static fn() => AutoscaleLoopDaemon::start()); + + $scaledUp = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(AutoscaleLoopDaemon::class); + $running = $st ? array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING) : []; + return count($running) >= 4; + }, timeout: 8.0); + self::assertTrue($scaledUp, 'fleet grows to the ramped-up target'); + + $scaledDown = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(AutoscaleLoopDaemon::class); + $running = $st ? array_filter($st->workers, fn($w) => $w->state === SlotState::RUNNING) : []; + return count($running) <= 2 && count($running) >= 1; + }, timeout: 8.0); + self::assertTrue($scaledDown, 'fleet shrinks after the stabilization window'); + } + + public function test_singleton_refuses_a_second_supervisor(): void + { + $a = $this->fork(static fn() => LoopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(LoopDaemon::class)) !== null && count($st->workers) === 2 + )); + + $b = $this->fork(static fn() => LoopDaemon::start()); + + // The second start is refused (already running) and exits promptly, while + // the first keeps supervising unchanged. + self::assertTrue($this->waitExit($b, 4.0), 'the second supervisor bails out'); + self::assertTrue($this->isAlive($a), 'the first supervisor is unaffected'); + $st = $this->daemonStatus(LoopDaemon::class); + self::assertNotNull($st); + self::assertSame($a, $st->pid); + self::assertCount(2, $st->workers); + } + + public function test_second_stop_signal_forces_a_stuck_fleet_down(): void + { + $sup = $this->fork(static fn() => StuckStopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(StuckStopDaemon::class)) !== null && $st->workers !== [] + )); + + // First signal begins a drain the wedged worker will never honour. + posix_kill($sup, SIGTERM); + usleep(1_500_000); + self::assertTrue($this->isAlive($sup), 'still draining (grace is 8s, worker is stuck)'); + + // Second signal forces the fleet down at once — well before grace. + $forcedAt = microtime(true); + posix_kill($sup, SIGTERM); + self::assertTrue($this->waitExit($sup, 4.0), 'the second signal forces it down'); + self::assertLessThan(6.0, microtime(true) - $forcedAt, 'forced, not waiting out the 8s grace'); + } + + public function test_per_worker_status_reports_busy_activity(): void + { + $this->fork(static fn() => BusyIdleDaemon::start()); + + $sawBusy = $this->pollUntil(function (): bool { + $st = $this->daemonStatus(BusyIdleDaemon::class); + return $st !== null + && $st->workers !== [] + && $st->workers[0]->activity === Activity::BUSY; + }, timeout: 8.0); + + self::assertTrue($sawBusy, 'the worker heartbeat surfaces BUSY activity'); + } + + public function test_sighup_reloads_without_stopping(): void + { + $sup = $this->fork(static fn() => LoopDaemon::start()); + self::assertTrue($this->pollUntil( + fn() => ($st = $this->daemonStatus(LoopDaemon::class)) !== null && count($st->workers) === 2 + )); + + posix_kill($sup, SIGHUP); + usleep(700_000); + + self::assertTrue($this->isAlive($sup), 'SIGHUP is reload, not stop'); + $st = $this->daemonStatus(LoopDaemon::class); + self::assertNotNull($st); + self::assertCount(2, $st->workers, 'the fleet is untouched by reload'); + } +} diff --git a/tests/Process/Integration/DispatchRunnerTest.php b/tests/Process/Integration/DispatchRunnerTest.php new file mode 100644 index 0000000..9963d04 --- /dev/null +++ b/tests/Process/Integration/DispatchRunnerTest.php @@ -0,0 +1,191 @@ +project = sys_get_temp_dir() . '/wk_dispatch_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $this->buildProject(); + + // The launcher signs the payload with this; Kernel::init reads it when it binds + // the launcher, and Dotenv is immutable so it will not overwrite it. + $this->originalKey = $_ENV['WINTER_KEY'] ?? null; + $_ENV['WINTER_KEY'] = self::SECRET; + + Kernel::init( + pathRoot: $this->project, + pathStorageRunnable: $this->project . '/storage/runnable', + ); + + // Kernel caches FileStorage by name against the path it was first built with. + foreach (['runnable', 'storages', 'volatiles'] as $cache) { + (new \ReflectionProperty(KernelStore::class, $cache))->setValue(null, []); + } + + @unlink(DispatchMarkerProcess::markerPath()); + } + + protected function tearDown(): void + { + $pid = $this->markerPid(); + if ($pid !== null && $this->isAlive($pid)) { + @posix_kill($pid, SIGTERM); + $deadline = microtime(true) + 4.0; + while ($this->isAlive($pid) && microtime(true) < $deadline) { + usleep(50_000); + } + @posix_kill($pid, SIGKILL); + } + + @unlink(DispatchMarkerProcess::markerPath()); + $this->removeTree($this->project); + + if ($this->originalKey === null) { + unset($_ENV['WINTER_KEY']); + } else { + $_ENV['WINTER_KEY'] = $this->originalKey; + } + } + + public function test_dispatch_boots_the_application_in_a_detached_process(): void + { + $log = $this->project . '/runner.log'; + + $pid = DispatchMarkerProcess::dispatch(output: $log); + + self::assertGreaterThan(0, $pid, 'the launcher returns the spawned shell PID'); + + $childPid = $this->awaitMarker(10.0); + + // The runner writes boot failures here ("No application class found", + // "bootstrap.php not found", a signature mismatch...), so an empty log is + // the proof that the whole chain got through cleanly. + self::assertSame('', trim((string) @file_get_contents($log)), 'the runner reported no error'); + self::assertNotNull($childPid, 'the dispatched process reached run() and wrote its marker'); + self::assertTrue($this->isAlive($childPid), 'it keeps running detached from this test process'); + } + + // ── fixture project ──────────────────────────────────────────────────────── + + /** + * Lays out a throwaway project the way composer installs one, because the runner + * resolves the project root from its own location (`dirname(__DIR__, 3)`): + * + * /bootstrap.php + * /vendor/bin/wKernelRunner (composer bin proxy) + * /vendor/flytachi/winter-kernel/wKernelRunner + */ + private function buildProject(): void + { + $repo = dirname(__DIR__, 3); + $pkg = $this->project . '/vendor/flytachi/winter-kernel'; + + @mkdir($this->project . '/vendor/bin', 0777, true); + @mkdir($pkg, 0777, true); + @mkdir($this->project . '/storage/runnable', 0777, true); + + // Copied, never symlinked: PHP resolves __DIR__ through symlinks, which would + // point the runner at this repository instead of the throwaway project. + copy($repo . '/wKernelRunner', $pkg . '/wKernelRunner'); + + file_put_contents( + $this->project . '/vendor/bin/wKernelRunner', + "#!/usr/bin/env php\nproject . '/bootstrap.php', <<project . '/.env', "WINTER_KEY=" . self::SECRET . "\nLOG_LEVEL=\n"); + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + /** Waits for the detached child to announce itself, returning its PID. */ + private function awaitMarker(float $timeout): ?int + { + $deadline = microtime(true) + $timeout; + while (microtime(true) < $deadline) { + $pid = $this->markerPid(); + if ($pid !== null) { + return $pid; + } + usleep(100_000); + } + + return null; + } + + private function markerPid(): ?int + { + $raw = @file_get_contents(DispatchMarkerProcess::markerPath()); + return is_string($raw) && trim($raw) !== '' ? (int) trim($raw) : null; + } + + /** posix_getpgid needs no permission, so it works across users. */ + private function isAlive(int $pid): bool + { + return $pid > 0 && @posix_getpgid($pid) !== false; + } + + private function removeTree(string $path): void + { + if ($path === '' || !is_dir($path)) { + return; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($path); + } +} diff --git a/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php b/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php new file mode 100644 index 0000000..3ad7f6b --- /dev/null +++ b/tests/Process/Integration/Fixtures/DispatchMarkerProcess.php @@ -0,0 +1,31 @@ +isRunning()) { + $this->sleep(0.2); + } + } +} diff --git a/tests/Process/Integration/IntegrationCase.php b/tests/Process/Integration/IntegrationCase.php new file mode 100644 index 0000000..976e066 --- /dev/null +++ b/tests/Process/Integration/IntegrationCase.php @@ -0,0 +1,144 @@ + Forked child PIDs to clean up. */ + private array $children = []; + + protected function setUp(): void + { + if (!extension_loaded('pcntl') || !extension_loaded('posix')) { + self::markTestSkipped('pcntl and posix are required for the integration tests.'); + } + + $this->storage = sys_get_temp_dir() . '/wk_it_' . getmypid() . '_' . bin2hex(random_bytes(4)); + @mkdir($this->storage . '/runnable', 0777, true); + + Kernel::init(pathRoot: $this->storage, pathStorageRunnable: $this->storage . '/runnable'); + Container::init(); + + // Kernel caches FileStorage by name against the path it was first built + // with. Each test uses a fresh temp dir, so drop the cache or a reused + // fixture class would read a previous test's (deleted) directory. + foreach (['runnable', 'storages', 'volatiles'] as $cache) { + (new \ReflectionProperty(KernelStore::class, $cache))->setValue(null, []); + } + } + + protected function tearDown(): void + { + foreach ($this->children as $pid) { + if ($this->isAlive($pid)) { + @posix_kill($pid, SIGTERM); + } + } + $deadline = microtime(true) + 4.0; + foreach ($this->children as $pid) { + while ($this->isAlive($pid) && microtime(true) < $deadline) { + if (pcntl_waitpid($pid, $s, WNOHANG) === $pid) { + break; + } + usleep(50_000); + } + @posix_kill($pid, SIGKILL); + @pcntl_waitpid($pid, $s, WNOHANG); + } + while (pcntl_waitpid(-1, $s, WNOHANG) > 0) { + // drain any remaining direct children + } + $this->children = []; + $this->rrmdir($this->storage); + } + + /** + * Runs $body in a forked child and returns its PID. The child resets inherited + * signal handlers and, when $body returns, vanishes via SIGKILL so the test + * runner's shutdown never runs (and never pollutes output) in the child. + */ + protected function fork(callable $body): int + { + $pid = pcntl_fork(); + if ($pid === 0) { + foreach ([SIGTERM, SIGINT, SIGHUP, SIGUSR1, SIGUSR2] as $signo) { + pcntl_signal($signo, SIG_DFL); + } + try { + $body(); + } catch (\Throwable) { + // observed via the store / markers, not the child exit + } + posix_kill(getmypid(), SIGKILL); + } + $this->children[] = $pid; + return $pid; + } + + /** + * Polls $cond until it is truthy or the timeout elapses. + */ + protected function pollUntil(callable $cond, float $timeout = 8.0, float $step = 0.1): bool + { + $deadline = microtime(true) + $timeout; + do { + if ($cond()) { + return true; + } + usleep((int) ($step * 1_000_000)); + } while (microtime(true) < $deadline); + + return (bool) $cond(); + } + + protected function isAlive(int $pid): bool + { + return $pid > 0 && posix_getpgid($pid) !== false; + } + + /** + * Waits for a forked child to exit (reaping it). Returns true once gone. + */ + protected function waitExit(int $pid, float $timeout = 8.0): bool + { + $deadline = microtime(true) + $timeout; + do { + $r = pcntl_waitpid($pid, $s, WNOHANG); + if ($r === $pid || $r === -1) { + return true; + } + usleep(100_000); + } while (microtime(true) < $deadline); + + return false; + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? $this->rrmdir($path) : @unlink($path); + } + @rmdir($dir); + } +} diff --git a/tests/Process/Integration/ProcessSignalIntegrationTest.php b/tests/Process/Integration/ProcessSignalIntegrationTest.php new file mode 100644 index 0000000..b9a3c4b --- /dev/null +++ b/tests/Process/Integration/ProcessSignalIntegrationTest.php @@ -0,0 +1,114 @@ +marker = $this->storage . '/marker'; + putenv('WK_MARKER=' . $this->marker); + } + + protected function tearDown(): void + { + putenv('WK_MARKER'); + parent::tearDown(); + } + + /** @return list */ + private function markers(): array + { + if (!is_file($this->marker)) { + return []; + } + return array_values(array_filter(explode("\n", (string) file_get_contents($this->marker)))); + } + + private function hasMarker(string $event): bool + { + return in_array($event, $this->markers(), true); + } + + private function startAndWaitReady(): int + { + $pid = $this->fork(static fn() => SignalProcess::start()); + self::assertTrue( + $this->pollUntil(fn() => $this->hasMarker('start') && SignalProcess::status() !== null), + 'the process should reach its run loop' + ); + return $pid; + } + + public function test_sigterm_stops_and_fires_on_terminate(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGTERM); + + self::assertTrue($this->waitExit($pid), 'SIGTERM stops the process'); + self::assertTrue($this->hasMarker('terminate'), 'onTerminate() fired'); + self::assertNull(SignalProcess::status(), 'the record is removed on exit'); + } + + public function test_sigint_stops_and_fires_on_interrupt(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGINT); + + self::assertTrue($this->waitExit($pid), 'SIGINT stops the process'); + self::assertTrue($this->hasMarker('interrupt'), 'onInterrupt() fired'); + } + + public function test_sighup_fires_reload_without_stopping(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGHUP); + + self::assertTrue($this->pollUntil(fn() => $this->hasMarker('reload')), 'onReload() fired'); + self::assertTrue($this->isAlive($pid), 'SIGHUP is reload, not stop'); + + posix_kill($pid, SIGTERM); + self::assertTrue($this->waitExit($pid)); + } + + public function test_sigusr1_and_sigusr2_fire_without_stopping(): void + { + $pid = $this->startAndWaitReady(); + + posix_kill($pid, SIGUSR1); + self::assertTrue($this->pollUntil(fn() => $this->hasMarker('user1')), 'onUser1() fired'); + + posix_kill($pid, SIGUSR2); + self::assertTrue($this->pollUntil(fn() => $this->hasMarker('user2')), 'onUser2() fired'); + + self::assertTrue($this->isAlive($pid), 'user signals do not stop the process'); + + posix_kill($pid, SIGTERM); + self::assertTrue($this->waitExit($pid)); + } + + public function test_singleton_refuses_a_second_bare_process(): void + { + $a = $this->startAndWaitReady(); + + $b = $this->fork(static fn() => SignalProcess::start()); + + self::assertTrue($this->waitExit($b, 4.0), 'the second instance bails out'); + self::assertTrue($this->isAlive($a), 'the first instance is unaffected'); + $st = SignalProcess::status(); + self::assertNotNull($st); + self::assertSame($a, $st->pid); + } +} diff --git a/tests/Process/ProcessStateTest.php b/tests/Process/ProcessStateTest.php new file mode 100644 index 0000000..c336ac0 --- /dev/null +++ b/tests/Process/ProcessStateTest.php @@ -0,0 +1,37 @@ +getBackingType()?->getName()); + } + + public function test_cases_and_ordinal_values(): void + { + self::assertSame(0, ProcessState::NEW->value); + self::assertSame(1, ProcessState::RUNNING->value); + self::assertSame(2, ProcessState::STOPPING->value); + self::assertSame(3, ProcessState::TERMINATED->value); + self::assertSame(4, ProcessState::FAILED->value); + self::assertSame(5, ProcessState::RESTARTING->value); + } + + public function test_exactly_six_cases(): void + { + self::assertCount(6, ProcessState::cases()); + } + + public function test_name_is_stable_label(): void + { + self::assertSame('RUNNING', ProcessState::RUNNING->name); + self::assertSame('FAILED', ProcessState::FAILED->name); + } +} diff --git a/tests/Process/ProcessStatusTest.php b/tests/Process/ProcessStatusTest.php new file mode 100644 index 0000000..1d00001 --- /dev/null +++ b/tests/Process/ProcessStatusTest.php @@ -0,0 +1,68 @@ +concurrency); + self::assertNull($s->usage); + self::assertSame(0, $s->heartbeatAt); + } + + public function test_get_started_at_is_formatted(): void + { + $s = new ProcessStatus(1, 'Foo', ProcessState::RUNNING, Activity::IDLE, 1_600_000_000); + self::assertSame(date('Y-m-d H:i:s P', 1_600_000_000), $s->getStartedAt()); + } + + public function test_json_shape_and_value_encoding(): void + { + $started = time() - 5; + $json = (new ProcessStatus( + pid: 200, + className: 'App\\Worker', + state: ProcessState::STOPPING, + activity: Activity::BUSY, + startedAt: $started, + concurrency: 8, + usage: null, + heartbeatAt: $started + 3, + ))->jsonSerialize(); + + self::assertSame( + ['pid', 'class', 'state', 'activity', 'started_at', 'uptime', 'concurrency', 'heartbeat_at', 'usage'], + array_keys($json) + ); + self::assertSame(200, $json['pid']); + self::assertSame('App\\Worker', $json['class']); + self::assertSame('STOPPING', $json['state']); // ProcessState->name + self::assertSame('busy', $json['activity']); // Activity->value + self::assertSame($started, $json['started_at']); + self::assertGreaterThanOrEqual(5, $json['uptime']); + self::assertSame(8, $json['concurrency']); + self::assertSame($started + 3, $json['heartbeat_at']); + self::assertNull($json['usage']); + } + + /** + * Regression: a backed Activity + JsonSerializable must encode cleanly — the + * pure-enum activity previously made json_encode return false. + */ + public function test_encodes_to_json_string(): void + { + $s = new ProcessStatus(1, 'Foo', ProcessState::RUNNING, Activity::BUSY, time()); + $encoded = json_encode($s); + self::assertIsString($encoded); + self::assertArrayHasKey('activity', json_decode($encoded, true)); + } +} diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php new file mode 100644 index 0000000..0c8e537 --- /dev/null +++ b/tests/Process/ProcessTest.php @@ -0,0 +1,70 @@ +invoke($p); + } + + public function test_title_name_defaults_to_short_class_name(): void + { + self::assertSame('SampleProcess', $this->invoke(new SampleProcess(), 'titleName')); + } + + public function test_title_name_prefers_explicit_process_title(): void + { + self::assertSame('custom-title', $this->invoke(new TitledProcess(), 'titleName')); + } + + public function test_build_process_title_uses_the_winter_process_prefix(): void + { + self::assertSame('winter-process: SampleProcess', $this->invoke(new SampleProcess(), 'buildProcessTitle')); + self::assertSame('winter-process: custom-title', $this->invoke(new TitledProcess(), 'buildProcessTitle')); + } + + public function test_after_fork_runs_the_fork_reset_handlers(): void + { + $ran = false; + ForkReset::register(static function () use (&$ran): void { + $ran = true; + }); + + $this->invoke(new SampleProcess(), 'afterFork'); + + self::assertTrue($ran); + } + + public function test_touch_is_a_noop_for_a_bare_process(): void + { + // No worker slot → no heartbeat write, no error. + $this->invoke(new SampleProcess(), 'touch'); + $this->addToAssertionCount(1); + } + + public function test_run_is_abstract_on_the_base(): void + { + self::assertTrue((new \ReflectionMethod(Process::class, 'run'))->isAbstract()); + } +} diff --git a/tests/Process/ResourceUsageTest.php b/tests/Process/ResourceUsageTest.php new file mode 100644 index 0000000..bf70290 --- /dev/null +++ b/tests/Process/ResourceUsageTest.php @@ -0,0 +1,58 @@ +pid); + self::assertIsInt($usage->ppid); + self::assertIsString($usage->user); + self::assertIsFloat($usage->cpu); + self::assertIsFloat($usage->memory); + self::assertIsInt($usage->rssKb); + self::assertIsString($usage->elapsed); + self::assertIsString($usage->command); + } + + public function test_of_pid_returns_null_for_a_dead_pid(): void + { + // A PID far above any live process on a normal system. + self::assertNull(ResourceUsage::ofPid(4_194_303)); + } + + public function test_rss_mb_converts_from_kb(): void + { + $usage = new ResourceUsage(1, 0, 'me', 0.0, 0.0, 2048, '00:01', 'php'); + self::assertSame(2.0, $usage->rssMb()); + } + + public function test_json_shape_and_types(): void + { + $json = (new ResourceUsage(7, 1, 'root', 1.5, 0.3, 1536, '01:23', 'php worker'))->jsonSerialize(); + + self::assertSame( + ['pid', 'ppid', 'user', 'cpu', 'memory', 'rss_mb', 'elapsed', 'command'], + array_keys($json) + ); + self::assertSame(7, $json['pid']); + self::assertSame('root', $json['user']); + self::assertSame(1.5, $json['cpu']); + self::assertSame(1.5, $json['rss_mb']); // 1536 KB rounded to 1 decimal + self::assertSame('php worker', $json['command']); + } + + public function test_encodes_to_json_string(): void + { + self::assertIsString(json_encode(new ResourceUsage(1, 0, 'me', 0.0, 0.0, 0, '0', 'x'))); + } +} diff --git a/tests/Process/UnitOfWorkScopeTest.php b/tests/Process/UnitOfWorkScopeTest.php new file mode 100644 index 0000000..5451bcc --- /dev/null +++ b/tests/Process/UnitOfWorkScopeTest.php @@ -0,0 +1,120 @@ +invoke($this); + } +} + +/** + * A worker's unit of work is also its request scope. + * + * Under HTTP the scope ends with the coroutine carrying the request. A worker has no + * such boundary — its whole body runs in one coroutine, so a `#[Request]` bean resolved + * inside it would survive every iteration and hand the previous job's state to the next. + * Measured before the fix: four iterations, one object, each seeing what the one before + * it wrote. + * + * `markBusy()` already marks where a unit begins, so it is the boundary. That keeps the + * developer from having to know the scope exists in order to get it right. + */ +final class UnitOfWorkScopeTest extends TestCase +{ + protected function setUp(): void + { + Container::init(); + } + + public function test_each_unit_of_work_gets_a_fresh_request_bean(): void + { + $worker = new UowWorker(); + $c = Container::getInstance(); + + $worker->beginUnit(); + $first = $c->make(UowJobContext::class); + $first->job = 'job-1'; + + $worker->beginUnit(); + $second = $c->make(UowJobContext::class); + + self::assertNotSame($first, $second); + self::assertSame('unset', $second->job, "The next job must not inherit the last one's state."); + } + + public function test_within_one_unit_the_bean_is_the_same(): void + { + $worker = new UowWorker(); + $c = Container::getInstance(); + + $worker->beginUnit(); + + self::assertSame( + $c->make(UowJobContext::class), + $c->make(UowJobContext::class), + 'A unit of work is one scope — that is the whole point of #[Request].', + ); + } + + /** + * Ending a scope is not resetting the container: a worker's singletons carry state + * across units on purpose — a connection pool, a counter, a warm cache. + */ + public function test_singletons_survive_the_unit_boundary(): void + { + $worker = new UowWorker(); + $c = Container::getInstance(); + + $worker->beginUnit(); + $c->make(UowSharedCounter::class)->count = 7; + + $worker->beginUnit(); + + self::assertSame(7, $c->make(UowSharedCounter::class)->count); + } + + /** + * A body that never marks a unit has declared no boundary, so nothing is reset — + * the previous behaviour, kept for processes that simply run. + */ + public function test_a_body_that_never_marks_a_unit_keeps_its_bean(): void + { + $c = Container::getInstance(); + + $first = $c->make(UowJobContext::class); + $first->job = 'still here'; + + self::assertSame('still here', $c->make(UowJobContext::class)->job); + } +} diff --git a/tests/Route/ActuatorHealthCodeTest.php b/tests/Route/ActuatorHealthCodeTest.php new file mode 100644 index 0000000..9c8eb9d --- /dev/null +++ b/tests/Route/ActuatorHealthCodeTest.php @@ -0,0 +1,145 @@ +invoke($router, StubIndicator::class, null); + + return $router; + } + + private function get(string $uri): FakeResponse + { + $response = new FakeResponse(); + $this->actuator()->handle(new FakeRequest('GET', $uri), $response); + + return $response; + } + + protected function tearDown(): void + { + StubIndicator::$report = ['status' => 'up', 'components' => []]; + } + + public function test_down_answers_503(): void + { + StubIndicator::$report = ['status' => 'down', 'components' => []]; + + $response = $this->get('/actuator/health'); + + self::assertSame(503, $response->status); + self::assertSame('down', $response->json()['status'], 'the body still carries the report'); + } + + public function test_degraded_answers_200(): void + { + StubIndicator::$report = ['status' => 'degraded', 'components' => []]; + + self::assertSame(200, $this->get('/actuator/health')->status); + } + + public function test_up_answers_200(): void + { + self::assertSame(200, $this->get('/actuator/health')->status); + } + + /** `/actuator` falls through to the health method, so it must carry the verdict too. */ + public function test_the_bare_actuator_route_answers_503_when_down(): void + { + StubIndicator::$report = ['status' => 'down', 'components' => []]; + + self::assertSame(503, $this->get('/actuator')->status); + } + + /** + * Only health reports a verdict. Another endpoint may use the word `status` for + * something of its own — a deployment state, a licence, a queue — and that must not + * be read as "the application is down". + */ + public function test_a_status_key_on_another_endpoint_is_not_a_verdict(): void + { + self::assertSame('down', new StubIndicator()->info()['status'], 'the fixture must bait it'); + + self::assertSame(200, $this->get('/actuator/info')->status); + } + + /** A custom indicator may hand back the enum rather than its value. */ + public function test_a_status_enum_is_understood(): void + { + StubIndicator::$report = ['status' => Status::Down, 'components' => []]; + + self::assertSame(503, $this->get('/actuator/health')->status); + } + + public function test_a_report_without_a_status_stays_200(): void + { + StubIndicator::$report = ['components' => []]; + + self::assertSame(200, $this->get('/actuator/health')->status); + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** An indicator whose health report the test dictates. */ +final class StubIndicator implements HealthIndicatorInterface +{ + public static array $report = ['status' => 'up', 'components' => []]; + + public function health(): array + { + return self::$report; + } + + /** Carries a `status` of its own — the bait for the health-only check. */ + public function info(): array + { + return ['framework' => 'winter', 'status' => 'down']; + } + + public function metrics(): array + { + return []; + } + + public function env(): array + { + return []; + } + + public function loggers(): array + { + return []; + } + + public function mappings(): array + { + return []; + } +} diff --git a/tests/Route/ApplicationBootTest.php b/tests/Route/ApplicationBootTest.php new file mode 100644 index 0000000..5b7f131 --- /dev/null +++ b/tests/Route/ApplicationBootTest.php @@ -0,0 +1,136 @@ +isInitialized() ? $prop->getValue() : null; + + // Errors are silenced when DEBUG is off, which would hide a boot failure. + $_ENV['DEBUG'] = 'true'; + + $appDir = __DIR__ . '/Fixtures/App'; + Kernel::init(pathRoot: $appDir); + + $container = Container::init(); + Scanner::run($appDir)->collect(new DICollector($container))->execute(); + + self::$router = Router::fromScan($appDir); + } + + public static function tearDownAfterClass(): void + { + unset($_ENV['DEBUG']); + self::$router = null; + + if (self::$originalRoot !== null) { + KernelConfig::$pathRoot = self::$originalRoot; + } + } + + /** @param array $query */ + private function send(string $method, string $uri, array $query = []): FakeResponse + { + $response = new FakeResponse(); + self::$router->handle(new FakeRequest($method, $uri, [], $query), $response); + + return $response; + } + + public function test_the_scan_discovers_routes_from_attributes(): void + { + $routes = array_map( + static fn(array $r): string => $r['method'] . ' ' . $r['path'], + self::$router->getRoutesSummary(), + ); + + self::assertContains('GET /demo/ping', $routes, 'nothing was registered by hand here'); + self::assertContains('POST /demo/items', $routes); + } + + public function test_the_class_prefix_combines_with_the_method_path(): void + { + // #[RequestMapping('/demo')] + #[GetMapping('/ping')] + self::assertSame(200, $this->send('GET', '/demo/ping')->status); + self::assertSame(404, $this->send('GET', '/ping')->status, 'the prefix is not optional'); + } + + public function test_the_container_injects_the_controller_dependency(): void + { + $response = $this->send('GET', '/demo/hello/world'); + + // 'hello world' can only come from the autowired GreetingService. + self::assertSame(['message' => 'hello world'], $response->json()); + } + + public function test_a_path_variable_reaches_the_method_argument(): void + { + self::assertSame(['message' => 'hello winter'], $this->send('GET', '/demo/hello/winter')->json()); + } + + public function test_query_parameters_bind_and_defaults_apply(): void + { + self::assertSame( + ['q' => 'winter', 'limit' => 10], + $this->send('GET', '/demo/search', ['q' => 'winter'])->json(), + 'limit is absent, so its PHP default is used', + ); + + self::assertSame( + ['q' => 'winter', 'limit' => 5], + $this->send('GET', '/demo/search', ['q' => 'winter', 'limit' => '5'])->json(), + 'and it is cast to the declared int', + ); + } + + public function test_the_request_object_is_injectable_by_type(): void + { + self::assertSame( + ['method' => 'GET', 'uri' => '/demo/echo'], + $this->send('GET', '/demo/echo')->json(), + ); + } + + public function test_the_http_method_of_the_mapping_is_honoured(): void + { + self::assertSame(200, $this->send('POST', '/demo/items')->status); + + $wrongMethod = $this->send('GET', '/demo/items'); + self::assertSame(405, $wrongMethod->status); + self::assertStringContainsString('POST', (string) $wrongMethod->header_('Allow')); + } + + public function test_an_unmapped_path_is_not_found(): void + { + self::assertSame(404, $this->send('GET', '/demo/nothing-here')->status); + } +} diff --git a/tests/Route/Fixtures/App/DemoController.php b/tests/Route/Fixtures/App/DemoController.php new file mode 100644 index 0000000..0403f09 --- /dev/null +++ b/tests/Route/Fixtures/App/DemoController.php @@ -0,0 +1,60 @@ + $this->greetings->greet($name)]; + } + + /** Query parameters and their defaults. */ + #[GetMapping('/search')] + public function search(#[RequestParam] string $q, #[RequestParam] int $limit = 10): array + { + return ['q' => $q, 'limit' => $limit]; + } + + /** The raw request object is injectable by type. */ + #[GetMapping('/echo')] + public function echoMethod(HttpRequest $request): array + { + return ['method' => $request->getMethod(), 'uri' => $request->getUri()]; + } + + #[PostMapping('/items')] + public function create(): array + { + return ['created' => true]; + } +} diff --git a/tests/Route/Fixtures/App/GreetingService.php b/tests/Route/Fixtures/App/GreetingService.php new file mode 100644 index 0000000..40d979c --- /dev/null +++ b/tests/Route/Fixtures/App/GreetingService.php @@ -0,0 +1,17 @@ + $headers */ + public function __construct( + private readonly string $method = 'GET', + private readonly string $uri = '/', + private readonly array $headers = [], + private readonly array $query = [], + private readonly array $body = [], + ) { + } + + public function getMethod(): string + { + return $this->method; + } + + public function getUri(): string + { + return $this->uri; + } + + public function getQueryParams(): array + { + return $this->query; + } + + public function getParsedBody(): array + { + return $this->body; + } + + public function getRawBody(): string + { + return ''; + } + + public function getHeader(string $name): ?string + { + foreach ($this->headers as $key => $value) { + if (strcasecmp($key, $name) === 0) { + return $value; + } + } + + return null; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getUploadedFiles(): array + { + return []; + } + + public function getServerParam(string $key): ?string + { + return null; + } + + public function getClientIp(): string + { + return '127.0.0.1'; + } + + public function getClientTimezone(): ?string + { + return null; + } + + public function getScheme(): string + { + return 'http'; + } + + public function getHost(): string + { + return 'localhost'; + } + + public function getPort(): int + { + return 80; + } + + public function getBaseUrl(): string + { + return 'http://localhost'; + } +} diff --git a/tests/Route/Fixtures/FakeResponse.php b/tests/Route/Fixtures/FakeResponse.php new file mode 100644 index 0000000..1bf1c9b --- /dev/null +++ b/tests/Route/Fixtures/FakeResponse.php @@ -0,0 +1,61 @@ + */ + public array $headers = []; + public ?string $body = null; + public bool $ended = false; + public ?string $sentFile = null; + + public function status(int $code): void + { + $this->status = $code; + } + + public function header(string $name, string $value): void + { + $this->headers[$name] = $value; + } + + public function end(string $body = ''): void + { + $this->body = $body; + $this->ended = true; + } + + public function sendfile(string $path, int $offset = 0, int $length = 0): void + { + $this->sentFile = $path; + $this->ended = true; + } + + /** Case-insensitive header lookup, as a client would see it. */ + public function header_(string $name): ?string + { + foreach ($this->headers as $key => $value) { + if (strcasecmp($key, $name) === 0) { + return $value; + } + } + + return null; + } + + /** The decoded JSON body, or null when the body is absent or not JSON. */ + public function json(): mixed + { + return $this->body === null ? null : json_decode($this->body, true); + } +} diff --git a/tests/Route/Fixtures/RecordingMiddleware.php b/tests/Route/Fixtures/RecordingMiddleware.php new file mode 100644 index 0000000..3577f9c --- /dev/null +++ b/tests/Route/Fixtures/RecordingMiddleware.php @@ -0,0 +1,58 @@ + */ + public static array $trace = []; + + public static function reset(): void + { + self::$trace = []; + } + + protected function tag(): string + { + return 'mw'; + } + + public function before(HttpRequest $request, HttpResponse $response): void + { + self::$trace[] = 'before:' . $this->tag(); + } + + public function after(mixed $result): mixed + { + self::$trace[] = 'after:' . $this->tag(); + + return is_string($result) ? $result . '|' . $this->tag() : $result; + } +} + +final class FirstMiddleware extends RecordingMiddleware +{ + protected function tag(): string + { + return 'first'; + } +} + +final class SecondMiddleware extends RecordingMiddleware +{ + protected function tag(): string + { + return 'second'; + } +} diff --git a/tests/Route/Fixtures/ServerProcess.php b/tests/Route/Fixtures/ServerProcess.php new file mode 100644 index 0000000..c16f320 --- /dev/null +++ b/tests/Route/Fixtures/ServerProcess.php @@ -0,0 +1,192 @@ +port; + } + + public function log(): string + { + return (string) @file_get_contents($this->logFile); + } + + public function url(string $path): string + { + return 'http://127.0.0.1:' . $this->port . $path; + } + + /** Boots the server and returns once it accepts connections, or false on timeout. */ + public function start(float $timeout = 15.0): bool + { + $this->port = self::freePort(); + $this->storage = sys_get_temp_dir() . '/wk_serve_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $this->runner = $this->storage . '.php'; + $this->logFile = $this->storage . '.log'; + + // The entry a project would write by hand: load the autoloader, run the app. + $autoload = dirname(__DIR__, 3) . '/vendor/autoload.php'; + file_put_contents($this->runner, sprintf( + "port, + )); + + $this->pid = (int) trim((string) shell_exec(sprintf( + 'WK_SERVE_STORAGE=%s %s %s >> %s 2>&1 & echo $!', + escapeshellarg($this->storage), + escapeshellarg(PHP_BINARY), + escapeshellarg($this->runner), + escapeshellarg($this->logFile), + ))); + + return $this->awaitReady($timeout); + } + + public function signal(int $signal): void + { + if ($this->pid > 0) { + @posix_kill($this->pid, $signal); + } + } + + public function isAlive(): bool + { + return $this->pid > 0 && @posix_getpgid($this->pid) !== false; + } + + /** Waits for the process to leave on its own; false when it outstays the timeout. */ + public function awaitExit(float $timeout): bool + { + $deadline = microtime(true) + $timeout; + while (microtime(true) < $deadline) { + if (!$this->isAlive()) { + return true; + } + usleep(100_000); + } + + return false; + } + + /** Stops the server if it is still up, then removes everything it left behind. */ + public function stop(): void + { + if ($this->pid > 0) { + @exec(sprintf('kill -TERM %d 2>/dev/null', $this->pid)); + $this->awaitExit(4.0); + @exec(sprintf('kill -KILL %d 2>/dev/null', $this->pid)); + $this->pid = 0; + } + + @unlink($this->runner); + @unlink($this->logFile); + self::removeTree($this->storage); + } + + /** The storage tree nests (storage/runnable//…), so it has to go depth-first. */ + private static function removeTree(string $path): void + { + if ($path === '' || !is_dir($path)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($path); + } + + /** @return array{status: int, body: string, headers: list} */ + public function request(string $method, string $path): array + { + $context = stream_context_create(['http' => [ + 'method' => $method, + 'timeout' => 5, + 'ignore_errors' => true, // 4xx/5xx must come back as a response, not a warning + ]]); + + $body = @file_get_contents($this->url($path), false, $context); + $headers = $http_response_header ?? []; + + return [ + 'status' => self::statusOf($headers), + 'body' => $body === false ? '' : $body, + 'headers' => $headers, + ]; + } + + /** @param list $headers */ + public static function statusOf(array $headers): int + { + foreach ($headers as $line) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m) === 1) { + return (int) $m[1]; + } + } + + return 0; + } + + /** @param list $headers */ + public static function headerOf(array $headers, string $name): string + { + foreach ($headers as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2 && strcasecmp(trim($parts[0]), $name) === 0) { + return trim($parts[1]); + } + } + + return ''; + } + + private function awaitReady(float $timeout): bool + { + $deadline = microtime(true) + $timeout; + while (microtime(true) < $deadline) { + $socket = @fsockopen('127.0.0.1', $this->port, $errno, $errstr, 0.3); + if ($socket !== false) { + fclose($socket); + return true; + } + usleep(200_000); + } + + return false; + } + + /** Asks the OS for an unused port, so parallel runs do not collide. */ + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + $name = stream_socket_get_name($socket, false); + fclose($socket); + + return (int) substr((string) $name, strrpos((string) $name, ':') + 1); + } +} diff --git a/tests/Route/GracefulShutdownTest.php b/tests/Route/GracefulShutdownTest.php new file mode 100644 index 0000000..b95f096 --- /dev/null +++ b/tests/Route/GracefulShutdownTest.php @@ -0,0 +1,82 @@ +server = new ServerProcess(); + if (!$this->server->start()) { + $log = $this->server->log(); + $this->server->stop(); + $this->server = null; + self::markTestSkipped("the server did not come up in time:\n" . substr($log, 0, 600)); + } + } + + protected function tearDown(): void + { + $this->server?->stop(); + $this->server = null; + } + + public function test_sigterm_stops_the_server_without_a_forced_kill(): void + { + // Serve something first: a worker that has handled a request is the one whose + // timers and pools are actually live. + self::assertSame(200, $this->server->request('GET', '/demo/ping')['status']); + + $this->server->signal(SIGTERM); + + self::assertTrue( + $this->server->awaitExit(self::EXIT_BUDGET), + "the server was still running " . self::EXIT_BUDGET . "s after SIGTERM:\n" . $this->server->log(), + ); + + $log = $this->server->log(); + foreach (['9101', 'exit timeout', 'forced termination'] as $symptom) { + self::assertStringNotContainsStringIgnoringCase( + $symptom, + $log, + "the reactor could not drain — something in the worker still holds a timer:\n" . $log, + ); + } + } + + public function test_it_stops_cleanly_even_without_serving_anything(): void + { + // The timers are armed at workerStart, so an idle worker has to release them too. + $this->server->signal(SIGTERM); + + self::assertTrue($this->server->awaitExit(self::EXIT_BUDGET), $this->server->log()); + self::assertStringNotContainsStringIgnoringCase('9101', $this->server->log()); + } +} diff --git a/tests/Route/RequestWatchdogTest.php b/tests/Route/RequestWatchdogTest.php new file mode 100644 index 0000000..d6c49ff --- /dev/null +++ b/tests/Route/RequestWatchdogTest.php @@ -0,0 +1,502 @@ +invoke(null, $seconds); + } + + /** + * The sweep interval *is* the accuracy: a request that goes overdue right after one + * pass waits for the next, so the deadline overshoots by up to an interval. At one + * second — the first choice here — a three-second timeout answered in 3.37 s, which + * is the average overshoot of a 750 ms sweep, not network noise. + */ + public function test_a_long_deadline_is_still_measured_to_a_tenth_of_a_second(): void + { + self::assertSame(0.1, $this->interval(3.0), 'a 3 s timeout must not sweep every 750 ms'); + self::assertSame(0.1, $this->interval(30.0)); + self::assertSame(0.1, $this->interval(600.0), 'even a ten-minute deadline stays precise'); + } + + /** A short deadline gets a proportionally short sweep, down to a floor. */ + public function test_a_short_deadline_sweeps_proportionally(): void + { + self::assertSame(0.075, $this->interval(0.3), 'a quarter of the deadline'); + self::assertSame(0.05, $this->interval(0.2), '…until the floor'); + self::assertSame(0.05, $this->interval(0.01), 'a tiny deadline must not spin the reactor'); + } + + // ── Registration ─────────────────────────────────────────────────────────── + + public function test_nothing_is_watched_while_the_deadline_is_disabled(): void + { + $seen = null; + + \Swoole\Coroutine\run(static function () use (&$seen): void { + RequestWatchdog::enable(0.0); + $seen = RequestWatchdog::register(); + RequestWatchdog::disable(); + }); + + self::assertNull($seen, 'a disabled deadline registers nobody'); + self::assertSame(0, RequestWatchdog::watching()); + } + + public function test_a_request_is_watched_and_released(): void + { + $during = $after = null; + + \Swoole\Coroutine\run(static function () use (&$during, &$after): void { + RequestWatchdog::enable(5.0); + $cid = RequestWatchdog::register(); + $during = RequestWatchdog::watching(); + RequestWatchdog::release($cid); + $after = RequestWatchdog::watching(); + RequestWatchdog::disable(); + }); + + self::assertSame(1, $during); + self::assertSame(0, $after, 'a finished request is forgotten'); + } + + public function test_releasing_something_never_registered_is_harmless(): void + { + RequestWatchdog::release(null); + RequestWatchdog::release(999999); + + $this->addToAssertionCount(1); + } + + /** + * Releasing must also clear the expiry mark, because **Swoole reuses coroutine ids**. + * A mark left behind on a finished request would be found by the next request handed + * the same id, which would then be answered 504 having done nothing wrong. + */ + public function test_releasing_clears_the_expiry_mark_so_a_reused_id_starts_clean(): void + { + $expiredBefore = $expiredAfterReuse = null; + + \Swoole\Coroutine\run(static function () use (&$expiredBefore, &$expiredAfterReuse): void { + RequestWatchdog::enable(0.05); + $cid = null; + + Coroutine::create(static function () use (&$cid, &$expiredBefore): void { + $cid = RequestWatchdog::register(); + try { + Coroutine::sleep(1); + } catch (\Throwable) { + // timed out, as intended + } + $expiredBefore = RequestWatchdog::hasExpired($cid); + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.4); + // Whatever id that request had, a later one may be given it again. + $expiredAfterReuse = RequestWatchdog::hasExpired($cid); + RequestWatchdog::disable(); + }); + + self::assertTrue($expiredBefore, 'the request really did time out'); + self::assertFalse($expiredAfterReuse, 'the mark must not outlive the request that earned it'); + } + + // ── The deadline ─────────────────────────────────────────────────────────── + + /** The case the class exists for: a waiting request is interrupted, cleanly. */ + public function test_a_waiting_request_is_cancelled_at_its_deadline(): void + { + $outcome = null; + $finallyRan = false; + $deferRan = false; + + \Swoole\Coroutine\run(static function () use (&$outcome, &$finallyRan, &$deferRan): void { + RequestWatchdog::enable(0.15); + + Coroutine::create(static function () use (&$outcome, &$finallyRan, &$deferRan): void { + $cid = RequestWatchdog::register(); + Coroutine::defer(static function () use (&$deferRan, $cid): void { + $deferRan = true; // the pool returns its connection here + RequestWatchdog::release($cid); + }); + try { + Coroutine::sleep(5); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } finally { + $finallyRan = true; // …and a transaction closes here + } + }); + + Coroutine::sleep(0.4); + RequestWatchdog::disable(); + }); + + self::assertSame('Swoole\Coroutine\CanceledException', $outcome); + self::assertTrue($finallyRan, 'finally must run — transactions have to close'); + self::assertTrue($deferRan, 'defer must run — pooled connections have to come back'); + } + + /** + * Time spent queueing counts against the deadline. + * + * Under `worker_max_concurrency` a request's coroutine is not created until the worker + * lets it through, so the watchdog never sees the wait — measured with a limit of one + * and a 0.3-second handler, five simultaneous requests waited 0.000 to 1.206 seconds + * before any of their code ran. Without this, each would then start a fresh full + * budget while the client had already been waiting. + */ + public function test_time_spent_queueing_is_charged_to_the_deadline(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(1.0); + + Coroutine::create(static function () use (&$outcome): void { + // 0.9 s of the one-second budget was spent waiting to be picked up. + $cid = RequestWatchdog::register(elapsed: 0.9); + try { + Coroutine::sleep(0.3); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame( + 'Swoole\Coroutine\CanceledException', + $outcome, + 'a 0.3 s body must not survive on a 1 s budget already 0.9 s spent', + ); + } + + /** A budget wholly spent queueing is cancelled at the first sweep, not granted anew. */ + public function test_a_request_that_queued_past_its_budget_does_not_start_afresh(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(0.5); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(elapsed: 2.0); + try { + Coroutine::sleep(5); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.4); + RequestWatchdog::disable(); + }); + + self::assertSame('Swoole\Coroutine\CanceledException', $outcome); + } + + /** Nothing queued, nothing charged — the ordinary request is unaffected. */ + public function test_a_request_that_did_not_queue_keeps_its_whole_budget(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(1.0); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(elapsed: 0.0); + try { + Coroutine::sleep(0.3); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame('finished', $outcome); + } + + public function test_a_request_inside_its_deadline_is_untouched(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(1.0); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(); + try { + Coroutine::sleep(0.05); + $outcome = RequestWatchdog::hasExpired($cid) ? 'expired' : 'ok'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.3); + RequestWatchdog::disable(); + }); + + self::assertSame('ok', $outcome); + } + + /** + * A handler that catches the cancellation must not get away with it: every further + * wait is cut short, and the request stays marked so the framework answers 504 + * instead of sending a result built from queries that never ran. + */ + public function test_swallowing_the_cancellation_does_not_defeat_the_deadline(): void + { + $completedWaits = 0; + $expired = null; + + \Swoole\Coroutine\run(static function () use (&$completedWaits, &$expired): void { + RequestWatchdog::enable(0.1); + + Coroutine::create(static function () use (&$completedWaits, &$expired): void { + $cid = RequestWatchdog::register(); + for ($i = 0; $i < 4; $i++) { + try { + Coroutine::sleep(0.5); + $completedWaits++; // never, once the deadline passed + } catch (\Throwable) { + // swallowed on purpose + } + } + $expired = RequestWatchdog::hasExpired($cid); + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(1.0); + RequestWatchdog::disable(); + }); + + self::assertSame(0, $completedWaits, 'no wait of an overdue request may complete'); + self::assertTrue($expired, 'the framework must know to answer 504'); + } + + // ── Per-route override ───────────────────────────────────────────────────── + + public function test_a_route_may_extend_its_deadline(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(0.1); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(); + RequestWatchdog::extend($cid, 5.0); // #[Timeout(5)] on this route + try { + Coroutine::sleep(0.3); // longer than the global deadline + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame('finished', $outcome, 'the route\'s own deadline must win'); + } + + /** `#[Timeout(0)]` opts a route out entirely. */ + public function test_a_route_may_opt_out_of_the_deadline(): void + { + $outcome = null; + + \Swoole\Coroutine\run(static function () use (&$outcome): void { + RequestWatchdog::enable(0.1); + + Coroutine::create(static function () use (&$outcome): void { + $cid = RequestWatchdog::register(); + RequestWatchdog::extend($cid, 0.0); + try { + Coroutine::sleep(0.3); + $outcome = 'finished'; + } catch (\Throwable $e) { + $outcome = $e::class; + } + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame('finished', $outcome); + self::assertSame(0, RequestWatchdog::watching(), 'an opted-out route is not watched'); + } + + public function test_extending_something_never_registered_is_harmless(): void + { + RequestWatchdog::extend(null, 10.0); + + $this->addToAssertionCount(1); + } + + // ── Nothing accumulates ──────────────────────────────────────────────────── + + /** @return array{deadlines: int, expired: int} */ + private function registrySize(): array + { + return [ + 'deadlines' => count(new \ReflectionProperty(RequestWatchdog::class, 'deadlines')->getValue()), + 'expired' => count(new \ReflectionProperty(RequestWatchdog::class, 'expired')->getValue()), + ]; + } + + /** + * The registry lives as long as the worker, so anything left in it is left for hours. + * Two hundred requests — half of them timing out — must leave it exactly as they + * found it. + */ + public function test_the_registry_returns_to_empty_after_many_requests(): void + { + \Swoole\Coroutine\run(static function (): void { + RequestWatchdog::enable(0.08); + + for ($i = 0; $i < 200; $i++) { + $slow = $i % 2 === 0; + Coroutine::create(static function () use ($slow): void { + $cid = RequestWatchdog::register(); + Coroutine::defer(static fn() => RequestWatchdog::release($cid)); + try { + Coroutine::sleep($slow ? 1.0 : 0.001); + } catch (\Throwable) { + // half of them are cancelled, on purpose + } + }); + } + + Coroutine::sleep(0.6); + RequestWatchdog::disable(); + }); + + self::assertSame(['deadlines' => 0, 'expired' => 0], $this->registrySize()); + } + + /** + * A request that ends between its deadline and the next sweep is never cancelled, so + * only its `defer` would clear it. The sweep drops it too rather than trusting that — + * an entry kept for a coroutine that no longer exists would never leave. + */ + public function test_a_request_gone_before_the_sweep_leaves_nothing_behind(): void + { + $sweep = new \ReflectionMethod(RequestWatchdog::class, 'sweep'); + $deadlines = new \ReflectionProperty(RequestWatchdog::class, 'deadlines'); + + // A coroutine id that has certainly finished, already past its deadline. + $deadlines->setValue(null, [999_999 => hrtime(true) / 1e9 - 1.0]); + + \Swoole\Coroutine\run(static fn() => $sweep->invoke(null)); + + self::assertSame(['deadlines' => 0, 'expired' => 0], $this->registrySize()); + } + + /** Shutting the worker down forgets everything — nothing survives into the next one. */ + public function test_disable_clears_the_registry(): void + { + \Swoole\Coroutine\run(static function (): void { + RequestWatchdog::enable(5.0); + RequestWatchdog::register(); + RequestWatchdog::disable(); + }); + + self::assertSame(['deadlines' => 0, 'expired' => 0], $this->registrySize()); + self::assertSame(0, RequestWatchdog::watching()); + } + + // ── The limit, stated rather than hidden ─────────────────────────────────── + + /** + * A handler that never yields cannot be interrupted — and the sweep cannot even run + * while it holds the loop. This is a property of a single-threaded event loop, not + * something the watchdog could fix; PHP-FPM has the mirror-image limitation, killing + * a runaway loop but not a hung query. + */ + public function test_a_cpu_bound_request_is_not_interrupted(): void + { + $finished = false; + + \Swoole\Coroutine\run(static function () use (&$finished): void { + RequestWatchdog::enable(0.05); + + Coroutine::create(static function () use (&$finished): void { + $cid = RequestWatchdog::register(); + $x = 0; + for ($i = 0; $i < 20_000_000; $i++) { // no yield point anywhere + $x += $i; + } + $finished = true; + RequestWatchdog::release($cid); + }); + + Coroutine::sleep(0.3); + RequestWatchdog::disable(); + }); + + self::assertTrue($finished, 'a CPU-bound handler runs to completion — documented, not a bug'); + } +} diff --git a/tests/Route/RouterDispatchTest.php b/tests/Route/RouterDispatchTest.php new file mode 100644 index 0000000..88ac7a4 --- /dev/null +++ b/tests/Route/RouterDispatchTest.php @@ -0,0 +1,202 @@ +handle(new FakeRequest($method, $uri), $response); + + return $response; + } + + // ── Matching ─────────────────────────────────────────────────────────────── + + public function test_a_static_route_reaches_its_handler(): void + { + $router = new Router()->get('/ping', static fn(): string => 'pong'); + + $response = $this->send($router, 'GET', '/ping'); + + self::assertSame(200, $response->status); + self::assertSame('pong', $response->body); + } + + public function test_a_dynamic_segment_is_passed_to_the_handler(): void + { + $router = new Router()->get('/users/{id:\d+}', static fn($req, $res, array $p): array => ['id' => $p['id']]); + + $response = $this->send($router, 'GET', '/users/42'); + + self::assertSame(200, $response->status); + self::assertSame(['id' => '42'], $response->json()); + } + + public function test_a_segment_constraint_is_enforced(): void + { + $router = new Router()->get('/users/{id:\d+}', static fn(): string => 'never'); + + $response = $this->send($router, 'GET', '/users/abc'); + + self::assertSame(404, $response->status, 'a non-numeric id must not match \d+'); + } + + public function test_an_unknown_path_is_not_found(): void + { + $router = new Router()->get('/ping', static fn(): string => 'pong'); + + $response = $this->send($router, 'GET', '/nope'); + + self::assertSame(404, $response->status); + self::assertSame(['code' => 404, 'message' => 'Not Found'], $response->json()); + } + + public function test_a_known_path_with_the_wrong_method_reports_what_is_allowed(): void + { + $router = new Router() + ->post('/users', static fn(): string => 'created') + ->put('/users', static fn(): string => 'replaced'); + + $response = $this->send($router, 'DELETE', '/users'); + + self::assertSame(405, $response->status); + self::assertNotNull($response->header_('Allow')); + foreach (['POST', 'PUT'] as $method) { + self::assertStringContainsString($method, (string) $response->header_('Allow')); + } + } + + public function test_each_verb_helper_registers_its_own_method(): void + { + $router = new Router(); + foreach (['get', 'post', 'put', 'patch', 'delete'] as $verb) { + $router->{$verb}('/thing', static fn(): string => $verb); + } + + foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $method) { + self::assertSame(200, $this->send($router, $method, '/thing')->status, $method); + } + } + + public function test_options_is_answered_before_dispatch(): void + { + $router = new Router()->post('/users', static fn(): string => 'created'); + + $response = $this->send($router, 'OPTIONS', '/users'); + + self::assertSame(204, $response->status, 'preflight is intercepted, the handler never runs'); + self::assertSame('', $response->body); + } + + // ── Registration guards ──────────────────────────────────────────────────── + + public function test_a_duplicate_static_route_is_rejected(): void + { + $router = new Router()->get('/ping', static fn(): string => 'a'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Ambiguous handler methods mapped'); + + $router->get('/ping', static fn(): string => 'b'); + } + + public function test_a_duplicate_dynamic_route_is_rejected(): void + { + $router = new Router()->get('/users/{id}', static fn(): string => 'a'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Ambiguous handler methods mapped'); + + $router->get('/users/{id}', static fn(): string => 'b'); + } + + public function test_the_same_path_under_different_methods_is_fine(): void + { + $router = new Router() + ->get('/users', static fn(): string => 'list') + ->post('/users', static fn(): string => 'create'); + + self::assertSame('list', $this->send($router, 'GET', '/users')->body); + self::assertSame('create', $this->send($router, 'POST', '/users')->body); + } + + // ── Response serialisation ───────────────────────────────────────────────── + + public function test_an_array_return_is_serialised_as_json(): void + { + $router = new Router()->get('/j', static fn(): array => ['a' => 1, 'b' => [2, 3]]); + + self::assertSame(['a' => 1, 'b' => [2, 3]], $this->send($router, 'GET', '/j')->json()); + } + + public function test_a_null_return_sends_nothing(): void + { + // The handler took over the response itself; the router must not write over it. + $router = new Router()->get('/silent', static fn(): null => null); + + $response = $this->send($router, 'GET', '/silent'); + + self::assertFalse($response->ended); + self::assertNull($response->status); + } + + // ── Failures ─────────────────────────────────────────────────────────────── + + public function test_an_unexpected_exception_becomes_a_500(): void + { + $router = new Router()->get('/boom', static function (): never { + throw new RuntimeException('kaboom'); + }); + + $response = $this->send($router, 'GET', '/boom'); + + self::assertSame(500, $response->status); + self::assertSame('kaboom', $response->json()['message'] ?? null); + } + + public function test_a_response_exception_carries_its_own_status(): void + { + $router = new Router()->get('/teapot', static function (): never { + throw new ResponseException('short and stout', HttpCode::IM_A_TEAPOT); + }); + + $response = $this->send($router, 'GET', '/teapot'); + + self::assertSame(418, $response->status); + self::assertSame('short and stout', $response->json()['message'] ?? null); + } + + // ── Introspection ────────────────────────────────────────────────────────── + + public function test_the_route_summary_lists_every_registration(): void + { + $router = new Router() + ->get('/a', static fn(): string => 'a') + ->post('/b/{id:\d+}', static fn(): string => 'b'); + + $summary = $router->getRoutesSummary(); + + $pairs = array_map(static fn(array $r): string => $r['method'] . ' ' . $r['path'], $summary); + self::assertContains('GET /a', $pairs); + self::assertContains('POST /b/{id:\d+}', $pairs); + } +} diff --git a/tests/Route/RouterMiddlewareTest.php b/tests/Route/RouterMiddlewareTest.php new file mode 100644 index 0000000..755b2d6 --- /dev/null +++ b/tests/Route/RouterMiddlewareTest.php @@ -0,0 +1,98 @@ + $middlewares */ + private function dispatch(array $middlewares, mixed $handler = null): FakeResponse + { + $router = new Router(); + $router->add('GET', '/m', $handler ?? static fn(): string => 'body', $middlewares); + + $response = new FakeResponse(); + $router->handle(new FakeRequest('GET', '/m'), $response); + + return $response; + } + + private static function def(string $class): array + { + return ['class' => $class, 'args' => []]; + } + + public function test_before_runs_in_order_and_after_unwinds_in_reverse(): void + { + $this->dispatch([self::def(FirstMiddleware::class), self::def(SecondMiddleware::class)]); + + self::assertSame( + ['before:first', 'before:second', 'after:second', 'after:first'], + RecordingMiddleware::$trace, + ); + } + + public function test_after_can_transform_the_result_on_the_way_out(): void + { + $response = $this->dispatch([self::def(FirstMiddleware::class), self::def(SecondMiddleware::class)]); + + // 'body' → innermost (second) wraps first, then first — mirroring the unwind. + self::assertSame('body|second|first', $response->body); + } + + public function test_a_single_middleware_still_runs_both_hooks(): void + { + $response = $this->dispatch([self::def(FirstMiddleware::class)]); + + self::assertSame(['before:first', 'after:first'], RecordingMiddleware::$trace); + self::assertSame('body|first', $response->body); + } + + public function test_a_route_without_middleware_is_untouched(): void + { + $response = $this->dispatch([]); + + self::assertSame([], RecordingMiddleware::$trace); + self::assertSame('body', $response->body); + } + + public function test_a_failing_handler_skips_the_after_hooks(): void + { + // after() is the unwind of a *successful* call; on a throw the error path takes + // over, so a middleware must not assume after() always follows before(). + $response = $this->dispatch( + [self::def(FirstMiddleware::class)], + static function (): never { + throw new RuntimeException('kaboom'); + }, + ); + + self::assertSame(['before:first'], RecordingMiddleware::$trace); + self::assertSame(500, $response->status); + } +} diff --git a/tests/Route/RouterTimeoutResponseTest.php b/tests/Route/RouterTimeoutResponseTest.php new file mode 100644 index 0000000..4919043 --- /dev/null +++ b/tests/Route/RouterTimeoutResponseTest.php @@ -0,0 +1,144 @@ +handle(new FakeRequest('GET', $uri), $response); + }); + + Coroutine::sleep($deadline + 0.5); + RequestWatchdog::disable(); + }); + + return $response; + } + + /** A handler that waits too long: cancelled, and the client is told why. */ + public function test_a_timed_out_request_answers_504(): void + { + $router = new Router()->get('/', static function () { + Coroutine::sleep(5); + return ResponseEntity::ok('never reached'); + }); + + $response = $this->send($router, 0.15); + + self::assertSame(504, $response->status); + self::assertSame( + ['code' => 504, 'message' => 'Gateway Timeout'], + $response->json(), + 'not the coroutine exception\'s empty message and code 0', + ); + } + + /** + * A handler that catches everything reaches its end with a result built from queries + * that never ran. That result must not be what the client receives. + */ + public function test_a_handler_that_swallows_the_cancellation_still_answers_504(): void + { + $router = new Router()->get('/', static function () { + for ($i = 0; $i < 4; $i++) { + try { + Coroutine::sleep(0.5); + } catch (\Throwable) { + // swallowed on purpose + } + } + return ResponseEntity::ok(['report' => 'built from nothing']); + }); + + $response = $this->send($router, 0.15); + + self::assertSame(504, $response->status); + self::assertSame(['code' => 504, 'message' => 'Gateway Timeout'], $response->json()); + } + + /** The response is written once — a second write would be the old double-send. */ + public function test_the_response_is_sent_exactly_once(): void + { + $router = new Router()->get('/', static function () { + Coroutine::sleep(5); + return ResponseEntity::ok('never reached'); + }); + + $response = $this->send($router, 0.15); + + self::assertTrue($response->ended); + self::assertSame(504, $response->status, 'a later write would have overwritten this'); + } + + public function test_a_request_within_its_deadline_is_untouched(): void + { + $router = new Router()->get('/', static function () { + Coroutine::sleep(0.02); + return ResponseEntity::ok('done'); + }); + + $response = $this->send($router, 1.0); + + self::assertSame(200, $response->status); + self::assertSame('done', $response->body, 'a plain string is sent as-is, not wrapped'); + } + + /** + * A real failure on a request that also timed out must still name the real failure — + * the timeout wraps it as the cause rather than replacing it. + */ + public function test_an_error_that_is_not_a_timeout_keeps_its_own_status(): void + { + $router = new Router()->get('/', static function () { + throw new \RuntimeException('something else went wrong'); + }); + + $response = $this->send($router, 1.0); + + self::assertNotSame(504, $response->status); + } +} diff --git a/tests/Route/ServeHttpTest.php b/tests/Route/ServeHttpTest.php new file mode 100644 index 0000000..cff8c7a --- /dev/null +++ b/tests/Route/ServeHttpTest.php @@ -0,0 +1,102 @@ +start()) { + $log = self::$server->log(); + self::$server->stop(); + self::$server = null; + self::markTestSkipped("the server did not come up in time:\n" . substr($log, 0, 600)); + } + } + + public static function tearDownAfterClass(): void + { + self::$server?->stop(); + self::$server = null; + } + + public function test_it_answers_a_simple_route(): void + { + $response = self::$server->request('GET', '/demo/ping'); + + self::assertSame(200, $response['status']); + self::assertSame('pong', $response['body']); + } + + public function test_a_path_variable_survives_the_wire(): void + { + $response = self::$server->request('GET', '/demo/hello/winter'); + + self::assertSame(200, $response['status']); + self::assertSame(['message' => 'hello winter'], json_decode($response['body'], true)); + } + + public function test_a_query_string_is_parsed_and_cast(): void + { + $response = self::$server->request('GET', '/demo/search?q=snow&limit=3'); + + self::assertSame(['q' => 'snow', 'limit' => 3], json_decode($response['body'], true)); + } + + public function test_a_post_route_is_reachable(): void + { + $response = self::$server->request('POST', '/demo/items'); + + self::assertSame(200, $response['status']); + self::assertSame(['created' => true], json_decode($response['body'], true)); + } + + public function test_an_unknown_path_returns_404_over_http(): void + { + self::assertSame(404, self::$server->request('GET', '/definitely-not-here')['status']); + } + + public function test_the_wrong_method_returns_405_with_allow(): void + { + $response = self::$server->request('GET', '/demo/items'); + + self::assertSame(405, $response['status']); + self::assertStringContainsString('POST', ServerProcess::headerOf($response['headers'], 'Allow')); + } + + public function test_json_responses_carry_a_json_content_type(): void + { + $response = self::$server->request('GET', '/demo/hello/winter'); + + self::assertStringContainsString( + 'application/json', + strtolower(ServerProcess::headerOf($response['headers'], 'Content-Type')), + ); + } +} diff --git a/tests/Schedule/Fixtures/AbstractScheduled.php b/tests/Schedule/Fixtures/AbstractScheduled.php new file mode 100644 index 0000000..05bf806 --- /dev/null +++ b/tests/Schedule/Fixtures/AbstractScheduled.php @@ -0,0 +1,16 @@ +delayRuns++; + } + + #[Scheduled(fixedRate: 2.0, initialDelay: 1.5)] + public function onRate(): void + { + $this->rateRuns++; + } + + public function notScheduled(): void + { + } +} diff --git a/tests/Schedule/Fixtures/StaticScheduled.php b/tests/Schedule/Fixtures/StaticScheduled.php new file mode 100644 index 0000000..d6ba3d9 --- /dev/null +++ b/tests/Schedule/Fixtures/StaticScheduled.php @@ -0,0 +1,16 @@ +marker = $this->storage . '/marker'; + putenv('WK_MARKER=' . $this->marker); + } + + protected function tearDown(): void + { + putenv('WK_MARKER'); + parent::tearDown(); + } + + private function tickCount(): int + { + if (!is_file($this->marker)) { + return 0; + } + return count(array_filter(explode("\n", (string) file_get_contents($this->marker)))); + } + + public function test_fires_repeatedly_then_stops_gracefully(): void + { + $pid = $this->fork(static fn() => MarkerScheduler::start()); + + // The task (fixedRate 0.2s) should fire several times, proving the loop + // dispatches through the engine and the bean is resolved and invoked. + self::assertTrue( + $this->pollUntil(fn() => $this->tickCount() >= 3 && MarkerScheduler::status() !== null), + 'the scheduler should fire its task repeatedly' + ); + + posix_kill($pid, SIGTERM); + + self::assertTrue($this->waitExit($pid), 'SIGTERM stops the scheduler'); + self::assertNull(MarkerScheduler::status(), 'the record is removed on exit'); + } +} diff --git a/tests/Schedule/ScheduledCollectorTest.php b/tests/Schedule/ScheduledCollectorTest.php new file mode 100644 index 0000000..080391a --- /dev/null +++ b/tests/Schedule/ScheduledCollectorTest.php @@ -0,0 +1,123 @@ +collect($class, new ReflectionClass($class)); + return $collector->getResult(); + } + + public function test_collects_annotated_methods_and_ignores_plain_ones(): void + { + $tasks = $this->collect(SampleScheduled::class); + + self::assertCount(2, $tasks); + $byMethod = []; + foreach ($tasks as $task) { + $byMethod[$task->methodName] = $task; + } + + self::assertArrayHasKey('onDelay', $byMethod); + self::assertArrayHasKey('onRate', $byMethod); + self::assertArrayNotHasKey('notScheduled', $byMethod); + + self::assertSame(SampleScheduled::class, $byMethod['onDelay']->className); + self::assertInstanceOf(FixedDelayTrigger::class, $byMethod['onDelay']->trigger); + self::assertSame(0.0, $byMethod['onDelay']->initialDelay); + + self::assertInstanceOf(FixedRateTrigger::class, $byMethod['onRate']->trigger); + self::assertSame(1.5, $byMethod['onRate']->initialDelay); + } + + public function test_no_trigger_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/no trigger/'); + $this->collect(NoTriggerScheduled::class); + } + + public function test_two_triggers_are_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/more than one trigger/'); + $this->collect(TwoTriggerScheduled::class); + } + + public function test_non_positive_period_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/greater than 0/'); + $this->collect(NonPositiveScheduled::class); + } + + public function test_static_method_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/non-static/'); + $this->collect(StaticScheduled::class); + } + + public function test_method_with_required_argument_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/no required arguments/'); + $this->collect(ArgScheduled::class); + } + + public function test_abstract_class_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/non-instantiable/'); + $this->collect(AbstractScheduled::class); + } + + public function test_cron_is_accepted(): void + { + $tasks = $this->collect(CronScheduled::class); + + self::assertCount(1, $tasks); + self::assertInstanceOf(CronTrigger::class, $tasks[0]->trigger); + self::assertSame('cron 0 2 * * *', $tasks[0]->trigger->describe()); + } + + public function test_malformed_cron_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/cron/'); + $this->collect(BadCronScheduled::class); + } + + public function test_initial_delay_with_cron_is_rejected(): void + { + $this->expectException(ScheduleConfigException::class); + $this->expectExceptionMessageMatches('/initialDelay is not supported with cron/'); + $this->collect(CronInitialDelayScheduled::class); + } +} diff --git a/tests/Schedule/ScheduledTaskTest.php b/tests/Schedule/ScheduledTaskTest.php new file mode 100644 index 0000000..a5a1033 --- /dev/null +++ b/tests/Schedule/ScheduledTaskTest.php @@ -0,0 +1,25 @@ +id()); + self::assertSame(1.5, $task->initialDelay); + self::assertFalse($task->inFlight); + self::assertSame(0, $task->runs); + self::assertNull($task->lastStartAt); + self::assertNull($task->lastEndAt); + self::assertSame(0.0, $task->nextFireAt); + } +} diff --git a/tests/Schedule/ScheduledTest.php b/tests/Schedule/ScheduledTest.php new file mode 100644 index 0000000..6580ae1 --- /dev/null +++ b/tests/Schedule/ScheduledTest.php @@ -0,0 +1,42 @@ +fixedDelay); + self::assertNull($s->fixedRate); + self::assertNull($s->cron); + self::assertSame(0.0, $s->initialDelay); + } + + public function test_named_arguments(): void + { + $s = new Scheduled(fixedRate: 2.0, initialDelay: 10.0); + self::assertSame(2.0, $s->fixedRate); + self::assertSame(10.0, $s->initialDelay); + self::assertNull($s->fixedDelay); + } + + public function test_attribute_is_readable_from_a_method(): void + { + $method = new ReflectionMethod(SampleScheduled::class, 'onRate'); + $attrs = $method->getAttributes(Scheduled::class); + self::assertCount(1, $attrs); + + /** @var Scheduled $inst */ + $inst = $attrs[0]->newInstance(); + self::assertSame(2.0, $inst->fixedRate); + self::assertSame(1.5, $inst->initialDelay); + } +} diff --git a/tests/Schedule/SchedulerExtensionPointTest.php b/tests/Schedule/SchedulerExtensionPointTest.php new file mode 100644 index 0000000..2154f17 --- /dev/null +++ b/tests/Schedule/SchedulerExtensionPointTest.php @@ -0,0 +1,44 @@ +` so an + * application can subclass it and source tasks its own way. Marking the class `final` + * once silently revoked that: the subclass stopped loading, the forked scheduler died + * without a word, and only an integration test — excluded from the default run — + * noticed, by timing out. These assertions are cheap and fail immediately instead. + */ +final class SchedulerExtensionPointTest extends TestCase +{ + public function test_the_scheduler_can_be_subclassed(): void + { + self::assertFalse( + new ReflectionClass(Scheduler::class)->isFinal(), + 'EnableScheduler documents a subclass overriding discovery, so this cannot be final', + ); + } + + public function test_discovery_is_an_overridable_hook(): void + { + $discover = new ReflectionMethod(Scheduler::class, 'discover'); + + self::assertTrue($discover->isProtected(), 'subclasses override discover() to source tasks'); + self::assertFalse($discover->isFinal()); + } + + public function test_the_attribute_defaults_to_the_built_in_scheduler(): void + { + self::assertSame(Scheduler::class, new EnableScheduler()->class); + } +} diff --git a/tests/Schedule/SchedulerTest.php b/tests/Schedule/SchedulerTest.php new file mode 100644 index 0000000..1cf7237 --- /dev/null +++ b/tests/Schedule/SchedulerTest.php @@ -0,0 +1,136 @@ +setValue($scheduler, $tasks); + return $scheduler; + } + + private function untilNext(Scheduler $scheduler, float $now): float + { + return new ReflectionMethod(Scheduler::class, 'untilNext')->invoke($scheduler, $now); + } + + public function test_seed_sets_first_fire_from_now_plus_initial_delay(): void + { + $a = $this->task(0.0); + $b = $this->task(2.5); + $scheduler = $this->withTasks([$a, $b]); + + new ReflectionMethod(Scheduler::class, 'seed')->invoke($scheduler, 100.0); + + self::assertSame(100.0, $a->nextFireAt); + self::assertSame(102.5, $b->nextFireAt); + } + + public function test_until_next_idles_max_when_no_tasks(): void + { + // MAX_SLEEP = 1.0 + self::assertSame(1.0, $this->untilNext($this->withTasks([]), 100.0)); + } + + public function test_until_next_idles_max_when_all_in_flight(): void + { + $t = $this->task(); + $t->nextFireAt = 100.0; // due, but in flight → excluded + $t->inFlight = true; + self::assertSame(1.0, $this->untilNext($this->withTasks([$t]), 100.0)); + } + + public function test_until_next_floors_a_past_due_task(): void + { + // MIN_SLEEP = 0.01 + $t = $this->task(); + $t->nextFireAt = 90.0; // overdue + self::assertSame(0.01, $this->untilNext($this->withTasks([$t]), 100.0)); + } + + public function test_until_next_caps_a_far_future_task(): void + { + $t = $this->task(); + $t->nextFireAt = 200.0; // far away → capped at MAX_SLEEP + self::assertSame(1.0, $this->untilNext($this->withTasks([$t]), 100.0)); + } + + public function test_until_next_returns_soonest_within_the_window(): void + { + $a = $this->task(); + $a->nextFireAt = 100.7; + $b = $this->task(); + $b->nextFireAt = 100.3; // sooner + self::assertEqualsWithDelta(0.3, $this->untilNext($this->withTasks([$a, $b]), 100.0), 1e-9); + } + + public function test_until_next_polls_soon_while_a_run_is_in_flight(): void + { + // Nothing is due for a while, but a run is in flight — the loop must poll + // soon (REAP_POLL = 0.05) to reap it, not idle a whole MAX_SLEEP. + $t = $this->task(); + $t->nextFireAt = 200.0; + $scheduler = $this->withTasks([$t]); + new ReflectionProperty(Scheduler::class, 'running')->setValue($scheduler, [ + 0 => CompletableFuture::completedFuture(null), + ]); + self::assertSame(0.05, $this->untilNext($scheduler, 100.0)); + } + + public function test_reap_finalizes_only_settled_runs(): void + { + // Task 0's run has completed; task 1's is still in flight. + $done = $this->task(); + $done->inFlight = true; + $done->lastStartAt = 100.0; + $pending = $this->task(); + $pending->inFlight = true; + $pending->lastStartAt = 100.0; + + $scheduler = $this->withTasks([$done, $pending]); + new ReflectionProperty(Scheduler::class, 'running')->setValue($scheduler, [ + 0 => CompletableFuture::completedFuture(null), + 1 => new CompletableFuture(), + ]); + + new ReflectionMethod(Scheduler::class, 'reap')->invoke($scheduler); + + // The settled run is finalized: released, counted, its next fire advanced. + self::assertFalse($done->inFlight); + self::assertSame(1, $done->runs); + self::assertNotNull($done->lastEndAt); + self::assertGreaterThan(0.0, $done->nextFireAt); + + // The pending run is untouched and still tracked. + self::assertTrue($pending->inFlight); + self::assertSame(0, $pending->runs); + + $running = new ReflectionProperty(Scheduler::class, 'running')->getValue($scheduler); + self::assertArrayNotHasKey(0, $running); + self::assertArrayHasKey(1, $running); + } +} diff --git a/tests/Schedule/Trigger/CronTriggerTest.php b/tests/Schedule/Trigger/CronTriggerTest.php new file mode 100644 index 0000000..345d2e5 --- /dev/null +++ b/tests/Schedule/Trigger/CronTriggerTest.php @@ -0,0 +1,109 @@ +tz = date_default_timezone_get(); + date_default_timezone_set('UTC'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->tz); + } + + private function next(string $expr, int $fromTs): int + { + return (int) new CronTrigger($expr)->nextFireTime((float) $fromTs, null, null); + } + + public function test_daily_at_night(): void + { + // Mon 2026-06-15 10:30:15 → next 02:00 is the following day. + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame(mktime(2, 0, 0, 6, 16, 2026), $this->next('0 2 * * *', $now)); + } + + public function test_every_minute_and_hour(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame(mktime(10, 31, 0, 6, 15, 2026), $this->next('* * * * *', $now)); + self::assertSame(mktime(11, 0, 0, 6, 15, 2026), $this->next('0 * * * *', $now)); + } + + public function test_step_and_list(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame(mktime(10, 45, 0, 6, 15, 2026), $this->next('*/15 * * * *', $now)); + self::assertSame(mktime(18, 30, 0, 6, 15, 2026), $this->next('30 6,18 * * *', $now)); + } + + public function test_weekday_range_skips_the_weekend(): void + { + // Sat 2026-06-13 09:00 → 08:00 on weekdays lands on Mon 2026-06-15. + $sat = mktime(9, 0, 0, 6, 13, 2026); + self::assertSame(mktime(8, 0, 0, 6, 15, 2026), $this->next('0 8 * * 1-5', $sat)); + } + + public function test_leap_day_resolves_across_years(): void + { + $now = mktime(10, 30, 0, 6, 15, 2026); + self::assertSame(mktime(0, 0, 0, 2, 29, 2028), $this->next('0 0 29 2 *', $now)); + } + + public function test_macro_matches_expanded_form(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + self::assertSame($this->next('0 * * * *', $now), $this->next('@hourly', $now)); + self::assertSame($this->next('0 0 * * *', $now), $this->next('@daily', $now)); + } + + public function test_sunday_is_both_zero_and_seven(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); // Monday + self::assertSame($this->next('0 0 * * 0', $now), $this->next('0 0 * * 7', $now)); + } + + public function test_first_fire_ignores_initial_delay(): void + { + $now = mktime(10, 30, 15, 6, 15, 2026); + $trigger = new CronTrigger('0 2 * * *'); + self::assertSame( + $trigger->nextFireTime((float) $now, null, null), + $trigger->firstFireTime((float) $now, 9999.0) + ); + } + + public function test_describe(): void + { + self::assertSame('cron 0 2 * * *', new CronTrigger('0 2 * * *')->describe()); + } + + /** + * @return list + */ + public static function malformed(): array + { + return [[''], ['1 2 3'], ['60 * * * *'], ['* * * * 9'], ['a b c d e'], ['*/0 * * * *'], ['5-1 * * * *']]; + } + + #[DataProvider('malformed')] + public function test_malformed_expressions_throw(string $expr): void + { + $this->expectException(InvalidArgumentException::class); + new CronTrigger($expr); + } +} diff --git a/tests/Schedule/Trigger/FixedDelayTriggerTest.php b/tests/Schedule/Trigger/FixedDelayTriggerTest.php new file mode 100644 index 0000000..0a271d9 --- /dev/null +++ b/tests/Schedule/Trigger/FixedDelayTriggerTest.php @@ -0,0 +1,37 @@ +nextFireTime(100.0, null, null)); + } + + public function test_next_fire_is_measured_from_last_end_not_start(): void + { + $t = new FixedDelayTrigger(5.0); + // Started at 100, finished at 108 (an 8s run): the next fire is 108 + 5. + self::assertSame(113.0, $t->nextFireTime(108.0, 100.0, 108.0)); + } + + public function test_first_fire_is_boot_plus_initial_delay(): void + { + $t = new FixedDelayTrigger(5.0); + self::assertSame(110.0, $t->firstFireTime(100.0, 10.0)); + self::assertSame(100.0, $t->firstFireTime(100.0, 0.0)); + } + + public function test_describe(): void + { + self::assertSame('fixedDelay 5s', new FixedDelayTrigger(5.0)->describe()); + } +} diff --git a/tests/Schedule/Trigger/FixedRateTriggerTest.php b/tests/Schedule/Trigger/FixedRateTriggerTest.php new file mode 100644 index 0000000..1a90463 --- /dev/null +++ b/tests/Schedule/Trigger/FixedRateTriggerTest.php @@ -0,0 +1,43 @@ +nextFireTime(100.0, null, null)); + } + + public function test_next_fire_is_measured_from_last_start(): void + { + $t = new FixedRateTrigger(2.0); + // Started at 100, finished at 100.5 (a short run): next fire is 100 + 2. + self::assertSame(102.0, $t->nextFireTime(100.5, 100.0, 100.5)); + } + + public function test_overrun_fires_once_now_without_burst(): void + { + $t = new FixedRateTrigger(2.0); + // Started at 100, still finishing at 105 (a 5s run > 2s rate): the + // next fire is already overdue, so it fires now — not once per missed tick. + self::assertSame(105.0, $t->nextFireTime(105.0, 100.0, 105.0)); + } + + public function test_first_fire_is_boot_plus_initial_delay(): void + { + $t = new FixedRateTrigger(2.0); + self::assertSame(110.0, $t->firstFireTime(100.0, 10.0)); + } + + public function test_describe(): void + { + self::assertSame('fixedRate 2s', new FixedRateTrigger(2.0)->describe()); + } +} diff --git a/tests/Unit/Pagination/CursorTokenTest.php b/tests/Unit/Pagination/CursorTokenTest.php new file mode 100644 index 0000000..d57ca3c --- /dev/null +++ b/tests/Unit/Pagination/CursorTokenTest.php @@ -0,0 +1,92 @@ +expectException(InvalidCursorException::class); + $this->expectExceptionMessage('signature mismatch'); + + CursorToken::decode($token, self::SIGNATURE); + } + + /** @return array */ + public static function forgeries(): array + { + $wrap = static fn(array $payload): string => base64_encode((string) json_encode($payload)); + + return [ + 'not base64' => ['!!! not base64 !!!'], + 'not json' => [base64_encode('plain text')], + 'json but not object' => [base64_encode('[1,2,3]')], + 'missing fields' => [$wrap(['s' => self::SIGNATURE])], + 'values not a list' => [$wrap(['s' => self::SIGNATURE, 'v' => 'nope', 'd' => 'f'])], + 'unknown direction' => [$wrap(['s' => self::SIGNATURE, 'v' => [1], 'd' => 'sideways'])], + 'nested array value' => [$wrap(['s' => self::SIGNATURE, 'v' => [[1, 2]], 'd' => 'f'])], + 'object value' => [$wrap(['s' => self::SIGNATURE, 'v' => [['a' => 1]], 'd' => 'f'])], + ]; + } + + #[DataProvider('forgeries')] + public function test_a_forged_token_is_refused_as_an_invalid_cursor(string $token): void + { + // The type matters as much as the rejection: callers catch InvalidCursorException + // to answer 400. Anything else escapes as a 500. + $this->expectException(InvalidCursorException::class); + + CursorToken::decode($token, self::SIGNATURE); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e5a6f6e..f981f33 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -29,5 +29,9 @@ LoggerFactory::setManager(new LoggerManager( contextStorage: new ProcessContext(), - channels: ['sys' => $null, 'http' => $null, 'cli' => $null], + channels: ['sys' => $null, 'http' => $null], )); + +// LoggerFactory's built-in default channel is 'cli'; pin it to 'sys' to match +// Kernel::init() now that 'cli' is no longer a registered channel. +LoggerFactory::setDefaultChannel('sys'); diff --git a/tests/function/TransTest.php b/tests/function/TransTest.php index 7bc4591..75cfc96 100644 --- a/tests/function/TransTest.php +++ b/tests/function/TransTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Flytachi\Winter\K2\Tests\Func; +namespace Flytachi\Winter\Kernel\Tests\Func; -use Flytachi\Winter\K2\Localization\Locale; +use Flytachi\Winter\Kernel\Localization\Locale; use PHPUnit\Framework\TestCase; final class TransTest extends TestCase diff --git a/wKernelRunner b/wKernelRunner index 1fc3563..b16b534 100755 --- a/wKernelRunner +++ b/wKernelRunner @@ -3,18 +3,52 @@ declare(strict_types=1); -if (PHP_VERSION_ID < 80300) { - fwrite(STDERR, "Please use PHP version 8.3 or higher.\n"); +/* + wKernelRunner — the child side of Process::dispatch(). + ------------------------------------------------------ + Detaching a process cannot be a fork (the parent may be a Swoole worker whose + reactor must not be duplicated), so the thread launcher spawns a fresh PHP + process running this file: + + php vendor/bin/wKernelRunner --namespace=… --name=… [--detach] [--shmkey=…] + + A new process shares nothing with its parent, so the application is booted again + from scratch before the staged payload runs — otherwise the dispatched class has + no container, no logging and no configuration. + + The project's bootstrap file is the single place that loads the autoloader and + declares the application class, exactly as `call` uses it; this runner follows the + same convention and then hands off to WinterApplication::executor(). +*/ + +use Flytachi\Winter\Kernel\WinterApplication; + +if (PHP_VERSION_ID < 80400) { + fwrite(STDERR, "Please use PHP version 8.4 or higher.\n"); exit(1); } -$fileAutoloader = dirname(__DIR__, 3) . '/bootstrap.php'; +// vendor/flytachi/winter-kernel/wKernelRunner → the project root. +$bootstrap = dirname(__DIR__, 3) . '/bootstrap.php'; -if (!file_exists($fileAutoloader)) { - fwrite(STDERR, "Error: bootstrap.php not found at {$fileAutoloader}\n"); +if (!file_exists($bootstrap)) { + fwrite(STDERR, "Error: bootstrap.php not found at {$bootstrap}\n"); exit(1); } -require_once $fileAutoloader; +require_once $bootstrap; + +// A background worker outlives the request that spawned it and streams its output +// to a log, so no time limit, no output buffering and no abort on client hang-up. +set_time_limit(0); +ob_implicit_flush(); +ignore_user_abort(true); + +try { + $appClass = WinterApplication::discoverAppClass(); +} catch (Throwable $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + exit(1); +} -Boot::executor($argv); +$appClass::executor($argv);