database = $database; $this->runs_table = $tables['runs']; $this->tasks_table = $tables['tasks']; } /** * @param array $cursor Initial resumable cursor. */ public function enqueue( int $run_id, string $check_id, array $cursor = array() ): string { if ( $run_id < 1 || 1 !== preg_match( '/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/', $check_id ) ) { throw new InvalidArgumentException( 'invalid_task' ); } $uuid = wp_generate_uuid4(); $inserted = $this->database->insert( $this->tasks_table, array( 'run_id' => $run_id, 'task_uuid' => $uuid, 'check_id' => $check_id, 'status' => TaskStatus::QUEUED, 'cursor_json' => $this->encode_cursor( $cursor ), 'attempts' => 0, 'updated_at' => current_time( 'mysql', true ), ), array( '%d', '%s', '%s', '%s', '%s', '%d', '%s' ) ); if ( false === $inserted ) { throw new StorageException( 'task_enqueue_failed' ); } return $uuid; } public function acquire_next( int $run_id, int $ttl_seconds = 60 ): ?TaskLease { if ( $run_id < 1 || $ttl_seconds < 10 || $ttl_seconds > 300 ) { throw new InvalidArgumentException( 'invalid_lease_request' ); } $now = current_time( 'mysql', true ); // Both table names are selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $candidates = $this->database->get_results( $this->database->prepare( "SELECT t.id, t.run_id, t.task_uuid, t.check_id, t.cursor_json, t.attempts FROM {$this->tasks_table} t INNER JOIN {$this->runs_table} r ON r.id = t.run_id WHERE t.run_id = %d AND r.status = %s AND t.attempts < %d AND (t.status = %s OR (t.status = %s AND t.lock_expires_at < %s)) ORDER BY t.id ASC LIMIT 5", $run_id, RunStatus::RUNNING, self::MAX_ATTEMPTS, TaskStatus::QUEUED, TaskStatus::RUNNING, $now ), ARRAY_A ); foreach ( $candidates as $candidate ) { $lease = $this->try_acquire( $candidate, $now, $ttl_seconds ); if ( null !== $lease ) { return $lease; } } return null; } /** * Save a bounded cursor and release the task for another chunk. * * @param array $cursor Resumable cursor. */ public function checkpoint( TaskLease $lease, array $cursor ): bool { return $this->finish_lease( $lease, TaskStatus::QUEUED, $cursor, null, false ); } /** * Save successful incremental progress without treating the next chunk as a * retry. Lease attempts remain available for genuinely abandoned workers. * * @param array $cursor Resumable cursor. */ public function checkpoint_progress( TaskLease $lease, array $cursor ): bool { return $this->finish_lease( $lease, TaskStatus::QUEUED, $cursor, null, true ); } /** * Mark a task complete and discard its bearer lease. * * @param array $cursor Final cursor metadata. */ public function complete( TaskLease $lease, array $cursor = array() ): bool { return $this->finish_lease( $lease, TaskStatus::COMPLETED, $cursor, null, false ); } public function fail( TaskLease $lease, string $error_code ): bool { if ( 1 !== preg_match( '/^[a-z][a-z0-9_]{1,63}$/', $error_code ) ) { throw new InvalidArgumentException( 'invalid_error_code' ); } return $this->finish_lease( $lease, TaskStatus::FAILED, $lease->cursor(), $error_code, false ); } public function cancel_open( int $run_id ): int { if ( $run_id < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $this->database->query( $this->database->prepare( "UPDATE {$this->tasks_table} SET status = %s, lock_hash = NULL, lock_expires_at = NULL, updated_at = %s WHERE run_id = %d AND status IN (%s, %s)", TaskStatus::CANCELLED, current_time( 'mysql', true ), $run_id, TaskStatus::QUEUED, TaskStatus::RUNNING ) ); if ( false === $result ) { throw new StorageException( 'task_cancel_failed' ); } return $result; } /** * @return array{queued:int,running:int,completed:int,failed:int,cancelled:int,total:int} */ public function counts( int $run_id ): array { if ( $run_id < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } $counts = array_fill_keys( TaskStatus::all(), 0 ); // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $this->database->get_results( $this->database->prepare( "SELECT status, COUNT(*) AS task_count FROM {$this->tasks_table} WHERE run_id = %d GROUP BY status", $run_id ), ARRAY_A ); foreach ( $rows as $row ) { $status = (string) $row['status']; if ( array_key_exists( $status, $counts ) ) { $counts[ $status ] = (int) $row['task_count']; } } return array( 'queued' => $counts[ TaskStatus::QUEUED ], 'running' => $counts[ TaskStatus::RUNNING ], 'completed' => $counts[ TaskStatus::COMPLETED ], 'failed' => $counts[ TaskStatus::FAILED ], 'cancelled' => $counts[ TaskStatus::CANCELLED ], 'total' => array_sum( $counts ), ); } public function recover_exhausted( int $run_id ): int { $now = current_time( 'mysql', true ); // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $this->database->query( $this->database->prepare( "UPDATE {$this->tasks_table} SET status = %s, error_code = %s, lock_hash = NULL, lock_expires_at = NULL, updated_at = %s WHERE run_id = %d AND status = %s AND attempts >= %d AND lock_expires_at < %s", TaskStatus::FAILED, 'lease_attempts_exhausted', $now, $run_id, TaskStatus::RUNNING, self::MAX_ATTEMPTS, $now ) ); if ( false === $result ) { throw new StorageException( 'task_recovery_failed' ); } return $result; } /** * @param array $candidate Selected task row. */ private function try_acquire( array $candidate, string $now, int $ttl_seconds ): ?TaskLease { $token = bin2hex( random_bytes( 32 ) ); $token_hash = hash( 'sha256', $token ); $expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl_seconds ); // The compare-and-swap prevents two workers from owning the same task. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $updated = $this->database->query( $this->database->prepare( "UPDATE {$this->tasks_table} SET status = %s, attempts = attempts + 1, lock_hash = %s, lock_expires_at = %s, error_code = NULL, updated_at = %s WHERE id = %d AND attempts < %d AND (status = %s OR (status = %s AND lock_expires_at < %s))", TaskStatus::RUNNING, $token_hash, $expires_at, $now, (int) $candidate['id'], self::MAX_ATTEMPTS, TaskStatus::QUEUED, TaskStatus::RUNNING, $now ) ); if ( 1 !== $updated ) { return null; } return new TaskLease( (int) $candidate['id'], (int) $candidate['run_id'], (string) $candidate['task_uuid'], (string) $candidate['check_id'], $this->decode_cursor( (string) $candidate['cursor_json'] ), (int) $candidate['attempts'] + 1, $token, $expires_at ); } /** * @param array $cursor Updated task cursor. */ private function finish_lease( TaskLease $lease, string $status, array $cursor, ?string $error_code, bool $reset_attempts ): bool { $token_hash = hash( 'sha256', $lease->token() ); $now = current_time( 'mysql', true ); // The raw bearer token never enters SQL or storage; only its digest does. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $updated = $this->database->query( $this->database->prepare( "UPDATE {$this->tasks_table} SET status = %s, cursor_json = %s, lock_hash = NULL, lock_expires_at = NULL, error_code = %s, updated_at = %s, attempts = IF(%d, 0, attempts) WHERE id = %d AND task_uuid = %s AND status = %s AND lock_hash = %s AND lock_expires_at >= %s", $status, $this->encode_cursor( $cursor ), $error_code, $now, $reset_attempts ? 1 : 0, $lease->task_id(), $lease->task_uuid(), TaskStatus::RUNNING, $token_hash, $now ) ); return 1 === $updated; } /** * @param array $cursor Task cursor. */ private function encode_cursor( array $cursor ): string { $json = wp_json_encode( $cursor, JSON_UNESCAPED_SLASHES ); if ( false === $json || strlen( $json ) > self::MAX_CURSOR_BYTES ) { throw new StorageException( 'invalid_task_cursor' ); } return $json; } /** * @return array */ private function decode_cursor( string $json ): array { if ( strlen( $json ) > self::MAX_CURSOR_BYTES ) { throw new StorageException( 'invalid_task_cursor' ); } try { $cursor = json_decode( $json, true, 64, JSON_THROW_ON_ERROR ); } catch ( JsonException $exception ) { // The previous exception is retained for local debugging, never rendered. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new StorageException( 'invalid_task_cursor', 0, $exception ); } if ( ! is_array( $cursor ) ) { throw new StorageException( 'invalid_task_cursor' ); } return $cursor; } }