0). * @param int $lockTimeoutSeconds Reclamation * threshold * in seconds * (must be > 0). * @param AuditEventWriter|null $auditWriter Optional * §10.1 audit * writer; when * null, the * worker does * not emit * audit rows. * @param int $jobSpacingMilliseconds Minimum * delay between * handled sync * jobs. * @param callable|null $sleeper Optional * test seam that * receives the * delay in * microseconds. * * @throws InvalidArgumentException When `$workerId` is empty or * longer than 64 bytes, when * `$batchSize <= 0`, or when * `$lockTimeoutSeconds <= 0`. */ public function __construct( SyncJobRepository $jobs, SyncProductJobHandler $handler, Clock $clock, string $workerId, int $batchSize = self::DEFAULT_BATCH_SIZE, int $lockTimeoutSeconds = self::DEFAULT_LOCK_TIMEOUT_SECONDS, ?AuditEventWriter $auditWriter = null, int $jobSpacingMilliseconds = self::DEFAULT_JOB_SPACING_MILLISECONDS, ?callable $sleeper = null ) { $length = strlen( $workerId ); if ( $length === 0 || $length > 64 ) { $message = sprintf( 'SyncWorker requires workerId of 1-64 bytes, got %d.', $length ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $batchSize <= 0 ) { $message = sprintf( 'SyncWorker requires batchSize > 0, got %d.', $batchSize ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $lockTimeoutSeconds <= 0 ) { $message = sprintf( 'SyncWorker requires lockTimeoutSeconds > 0, got %d.', $lockTimeoutSeconds ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $jobSpacingMilliseconds < 0 ) { $message = sprintf( 'SyncWorker requires jobSpacingMilliseconds >= 0, got %d.', $jobSpacingMilliseconds ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->jobs = $jobs; $this->handler = $handler; $this->clock = $clock; $this->workerId = $workerId; $this->batchSize = $batchSize; $this->lockTimeoutSeconds = $lockTimeoutSeconds; $this->auditWriter = $auditWriter; $this->jobSpacingMilliseconds = $jobSpacingMilliseconds; $this->sleeper = $sleeper ?? static function ( int $microseconds ): void { if ( $microseconds > 0 ) { usleep( $microseconds ); } }; } /** * Stable per-process lock-holder identifier. */ public function workerId(): string { return $this->workerId; } /** * Per-tick drain cap. */ public function batchSize(): int { return $this->batchSize; } /** * Lock-timeout reclamation window in seconds. */ public function lockTimeoutSeconds(): int { return $this->lockTimeoutSeconds; } /** * Minimum delay between handled jobs in a single tick. */ public function jobSpacingMilliseconds(): int { return $this->jobSpacingMilliseconds; } /** * Drain up to {@see self::$batchSize} jobs and return the * resulting tick summary. * * Stamps a single UTC "now" instant at the top of the tick, then * loops `claim → handle → accumulate` until either the queue is * idle (`claimNext()` returns null) or the batch budget is * exhausted. Jobs of an unrecognized type are released back to * the queue's lock-timeout reclamation path by skipping them * without invoking the handler — they will time-out alongside * the §8.5 stale-lock cohort. */ public function tick(): SyncWorkerTickResult { $now = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) ); $cutoff = $now->modify( '-' . $this->lockTimeoutSeconds . ' seconds' ); $claimed = 0; $skipped = 0; $outcomes = array(); $handledSyncProductJobs = 0; for ( $i = 0; $i < $this->batchSize; $i++ ) { $record = $this->jobs->claimNext( $this->workerId, $now, $cutoff ); if ( $record === null ) { break; } ++$claimed; if ( $record->jobType() !== self::JOB_TYPE_SYNC_PRODUCT ) { ++$skipped; continue; } if ( $handledSyncProductJobs > 0 ) { $this->sleepBetweenJobs(); } $outcome = $this->handler->handle( $record ); $outcomes[] = $outcome; ++$handledSyncProductJobs; $this->emitJobAudit( $record, $outcome ); } return new SyncWorkerTickResult( $claimed, $skipped, $outcomes, $now ); } /** * Enforce the configured per-tick job spacing. */ private function sleepBetweenJobs(): void { if ( $this->jobSpacingMilliseconds <= 0 ) { return; } $microseconds = $this->jobSpacingMilliseconds * 1000; ( $this->sleeper )( $microseconds ); } /** * Emit a `sync_job_terminal` audit row describing the * just-handled job's outcome. * * No-op when no audit writer was injected at construction * time. Failure to persist the row is swallowed per the * {@see AuditEventWriter} "tolerate audit emit failure" * contract — losing one audit row never derails the * surrounding tick (the job's terminal-state row in * `affilun_sync_jobs` is the authoritative record either way). * * Outcome → §10.2 result token mapping: * * - {@see SyncJobOutcome::OUTCOME_SUCCESS} * → {@see AuditEventDraft::RESULT_SUCCESS} with * `event_type='sync.job.completed'` and `action='complete'`. * * - {@see SyncJobOutcome::OUTCOME_RETRY_SCHEDULED} * → {@see AuditEventDraft::RESULT_FAILED} with * `event_type='sync.job.retry_scheduled'` and * `action='retry'`. Retry is modeled as a non-terminal * failure so the audit trail surfaces every transient * hiccup; the `nextAvailableAt` instant is carried in the * context payload so an operator can correlate the row * with the upcoming retry tick. * * - {@see SyncJobOutcome::OUTCOME_FAILED_TERMINAL} * → {@see AuditEventDraft::RESULT_FAILED} with * `event_type='sync.job.failed'` and `action='fail'`. * * The draft uses {@see AuditEventDraft::forJobAction()} so * the §12.4 envelope projection's `job_id` lift surfaces the * sync-job id on the read side. The resolved `product_id` * (extracted from the job payload) lands as the `target_id` * with `target_type='product'` so the per-target audit * drilldown groups every retry / success / failure for the * same product together. * * @param SyncJobRecord $record Job record the worker just * handled (carries the job id * and payload). * @param SyncJobOutcome $outcome Handler outcome to encode. */ private function emitJobAudit( SyncJobRecord $record, SyncJobOutcome $outcome ): void { if ( $this->auditWriter === null ) { return; } $jobId = $record->id(); $productId = self::extractProductId( $record->payloadJson() ); try { if ( $outcome->isSuccess() ) { $eventType = 'sync.job.completed'; $action = 'complete'; $result = AuditEventDraft::RESULT_SUCCESS; $summary = sprintf( 'Sync job %s completed (attempts=%d).', $jobId === null ? '(unpersisted)' : (string) $jobId, $outcome->attempts() ); $context = array( 'attempts' => $outcome->attempts(), ); } elseif ( $outcome->isRetry() ) { $eventType = 'sync.job.retry_scheduled'; $action = 'retry'; $result = AuditEventDraft::RESULT_FAILED; $summary = sprintf( 'Sync job %s scheduled for retry (attempts=%d).', $jobId === null ? '(unpersisted)' : (string) $jobId, $outcome->attempts() ); $context = array( 'attempts' => $outcome->attempts(), 'error_code' => $outcome->errorCode(), 'next_available_at' => $outcome->nextAvailableAt() === null ? null : $outcome->nextAvailableAt()->format( DATE_ATOM ), ); } else { // Terminal failure. $eventType = 'sync.job.failed'; $action = 'fail'; $result = AuditEventDraft::RESULT_FAILED; $summary = sprintf( 'Sync job %s failed terminally (attempts=%d).', $jobId === null ? '(unpersisted)' : (string) $jobId, $outcome->attempts() ); $context = array( 'attempts' => $outcome->attempts(), 'error_code' => $outcome->errorCode(), 'error_message' => $outcome->errorMessage(), ); } $draft = AuditEventDraft::forJobAction( $eventType, 'sync_job_terminal', $action, $result, $summary, $jobId === null ? '(unpersisted)' : (string) $jobId, $productId === null ? null : 'product', $productId === null ? null : (string) $productId, array(), $context ); $this->auditWriter->record( $draft ); } catch ( RuntimeException $unused ) { // Per the AuditEventWriter contract, audit emit failure // must not derail the surrounding worker tick — the // job's terminal-state row in affilun_sync_jobs is the // authoritative record either way. unset( $unused ); } } /** * Extract the `product_id` field from the supplied JSON * payload string, returning null when the field is missing, * malformed, or non-positive. * * Mirrors the equivalent helper on * {@see SyncProductJobHandler} so the audit row can carry the * same `target_id` value the handler resolved from the * payload, without coupling the worker to the handler's * private surface. * * @param string $payloadJson Job payload JSON string. */ private static function extractProductId( string $payloadJson ): ?int { $decoded = json_decode( $payloadJson, true ); if ( ! is_array( $decoded ) ) { return null; } if ( ! array_key_exists( 'product_id', $decoded ) ) { return null; } $raw = $decoded['product_id']; if ( ! is_int( $raw ) && ! ( is_string( $raw ) && ctype_digit( $raw ) ) ) { return null; } $id = (int) $raw; return $id > 0 ? $id : null; } /** * Convenience accessor exposed for tests and diagnostics so * downstream code does not have to re-derive the cutoff * arithmetic when validating §8.5 reclamation behavior. * * @param DateTimeImmutable $now Reference instant. */ public function lockTimeoutCutoffFor( DateTimeImmutable $now ): DateTimeImmutable { return $now ->setTimezone( new DateTimeZone( 'UTC' ) ) ->modify( '-' . $this->lockTimeoutSeconds . ' seconds' ); } }