*/ private const VALID_OUTCOMES = array( self::OUTCOME_SUCCESS, self::OUTCOME_RETRY_SCHEDULED, self::OUTCOME_FAILED_TERMINAL, ); /** * Outcome discriminator. */ private string $outcome; /** * Stable machine-readable error code, or null on success. */ private ?string $errorCode; /** * Human-readable error summary, or null on success. */ private ?string $errorMessage; /** * Post-handler attempt count. */ private int $attempts; /** * UTC instant at which the worker may re-claim the job, or * null when no retry is scheduled. */ private ?DateTimeImmutable $nextAvailableAt; /** * Construct an outcome with explicit values for every field. * * Validation summary: * * - `$outcome` MUST be a member of {@see self::VALID_OUTCOMES}. * - `$errorCode` and `$errorMessage` MUST both be non-null * non-empty strings when `$outcome` is not * {@see self::OUTCOME_SUCCESS}, and MUST both be null when * `$outcome` is {@see self::OUTCOME_SUCCESS}. * - `$attempts` MUST be non-negative. * - `$nextAvailableAt` MUST be non-null when `$outcome` is * {@see self::OUTCOME_RETRY_SCHEDULED}, and MUST be null * otherwise. * * @param string $outcome Outcome discriminator. * @param string|null $errorCode Stable error code, or null. * @param string|null $errorMessage Human-readable summary, or null. * @param int $attempts Post-handler attempt count. * @param DateTimeImmutable|null $nextAvailableAt Retry instant, or null. * * @throws InvalidArgumentException When any of the validation * rules above is violated. */ public function __construct( string $outcome, ?string $errorCode, ?string $errorMessage, int $attempts, ?DateTimeImmutable $nextAvailableAt ) { if ( ! in_array( $outcome, self::VALID_OUTCOMES, true ) ) { $message = sprintf( 'SyncJobOutcome: outcome must be one of [%s]; got "%s".', implode( ', ', self::VALID_OUTCOMES ), $outcome ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $attempts < 0 ) { $message = sprintf( 'SyncJobOutcome: attempts must be >= 0; got %d.', $attempts ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $outcome === self::OUTCOME_SUCCESS ) { if ( $errorCode !== null || $errorMessage !== null ) { $message = 'SyncJobOutcome: success outcomes must carry null errorCode and errorMessage.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $nextAvailableAt !== null ) { $message = 'SyncJobOutcome: success outcomes must carry null nextAvailableAt.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } else { if ( $errorCode === null || $errorCode === '' ) { $message = 'SyncJobOutcome: non-success outcomes require a non-empty errorCode.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $errorMessage === null || $errorMessage === '' ) { $message = 'SyncJobOutcome: non-success outcomes require a non-empty errorMessage.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } if ( $outcome === self::OUTCOME_RETRY_SCHEDULED ) { if ( $nextAvailableAt === null ) { $message = 'SyncJobOutcome: retry_scheduled outcomes require a non-null nextAvailableAt.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } elseif ( $outcome === self::OUTCOME_FAILED_TERMINAL && $nextAvailableAt !== null ) { $message = 'SyncJobOutcome: failed_terminal outcomes must carry null nextAvailableAt.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->outcome = $outcome; $this->errorCode = $errorCode; $this->errorMessage = $errorMessage; $this->attempts = $attempts; $this->nextAvailableAt = self::normalizeToUtc( $nextAvailableAt ); } /** * Construct a success outcome. * * @param int $attempts Post-handler attempt count. */ public static function success( int $attempts ): self { return new self( self::OUTCOME_SUCCESS, null, null, $attempts, null ); } /** * Construct a retry-scheduled outcome. * * @param int $attempts Post-handler attempt count. * @param string $errorCode Stable error code. * @param string $errorMessage Human-readable summary. * @param DateTimeImmutable $nextAvailableAt UTC instant for the next attempt. */ public static function retryScheduled( int $attempts, string $errorCode, string $errorMessage, DateTimeImmutable $nextAvailableAt ): self { return new self( self::OUTCOME_RETRY_SCHEDULED, $errorCode, $errorMessage, $attempts, $nextAvailableAt ); } /** * Construct a failed-terminal outcome. * * @param int $attempts Post-handler attempt count. * @param string $errorCode Stable error code. * @param string $errorMessage Human-readable summary. */ public static function failedTerminal( int $attempts, string $errorCode, string $errorMessage ): self { return new self( self::OUTCOME_FAILED_TERMINAL, $errorCode, $errorMessage, $attempts, null ); } /** * Outcome discriminator token. */ public function outcome(): string { return $this->outcome; } /** * Stable machine-readable error code, or null on success. */ public function errorCode(): ?string { return $this->errorCode; } /** * Human-readable error summary, or null on success. */ public function errorMessage(): ?string { return $this->errorMessage; } /** * Post-handler attempt count. */ public function attempts(): int { return $this->attempts; } /** * UTC instant at which the worker may re-claim the job, or * null when no retry is scheduled. */ public function nextAvailableAt(): ?DateTimeImmutable { return $this->nextAvailableAt; } /** * Whether this outcome represents a successful sync. */ public function isSuccess(): bool { return $this->outcome === self::OUTCOME_SUCCESS; } /** * Whether this outcome represents a transient failure with a * scheduled retry. */ public function isRetry(): bool { return $this->outcome === self::OUTCOME_RETRY_SCHEDULED; } /** * Whether this outcome represents a non-retryable terminal * failure. */ public function isTerminal(): bool { return $this->outcome === self::OUTCOME_FAILED_TERMINAL; } /** * Normalize the given timestamp to UTC, or return null when * the input is null. * * @param DateTimeImmutable|null $value Input timestamp. */ private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable { if ( $value === null ) { return null; } return $value->setTimezone( new DateTimeZone( 'UTC' ) ); } }