= 0`, `skipped >= 0`, `skipped <= claimed`. * - `count(outcomes) === claimed - skipped`. * - `tickStartedAt` is a UTC `\DateTimeImmutable`. * * The constructor enforces the numeric invariants so a programming * error in the worker (mis-counted skip, mis-sized outcome list) * surfaces at construction time rather than as a confusing assertion * failure further downstream. * * @package Affilun\Application\Sync */ declare(strict_types=1); namespace Affilun\Application\Sync; use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; /** * Immutable per-tick outcome carried back from {@see SyncWorker::tick()}. * * See the file-level docblock for the field semantics, the * `claimed` / `skipped` / `outcomes` invariants, and the role this * value object plays at the worker-to-scheduler observability seam. */ final class SyncWorkerTickResult { /** * Total jobs transitioned to `running` during the tick. */ private int $claimed; /** * Subset of `claimed` whose `job_type` had no registered handler. */ private int $skipped; /** * Per-job handler outcomes, in processing order. * * @var list */ private array $outcomes; /** * UTC instant stamped at the top of the tick. */ private DateTimeImmutable $tickStartedAt; /** * Construct the tick result with explicit per-axis values. * * @param int $claimed Total jobs claimed. * @param int $skipped Subset of `$claimed` * whose job_type had * no registered handler. * @param list $outcomes Per-handled-job outcomes. * @param DateTimeImmutable $tickStartedAt UTC tick-start instant. * * @throws InvalidArgumentException When `$claimed`, `$skipped`, * or `count($outcomes)` violate * the file-level invariants, or * when `$outcomes` contains a * non-{@see SyncJobOutcome} * entry. */ public function __construct( int $claimed, int $skipped, array $outcomes, DateTimeImmutable $tickStartedAt ) { if ( $claimed < 0 ) { $message = sprintf( 'SyncWorkerTickResult: claimed must be >= 0, got %d.', $claimed ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $skipped < 0 ) { $message = sprintf( 'SyncWorkerTickResult: skipped must be >= 0, got %d.', $skipped ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $skipped > $claimed ) { $message = sprintf( 'SyncWorkerTickResult: skipped (%d) must be <= claimed (%d).', $skipped, $claimed ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $expectedOutcomes = $claimed - $skipped; $actualOutcomes = count( $outcomes ); if ( $actualOutcomes !== $expectedOutcomes ) { $message = sprintf( 'SyncWorkerTickResult: outcomes count (%d) must equal claimed - skipped (%d).', $actualOutcomes, $expectedOutcomes ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized = array(); foreach ( $outcomes as $index => $outcome ) { if ( ! $outcome instanceof SyncJobOutcome ) { $message = sprintf( 'SyncWorkerTickResult: outcomes[%d] is not a SyncJobOutcome.', (int) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = $outcome; } $this->claimed = $claimed; $this->skipped = $skipped; $this->outcomes = $normalized; $this->tickStartedAt = $tickStartedAt->setTimezone( new DateTimeZone( 'UTC' ) ); } /** * Total jobs transitioned to `running` during the tick. */ public function claimed(): int { return $this->claimed; } /** * Subset of `claimed` whose `job_type` had no registered handler. */ public function skipped(): int { return $this->skipped; } /** * Number of jobs the handler actually processed * (`claimed - skipped`). */ public function handled(): int { return $this->claimed - $this->skipped; } /** * Per-job handler outcomes, in processing order. * * @return list */ public function outcomes(): array { return $this->outcomes; } /** * UTC instant stamped at the top of the tick. */ public function tickStartedAt(): DateTimeImmutable { return $this->tickStartedAt; } /** * Whether the tick performed no work (no jobs were claimed). * * Convenience predicate so the surrounding scheduler can elide * "worker tick observed nothing" log lines on idle ticks without * having to inspect {@see self::claimed()} directly. */ public function isIdle(): bool { return $this->claimed === 0; } /** * Number of handler outcomes that reported {@see SyncJobOutcome::isSuccess()}. */ public function successCount(): int { $count = 0; foreach ( $this->outcomes as $outcome ) { if ( $outcome->isSuccess() ) { ++$count; } } return $count; } /** * Number of handler outcomes that reported {@see SyncJobOutcome::isRetry()}. */ public function retryCount(): int { $count = 0; foreach ( $this->outcomes as $outcome ) { if ( $outcome->isRetry() ) { ++$count; } } return $count; } /** * Number of handler outcomes that reported {@see SyncJobOutcome::isTerminal()}. */ public function terminalFailureCount(): int { $count = 0; foreach ( $this->outcomes as $outcome ) { if ( $outcome->isTerminal() ) { ++$count; } } return $count; } }