$data Column values. * * @return int The new row ID, or 0 when the write failed. */ public function open( array $data ): int { $now = $this->now(); return $this->insert_row( array_merge( array( 'status' => self::PENDING, 'attempts' => 0, 'created_at' => $now, 'updated_at' => $now, ), $data ) ); } /** * Writes the outcome of an attempt. * * @param int $id Row to update. * @param array $data Column values. * * @return bool True when the row changed. */ public function close( int $id, array $data ): bool { return $this->update_row( $id, array_merge( $data, array( 'updated_at' => $this->now() ) ) ); } /** * Returns a single delivery. * * @param int $id Row ID. * * @return array|null The row, or null when it is gone. */ public function find( int $id ): ?array { return $this->find_row( $id ); } /** * Returns the deliveries whose next attempt is due. * * @param int $limit Optional. Largest number of rows returned. Default 20. * * @return array> The rows, oldest first. */ public function due( int $limit = 20 ): array { $query = $this->query_builder() ->where_equals( 'status', self::PENDING ) ->where( 'next_attempt_at IS NOT NULL AND next_attempt_at <= %s', array( $this->now() ) ) ->order_by( 'next_attempt_at', 'ASC', array( 'next_attempt_at' ), 'next_attempt_at' ) ->paginate( max( 1, min( 100, $limit ) ), 1 ); return $this->select( $query ); } /** * Formats a Unix timestamp the way the rest of this table is written. * * Every other column here is `current_time( 'mysql' )`, which is *site* time, * and `due()` compares `next_attempt_at` against `now()` on the same footing. * `next_attempt_at` was being written with `gmdate()`, which is UTC — so on * any site not running at UTC the two disagreed by the offset, and a retry * scheduled an hour out became due immediately on a site at UTC+1 or an hour * late on one at UTC-1. One clock, used everywhere. * * @param int $timestamp Unix timestamp. * * @return string The time in the same frame as `current_time( 'mysql' )`. */ public static function stamp( int $timestamp ): string { $offset = (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ); return gmdate( 'Y-m-d H:i:s', $timestamp + $offset ); } /** * Takes ownership of a due delivery for a short lease. * * `due()` used to hand rows out without marking them, and WP-Cron has no * mutex: two overlapping runs read the same batch and both posted it, which * for a webhook means the endpoint receives the submission twice and the * `attempts` counter advances non-monotonically, letting a delivery overrun * the five-attempt ceiling. * * The claim is a conditional UPDATE pushing `next_attempt_at` into the future, * so it is decided by the database rather than by a read the caller did a * moment ago. The lease is short: if the process handling the row dies, the * row simply becomes due again. * * @param int $id Row to claim. * @param int $lease Optional. Seconds to hold the row for. Default 300. * * @return bool True when this caller now owns the row. */ public function claim( int $id, int $lease = 300 ): bool { if ( $id <= 0 ) { return false; } $now = $this->now(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Conditional claim; the CRUD helpers cannot express the WHERE. $claimed = $this->db->query( $this->db->prepare( sprintf( 'UPDATE %s SET next_attempt_at = %%s, updated_at = %%s WHERE id = %%d AND status = %%s AND next_attempt_at IS NOT NULL AND next_attempt_at <= %%s', $this->table() ), self::stamp( time() + max( 60, $lease ) ), $now, $id, self::PENDING, $now ) ); return 1 === $claimed; } /** * Returns the most recent deliveries of a form. * * @param int $form_id Form to read. * @param int $limit Optional. Largest number of rows returned. Default 20. * * @return array> The rows, newest first. */ public function recent( int $form_id, int $limit = 20 ): array { $query = $this->query_builder() ->where_equals( 'form_id', $form_id, '%d' ) ->order_by( 'id', 'DESC', array( 'id' ), 'id' ) ->paginate( max( 1, min( 100, $limit ) ), 1 ); return $this->select( $query ); } /** * Deletes finished deliveries older than a number of days. * * Pending rows are left alone whatever their age: they are work still to do, * and a retry schedule that stretches past the retention window should not * quietly lose its queue. * * @param int $days Age in days. */ public function purge( int $days ): int { if ( $days <= 0 ) { return 0; } // Same clock the rows are written in; see stamp(). A UTC bound compared // against site-time `updated_at` was off by the site's offset either way. $before = self::stamp( time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk delete by age, outside the CRUD helpers. $deleted = $this->db->query( $this->db->prepare( sprintf( 'DELETE FROM %s WHERE status <> %%s AND updated_at < %%s', $this->table() ), self::PENDING, $before ) ); return is_int( $deleted ) ? $deleted : 0; } /** * Returns the table this repository reads and writes. */ protected function table(): string { return Schema::table( Schema::WEBHOOK_LOG ); } /** * Returns the writable columns and their placeholder formats. * * @return array Formats keyed by column name. */ protected function formats(): array { return array( 'form_id' => '%d', 'submission_id' => '%d', 'webhook_key' => '%s', 'url' => '%s', 'status' => '%s', 'response_code' => '%d', 'attempts' => '%d', 'duration_ms' => '%d', 'payload' => '%s', 'response' => '%s', 'message' => '%s', 'next_attempt_at' => '%s', 'created_at' => '%s', 'updated_at' => '%s', ); } }