= 0`, `enqueued >= 0`, `skipped >= 0`. * - `enqueued + skipped === scanned`. * - `count(enqueuedProductIds) === enqueued`. * - Every entry in `enqueuedProductIds` is a positive integer. * - `tickStartedAt` is a UTC `\DateTimeImmutable`. * * The constructor enforces these invariants so a programming error * in the scheduler (mis-counted skip, mis-sized enqueued-ids list, * non-positive id) 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 PeriodicSyncScheduler::tick()}. * * See the file-level docblock for the field semantics, the * `scanned` / `enqueued` / `skipped` invariants, and the role this * value object plays at the scheduler-to-cron observability seam. */ final class PeriodicSyncTickResult { /** * Total records returned by the stale-product finder for this * tick. */ private int $scanned; /** * Records successfully handed to the dispatcher for enqueue. */ private int $enqueued; /** * Records skipped because the finder violated its contract * (no persisted id, wrong type). */ private int $skipped; /** * Persisted product ids enqueued during the tick, in scan order. * * @var list */ private array $enqueuedProductIds; /** * UTC instant stamped at the top of the tick. */ private DateTimeImmutable $tickStartedAt; /** * Construct the tick result with explicit per-axis values. * * @param int $scanned Total records the * finder returned. * @param int $enqueued Records actually * enqueued. * @param int $skipped Records skipped * defensively. * @param list $enqueuedProductIds Per-record product * ids in scan order. * @param DateTimeImmutable $tickStartedAt UTC tick-start * instant. * * @throws InvalidArgumentException When `$scanned`, `$enqueued`, * `$skipped`, or * `count($enqueuedProductIds)` * violate the file-level * invariants, or when * `$enqueuedProductIds` * contains a non-positive * integer. */ public function __construct( int $scanned, int $enqueued, int $skipped, array $enqueuedProductIds, DateTimeImmutable $tickStartedAt ) { if ( $scanned < 0 ) { $message = sprintf( 'PeriodicSyncTickResult: scanned must be >= 0, got %d.', $scanned ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $enqueued < 0 ) { $message = sprintf( 'PeriodicSyncTickResult: enqueued must be >= 0, got %d.', $enqueued ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $skipped < 0 ) { $message = sprintf( 'PeriodicSyncTickResult: 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 ( $enqueued + $skipped !== $scanned ) { $message = sprintf( 'PeriodicSyncTickResult: enqueued (%d) + skipped (%d) must equal scanned (%d).', $enqueued, $skipped, $scanned ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $idCount = count( $enqueuedProductIds ); if ( $idCount !== $enqueued ) { $message = sprintf( 'PeriodicSyncTickResult: enqueuedProductIds count (%d) must equal enqueued (%d).', $idCount, $enqueued ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized = array(); foreach ( $enqueuedProductIds as $index => $productId ) { if ( ! is_int( $productId ) || $productId <= 0 ) { $message = sprintf( 'PeriodicSyncTickResult: enqueuedProductIds[%d] must be a positive integer.', (int) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = $productId; } $this->scanned = $scanned; $this->enqueued = $enqueued; $this->skipped = $skipped; $this->enqueuedProductIds = $normalized; $this->tickStartedAt = $tickStartedAt->setTimezone( new DateTimeZone( 'UTC' ) ); } /** * Total records returned by the stale-product finder. */ public function scanned(): int { return $this->scanned; } /** * Records successfully enqueued through the dispatcher. */ public function enqueued(): int { return $this->enqueued; } /** * Records skipped because the finder violated its contract. */ public function skipped(): int { return $this->skipped; } /** * Persisted product ids enqueued during the tick, in scan order. * * @return list */ public function enqueuedProductIds(): array { return $this->enqueuedProductIds; } /** * UTC instant stamped at the top of the tick. */ public function tickStartedAt(): DateTimeImmutable { return $this->tickStartedAt; } /** * Whether the tick observed an empty stale cohort. * * Convenience predicate so the surrounding cron service provider * can elide "periodic sweep observed nothing" log lines on idle * ticks without having to inspect {@see self::scanned()} directly. */ public function isIdle(): bool { return $this->scanned === 0; } }