repository = new Repository(); $this->media = new MediaImporter(); $this->attributeLookup = $attributeLookup ?? new AttributeLookupRebuilder(); } /** @param array $mediaGate */ public function queueRun(int $runId, array $mediaGate = []): array { $this->assertScheduler(); $this->repository->run($runId); $jobId = $this->repository->createPublicationJob($runId, [], get_current_user_id(), $mediaGate); $this->schedulePublication($jobId, 0, true); return $this->repository->job($jobId); } public function releaseMediaGate(int $jobId, bool $timedOut = false, bool $schedule = true): array { $job = $this->repository->job($jobId); $gate = $this->mediaGate($job); if (!$gate['enabled']) { throw new RuntimeException('This publication job does not use a media gate.'); } if (!$gate['ready']) { $gate['ready'] = true; $gate['released_at'] = current_time('mysql', true); $gate['timed_out'] = $timedOut; $payload = $job['payload']; $payload['media_gate'] = $gate; if (in_array($job['status'], ['queued', 'processing', 'processing_media', 'paused'], true)) { $data = ['payload' => $payload]; if ($job['status'] === 'processing_media' && $job['stage'] === 'waiting_images') { $data['stage'] = 'images'; } $this->repository->updateJobWhenStatus( $jobId, $data, ['queued', 'processing', 'processing_media', 'paused'] ); } else { $this->repository->updateJob($jobId, ['payload' => $payload]); } $job = $this->repository->job($jobId); } if ($schedule && $job['status'] === 'processing_media' && $job['stage'] === 'images') { $this->unscheduleMediaWorkers($jobId); $this->scheduleMedia($jobId, null, true); $job = $this->repository->job($jobId); } return $job; } public function processPublication(int $jobId, bool $scheduleNext = true): void { $job = $this->repository->job($jobId); if (in_array($job['status'], ['completed', 'failed', 'cancelled', 'paused'], true)) { return; } $lock = new WorkerLock(); if (!$lock->acquire('publication-' . $jobId, 120)) { $this->schedulePublication($jobId, 15, true); return; } global $wpdb; $transactionOpen = false; try { $job = $this->repository->job($jobId); if (!in_array($job['status'], ['queued', 'processing'], true)) { return; } $this->repository->recordWorkerStarted($jobId, 'products'); $settings = $this->settings(); if ($job['status'] === 'queued') { $this->repository->updateJobWhenStatus($jobId, [ 'status' => 'processing', 'stage' => 'products', 'started_at' => current_time('mysql', true), ], ['queued']); $job = $this->repository->job($jobId); if ($job['status'] !== 'processing') { return; } } $ids = $this->repository->eligibleJobChanges($job, $settings['publication_batch_size']); if (!$ids) { $this->syncJobProgress($jobId); $this->finishProducts($jobId); return; } $started = microtime(true); $processed = 0; $mediaQueued = 0; $touchedProductIds = []; $lastError = null; // WooCommerce CRUD can issue dozens of autocommitted writes per // product. One short transaction per restartable batch avoids a // disk flush for every post, meta, relationship, and audit row. $wpdb->query('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); $transactionOpen = $wpdb->query('START TRANSACTION') !== false; wp_defer_term_counting(true); $this->attributeLookup->beginBulkPublication((int) $job['total']); foreach ($ids as $id) { if (!$this->isJobStatus($jobId, ['processing'])) { break; } if ($processed > 0 && microtime(true) - $started >= $settings['worker_time_budget']) { break; } ++$processed; try { $result = $this->bridge->publishChange($id, $jobId); $mediaQueued += $result['media_queued']; if ($result['product_id'] > 0) { $touchedProductIds[] = (int) $result['product_id']; } } catch (Throwable $error) { $lastError = $this->redact($error->getMessage()); } } // Do not flush taxonomy counts after every small restartable batch. // WooCommerce recounts scan the product relationships table and can // dominate the write time on small stores. Counts are rebuilt once // when the product stage is complete. $this->attributeLookup->endBulkPublication(); $current = $this->syncJobProgress($jobId); $this->repository->updateJob($jobId, [ 'media_total' => (int) $current['media_total'] + $mediaQueued, 'error' => $lastError, ]); if ($transactionOpen) { $wpdb->query('COMMIT'); $transactionOpen = false; } $this->cleanProductCaches($touchedProductIds); $job = $this->repository->job($jobId); if ($job['status'] !== 'processing') { return; } if ((int) $job['processed'] >= (int) $job['total']) { $this->finishProducts($jobId); } elseif ($scheduleNext && $this->repository->eligibleJobChanges($job, 1)) { $this->schedulePublication($jobId, $settings['worker_delay']); } elseif (!$this->repository->eligibleJobChanges($job, 1)) { $this->finishProducts($jobId); } } finally { $this->attributeLookup->endBulkPublication(); if ($transactionOpen) { $wpdb->query('ROLLBACK'); } $lock->release(); } } public function processMedia(int $jobId): void { $job = $this->repository->job($jobId); if (in_array($job['status'], ['completed', 'failed', 'cancelled', 'paused'], true)) { return; } $lock = new WorkerLock(); if (!$lock->acquire('media-' . $jobId, 120)) { $this->scheduleMedia($jobId, time() + 15); return; } try { $job = $this->repository->job($jobId); if ($job['status'] !== 'processing_media') { return; } $gate = $this->mediaGate($job); if ($gate['enabled'] && !$gate['ready']) { if ($gate['deadline'] > time()) { $this->scheduleMedia($jobId, $gate['deadline'], true); return; } $job = $this->releaseMediaGate($jobId, true, false); } $this->repository->recordWorkerStarted($jobId, 'media'); $settings = $this->settings(); $items = $this->repository->nextMediaBatch($jobId, $settings['image_batch_size']); if (!$items) { $this->finishOrRescheduleMedia($jobId); return; } $started = microtime(true); $affected = []; $mediaTimings = []; foreach ($items as $item) { if (!$this->isJobStatus($jobId, ['processing_media'])) { break; } if ($affected && microtime(true) - $started >= $settings['worker_time_budget']) { break; } $attempt = (int) $item['attempts'] + 1; if ( !$this->repository->updateMediaForActiveJob((int) $item['id'], $jobId, [ 'status' => 'processing', 'attempts' => $attempt, 'error' => null, ]) ) { break; } try { $attachmentId = $this->media->import( (int) $item['product_id'], (string) $item['source_url'], $settings['image_timeout'], $settings['image_max_bytes'], function (array $timing) use (&$mediaTimings): void { $mediaTimings[] = $timing; } ); if ( $this->repository->updateMediaForActiveJob((int) $item['id'], $jobId, [ 'status' => 'completed', 'attachment_id' => $attachmentId, ]) ) { $affected[(int) $item['change_id']] = (int) $item['product_id']; } } catch (Throwable $error) { $terminal = $attempt >= $settings['image_max_attempts']; $updated = $this->repository->updateMediaForActiveJob((int) $item['id'], $jobId, [ 'status' => $terminal ? 'failed' : 'retry', 'available_at' => gmdate('Y-m-d H:i:s', time() + (60 * (2 ** max(0, $attempt - 1)))), 'error' => $this->redact($error->getMessage()), ]); if ($updated && $terminal) { try { $this->bridge->rollbackStructuralFamilyForChange( (int) $item['change_id'] ); } catch (Throwable $_rollbackError) { // The media error stays authoritative. The job and // release still fail for manual recovery. } } if ($updated) { do_action('crocoai_dbridge_image_error', $error, $item['source_url'], $item['product_id']); } } } $this->repository->recordMediaTimings($jobId, $mediaTimings); foreach ($affected as $changeId => $productId) { if (!$this->isJobStatus($jobId, ['processing_media'])) { break; } if (!wc_get_product($productId)) { continue; } $attachments = $this->repository->mediaAttachments($changeId); $this->media->applyToProduct($productId, $attachments); // A translation is duplicated when the product is published, // which is before its images are imported. The copy is made // from a product that has no images yet, so give each copy the // same images now that they exist. foreach ($this->translationsOf($changeId, $productId) as $translationId) { try { $this->media->applyToProduct($translationId, $attachments); } catch (Throwable $error) { do_action('crocoai_dbridge_image_error', $error, '', $translationId); } } } $this->finishOrRescheduleMedia($jobId); } finally { $lock->release(); } } /** * The other language copies of one published product. * * @return array */ private function translationsOf(int $changeId, int $productId): array { try { $change = $this->repository->change($changeId); $record = $this->repository->record((int) $change['record_id']); $adapter = MultilingualAdapterFactory::fromCanonical((array) $record['canonical']); $map = $adapter->translationMap($productId); } catch (Throwable $error) { return []; } $targets = []; foreach ($map as $translated) { $translated = (int) $translated; if ($translated > 0 && $translated !== $productId) { $targets[$translated] = $translated; } } return array_values($targets); } public function ensureActiveJobs(): void { if (!function_exists('as_has_scheduled_action')) { return; } foreach ($this->repository->activeJobs() as $job) { $jobId = (int) $job['id']; $hook = $job['status'] === 'processing_media' ? 'crocoai_dbridge_process_media_job' : 'crocoai_dbridge_process_publication_job'; $legacyHook = $hook === 'crocoai_dbridge_process_media_job' ? 'wpi_process_media_job' : 'wpi_process_publication_job'; if ( !as_has_scheduled_action($hook, [$jobId], 'crocoai-data-bridge') && !as_has_scheduled_action($legacyHook, [$jobId], 'woo-product-importer') ) { if ($hook === 'crocoai_dbridge_process_media_job') { $this->scheduleMedia($jobId, $this->mediaScheduleTimestamp($job), true); } else { $this->schedulePublication($jobId, 15, true); } } } $this->attributeLookup->ensureScheduled(); } public function pause(int $jobId): array { $job = $this->repository->job($jobId); if (!in_array($job['status'], ['queued', 'processing', 'processing_media'], true)) { throw new RuntimeException('Only an active publication job can be paused.'); } if ( !$this->repository->updateJobWhenStatus( $jobId, ['status' => 'paused'], ['queued', 'processing', 'processing_media'] ) ) { throw new RuntimeException('Only an active publication job can be paused.'); } return $this->repository->job($jobId); } public function resume(int $jobId): array { $job = $this->repository->job($jobId); if ($job['status'] !== 'paused') { throw new RuntimeException('Only a paused publication job can be resumed.'); } $mediaStage = in_array($job['stage'], ['images', 'waiting_images'], true); $gate = $this->mediaGate($job); $stage = $mediaStage && (!$gate['enabled'] || $gate['ready']) ? 'images' : $job['stage']; if ( !$this->repository->updateJobWhenStatus( $jobId, [ 'status' => $mediaStage ? 'processing_media' : 'processing', 'stage' => $stage, 'finished_at' => null, ], ['paused'] ) ) { throw new RuntimeException('Only a paused publication job can be resumed.'); } if ($mediaStage) { $job = $this->repository->job($jobId); $this->scheduleMedia($jobId, $this->mediaScheduleTimestamp($job), true); } else { $this->schedulePublication($jobId, $this->settings()['worker_delay'], true); } return $this->repository->job($jobId); } public function cancel(int $jobId): array { $job = $this->repository->job($jobId); $cancellable = ['queued', 'processing', 'processing_media', 'paused']; if ($job['status'] !== 'cancelled') { if (!in_array($job['status'], $cancellable, true)) { throw new RuntimeException('Only an active or paused publication job can be cancelled.'); } if ( !$this->repository->updateJobWhenStatus($jobId, [ 'status' => 'cancelled', 'finished_at' => current_time('mysql', true), ], $cancellable) ) { $job = $this->repository->job($jobId); if ($job['status'] !== 'cancelled') { throw new RuntimeException('Only an active or paused publication job can be cancelled.'); } } } $this->repository->cancelUnfinishedMedia($jobId); $this->unscheduleJobWorkers($jobId); WorkerLock::releaseJob($jobId); return $this->repository->job($jobId); } private function finishProducts(int $jobId): void { if (!$this->isJobStatus($jobId, ['processing'])) { return; } $job = $this->syncJobProgress($jobId); if ((int) $job['processed'] < (int) $job['total']) { return; } $counts = $this->repository->mediaCounts($jobId); if ($counts['waiting'] > 0) { $gate = $this->mediaGate($job); $waitingForImages = $gate['enabled'] && !$gate['ready']; if ( !$this->repository->updateJobWhenStatus($jobId, [ 'status' => 'processing_media', 'stage' => $waitingForImages ? 'waiting_images' : 'images', 'media_total' => $counts['total'], 'media_processed' => $counts['completed'] + $counts['failed'], 'media_failed' => $counts['failed'], ], ['processing']) ) { return; } $this->unscheduleProductWorkers($jobId); $this->scheduleMedia( $jobId, $waitingForImages ? $gate['deadline'] : time() + $this->settings()['media_worker_delay'], true ); $this->recountProductTerms(); return; } if ($this->complete($jobId, $counts)) { $this->recountProductTerms(); } } private function finishOrRescheduleMedia(int $jobId): void { if (!$this->isJobStatus($jobId, ['processing_media'])) { return; } $counts = $this->repository->mediaCounts($jobId); if ($counts['waiting'] > 0) { if ( !$this->repository->updateJobWhenStatus($jobId, [ 'status' => 'processing_media', 'stage' => 'images', 'media_total' => $counts['total'], 'media_processed' => $counts['completed'] + $counts['failed'], 'media_failed' => $counts['failed'], 'finished_at' => null, ], ['processing_media']) ) { return; } $next = $this->repository->nextMediaTimestamp($jobId) ?: time(); $this->scheduleMedia($jobId, max(time() + $this->settings()['media_worker_delay'], $next)); return; } $this->complete($jobId, $counts); } private function complete(int $jobId, array $mediaCounts): bool { $job = $this->syncJobProgress($jobId); $failed = (int) $job['failed'] > 0 || (int) $mediaCounts['failed'] > 0; if ( !$this->repository->updateJobWhenStatus($jobId, [ 'status' => $failed ? 'failed' : 'completed', 'stage' => $failed ? 'failed' : 'complete', 'media_total' => $mediaCounts['total'], 'media_processed' => $mediaCounts['completed'] + $mediaCounts['failed'], 'media_failed' => $mediaCounts['failed'], 'finished_at' => current_time('mysql', true), ], ['processing', 'processing_media']) ) { return false; } $this->unscheduleJobWorkers($jobId); $job = $this->repository->job($jobId); $this->repository->completeRemoteReleaseForRun( $job['run_id'], $failed ? 'failed' : 'completed' ); if ($this->attributeLookup->shouldDefer((int) $job['total'])) { $this->attributeLookup->start($jobId); } return true; } public function processAttributeLookup(): void { $this->attributeLookup->process(); } private function syncJobProgress(int $jobId): array { $job = $this->repository->job($jobId); $this->repository->updateJob($jobId, $this->repository->jobChangeCounts($job)); return $this->repository->job($jobId); } /** @param array $productIds */ private function cleanProductCaches(array $productIds): void { foreach (array_unique($productIds) as $productId) { clean_post_cache($productId); if (function_exists('wc_delete_product_transients')) { wc_delete_product_transients($productId); } } } private function recountProductTerms(): void { // Flush any terms deferred in the final batch, then rebuild every // product taxonomy once so earlier batches cannot leave stale counts. wp_defer_term_counting(false); foreach (get_object_taxonomies('product') as $taxonomy) { $termIds = get_terms([ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'fields' => 'ids', ]); if (!is_wp_error($termIds) && $termIds) { wp_update_term_count_now(array_map('intval', $termIds), $taxonomy); } } } private function schedulePublication(int $jobId, int $delay = 0, bool $unique = false): void { if (!$this->isJobStatus($jobId, ['queued', 'processing'])) { return; } $scheduledAt = microtime(true); $dueAt = $scheduledAt + max(0, $delay); if ($delay > 0) { $actionId = as_schedule_single_action((int) $dueAt, 'crocoai_dbridge_process_publication_job', [$jobId], 'crocoai-data-bridge', $unique); } else { $actionId = as_enqueue_async_action('crocoai_dbridge_process_publication_job', [$jobId], 'crocoai-data-bridge', $unique); } if ($actionId) { $this->repository->recordWorkerScheduled($jobId, 'products', (float) $dueAt); } if (!$this->isJobStatus($jobId, ['queued', 'processing'])) { $this->unscheduleJobWorkers($jobId); } } private function scheduleMedia(int $jobId, ?int $timestamp = null, bool $unique = false): void { if (!$this->isJobStatus($jobId, ['processing_media'])) { return; } if ($timestamp !== null && $timestamp > time()) { $dueAt = (float) $timestamp; $actionId = as_schedule_single_action($timestamp, 'crocoai_dbridge_process_media_job', [$jobId], 'crocoai-data-bridge', $unique); } else { $dueAt = microtime(true); $actionId = as_enqueue_async_action('crocoai_dbridge_process_media_job', [$jobId], 'crocoai-data-bridge', $unique); } if ($actionId) { $this->repository->recordWorkerScheduled($jobId, 'media', (float) $dueAt); } if (!$this->isJobStatus($jobId, ['processing_media'])) { $this->unscheduleJobWorkers($jobId); } } /** @param array $statuses */ private function isJobStatus(int $jobId, array $statuses): bool { return in_array($this->repository->job($jobId)['status'], $statuses, true); } private function unscheduleJobWorkers(int $jobId): void { if (!function_exists('as_unschedule_all_actions')) { return; } foreach (['crocoai_dbridge_process_publication_job', 'crocoai_dbridge_process_media_job'] as $hook) { as_unschedule_all_actions($hook, [$jobId], 'crocoai-data-bridge'); } foreach (['wpi_process_publication_job', 'wpi_process_media_job'] as $hook) { as_unschedule_all_actions($hook, [$jobId], 'woo-product-importer'); } } private function unscheduleProductWorkers(int $jobId): void { if (function_exists('as_unschedule_all_actions')) { as_unschedule_all_actions( 'crocoai_dbridge_process_publication_job', [$jobId], 'crocoai-data-bridge' ); as_unschedule_all_actions( 'wpi_process_publication_job', [$jobId], 'woo-product-importer' ); as_unschedule_all_actions( 'wpi_process_publication_job', [$jobId], 'woo-product-importer' ); } } private function unscheduleMediaWorkers(int $jobId): void { if (function_exists('as_unschedule_all_actions')) { as_unschedule_all_actions( 'crocoai_dbridge_process_media_job', [$jobId], 'crocoai-data-bridge' ); as_unschedule_all_actions( 'wpi_process_media_job', [$jobId], 'woo-product-importer' ); } } /** @return array{enabled:bool,ready:bool,deadline:int,released_at:?string,timed_out:bool} */ private function mediaGate(array $job): array { $gate = (array) (($job['payload']['media_gate'] ?? null) ?: []); return [ 'enabled' => !empty($gate['enabled']), 'ready' => !empty($gate['ready']), 'deadline' => max(0, (int) ($gate['deadline'] ?? 0)), 'released_at' => isset($gate['released_at']) ? (string) $gate['released_at'] : null, 'timed_out' => !empty($gate['timed_out']), ]; } private function mediaScheduleTimestamp(array $job): ?int { $gate = $this->mediaGate($job); if ($gate['enabled'] && !$gate['ready']) { return max(time() + 1, $gate['deadline']); } return $this->repository->nextMediaTimestamp((int) $job['id']); } private function assertScheduler(): void { if (!function_exists('as_enqueue_async_action') || !function_exists('as_schedule_single_action')) { throw new RuntimeException('WooCommerce Action Scheduler is unavailable; publication was not queued.'); } } private function settings(): array { $settings = get_option('crocoai_dbridge_settings', []); return [ 'publication_batch_size' => min(100, max(1, (int) ($settings['publication_batch_size'] ?? 5))), 'worker_time_budget' => min(25, max(5, (int) ($settings['worker_time_budget'] ?? 15))), 'worker_delay' => min(300, max(0, (int) ($settings['worker_delay'] ?? 10))), 'media_worker_delay' => min(300, max(0, (int) ($settings['media_worker_delay'] ?? 0))), 'image_timeout' => min(15, max(3, (int) ($settings['image_timeout'] ?? 10))), 'image_max_bytes' => min(20 * MB_IN_BYTES, max(256 * KB_IN_BYTES, (int) ($settings['image_max_bytes'] ?? 5 * MB_IN_BYTES))), 'image_max_attempts' => min(5, max(1, (int) ($settings['image_max_attempts'] ?? 3))), 'image_batch_size' => min(10, max(1, (int) ($settings['image_batch_size'] ?? 3))), ]; } private function redact(string $message): string { return preg_replace('/(?