diff --git a/README.md b/README.md index 3a696e9..38aa5da 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ Every setting can be configured via PHP constant (in `wp-config.php`) or environ | `QUEUE_WORKER_JOB_TIMEOUT` | `300` | Per-job timeout in seconds (SIGALRM) | | `QUEUE_WORKER_BATCH_TIMEOUT` | `3600` | Subprocess timeout in seconds (safety net) | | `QUEUE_WORKER_RESCAN_INTERVAL` | `60` | Seconds between database rescans | +| `QUEUE_WORKER_SCHEDULING_HORIZON` | `3600` | Only keep timers for jobs due within this many seconds; never shorter than the rescan interval | +| `QUEUE_WORKER_SCAN_TIMEOUT` | `300` | Full-network scanner subprocess timeout in seconds | | `QUEUE_WORKER_MEMORY_LIMIT` | `200` | Memory limit in MB before auto-restart | | `QUEUE_WORKER_UPTIME_LIMIT` | `3600` | Max uptime in seconds before auto-restart | | `QUEUE_WORKER_LOG_FILE` | auto-detect | Path to log for admin viewer | diff --git a/bin/scan-cron.php b/bin/scan-cron.php index 8149327..823ff36 100644 --- a/bin/scan-cron.php +++ b/bin/scan-cron.php @@ -67,6 +67,7 @@ } use QueueWorker\Bootstrap; +use QueueWorker\Config; use QueueWorker\Cron_Event_Filter; use QueueWorker\Job_Payload; @@ -90,9 +91,13 @@ require_once $wp_load; +$scheduling_horizon = max(1, (int) ($payload['scheduling_horizon'] ?? Config::scheduling_horizon())); +$scan_timeout = max(1, (int) ($payload['scan_timeout'] ?? Config::scan_timeout())); $payloads = []; $isolated_network_id = (int) ($payload['isolated_network_id'] ?? 0); -if ($isolated_network_id > 0) { +if (!empty($payload['full_network'])) { + $payloads = qw_scan_full_network_jobs($scheduling_horizon, $scan_timeout); +} elseif ($isolated_network_id > 0) { if (!defined('WU_MT_LEGACY_ISOLATED_NETWORK') || (int) WU_MT_LEGACY_ISOLATED_NETWORK !== $isolated_network_id ) { @@ -113,7 +118,7 @@ switch_to_blog($local_site_id); } - $payloads = array_merge($payloads, qw_scan_current_site_jobs()); + $payloads = array_merge($payloads, qw_scan_current_site_jobs($scheduling_horizon)); if ($switched) { restore_current_blog(); @@ -131,7 +136,7 @@ switch_to_blog($site_id); } - $payloads = qw_scan_current_site_jobs(); + $payloads = qw_scan_current_site_jobs($scheduling_horizon); } // A plugin can leave nested output buffers open. Discard every buffer opened @@ -158,16 +163,96 @@ fwrite(STDOUT, $encoded_payloads); -function qw_scan_current_site_jobs(): array +function qw_scan_full_network_jobs(int $scheduling_horizon, int $scan_timeout): array +{ + $payloads = []; + $deadline = time() + $scan_timeout; + $sovereign_sites = qw_scan_sovereign_site_entries(); + $isolated_networks = qw_scan_isolated_network_entries(); + $initial_blog_id = get_current_blog_id(); + $site_ids = is_multisite() ? get_sites(['number' => 0, 'fields' => 'ids']) : [$initial_blog_id]; + + foreach ($site_ids as $site_id) { + $site_id = (int) $site_id; + if (isset($sovereign_sites[$site_id])) { + continue; + } + + $site = get_site($site_id); + $network_id = $site ? (int) $site->site_id : 0; + if (isset($isolated_networks[$network_id])) { + continue; + } + + $switched = $site_id !== get_current_blog_id(); + if ($switched) { + switch_to_blog($site_id); + } + foreach (qw_scan_current_site_jobs($scheduling_horizon) as $payload) { + $payloads[] = $payload; + } + if ($switched) { + restore_current_blog(); + } + } + + if ($initial_blog_id !== get_current_blog_id()) { + switch_to_blog($initial_blog_id); + } + + foreach ($sovereign_sites as $site_id => $entry) { + $remaining = $deadline - time(); + if ($remaining <= 0) { + fwrite(STDERR, sprintf("Scan budget exhausted before sovereign site %d.\n", $site_id)); + return $payloads; + } + + $site_payloads = qw_scan_registry_jobs([ + 'site_id' => (int) $site_id, + 'site_url' => qw_scan_site_url_from_registry_entry($entry), + 'scheduling_horizon' => $scheduling_horizon, + 'scan_timeout' => $remaining, + ], 'sovereign site ' . $site_id, $remaining); + foreach ($site_payloads as $payload) { + $payloads[] = $payload; + } + } + + foreach ($isolated_networks as $network_id => $entry) { + $remaining = $deadline - time(); + if ($remaining <= 0) { + fwrite(STDERR, sprintf("Scan budget exhausted before isolated network %d.\n", $network_id)); + return $payloads; + } + + $network_payloads = qw_scan_registry_jobs([ + 'isolated_network_id' => (int) $network_id, + 'site_url' => qw_scan_site_url_from_registry_entry($entry), + 'scheduling_horizon' => $scheduling_horizon, + 'scan_timeout' => $remaining, + ], 'isolated network ' . $network_id, $remaining); + foreach ($network_payloads as $payload) { + $payloads[] = $payload; + } + } + + return $payloads; +} + +function qw_scan_current_site_jobs(int $scheduling_horizon): array { wp_cache_delete('cron', 'options'); wp_cache_delete('alloptions', 'options'); $payloads = []; + $deadline = time() + $scheduling_horizon; $crons = _get_cron_array(); if (is_array($crons)) { $seen_cron_signatures = []; foreach ($crons as $timestamp => $hooks) { + if ((int) $timestamp > $deadline) { + continue; + } if (!is_array($hooks)) { continue; } @@ -200,9 +285,11 @@ function qw_scan_current_site_jobs(): array ]); foreach ($actions as $action_id => $action) { $action_payload = Job_Payload::from_as_action($action_id); - if ($action_payload) { - $payloads[] = json_decode($action_payload->to_json(), true); + if (!$action_payload || $action_payload->timestamp > $deadline) { + continue; } + + $payloads[] = json_decode($action_payload->to_json(), true); } } catch (\Throwable $e) { fwrite(STDERR, "Action Scheduler scan failed: " . $e->getMessage() . "\n"); @@ -212,6 +299,192 @@ function qw_scan_current_site_jobs(): array return $payloads; } +function qw_scan_sovereign_site_entries(): array +{ + if (!defined('WP_CONTENT_DIR')) { + return []; + } + + $data = qw_scan_registry_data(WP_CONTENT_DIR . '/site-registry.data.json'); + if (empty($data['sites']) || !is_array($data['sites'])) { + return []; + } + + $entries = []; + foreach ($data['sites'] as $site_id => $entry) { + if (!is_array($entry) + || ($entry['isolation_model'] ?? '') !== 'sovereign' + || ($entry['status'] ?? 'active') !== 'active' + || qw_scan_site_url_from_registry_entry($entry) === '' + ) { + continue; + } + $entries[(int) $site_id] = $entry; + } + + return $entries; +} + +function qw_scan_isolated_network_entries(): array +{ + if (!defined('WP_CONTENT_DIR')) { + return []; + } + + $data = qw_scan_registry_data(WP_CONTENT_DIR . '/network-registry.data.json'); + if (empty($data['networks']) || !is_array($data['networks'])) { + return []; + } + + $entries = []; + foreach ($data['networks'] as $registry_id => $entry) { + if (!is_array($entry) + || ($entry['tier'] ?? '') !== 'isolated' + || ($entry['status'] ?? 'active') !== 'active' + || qw_scan_site_url_from_registry_entry($entry) === '' + ) { + continue; + } + + $network_id = (int) ($entry['network_id'] ?? $entry['id'] ?? $registry_id); + if ($network_id > 0) { + $entries[$network_id] = $entry; + } + } + + return $entries; +} + +function qw_scan_registry_data(string $path): array +{ + if (!is_readable($path)) { + return []; + } + + $data = json_decode((string) file_get_contents($path), true); + return is_array($data) ? $data : []; +} + +function qw_scan_site_url_from_registry_entry(array $entry): string +{ + $domains = $entry['domains'] ?? []; + if (!is_array($domains)) { + $domains = []; + } + if (!empty($entry['domain'])) { + array_unshift($domains, $entry['domain']); + } + + foreach ($domains as $domain) { + $domain = trim((string) $domain); + if ($domain !== '') { + return 'https://' . $domain . '/'; + } + } + + return ''; +} + +function qw_scan_registry_jobs(array $payload, string $description, int $scan_timeout): array +{ + $process = proc_open([PHP_BINARY, __FILE__, '--stdin'], [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + if (!is_resource($process)) { + fwrite(STDERR, sprintf("Could not start scanner for %s.\n", $description)); + return []; + } + + $encoded_payload = json_encode($payload); + if ($encoded_payload === false || fwrite($pipes[0], $encoded_payload) === false) { + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_terminate($process, 9); + proc_close($process); + fwrite(STDERR, sprintf("Could not configure scanner for %s.\n", $description)); + return []; + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $stdout = ''; + $stderr = ''; + $started = time(); + do { + $out = stream_get_contents($pipes[1]); + if ($out !== false && $out !== '') { + $stdout .= $out; + } + $err = stream_get_contents($pipes[2]); + if ($err !== false && $err !== '') { + $stderr .= $err; + } + + $status = proc_get_status($process); + if (!$status['running']) { + break; + } + if (time() - $started >= $scan_timeout) { + proc_terminate($process, 9); + fwrite(STDERR, sprintf("Scanner for %s exceeded %d seconds.\n", $description, $scan_timeout)); + break; + } + usleep(10000); + } while (true); + + $remaining = stream_get_contents($pipes[1]); + if ($remaining !== false && $remaining !== '') { + $stdout .= $remaining; + } + $remaining_error = stream_get_contents($pipes[2]); + if ($remaining_error !== false && $remaining_error !== '') { + $stderr .= $remaining_error; + } + fclose($pipes[1]); + fclose($pipes[2]); + $close_code = proc_close($process); + $exit_code = (int) ($status['exitcode'] ?? $close_code); + if ($exit_code === -1) { + $exit_code = $close_code; + } + + if ($exit_code !== 0 || $stdout === '') { + fwrite(STDERR, sprintf( + "Scanner for %s failed (exit %d, stdout_bytes=%d, stderr_bytes=%d).\n", + $description, + $exit_code, + strlen($stdout), + strlen($stderr) + )); + return []; + } + + try { + $jobs = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + fwrite(STDERR, sprintf("Scanner for %s returned invalid JSON.\n", $description)); + return []; + } + + if (!array_is_list($jobs)) { + fwrite(STDERR, sprintf("Scanner for %s returned an invalid payload shape.\n", $description)); + return []; + } + + foreach ($jobs as $job) { + if (!is_array($job)) { + fwrite(STDERR, sprintf("Scanner for %s returned an invalid payload shape.\n", $description)); + return []; + } + } + + return $jobs; +} + function qw_scan_action_scheduler_tables_exist(): bool { global $wpdb; diff --git a/bin/worker.php b/bin/worker.php index fc85f68..cb9c525 100644 --- a/bin/worker.php +++ b/bin/worker.php @@ -91,6 +91,7 @@ $process = new Worker_Process($wp_load, $primary_domain, $execute_script, $scan_script); $worker->onWorkerStart = [$process, 'on_worker_start']; +$worker->onWorkerStop = [$process, 'on_worker_stop']; $worker->onMessage = [$process, 'on_message']; Worker::runAll(); diff --git a/src/class-config.php b/src/class-config.php index 9fc06ce..da6e910 100644 --- a/src/class-config.php +++ b/src/class-config.php @@ -150,6 +150,16 @@ public static function rescan_interval(): int return (int) self::get('QUEUE_WORKER_RESCAN_INTERVAL', 60); } + public static function scheduling_horizon(): int + { + return max(1, self::rescan_interval(), (int) self::get('QUEUE_WORKER_SCHEDULING_HORIZON', 3600)); + } + + public static function scan_timeout(): int + { + return max(1, (int) self::get('QUEUE_WORKER_SCAN_TIMEOUT', 300)); + } + public static function action_scheduler_rescan_interval(): int { return max(1, (int) self::get('QUEUE_WORKER_AS_RESCAN_INTERVAL', 5)); diff --git a/src/class-worker-process.php b/src/class-worker-process.php index 7ec6b40..33a0f96 100644 --- a/src/class-worker-process.php +++ b/src/class-worker-process.php @@ -42,6 +42,8 @@ class Worker_Process private int $as_rescan_interval; private int $batch_timeout; private int $rescan_interval; + private int $scheduling_horizon; + private int $scan_timeout; private int $memory_limit; private int $uptime_limit; @@ -64,6 +66,8 @@ class Worker_Process private int $running_jobs = 0; private int $start_time; private bool $is_rescanning = false; + /** @var array{process: resource, pipes: array, started: int, stdout: string, stderr: string}|null */ + private ?array $active_scan_process = null; private int $last_rescan_started = 0; private int $last_rescan_finished = 0; private int $last_rescan_duration = 0; @@ -92,6 +96,8 @@ public function __construct(string $wp_load, string $primary_domain, string $exe $this->as_rescan_interval = Config::action_scheduler_rescan_interval(); $this->batch_timeout = Config::batch_timeout(); $this->rescan_interval = Config::rescan_interval(); + $this->scheduling_horizon = Config::scheduling_horizon(); + $this->scan_timeout = Config::scan_timeout(); $this->memory_limit = Config::memory_limit(); $this->uptime_limit = Config::uptime_limit(); $this->start_time = time(); @@ -129,7 +135,8 @@ public function on_worker_start(Worker $w): void // lane so due AS jobs are not starved behind noisy WP-Cron batches. Timer::add(1, fn() => $this->flush_batches()); - // Subprocess polling timer — every 0.5 seconds + // Subprocess polling timer — every 0.5 seconds. Full-network scans use + // the same non-blocking poll cycle so they cannot stall this event loop. Timer::add(0.5, fn() => $this->poll_processes($worker_id)); // Initial DB scan. Only one coordinator worker performs the expensive @@ -138,7 +145,7 @@ public function on_worker_start(Worker $w): void if ($this->is_rescan_coordinator($worker_id)) { Worker::log(sprintf('[W%d] Scanning database for pending jobs...', $worker_id)); $this->run_full_rescan($worker_id); - Worker::log(sprintf('[W%d] Loaded %d pending jobs.', $worker_id, count($this->pending_timers))); + Worker::log(sprintf('[W%d] Full-network scan started in a subprocess.', $worker_id)); } else { Worker::log(sprintf('[W%d] Skipping full-network scan; worker 0 is rescan coordinator.', $worker_id)); } @@ -210,6 +217,14 @@ public function on_message($connection, string $data): void } } + /** + * Workerman onWorkerStop callback. + */ + public function on_worker_stop(): void + { + $this->terminate_active_scan(); + } + // ------------------------------------------------------------------ // Private methods // ------------------------------------------------------------------ @@ -258,6 +273,10 @@ private function schedule_timer(Job_Payload $payload): void return; } + if ($payload->timestamp > time() + $this->scheduling_horizon) { + return; + } + $key = $payload->tracking_key(); if (isset($this->pending_timers[$key])) { return; @@ -732,6 +751,8 @@ private function running_process_count(string $lane): int */ private function poll_processes(int $worker_id): void { + $this->poll_full_rescan($worker_id); + foreach ($this->running_processes as $i => $proc) { $cron_site_lock_owner = $proc['cron_site_lock_owner']; $lock_refreshed = $proc['cron_site_lock_refreshed']; @@ -944,16 +965,215 @@ private function run_full_rescan(int $worker_id): void return; } + if ($this->active_scan_process !== null) { + Worker::log(sprintf('[W%d][RESCAN] Scanner process is still active; skipping overlap.', $worker_id)); + return; + } + $this->is_rescanning = true; $this->last_rescan_started = time(); + if (!$this->start_full_rescan($worker_id)) { + $this->finish_full_rescan(); + } + } + + private function start_full_rescan(int $worker_id): bool + { + if ($this->scan_script === '' || !file_exists($this->scan_script)) { + Worker::log(sprintf('[W%d][RESCAN][ERROR] Missing full-network scan script.', $worker_id)); + return false; + } + + $process = proc_open([PHP_BINARY, $this->scan_script, '--stdin'], [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + + if (!is_resource($process)) { + Worker::log(sprintf('[W%d][RESCAN][ERROR] Failed to spawn full-network scanner.', $worker_id)); + return false; + } + + $payload = json_encode([ + 'site_url' => 'https://' . $this->primary_domain . '/', + 'full_network' => true, + 'scheduling_horizon' => $this->scheduling_horizon, + 'scan_timeout' => $this->scan_timeout, + ]); + if ($payload === false || fwrite($pipes[0], $payload) === false) { + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_terminate($process, 9); + proc_close($process); + Worker::log(sprintf('[W%d][RESCAN][ERROR] Failed to send scanner configuration.', $worker_id)); + return false; + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $this->active_scan_process = [ + 'process' => $process, + 'pipes' => $pipes, + 'started' => time(), + 'stdout' => '', + 'stderr' => '', + ]; + + return true; + } + + private function poll_full_rescan(int $worker_id): void + { + if ($this->active_scan_process === null) { + return; + } + + $scan = $this->active_scan_process; + foreach ([1 => 'stdout', 2 => 'stderr'] as $pipe_id => $stream) { + $output = stream_get_contents($scan['pipes'][$pipe_id]); + if ($output !== false && $output !== '') { + $this->active_scan_process[$stream] .= $output; + } + } + + $status = proc_get_status($scan['process']); + if ($status['running']) { + if (time() - $scan['started'] >= $this->scan_timeout) { + $pid = (int) ($status['pid'] ?? 0); + $this->terminate_active_scan(); + Worker::log(sprintf( + '[W%d][RESCAN][TIMEOUT] Full-network scan exceeded %ds limit (pid %d).', + $worker_id, + $this->scan_timeout, + $pid + )); + $this->finish_full_rescan(); + } + return; + } + + foreach ([1 => 'stdout', 2 => 'stderr'] as $pipe_id => $stream) { + $remaining = stream_get_contents($scan['pipes'][$pipe_id]); + if ($remaining !== false && $remaining !== '') { + $this->active_scan_process[$stream] .= $remaining; + } + fclose($scan['pipes'][$pipe_id]); + } + $close_code = proc_close($scan['process']); + + $stdout = $this->active_scan_process['stdout']; + $stderr = $this->active_scan_process['stderr']; + $this->active_scan_process = null; + $exit_code = (int) $status['exitcode']; + if ($exit_code === -1) { + $exit_code = $close_code; + } + + if ($exit_code !== 0) { + Worker::log(sprintf( + '[W%d][RESCAN][FAIL] Full-network scanner exited with code %d (%s).', + $worker_id, + $exit_code, + $this->sovereign_scan_diagnostic('stderr', $stderr) + )); + $this->finish_full_rescan(); + return; + } + + if ($stderr !== '') { + Worker::log(sprintf( + '[W%d][RESCAN][WARN] Full-network scanner reported partial failures (%s).', + $worker_id, + $this->sovereign_scan_diagnostic('stderr', $stderr) + )); + } + + $scheduled = $this->schedule_scan_output($stdout, $stderr, $worker_id); + Worker::log(sprintf( + '[W%d][RESCAN][DONE] Loaded %d jobs within the %ds scheduling horizon.', + $worker_id, + $scheduled, + $this->scheduling_horizon + )); + $this->finish_full_rescan(); + } + + private function schedule_scan_output(string $stdout, string $stderr, int $worker_id): int + { + if ($stdout === '') { + Worker::log(sprintf( + '[W%d][RESCAN][FAIL] Full-network scanner returned empty output (%s).', + $worker_id, + $this->sovereign_scan_diagnostic('stderr', $stderr) + )); + return 0; + } + try { - $this->rescan_all_jobs(); - } finally { - $this->last_rescan_finished = time(); - $this->last_rescan_duration = max(0, $this->last_rescan_finished - $this->last_rescan_started); - $this->is_rescanning = false; + $payloads = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + Worker::log(sprintf( + '[W%d][RESCAN][FAIL] Full-network scanner returned invalid JSON (%s; %s; %s).', + $worker_id, + $e->getMessage(), + $this->sovereign_scan_diagnostic('stdout', $stdout), + $this->sovereign_scan_diagnostic('stderr', $stderr) + )); + return 0; + } + + if (!array_is_list($payloads)) { + Worker::log(sprintf('[W%d][RESCAN][FAIL] Full-network scanner returned an invalid payload shape.', $worker_id)); + return 0; + } + + $validated_payloads = []; + foreach ($payloads as $payload_data) { + if (!is_array($payload_data)) { + Worker::log(sprintf('[W%d][RESCAN][FAIL] Full-network scanner returned an invalid payload shape.', $worker_id)); + return 0; + } + try { + $validated_payloads[] = new Job_Payload($payload_data); + } catch (\Throwable $e) { + Worker::log(sprintf('[W%d][RESCAN][FAIL] Full-network scanner returned invalid routing metadata (%s).', $worker_id, $e->getMessage())); + return 0; + } + } + + $pending_before = count($this->pending_timers); + foreach ($validated_payloads as $payload) { + $this->schedule_timer($payload); + } + + return count($this->pending_timers) - $pending_before; + } + + private function finish_full_rescan(): void + { + $this->last_rescan_finished = time(); + $this->last_rescan_duration = max(0, $this->last_rescan_finished - $this->last_rescan_started); + $this->is_rescanning = false; + } + + private function terminate_active_scan(): void + { + if ($this->active_scan_process === null) { + return; + } + + proc_terminate($this->active_scan_process['process'], 9); + foreach ([1, 2] as $pipe_id) { + if (is_resource($this->active_scan_process['pipes'][$pipe_id])) { + fclose($this->active_scan_process['pipes'][$pipe_id]); + } } + proc_close($this->active_scan_process['process']); + $this->active_scan_process = null; } /** @@ -1445,6 +1665,7 @@ private function handle_command($connection, array $cmd): void 'running_details' => $running_details, 'rescan' => [ 'in_progress' => $this->is_rescanning, + 'horizon' => $this->scheduling_horizon, 'last_started' => $this->last_rescan_started, 'last_finished' => $this->last_rescan_finished, 'last_duration' => $this->last_rescan_duration, diff --git a/tests/regression.php b/tests/regression.php index 3e8a0c4..69b4388 100644 --- a/tests/regression.php +++ b/tests/regression.php @@ -559,6 +559,7 @@ function invoke_private(object $object, string $method, array $args = []) ]); assert_same($cron_route_a->tracking_key(), $cron_route_b->tracking_key(), 'WP-Cron identity must not depend on its bootstrap URL'); + putenv('QUEUE_WORKER_SCHEDULING_HORIZON=2147483647'); $failed_bootstrap_file = tempnam(sys_get_temp_dir(), 'qw-bootstrap-failure-'); assert_true(false !== $failed_bootstrap_file, 'Bootstrap failure fixture must be created'); assert_true( @@ -597,6 +598,29 @@ function invoke_private(object $object, string $method, array $args = []) invoke_private($identity_worker, 'schedule_timer', [$cron_route_a]); invoke_private($identity_worker, 'schedule_timer', [$cron_route_b]); assert_same(1, count(private_property($identity_worker, 'pending_timers')), 'Route variants of one WP-Cron event must collapse to one timer'); + $horizon_worker = new Worker_Process(__FILE__, 'example.test', __FILE__); + set_private_property($horizon_worker, 'scheduling_horizon', 60); + invoke_private($horizon_worker, 'schedule_timer', [new Job_Payload([ + 'site_id' => 7, + 'site_url' => 'https://tenant.example.test', + 'hook' => 'within_horizon_hook', + 'args' => [], + 'timestamp' => time() + 30, + 'source' => 'wp_cron', + ])]); + invoke_private($horizon_worker, 'schedule_timer', [new Job_Payload([ + 'site_id' => 7, + 'site_url' => 'https://tenant.example.test', + 'hook' => 'beyond_horizon_hook', + 'args' => [], + 'timestamp' => time() + 3600, + 'source' => 'wp_cron', + ])]); + assert_same( + 1, + count(private_property($horizon_worker, 'pending_timers')), + 'The worker must not retain timers beyond its bounded scheduling horizon' + ); Worker_Process::ensure_lock_table(); assert_true( @@ -745,6 +769,69 @@ function invoke_private(object $object, string $method, array $args = []) 'Sovereign scan script must isolate bootstrap output and fail safely when encoding JSON' ); assert_true(unlink($scan_fixture), 'Sovereign scan fixture must be removed'); + assert_true( + str_contains($scan_script, 'qw_scan_full_network_jobs($scheduling_horizon, $scan_timeout)') + && str_contains($scan_script, 'qw_scan_registry_jobs([') + && str_contains($scan_script, '$deadline = time() + $scheduling_horizon;'), + 'The scanner subprocess must enumerate the full network and reject jobs beyond the scheduling horizon' + ); + + $async_scan_fixture = tempnam(sys_get_temp_dir(), 'qw-async-scan-'); + assert_true(false !== $async_scan_fixture, 'Asynchronous scan fixture must be created'); + $async_scan_output = json_encode([[ + 'site_id' => 7, + 'site_url' => 'https://tenant.example.test', + 'hook' => 'async_scan_hook', + 'args' => [], + 'timestamp' => time() + 30, + 'source' => 'wp_cron', + ]]); + assert_true(false !== file_put_contents( + $async_scan_fixture, + 'on_worker_stop(); + assert_same(null, private_property($shutdown_scan_worker, 'active_scan_process'), 'Worker shutdown must terminate and reap the scanner subprocess'); + assert_true(unlink($async_scan_fixture), 'Asynchronous scan fixture must be removed'); \Workerman\Timer::$delays = []; $registry_content_dir = sys_get_temp_dir() . '/qw-regression-content-' . getmypid(); @@ -879,7 +966,7 @@ function invoke_private(object $object, string $method, array $args = []) assert_true( str_contains($scan_script, "defined('WU_MT_LEGACY_ISOLATED_NETWORK')") && str_contains($scan_script, "get_sites(['number' => 0, 'fields' => 'ids'])") - && str_contains($scan_script, 'qw_scan_current_site_jobs()'), + && str_contains($scan_script, 'qw_scan_current_site_jobs($scheduling_horizon)'), 'Isolated scanner bootstrap must validate the routed network and enumerate every tenant-local site' ); assert_true( @@ -1190,6 +1277,21 @@ function invoke_private(object $object, string $method, array $args = []) assert_true(isset($GLOBALS['test_crons'][$recurring_target]['duplicate_recurring_malformed_successor_hook'][$recurring_key]), 'A malformed recurring successor key must be repaired to WordPress canonical form'); assert_true(!isset($GLOBALS['test_crons'][$recurring_target]['duplicate_recurring_malformed_successor_hook'][$malformed_successor_key]), 'The malformed recurring successor key must be removed after repair'); + putenv('QUEUE_WORKER_SCHEDULING_HORIZON'); + putenv('QUEUE_WORKER_RESCAN_INTERVAL'); + assert_same(3600, Config::scheduling_horizon(), 'Scheduling horizon must default to one hour'); + putenv('QUEUE_WORKER_SCHEDULING_HORIZON=12'); + assert_same(60, Config::scheduling_horizon(), 'Scheduling horizon must not be shorter than the rescan interval'); + putenv('QUEUE_WORKER_RESCAN_INTERVAL=10'); + assert_same(12, Config::scheduling_horizon(), 'Scheduling horizon must remain configurable above the rescan interval'); + putenv('QUEUE_WORKER_SCAN_TIMEOUT'); + assert_same(300, Config::scan_timeout(), 'Full-network scan timeout must default to five minutes'); + putenv('QUEUE_WORKER_SCAN_TIMEOUT=0'); + assert_same(1, Config::scan_timeout(), 'Full-network scan timeout must be clamped to at least one second'); + putenv('QUEUE_WORKER_SCAN_TIMEOUT'); + putenv('QUEUE_WORKER_RESCAN_INTERVAL'); + putenv('QUEUE_WORKER_SCHEDULING_HORIZON=2147483647'); + putenv('QUEUE_WORKER_AS_RESCAN_INTERVAL'); assert_same(5, Config::action_scheduler_rescan_interval(), 'AS rescan interval must default to five seconds'); putenv('QUEUE_WORKER_AS_RESCAN_INTERVAL=12');