prepare(). // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter const ORDERPING_DB_VERSION = 5; const ORDERPING_OPTION_DB_VERSION = 'orderping_db_version'; const ORDERPING_DELIVER_HOOK = 'orderping_deliver_event'; const ORDERPING_SWEEP_HOOK = 'orderping_sweep_outbox'; const ORDERPING_AS_GROUP = 'orderping'; const ORDERPING_MAX_ATTEMPTS = 8; const ORDERPING_LEASE_SECONDS = 120; const ORDERPING_KICK_SECONDS = 30; const ORDERPING_KICK_MAX_PER_REQUEST = 5; /** * Outbox table name. * * @return string */ function orderping_outbox_table() { global $wpdb; return $wpdb->prefix . 'orderping_outbox'; } /** * Moves queued rows to the current contract before an "update required" block is lifted, so * the new plugin is not refused again for payloads built by the old one. A payload that is * not a plain object carrying a contract_version cannot be migrated safely: the row is * failed as 'contract_retired' with its payload blanked (nothing is resent). */ function orderping_requeue_for_current_contract() { global $wpdb; $table = orderping_outbox_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( "SELECT id, payload FROM {$table} WHERE status = 'pending'", ARRAY_A ); foreach ( (array) $rows as $row ) { $payload = json_decode( (string) $row['payload'], true ); if ( is_array( $payload ) && isset( $payload['contract_version'] ) && is_int( $payload['contract_version'] ) ) { if ( ORDERPING_CONTRACT_VERSION !== $payload['contract_version'] ) { $payload['contract_version'] = ORDERPING_CONTRACT_VERSION; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( $table, array( 'payload' => wp_json_encode( $payload ) ), array( 'id' => (int) $row['id'] ), array( '%s' ), array( '%d' ) ); } continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( $table, array( 'status' => 'failed', 'last_error' => 'contract_retired', 'payload' => '' ), array( 'id' => (int) $row['id'] ), array( '%s', '%s', '%s' ), array( '%d' ) ); } } /** * Creates or updates tables when the stored schema version is behind, so an * in-place plugin update (which does not fire the activation hook) also gets them. * Pairing, settings and queued rows are options and table rows that dbDelta leaves * alone, so an upgrade keeps them. A new plugin version also lifts an old * "update required" block, which the backend re-imposes if the contract is still refused. */ function orderping_maybe_upgrade_schema() { if ( get_option( ORDERPING_OPTION_PLUGIN_VERSION ) !== ORDERPING_VERSION ) { orderping_requeue_for_current_contract(); delete_option( ORDERPING_OPTION_UPDATE_REQUIRED ); update_option( ORDERPING_OPTION_PLUGIN_VERSION, ORDERPING_VERSION, false ); } if ( (int) get_option( ORDERPING_OPTION_DB_VERSION, 0 ) >= ORDERPING_DB_VERSION ) { return; } orderping_install_tables(); // Rows queued before rows carried a connection binding belong to the current pairing. $conn = orderping_connection(); if ( null !== $conn ) { global $wpdb; $table = orderping_outbox_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET connection_id = %s, connection_epoch = %d WHERE connection_id = ''", $conn['id'], $conn['epoch'] ) ); } update_option( ORDERPING_OPTION_DB_VERSION, ORDERPING_DB_VERSION, false ); } /** * Creates the outbox table (called from orderping_install_tables()). * UNIQUE(event_key) is what makes enqueueing an event idempotent. */ function orderping_install_outbox_table() { global $wpdb; $table = orderping_outbox_table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, event_key varchar(64) NOT NULL, order_id bigint(20) unsigned NOT NULL, payload longtext NOT NULL, connection_id varchar(64) NOT NULL DEFAULT '', connection_epoch bigint(20) unsigned NOT NULL DEFAULT 0, status varchar(16) NOT NULL DEFAULT 'pending', attempts int(10) unsigned NOT NULL DEFAULT 0, next_attempt_at bigint(20) unsigned NOT NULL DEFAULT 0, lease_until bigint(20) unsigned NOT NULL DEFAULT 0, last_error varchar(32) NOT NULL DEFAULT '', created_at bigint(20) unsigned NOT NULL, sent_at bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY event_key (event_key), KEY due (status,next_attempt_at) ) {$charset};" ); } /** * Writes an event row, then schedules delivery. The unique event key makes a * second enqueue of the same event a no-op. * * @param string $event_key Stable per-event key, e.g. placed-123. * @param int $order_id Order ID. * @param array $event Event payload (includes event_id). * @return bool|string True when this call created the row, 'duplicate' when a row * with this key already exists, false when nothing was stored. */ function orderping_outbox_enqueue( $event_key, $order_id, array $event ) { global $wpdb; // The row is bound to the connection it was captured under; a later pairing never sends it. $conn = orderping_connection(); $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->insert( orderping_outbox_table(), array( 'event_key' => $event_key, 'order_id' => $order_id, 'payload' => wp_json_encode( $event ), 'connection_id' => null === $conn ? '' : $conn['id'], 'connection_epoch' => null === $conn ? 0 : $conn['epoch'], 'status' => 'pending', 'next_attempt_at' => time(), 'created_at' => time(), ), array( '%s', '%d', '%s', '%s', '%d', '%s', '%d', '%d' ) ); $wpdb->suppress_errors( $suppress ); if ( 1 !== $rows ) { // A failed insert is a duplicate only if the row is really there. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM ' . orderping_outbox_table() . ' WHERE event_key = %s', $event_key ) ); return null !== $exists ? 'duplicate' : false; } orderping_schedule_delivery( (int) $wpdb->insert_id, time() ); return true; } /** * Schedules one delivery attempt in Action Scheduler (WP-Cron if unavailable). * * An immediate attempt ($at <= now) is an async action, plus a signed loopback "kick" fired * once at shutdown (orderping_kick_*) that delivers the row within seconds even on a frontend * checkout, where Action Scheduler's own async dispatch does nothing. A timed action only runs * on the WP-Cron tick, which on a quiet store can be minutes to hours away, so retries and * backoff stay timed. Actions are never unique: Action Scheduler's unique flag matches on hook * and group while ignoring args, so one pending row would suppress every other row's action. * Idempotency is the outbox claim (orderping_outbox_claim), so a duplicate action or a kick * racing the action is a no-op. If an enqueue fails the row falls back to a timed action, then * to WP-Cron, and the hourly sweep is the last backstop. Nothing here calls the backend. * * @param int $id Outbox row ID. * @param int $at Unix time. */ function orderping_schedule_delivery( $id, $at ) { $id = (int) $id; $immediate = $at <= time(); $queued = 0; if ( function_exists( 'as_schedule_single_action' ) ) { if ( $immediate && function_exists( 'as_enqueue_async_action' ) ) { $queued = (int) as_enqueue_async_action( ORDERPING_DELIVER_HOOK, array( $id ), ORDERPING_AS_GROUP, false ); } if ( 0 === $queued ) { // as_* return 0 on failure; a timed action (a minute out if it was meant to be now) still beats a stranded row. $queued = (int) as_schedule_single_action( $immediate ? time() + 60 : $at, ORDERPING_DELIVER_HOOK, array( $id ), ORDERPING_AS_GROUP, false ); } } if ( 0 === $queued ) { wp_schedule_single_event( $immediate ? time() + 60 : $at, ORDERPING_DELIVER_HOOK, array( $id ) ); } if ( $immediate ) { orderping_kick_queue( $id ); } } /** * True when a delivery action for this row is already pending (sweep dedupe only; never * a correctness check, the outbox claim is). * * @param int $id Outbox row ID. * @return bool */ function orderping_delivery_scheduled( $id ) { if ( function_exists( 'as_has_scheduled_action' ) ) { return (bool) as_has_scheduled_action( ORDERPING_DELIVER_HOOK, array( (int) $id ), ORDERPING_AS_GROUP ); } return false !== wp_next_scheduled( ORDERPING_DELIVER_HOOK, array( (int) $id ) ); } /** * Remembers a row to kick and fires the loopback once per request, at shutdown, so the * checkout response is not held up by it. Never throws into checkout. * * @param int $id Outbox row ID. */ function orderping_kick_queue( $id ) { static $ids = array(); if ( count( $ids ) >= ORDERPING_KICK_MAX_PER_REQUEST ) { return; } $ids[] = (int) $id; if ( 1 === count( $ids ) ) { add_action( 'shutdown', static function () use ( &$ids ) { foreach ( $ids as $row_id ) { orderping_kick_fire( $row_id ); } }, 99 ); } } /** * Logical path the kick token is signed for. * * @param int $id Outbox row ID. * @return string */ function orderping_kick_path( $id ) { return '/orderping/v1/kick/' . (int) $id; } /** * Fires one fire-and-forget loopback to our own REST route. The token is an HMAC over the row * id (direction "kick", see signature.php) with the connection secret, which is never sent. * A blocked or failing loopback changes nothing: the async action, cron and the sweep remain. * * @param int $id Outbox row ID. */ function orderping_kick_fire( $id ) { try { $conn = orderping_connection(); if ( null === $conn ) { return; } $ts = time(); $nonce = bin2hex( random_bytes( 16 ) ); wp_remote_post( rest_url( 'orderping/v1/kick' ), array( 'blocking' => false, 'timeout' => 0.01, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals 'body' => array( 'id' => (int) $id, 'ts' => $ts, 'n' => $nonce, 'sig' => orderping_sign( $conn['secret'], 'kick', 'POST', orderping_kick_path( $id ), $ts, $nonce, '' ), ), ) ); } catch ( \Throwable $e ) { return; } } /** * REST callback for the kick: verifies the token, then runs that one row's delivery now. * Cheap rejects come first (shape, connection, signature, then the replay claim). A valid * token can only ask for the delivery the outbox would run anyway, and the row is claimed * atomically, so a replay or a race with the Action Scheduler action sends nothing twice. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function orderping_rest_kick( WP_REST_Request $request ) { $id = (string) $request->get_param( 'id' ); $nonce = (string) $request->get_param( 'n' ); if ( ! preg_match( '/^\d{1,18}$/', $id ) || (int) $id < 1 ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } $conn = orderping_connection(); if ( null === $conn ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } $verdict = orderping_verify( $conn['secret'], 'kick', 'POST', orderping_kick_path( (int) $id ), (string) $request->get_param( 'ts' ), $nonce, (string) $request->get_param( 'sig' ), '', time() ); if ( true !== $verdict || ! orderping_claim_nonce( $nonce ) ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } // The caller is a non-blocking loopback that has already gone: finish the attempt even so. ignore_user_abort( true ); if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( ORDERPING_KICK_SECONDS ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, Squiz.PHP.DiscouragedFunctions } orderping_deliver_event( (int) $id ); return new WP_REST_Response( array( 'status' => 'ok' ), 200 ); } /** * Seconds to wait before attempt number $attempts + 1 (bounded, with jitter). * * @param int $attempts Attempts made so far (>= 1). * @return int */ function orderping_backoff_seconds( $attempts ) { $steps = array( 60, 300, 900, 3600, 10800, 21600, 43200 ); $base = $steps[ min( $attempts, count( $steps ) ) - 1 ]; return $base + wp_rand( 0, (int) ( $base / 10 ) ); } /** * Atomically claims a due row. Two concurrent workers cannot both win: the * UPDATE matches only a pending row, or a sending row whose lease expired. * * @param int $id Outbox row ID. * @return array|null The row, or null when not claimable. */ function orderping_outbox_claim( $id ) { global $wpdb; $table = orderping_outbox_table(); $now = time(); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $claimed = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'sending', lease_until = %d, attempts = attempts + 1 WHERE id = %d AND next_attempt_at <= %d AND ( status = 'pending' OR ( status = 'sending' AND lease_until < %d ) )", $now + ORDERPING_LEASE_SECONDS, $id, $now, $now ) ); if ( 1 !== (int) $claimed ) { return null; } return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A ); } /** * Action Scheduler callback: one delivery attempt. * * @param int $id Outbox row ID. */ function orderping_deliver_event( $id ) { global $wpdb; if ( orderping_update_required() ) { // Blocked until the plugin is updated: keep the row untouched and look again later. orderping_schedule_delivery( (int) $id, time() + 6 * HOUR_IN_SECONDS ); return; } $row = orderping_outbox_claim( (int) $id ); if ( null === $row ) { return; } $table = orderping_outbox_table(); $event = json_decode( $row['payload'], true ); $now = time(); if ( ! is_array( $event ) ) { $wpdb->update( $table, array( 'status' => 'failed', 'payload' => '{}', 'last_error' => 'bad_payload' ), array( 'id' => $row['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return; } // Scope can change after capture (pilot phone set later, billing phone edited): recheck before any send. $order = wc_get_order( (int) $row['order_id'] ); if ( ! $order instanceof WC_Order ? '' !== orderping_pilot_phone_digits() : ! orderping_order_in_scope( $order ) ) { $wpdb->update( $table, array( 'status' => 'failed', 'payload' => '{}', 'last_error' => 'out_of_scope' ), array( 'id' => $row['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return; } // send_event re-reads the connection and refuses a row captured under another one. $result = orderping_send_event( $event, array( 'id' => (string) $row['connection_id'], 'epoch' => (int) $row['connection_epoch'] ) ); if ( $result['ok'] ) { $wpdb->update( $table, array( 'status' => 'sent', 'payload' => '{}', 'sent_at' => $now, 'last_error' => '' ), array( 'id' => $row['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return; } if ( 'update_required' === ( $result['error'] ?? '' ) ) { // The refused attempt does not count: the row waits for the update. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'pending', attempts = GREATEST( attempts - 1, 0 ), next_attempt_at = %d, last_error = 'update_required' WHERE id = %d", $now + 6 * HOUR_IN_SECONDS, $row['id'] ) ); orderping_schedule_delivery( (int) $row['id'], $now + 6 * HOUR_IN_SECONDS ); return; } $status = (int) $result['status']; // 4xx other than timeout/rate-limit will not succeed on retry; not connected // is permanent until the merchant pairs again. $permanent = in_array( $result['error'] ?? '', array( 'not_connected', 'stale_connection' ), true ) || ( $status >= 400 && $status < 500 && 408 !== $status && 429 !== $status ); $attempts = (int) $row['attempts']; $error = 0 === $status ? (string) ( $result['error'] ?? 'transport' ) : 'http_' . $status; if ( $permanent || $attempts >= ORDERPING_MAX_ATTEMPTS ) { $wpdb->update( $table, array( 'status' => 'failed', 'payload' => '{}', 'last_error' => $error ), array( 'id' => $row['id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return; } $next = $now + orderping_backoff_seconds( $attempts ); $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $table, array( 'status' => 'pending', 'next_attempt_at' => $next, 'last_error' => $error ), array( 'id' => $row['id'] ) ); orderping_schedule_delivery( (int) $row['id'], $next ); } /** * Hourly safety net: re-schedules due rows whose Action Scheduler action was * lost (crashed worker, cleared queue), and prunes old terminal rows. Bounded per run. */ function orderping_sweep_outbox() { global $wpdb; $table = orderping_outbox_table(); $now = time(); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$table} WHERE ( status = 'pending' AND next_attempt_at <= %d ) OR ( status = 'sending' AND lease_until < %d ) ORDER BY id LIMIT 50", $now, $now ) ); // Terminal rows hold no customer data (payload is blanked); drop them after 30 days. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE status IN ( 'sent', 'failed' ) AND created_at < %d", $now - 30 * DAY_IN_SECONDS ) ); foreach ( $ids as $id ) { if ( orderping_delivery_scheduled( (int) $id ) ) { continue; } orderping_schedule_delivery( (int) $id, $now ); } // Re-capture placed orders whose checkout/status hooks never ran. Must not break the sweep above. try { orderping_sweep_missed_orders(); } catch ( \Throwable $e ) { return; } }