*/ private $descriptors = []; /** @var array */ private $tasks = []; /** @var bool */ private $collected = false; /** @var bool */ private $resolved = false; public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Test seam. The next use re-fires {@see self::COLLECT_ACTION}, so the * rebuilt registry is identical to the booted one — resetting must never * leave a registry that only one module has repopulated. */ public static function reset(): void { self::$instance = null; } /** * Queue task classes for lazy resolution. * * Takes CLASS NAMES, not instances: nothing is constructed and no * `descriptor()` is called until the registry is actually used. * * @param string[] $classes */ public function register_classes( array $classes ): void { foreach ( $classes as $class ) { $this->pending_classes[] = $class; } $this->resolved = false; } /** * @return array Keyed by task id. */ public function descriptors(): array { $this->resolve(); return $this->descriptors; } /** * Descriptors for tasks that may run unattended. Manual-only tasks are * excluded from scheduled sweeps and from the run-everything action. * * @return array */ public function schedulable_descriptors(): array { return array_filter( $this->descriptors(), static function ( TaskDescriptor $descriptor ) { return $descriptor->schedulable; } ); } public function get_descriptor( string $id ): ?TaskDescriptor { $descriptors = $this->descriptors(); return $descriptors[ $id ] ?? null; } /** * @return string[] */ public function schedulable_ids(): array { return array_keys( $this->schedulable_descriptors() ); } /** * What one task would remove, without removing it. */ public function estimate( string $id, Context $context ): TaskResult { $task = $this->task( $id ); if ( null === $task ) { return TaskResult::empty(); } try { return $task->estimate( $context->with_dry_run( true ) ); } catch ( Throwable $e ) { return TaskResult::empty()->fail( sprintf( 'Estimate failed for "%s": %s', $id, $e->getMessage() ) ); } } /** * Run the named tasks in order, one result each. * * A task that throws is recorded and the sweep continues — one bad task must * not cost the others their run. A task that exhausts the budget ends the * sweep at that boundary; the rest are left for the next run. * * @param string[] $ids * @return array */ public function run( array $ids, Context $context ): array { $results = []; foreach ( $ids as $id ) { $task = $this->task( $id ); if ( null === $task ) { continue; } if ( ! $context->has_budget() ) { $results[ $id ] = TaskResult::empty()->stopped_on_budget(); break; } try { $results[ $id ] = $task->run( $context ); } catch ( Throwable $e ) { // Deliberately caught, not propagated: an unattended sweep that // aborts on the first failing task leaves every later task // permanently unrun, and the failure is invisible until someone // reads the journal. $results[ $id ] = TaskResult::empty()->fail( sprintf( 'Cleanup task "%s" failed: %s', $id, $e->getMessage() ) ); Helper::log( sprintf( 'Cleanup task "%s" threw: %s', $id, $e->getMessage() ), 'cleanup', 'error' ); } } return $results; } /** * Resolve a task instance, or null when the id is unknown. */ private function task( string $id ): ?CleanupTask { $this->resolve(); return $this->tasks[ $id ] ?? null; } /** * Ask contributors to declare themselves, then turn class names into * descriptors. Idempotent. */ private function resolve(): void { // Once per registry lifetime, and BEFORE the pending list is read below — // handlers call register_classes(), which appends to it. `collected` is // set first so a handler that touches the registry cannot recurse. if ( ! $this->collected ) { $this->collected = true; /** * Declare a module's cleanup tasks. * * @param TaskRegistry $registry Call `register_classes()` on it. */ do_action( self::COLLECT_ACTION, $this ); } if ( $this->resolved ) { return; } // Set BEFORE the loop for the same reason. $this->resolved = true; $pending = $this->pending_classes; $this->pending_classes = []; foreach ( $pending as $class ) { if ( ! is_string( $class ) || ! class_exists( $class ) ) { // A module that was disabled or removed between registering and // resolving simply contributes nothing. Not an error. continue; } try { $task = new $class(); if ( ! $task instanceof CleanupTask ) { continue; } $descriptor = TaskDescriptor::from_array( $task->descriptor(), $class ); } catch ( InvalidArgumentException $e ) { // A malformed descriptor is a bug in the contributing module. // Skip that one task and keep the rest of the registry usable — // taking the whole cleaner down over one bad contributor is // worse than running without it. Helper::log( $e->getMessage(), 'cleanup', 'error' ); continue; } catch ( Throwable $e ) { Helper::log( sprintf( 'Cleanup task %s could not be constructed: %s', $class, $e->getMessage() ), 'cleanup', 'error' ); continue; } if ( isset( $this->descriptors[ $descriptor->id ] ) ) { Helper::log( sprintf( 'Duplicate cleanup task id "%s" from %s — ignored.', $descriptor->id, $class ), 'cleanup', 'error' ); continue; } $this->descriptors[ $descriptor->id ] = $descriptor; $this->tasks[ $descriptor->id ] = $task; } } }