products = $products; $this->saveProduct = $saveProduct; $this->jobs = $jobs; $this->clock = $clock; $this->retryPolicy = $retryPolicy; $this->providerResolver = $providerResolver; $this->invalidator = $invalidator; } /** * Process the supplied claimed sync job and persist its * post-handle state. * * See the file-level docblock for the §10.2 process steps and * the failure-mapping rules. The returned outcome reflects the * persisted state of the job; callers MUST use the outcome (not * an in-flight assumption) to drive the worker loop. * * @param SyncJobRecord $job Claimed job record. */ public function handle( SyncJobRecord $job ): SyncJobOutcome { $now = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) ); try { $payload = SyncDispatcher::payloadFor( $job ); } catch ( RuntimeException $e ) { return $this->finishTerminal( $job, $now, self::CODE_UNEXPECTED_ERROR, self::truncateMessage( $e->getMessage() ) ); } $productId = self::extractProductId( $payload ); if ( $productId === null ) { return $this->finishTerminal( $job, $now, self::CODE_UNEXPECTED_ERROR, 'sync_product payload missing or invalid product_id' ); } $record = $this->products->findById( $productId ); if ( $record === null ) { return $this->finishTerminal( $job, $now, self::CODE_PRODUCT_NOT_FOUND, sprintf( 'Local product with id %d was not found.', $productId ) ); } try { $provider = $this->providerResolver->resolveItemLookup( $record->provider() ); } catch ( UnknownProviderException $e ) { return $this->finishTerminal( $job, $now, self::CODE_UNKNOWN_PROVIDER, self::truncateMessage( $e->getMessage() ) ); } try { $snapshot = $provider->getProduct( new ProviderProductId( $record->provider(), $record->providerItemId() ) ); } catch ( RateLimitExceededException $e ) { return $this->handleRetryableFailure( $job, $now, self::CODE_RATE_LIMIT_EXCEEDED, self::truncateMessage( $e->getMessage() ), self::extractRetryAfterSeconds( $e ) ); } catch ( TemporaryUnavailableException $e ) { return $this->handleRetryableFailure( $job, $now, self::CODE_TEMPORARY_UNAVAILABLE, self::truncateMessage( $e->getMessage() ), null ); } catch ( AuthenticationException $e ) { // Credentials are provider-wide configuration, not a // product defect. Fail this job without poisoning the // product row as broken. return $this->finishTerminal( $job, $now, self::CODE_AUTHENTICATION_ERROR, self::truncateMessage( $e->getMessage() ) ); } catch ( ProviderRequestException $e ) { if ( self::isUpstreamClientRejection( $e ) ) { $this->markRecordBroken( $record, $now, self::CODE_PROVIDER_REQUEST_ERROR, self::truncateMessage( $e->getMessage() ) ); } return $this->finishTerminal( $job, $now, self::CODE_PROVIDER_REQUEST_ERROR, self::truncateMessage( $e->getMessage() ) ); } catch ( ProviderProductNotFoundException $e ) { $this->markRecordBroken( $record, $now, self::CODE_PRODUCT_MISSING_AT_PROVIDER, self::truncateMessage( $e->getMessage() ) ); return $this->finishTerminal( $job, $now, self::CODE_PRODUCT_MISSING_AT_PROVIDER, self::truncateMessage( $e->getMessage() ) ); } catch ( ApplicationProductNotFoundException $e ) { $this->markRecordBroken( $record, $now, self::CODE_PRODUCT_MISSING_AT_PROVIDER, self::truncateMessage( $e->getMessage() ) ); return $this->finishTerminal( $job, $now, self::CODE_PRODUCT_MISSING_AT_PROVIDER, self::truncateMessage( $e->getMessage() ) ); } catch ( Throwable $e ) { return $this->finishTerminal( $job, $now, self::CODE_UNEXPECTED_ERROR, self::truncateMessage( $e->getMessage() ) ); } try { $this->saveProduct->execute( $snapshot ); } catch ( ApplicationProductNotFoundException $e ) { $this->markRecordBroken( $record, $now, self::CODE_PRODUCT_MISSING_AT_PROVIDER, self::truncateMessage( $e->getMessage() ) ); return $this->finishTerminal( $job, $now, self::CODE_PRODUCT_MISSING_AT_PROVIDER, self::truncateMessage( $e->getMessage() ) ); } catch ( Throwable $e ) { return $this->finishTerminal( $job, $now, self::CODE_UNEXPECTED_ERROR, self::truncateMessage( $e->getMessage() ) ); } unset( $snapshot ); if ( null !== $this->invalidator ) { // §10.2 step 7: invalidate render-cache entries that // reference this product so subsequent renders pick up // the freshly-persisted snapshot. The local product id // is guaranteed non-null here because $record was // loaded by id earlier in handle(); we re-extract via // the record rather than re-using $productId for // clarity at the invalidation seam. $recordId = $record->id(); if ( null !== $recordId ) { $this->invalidator->invalidateProductSync( $record->provider(), $record->providerItemId(), $recordId ); } } return $this->finishSuccess( $job, $now ); } /** * Persist the success state and return the matching outcome. * * @param SyncJobRecord $job Claimed job record. * @param DateTimeImmutable $now UTC instant for finishedAt / * updatedAt stamps. */ private function finishSuccess( SyncJobRecord $job, DateTimeImmutable $now ): SyncJobOutcome { $attempts = $job->attempts() + 1; $updated = $this->buildJobRecord( $job, array( 'status' => self::STATUS_SUCCEEDED, 'attempts' => $attempts, 'lockedAt' => null, 'lockedBy' => null, 'finishedAt' => $now, 'lastErrorCode' => null, 'lastError' => null, 'availableAt' => $job->availableAt(), 'updatedAt' => $now, ) ); $this->jobs->save( $updated ); return SyncJobOutcome::success( $attempts ); } /** * Persist a terminal-failure state and return the matching * outcome. * * @param SyncJobRecord $job Claimed job record. * @param DateTimeImmutable $now UTC instant for stamps. * @param string $errorCode Stable error code. * @param string $errorMessage Truncated error summary. */ private function finishTerminal( SyncJobRecord $job, DateTimeImmutable $now, string $errorCode, string $errorMessage ): SyncJobOutcome { $attempts = $job->attempts() + 1; $updated = $this->buildJobRecord( $job, array( 'status' => self::STATUS_FAILED, 'attempts' => $attempts, 'lockedAt' => null, 'lockedBy' => null, 'finishedAt' => $now, 'lastErrorCode' => $errorCode, 'lastError' => $errorMessage, 'availableAt' => $job->availableAt(), 'updatedAt' => $now, ) ); $this->jobs->save( $updated ); return SyncJobOutcome::failedTerminal( $attempts, $errorCode, $errorMessage ); } /** * Decide between rescheduling and terminating on a retryable * provider failure, persist the chosen state, and return the matching outcome. * * @param SyncJobRecord $job Claimed job record. * @param DateTimeImmutable $now UTC instant for stamps. * @param string $code Stable error code. * @param string $message Truncated error summary. * @param int|null $retryAfterSeconds Provider retry hint in seconds. */ private function handleRetryableFailure( SyncJobRecord $job, DateTimeImmutable $now, string $code, string $message, ?int $retryAfterSeconds ): SyncJobOutcome { $attempts = $job->attempts() + 1; if ( ! $this->retryPolicy->hasNextAttempt( $attempts ) ) { $updated = $this->buildJobRecord( $job, array( 'status' => self::STATUS_FAILED, 'attempts' => $attempts, 'lockedAt' => null, 'lockedBy' => null, 'finishedAt' => $now, 'lastErrorCode' => $code, 'lastError' => $message, 'availableAt' => $job->availableAt(), 'updatedAt' => $now, ) ); $this->jobs->save( $updated ); return SyncJobOutcome::failedTerminal( $attempts, $code, $message ); } $backoffSeconds = $retryAfterSeconds === null ? $this->retryPolicy->backoffSeconds( $attempts ) : $this->retryPolicy->withRetryAfter( $retryAfterSeconds, $attempts ); $nextAvailable = $now->add( new \DateInterval( sprintf( 'PT%dS', $backoffSeconds ) ) ); $updated = $this->buildJobRecord( $job, array( 'status' => self::STATUS_PENDING, 'attempts' => $attempts, 'lockedAt' => null, 'lockedBy' => null, 'finishedAt' => null, 'lastErrorCode' => $code, 'lastError' => $message, 'availableAt' => $nextAvailable, 'updatedAt' => $now, ) ); $this->jobs->save( $updated ); return SyncJobOutcome::retryScheduled( $attempts, $code, $message, $nextAvailable ); } /** * Normalize a provider retry-after hint into seconds. * * @param RateLimitExceededException $e Rate-limit exception. */ private static function extractRetryAfterSeconds( RateLimitExceededException $e ): ?int { $context = $e->context(); if ( ! array_key_exists( 'retry_after', $context ) ) { return null; } $value = $context['retry_after']; if ( is_int( $value ) ) { return $value > 0 ? $value : null; } if ( is_string( $value ) && preg_match( '/^\d+$/', trim( $value ) ) === 1 ) { $seconds = (int) trim( $value ); return $seconds > 0 ? $seconds : null; } return null; } /** * Whether a request-shape failure came from an upstream 4xx response. * * Factory-level {@see ProviderRequestException}s usually indicate an * internal programming/configuration mismatch and should not poison a * product row. Upstream 4xx lookup rejections, however, mean the stored * provider item identifier is not accepted by that provider and periodic * retries would repeat indefinitely. * * @param ProviderRequestException $e Provider request exception. */ private static function isUpstreamClientRejection( ProviderRequestException $e ): bool { $context = $e->context(); if ( ! array_key_exists( 'status_code', $context ) ) { return false; } $statusCode = $context['status_code']; if ( is_string( $statusCode ) && preg_match( '/^\d+$/', $statusCode ) === 1 ) { $statusCode = (int) $statusCode; } return is_int( $statusCode ) && $statusCode >= 400 && $statusCode <= 499; } /** * Transition the supplied product record's `syncState` to * `broken` and persist the change. * * Used on the {@see ProviderProductNotFoundException} catch path * (and the analogous {@see ApplicationProductNotFoundException} * path raised from {@see SaveProductUseCase}) so the policy / * finder layers stop scheduling further sync work for the row. * * @param ProductRecord $record Existing record. * @param DateTimeImmutable $now UTC instant for `updatedAt`. * @param string $errorCode Stable error code to persist on the product. * @param string $errorMessage Truncated error summary to persist on the product. */ private function markRecordBroken( ProductRecord $record, DateTimeImmutable $now, string $errorCode, string $errorMessage ): void { $updated = new ProductRecord( $record->id(), $record->provider(), $record->providerItemId(), $record->canonicalHash(), $record->title(), $record->brand(), $record->description(), $record->imagePrimaryUrl(), $record->imageGalleryJson(), $record->priceAmount(), $record->priceCurrency(), $record->priceLabel(), $record->availabilityStatus(), $record->detailUrl(), $record->affiliateUrl(), $record->rawPayloadJson(), $record->snapshotVersion(), SyncState::BROKEN, $record->lifecycleState(), $record->archivedAt(), $record->lastSyncedAt(), $record->lastRenderedAt(), $now, $errorCode, $errorMessage, $record->createdAt(), $now, $record->searchKeywords(), $record->searchKeywordsSource() ); $this->products->save( $updated ); } /** * Build a post-handle {@see SyncJobRecord} that preserves the * existing immutable columns (id, jobType, payload, priority, * maxAttempts, createdAt) and applies the supplied overrides. * * @param SyncJobRecord $job Claimed job record. * @param array $overrides Column overrides. */ private function buildJobRecord( SyncJobRecord $job, array $overrides ): SyncJobRecord { /** @var string $status */ $status = $overrides['status']; /** @var int $attempts */ $attempts = $overrides['attempts']; /** @var DateTimeImmutable|null $lockedAt */ $lockedAt = $overrides['lockedAt']; /** @var string|null $lockedBy */ $lockedBy = $overrides['lockedBy']; /** @var DateTimeImmutable|null $finishedAt */ $finishedAt = $overrides['finishedAt']; /** @var string|null $lastErrorCode */ $lastErrorCode = $overrides['lastErrorCode']; /** @var string|null $lastError */ $lastError = $overrides['lastError']; /** @var DateTimeImmutable $availableAt */ $availableAt = $overrides['availableAt']; /** @var DateTimeImmutable $updatedAt */ $updatedAt = $overrides['updatedAt']; return new SyncJobRecord( $job->id(), $job->jobType(), $job->payloadJson(), $status, $job->priority(), $attempts, $job->maxAttempts(), $availableAt, $lockedAt, $lockedBy, $finishedAt, $lastErrorCode, $lastError, $job->createdAt(), $updatedAt ); } /** * Extract the `product_id` field from the decoded payload. * * Returns null when the field is missing, not numeric, or * non-positive; callers map a null return onto the * `unexpected_error` terminal outcome. * * @param array $payload Decoded payload. */ private static function extractProductId( array $payload ): ?int { if ( ! array_key_exists( 'product_id', $payload ) ) { return null; } $raw = $payload['product_id']; if ( ! is_int( $raw ) && ! ( is_string( $raw ) && ctype_digit( $raw ) ) ) { return null; } $id = (int) $raw; if ( $id <= 0 ) { return null; } return $id; } /** * Trim and cap the supplied error message at * {@see self::MAX_ERROR_MESSAGE_LENGTH} characters. * * Falls back to the literal `'unknown error'` when the trimmed * input is empty so the persisted `last_error` column is never * blank — a blank message would conflict with the * {@see SyncJobOutcome} constructor's "non-success outcomes * require a non-empty errorMessage" rule. * * @param string $message Raw exception message. */ private static function truncateMessage( string $message ): string { $trimmed = trim( $message ); if ( $trimmed === '' ) { return 'unknown error'; } if ( function_exists( 'mb_substr' ) ) { return mb_substr( $trimmed, 0, self::MAX_ERROR_MESSAGE_LENGTH, 'UTF-8' ); } return substr( $trimmed, 0, self::MAX_ERROR_MESSAGE_LENGTH ); } }