}|null */ public function find(array $session, string $route, string $key): ?array { if (!$this->valid_key($key)) { return null; } global $wpdb; $table = Database::table('commentmate_idempotency'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic request reservation and expiry are stored in plugin tables; stale replay state could execute a mutation twice. $wpdb->prepare('SELECT response_code, response_body FROM %i WHERE session_id = %d AND route = %s AND request_key = %s AND expires_at > UTC_TIMESTAMP() LIMIT 1', $table, (int) $session['id'], $route, $key), ARRAY_A ); if (!is_array($row) || (int) $row['response_code'] < 200) { return null; } $body = json_decode((string) $row['response_body'], true); if (!is_array($body)) { return null; } return array( 'code' => (int) $row['response_code'], 'body' => $body, ); } /** * Atomically reserve a key before creating a comment. This prevents two * simultaneous retries from both observing an empty idempotency record and * creating duplicate comments. * * @return bool|array{code:int, body:array}|null * true: caller owns a new reservation; false: another request is * still processing it; array: completed response; null: DB failure. */ public function reserve(array $session, string $route, string $key): bool|array|null { if (!$this->valid_key($key)) { return null; } global $wpdb; $table = Database::table('commentmate_idempotency'); $now = current_time('mysql', true); $expires_at = gmdate('Y-m-d H:i:s', time() + DAY_IN_SECONDS); if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return null; } $committed = false; try { // Do not rely on INSERT…SELECT locking behaviour: under READ COMMITTED // its source rows may be a non-locking consistent read. Taking the same // session/review lifecycle lock used by comment writes means cleanup // cannot delete this review between validation and reservation. if (!Session_Service::lock_current_for_mutation($session)) { return null; } // The unique key is retained after expiry, so clear only this caller's // expired record while the session/review lifecycle lock is held. if (false === $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic request reservation and expiry are stored in plugin tables; stale replay state could execute a mutation twice. $wpdb->prepare('DELETE FROM %i WHERE session_id = %d AND route = %s AND request_key = %s AND expires_at <= UTC_TIMESTAMP()', $table, (int) $session['id'], $route, $key) )) { return null; } $inserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic request reservation and expiry are stored in plugin tables; stale replay state could execute a mutation twice. $wpdb->prepare('INSERT IGNORE INTO %i (review_id, session_id, route, request_key, response_code, response_body, created_at, expires_at) VALUES (%d, %d, %s, %s, 0, %s, %s, %s)', $table, (int) $session['review_id'], (int) $session['id'], $route, $key, '{}', $now, $expires_at) ); if (false === $inserted) { return null; } if (1 === $inserted) { if (false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return null; } $committed = true; return true; } $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic request reservation and expiry are stored in plugin tables; stale replay state could execute a mutation twice. $wpdb->prepare('SELECT response_code, response_body FROM %i WHERE session_id = %d AND route = %s AND request_key = %s AND expires_at > UTC_TIMESTAMP() LIMIT 1 FOR UPDATE', $table, (int) $session['id'], $route, $key), ARRAY_A ); if (!is_array($row)) { return null; } $result = false; if ((int) $row['response_code'] >= 200) { $body = json_decode((string) $row['response_body'], true); if (is_array($body)) { $result = array( 'code' => (int) $row['response_code'], 'body' => $body, ); } } if (false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return null; } $committed = true; return $result; } catch (\Throwable) { return null; } finally { if (!$committed) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } } } /** * Complete an existing reservation. Call this within the same transaction as * the side effect it represents, so a committed comment always has a replay * response and a rolled-back comment never does. * * @param array $body */ public function complete(array $session, string $route, string $key, int $status, array $body): bool { if (!$this->valid_key($key) || $status < 200) { return false; } $encoded = wp_json_encode($body); if (!is_string($encoded) || strlen($encoded) > 50000) { return false; } global $wpdb; $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic request reservation and expiry are stored in plugin tables; stale replay state could execute a mutation twice. Database::table('commentmate_idempotency'), array( 'response_code' => $status, 'response_body' => $encoded, 'expires_at' => gmdate('Y-m-d H:i:s', time() + DAY_IN_SECONDS), ), array( 'session_id' => (int) $session['id'], 'route' => $route, 'request_key' => $key, 'response_code' => 0, ), array('%d', '%s', '%s'), array('%d', '%s', '%s', '%d') ); return 1 === $updated; } public function abandon(array $session, string $route, string $key): void { if (!$this->valid_key($key)) { return; } global $wpdb; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic request reservation and expiry are stored in plugin tables; stale replay state could execute a mutation twice. $wpdb->prepare('DELETE FROM %i WHERE session_id = %d AND route = %s AND request_key = %s AND response_code = 0', Database::table('commentmate_idempotency'), (int) $session['id'], $route, $key) ); } public function key_from_request(\WP_REST_Request $request): string { return trim((string) $request->get_header('idempotency-key')); } public function valid_key(string $key): bool { return (bool) preg_match('/\A[A-Za-z0-9._-]{16,128}\z/', $key); } }