resolveProductRepository( $registry ); $jobs = $this->resolveSyncJobRepository( $registry ); $options = $this->resolveOptionStore( $registry ); $registryProviders = $this->resolveProviderRegistry( $registry ); $invalidator = $this->resolveCacheInvalidator( $registry ); $clock = new SystemClock(); $resolver = new RegistryBackedProviderResolver( $registryProviders ); $workerId = $this->resolveWorkerId( $options ); $keywordExtractor = new CloudProductSearchKeywordsExtractor( new OptionStoreSettingsRepository( $options ), new FallbackProductSearchKeywordsExtractor() ); $handler = new SyncProductJobHandler( $products, new SaveProductUseCase( $products, $clock, $keywordExtractor ), $jobs, $clock, RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT ), $resolver, $invalidator ); $auditWriter = $this->resolveOptionalAuditEventWriter( $registry, $clock ); $this->auditRetentionPurger = $this->resolveOptionalAuditRetentionPurger( $registry, $clock, $auditWriter ); $this->clickRetentionPurger = $this->resolveOptionalClickRetentionPurger( $registry, $clock ); $worker = new SyncWorker( $jobs, $handler, $clock, $workerId, SyncWorker::DEFAULT_BATCH_SIZE, SyncWorker::DEFAULT_LOCK_TIMEOUT_SECONDS, $auditWriter ); $policy = new SyncPolicy( self::DEFAULT_STALE_AFTER_SECONDS, self::DEFAULT_EXPIRED_AFTER_SECONDS, $clock ); $finder = new StaleProductFinder( $products, $policy ); $dispatcher = new SyncDispatcher( $jobs, $clock, RetryPolicy::forJobType( RetryPolicy::JOB_SYNC_PRODUCT ) ); $scheduler = new PeriodicSyncScheduler( $finder, $policy, $dispatcher, $clock ); $this->worker = $worker; $this->scheduler = $scheduler; $this->optionStore = $options; } /** * Wire the cron-related WordPress hooks through the supplied * {@see HookRegistrar}: * * - `cron_schedules` filter that registers the * {@see self::INTERVAL_EVERY_MINUTE} and * {@see self::INTERVAL_EVERY_FIVE_MINUTES} custom intervals. * * - `affilun_activate` action that schedules both cron events. * * - {@see self::HOOK_DISPATCH_JOBS} action that drives one * {@see SyncWorker::tick()} per fire. * * - {@see self::HOOK_PERIODIC_SYNC} action that drives one * {@see PeriodicSyncScheduler::tick()} per fire. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { $hooks->addFilter( 'cron_schedules', static function ( $schedules ): array { if ( ! is_array( $schedules ) ) { $schedules = array(); } if ( ! array_key_exists( self::INTERVAL_EVERY_MINUTE, $schedules ) ) { $schedules[ self::INTERVAL_EVERY_MINUTE ] = array( 'interval' => 60, 'display' => 'affilun: every minute', ); } if ( ! array_key_exists( self::INTERVAL_EVERY_FIVE_MINUTES, $schedules ) ) { $schedules[ self::INTERVAL_EVERY_FIVE_MINUTES ] = array( 'interval' => 300, 'display' => 'affilun: every five minutes', ); } if ( ! array_key_exists( self::INTERVAL_DAILY_AUDIT_PURGE, $schedules ) ) { $schedules[ self::INTERVAL_DAILY_AUDIT_PURGE ] = array( 'interval' => 86400, 'display' => 'affilun: daily audit retention purge', ); } if ( ! array_key_exists( self::INTERVAL_DAILY_CLICK_PURGE, $schedules ) ) { $schedules[ self::INTERVAL_DAILY_CLICK_PURGE ] = array( 'interval' => 86400, 'display' => 'affilun: daily click retention purge', ); } return $schedules; } ); $hooks->addAction( 'affilun_activate', function (): void { $this->onActivate(); } ); $hooks->addAction( 'init', function (): void { $this->ensureScheduled(); } ); $worker = $this->worker; $scheduler = $this->scheduler; $hooks->addAction( self::HOOK_DISPATCH_JOBS, static function () use ( $worker ): void { if ( $worker === null ) { return; } try { $worker->tick(); } catch ( Throwable $e ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional. error_log( 'AFFILUN SyncWorker tick failed: ' . $e->getMessage() ); } } } ); $hooks->addAction( self::HOOK_PERIODIC_SYNC, static function () use ( $scheduler ): void { if ( $scheduler === null ) { return; } try { $scheduler->tick(); } catch ( Throwable $e ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional. error_log( 'AFFILUN PeriodicSyncScheduler tick failed: ' . $e->getMessage() ); } } } ); $auditRetentionPurger = $this->auditRetentionPurger; $clickRetentionPurger = $this->clickRetentionPurger; $hooks->addAction( self::HOOK_AUDIT_RETENTION_PURGE, static function () use ( $auditRetentionPurger ): void { if ( $auditRetentionPurger === null ) { return; } try { $auditRetentionPurger->purge(); } catch ( Throwable $e ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional. error_log( 'AFFILUN AuditRetentionPurger purge failed: ' . $e->getMessage() ); } } } ); $hooks->addAction( self::HOOK_CLICK_RETENTION_PURGE, static function () use ( $clickRetentionPurger ): void { if ( $clickRetentionPurger === null ) { return; } try { $clickRetentionPurger->purge(); } catch ( Throwable $e ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Cron failure surface; intentional. error_log( 'AFFILUN ClickRetentionPurger purge failed: ' . $e->getMessage() ); } } } ); } /** * Activation entry point: persist the worker id (if absent) and * schedule both cron events on their custom intervals. * * Idempotent: re-running activation never double-schedules an * event because {@see wp_next_scheduled()} short-circuits the * second call. The worker id is also preserved across * deactivation / reactivation cycles so the §8.5 reclamation * audit trail stays correlated. */ public function onActivate(): void { if ( $this->optionStore !== null ) { $this->resolveWorkerId( $this->optionStore ); } $this->ensureScheduled(); } /** * Ensure existing installs have the required sync cron events. * * Plugin activation schedules these hooks, but WP-Cron rows can be * removed independently by cache/cron management tools or migrations. * Re-checking on normal bootstrap keeps the dispatch worker paired * with the periodic enqueue sweep so stale products do not accumulate * pending jobs indefinitely. */ public function ensureScheduled(): void { if ( function_exists( 'wp_next_scheduled' ) && function_exists( 'wp_schedule_event' ) ) { if ( ! wp_next_scheduled( self::HOOK_DISPATCH_JOBS ) ) { wp_schedule_event( time(), self::INTERVAL_EVERY_MINUTE, self::HOOK_DISPATCH_JOBS ); } if ( ! wp_next_scheduled( self::HOOK_PERIODIC_SYNC ) ) { wp_schedule_event( time(), self::INTERVAL_EVERY_FIVE_MINUTES, self::HOOK_PERIODIC_SYNC ); } if ( $this->auditRetentionPurger !== null && ! wp_next_scheduled( self::HOOK_AUDIT_RETENTION_PURGE ) ) { wp_schedule_event( time(), self::INTERVAL_DAILY_AUDIT_PURGE, self::HOOK_AUDIT_RETENTION_PURGE ); } if ( $this->clickRetentionPurger !== null && ! wp_next_scheduled( self::HOOK_CLICK_RETENTION_PURGE ) ) { wp_schedule_event( time(), self::INTERVAL_DAILY_CLICK_PURGE, self::HOOK_CLICK_RETENTION_PURGE ); } } } /** * Deactivation entry point: unschedule both cron events. * * Exposed as a public method so the plugin entry file's * deactivation hook can reach it through the bootstrap. The * worker id option is INTENTIONALLY preserved so reactivation * reuses the same identifier (see file-level docblock). */ public function onDeactivate(): void { if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_unschedule_event' ) ) { return; } foreach ( array( self::HOOK_DISPATCH_JOBS, self::HOOK_PERIODIC_SYNC, self::HOOK_AUDIT_RETENTION_PURGE, self::HOOK_CLICK_RETENTION_PURGE, ) as $hook ) { $timestamp = wp_next_scheduled( $hook ); while ( false !== $timestamp && null !== $timestamp ) { wp_unschedule_event( (int) $timestamp, $hook ); $timestamp = wp_next_scheduled( $hook ); if ( false === $timestamp || null === $timestamp ) { break; } } } } /** * Worker bound after register(), or null when register() has not * yet run (typical pure-unit test path). */ public function worker(): ?SyncWorker { return $this->worker; } /** * Scheduler bound after register(), or null when register() has * not yet run. */ public function scheduler(): ?PeriodicSyncScheduler { return $this->scheduler; } /** * Resolve the §8.5 worker id from the option store, generating * a fresh UUID-shaped value when none is persisted. * * @param OptionStore $options Option store binding. */ private function resolveWorkerId( OptionStore $options ): string { $existing = $options->get( self::OPTION_WORKER_ID ); if ( is_string( $existing ) && $existing !== '' ) { return $existing; } $workerId = self::generateWorkerId(); $options->update( self::OPTION_WORKER_ID, $workerId ); return $workerId; } /** * Generate a fresh stable worker identifier. * * Prefers `wp_generate_uuid4()` when available so the worker id * follows the WordPress convention; falls back to a pure-PHP * `random_bytes()` formatter otherwise. Truncates to 36 * characters so the value always fits inside the 64-byte * `locked_by` column. */ private static function generateWorkerId(): string { if ( function_exists( 'wp_generate_uuid4' ) ) { $uuid = (string) wp_generate_uuid4(); } else { $bytes = random_bytes( 16 ); $bytes[6] = chr( ( ord( $bytes[6] ) & 0x0F ) | 0x40 ); $bytes[8] = chr( ( ord( $bytes[8] ) & 0x3F ) | 0x80 ); $hex = bin2hex( $bytes ); $uuid = sprintf( '%s-%s-%s-%s-%s', substr( $hex, 0, 8 ), substr( $hex, 8, 4 ), substr( $hex, 12, 4 ), substr( $hex, 16, 4 ), substr( $hex, 20, 12 ) ); } return 'affilun-' . $uuid; } /** * Resolve {@see ProductRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductRepository}. */ private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'SyncCronServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing ProductRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $value = $registry->get( $key ); if ( ! $value instanceof ProductRepository ) { $message = sprintf( 'SyncCronServiceProvider expected ProductRepository under "%s"; got %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $value; } /** * Resolve {@see SyncJobRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see SyncJobRepository}. */ private function resolveSyncJobRepository( ServiceProviderRegistry $registry ): SyncJobRepository { $key = PersistenceServiceProvider::BINDING_SYNC_JOB_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'SyncCronServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing SyncJobRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $value = $registry->get( $key ); if ( ! $value instanceof SyncJobRepository ) { $message = sprintf( 'SyncCronServiceProvider expected SyncJobRepository under "%s"; got %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $value; } /** * Resolve {@see OptionStore} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not an * {@see OptionStore}. */ private function resolveOptionStore( ServiceProviderRegistry $registry ): OptionStore { $key = PersistenceServiceProvider::BINDING_OPTION_STORE; if ( ! $registry->has( $key ) ) { $message = 'SyncCronServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing OptionStore binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $value = $registry->get( $key ); if ( ! $value instanceof OptionStore ) { $message = sprintf( 'SyncCronServiceProvider expected OptionStore under "%s"; got %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $value; } /** * Resolve {@see ProviderRegistry} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProviderRegistry}. */ private function resolveProviderRegistry( ServiceProviderRegistry $registry ): ProviderRegistry { $key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY; if ( ! $registry->has( $key ) ) { $message = 'SyncCronServiceProvider requires ProviderServiceProvider to register before it' . ' (missing ProviderRegistry binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $value = $registry->get( $key ); if ( ! $value instanceof ProviderRegistry ) { $message = sprintf( 'SyncCronServiceProvider expected ProviderRegistry under "%s"; got %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $value; } /** * Resolve {@see CacheInvalidator} from the registry when * available; return null when the cache provider is absent. * * The handler contract makes the invalidator optional, so a * stripped-down bootstrap that omits the cache provider still * yields a functional worker — the trade-off is that successful * syncs will not invalidate the render cache until a future * tick observes a freshly-bound invalidator. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveCacheInvalidator( ServiceProviderRegistry $registry ): ?CacheInvalidator { $key = CacheServiceProvider::BINDING_CACHE_INVALIDATOR; if ( ! $registry->has( $key ) ) { return null; } $value = $registry->get( $key ); return $value instanceof CacheInvalidator ? $value : null; } /** * Soft-resolve the {@see AuditEventRepository} from the * registry and wrap it in a {@see RepositoryAuditEventWriter} * so the §11 sync worker can emit `sync_job_terminal` audit * rows for every handled job. * * Returns null when the binding is missing or carries an * unexpected value — pure-unit harnesses that don't bind the * audit repository keep working without modification, and the * {@see SyncWorker} stays silent on the audit channel rather * than failing loudly. * * @param ServiceProviderRegistry $registry Bootstrap registry. * @param Clock $clock Clock the writer * stamps `createdAt` * from. */ private function resolveOptionalAuditEventWriter( ServiceProviderRegistry $registry, Clock $clock ): ?RepositoryAuditEventWriter { $key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $events = $registry->get( $key ); if ( ! $events instanceof AuditEventRepository ) { return null; } return new RepositoryAuditEventWriter( $events, $clock ); } /** * Soft-resolve the {@see ClickEventRepository} and compose a click retention purger. * * @param ServiceProviderRegistry $registry Bootstrap registry. * @param Clock $clock Clock. */ private function resolveOptionalClickRetentionPurger( ServiceProviderRegistry $registry, Clock $clock ): ?ClickRetentionPurger { $key = PersistenceServiceProvider::BINDING_CLICK_EVENT_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $events = $registry->get( $key ); if ( ! $events instanceof ClickEventRepository ) { return null; } return new ClickRetentionPurger( $events, ClickRetentionPolicy::default(), $clock ); } /** * Soft-resolve the {@see AuditEventRepository} and compose audit retention purger. * * @param ServiceProviderRegistry $registry Bootstrap registry. * @param Clock $clock Clock. * @param AuditEventWriter|null $auditWriter Optional writer. */ private function resolveOptionalAuditRetentionPurger( ServiceProviderRegistry $registry, Clock $clock, ?AuditEventWriter $auditWriter ): ?AuditRetentionPurger { $key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $events = $registry->get( $key ); if ( ! $events instanceof AuditEventRepository ) { return null; } return new AuditRetentionPurger( $events, AuditRetentionPolicy::default(), $clock, $auditWriter ); } }