prefix . 'abtestkit_connected_outbox'; } /** Only setup explicitly calls installation; loading this module does no DDL. */ public static function install() { global $wpdb; $table = self::table(); $collation = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( "CREATE TABLE $table ( slot smallint unsigned NOT NULL, event_id char(36) NOT NULL, payload text NOT NULL, expires_at bigint unsigned NOT NULL, available_at bigint unsigned NOT NULL, attempts int unsigned NOT NULL DEFAULT 0, claim_token char(36) NOT NULL DEFAULT '', lease_expires_at bigint unsigned NOT NULL DEFAULT 0, last_error_code varchar(32) NOT NULL DEFAULT '', PRIMARY KEY (slot), UNIQUE KEY event_id (event_id), KEY due (available_at,lease_expires_at), KEY expiry (expires_at) ) $collation;" ); $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ) ); if ( ! is_array( $columns ) || array_diff( array( 'slot', 'event_id', 'payload', 'expires_at', 'available_at', 'attempts', 'claim_token', 'lease_expires_at', 'last_error_code' ), $columns ) ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } update_option( self::SCHEMA_OPTION, 1, false ); return true; } private static function ready() : bool { return 1 === (int) get_option( self::SCHEMA_OPTION, 0 ); } /** Expiration is bounded by original occurrence, never extended by retries. */ public static function prune( int $now ) { global $wpdb; if ( ! self::ready() ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at <= %d', self::table(), $now ) ); return false === $deleted ? new WP_Error( 'abtestkit_connected_storage_unavailable' ) : $deleted; } /** * Fixed primary-key slots enforce the row bound even across concurrent * writers. A collision retries allocation without overwriting another event. */ public static function enqueue( array $event, int $now ) { global $wpdb; $event = ABTestKit_Connected_Event::validate( $event, $now ); if ( is_wp_error( $event ) ) { return $event; } $pruned = self::prune( $now ); if ( is_wp_error( $pruned ) ) { return $pruned; } $payload = wp_json_encode( $event, JSON_UNESCAPED_SLASHES ); for ( $attempt = 0; $attempt < 3; ++$attempt ) { $existing = $wpdb->get_var( $wpdb->prepare( 'SELECT payload FROM %i WHERE event_id = %s', self::table(), $event['event_id'] ) ); if ( '' !== $wpdb->last_error ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } if ( null !== $existing ) { return hash_equals( $existing, $payload ) ? true : new WP_Error( 'abtestkit_connected_event_conflict' ); } $occupied = $wpdb->get_col( $wpdb->prepare( 'SELECT slot FROM %i ORDER BY slot LIMIT %d', self::table(), self::CAPACITY ) ); if ( '' !== $wpdb->last_error || ! is_array( $occupied ) ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } $slot = 1; foreach ( $occupied as $taken ) { if ( (int) $taken !== $slot ) { break; } ++$slot; } if ( $slot > self::CAPACITY ) { return new WP_Error( 'abtestkit_connected_queue_full' ); } // On a competing slot/event insert, preserve the existing row exactly. $written = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (slot,event_id,payload,expires_at,available_at) VALUES (%d,%s,%s,%d,%d) ON DUPLICATE KEY UPDATE slot = slot', self::table(), $slot, $event['event_id'], $payload, $event['occurred_at'] + ABTestKit_Connected_Event::MAX_AGE, $now ) ); if ( false === $written ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } // Some database adapters report a matched no-op update as one row. // Confirm this event, not the affected-row count, before accepting it. $saved = $wpdb->get_var( $wpdb->prepare( 'SELECT payload FROM %i WHERE event_id = %s', self::table(), $event['event_id'] ) ); if ( '' !== $wpdb->last_error ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } if ( null !== $saved ) { return hash_equals( $saved, $payload ) ? true : new WP_Error( 'abtestkit_connected_event_conflict' ); } } return new WP_Error( 'abtestkit_connected_queue_busy' ); } /** Compare-and-set claims prevent two workers from owning the same row. */ public static function claim( int $now, int $limit = self::MAX_BATCH ) { global $wpdb; $pruned = self::prune( $now ); if ( is_wp_error( $pruned ) ) { return $pruned; } $limit = max( 1, min( self::MAX_BATCH, $limit ) ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT event_id,payload FROM %i WHERE available_at <= %d AND lease_expires_at <= %d ORDER BY available_at,slot LIMIT %d', self::table(), $now, $now, $limit ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $rows ) ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } $claimed = array(); foreach ( $rows as $row ) { $token = ABTestKit_Connected_Event::new_id(); if ( is_wp_error( $token ) ) { return $token; } $updated = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET claim_token = %s, lease_expires_at = %d, attempts = LEAST(attempts + 1,65535) WHERE event_id = %s AND available_at <= %d AND lease_expires_at <= %d AND expires_at > %d', self::table(), $token, $now + self::LEASE_SECONDS, $row['event_id'], $now, $now, $now ) ); if ( false === $updated ) { return new WP_Error( 'abtestkit_connected_storage_unavailable' ); } if ( 1 === $updated ) { $claimed[] = array( 'event_id' => $row['event_id'], 'claim_token' => $token, 'payload' => $row['payload'] ); } } return $claimed; } /** Never acknowledge a new worker's claim using an expired worker response. */ public static function acknowledge( string $event_id, string $token, int $now ) : bool { global $wpdb; if ( ! self::ready() || ! ABTestKit_Connected_Event::valid_id( $event_id ) || ! ABTestKit_Connected_Event::valid_id( $token ) ) { return false; } return 1 === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE event_id = %s AND claim_token = %s AND lease_expires_at > %d AND expires_at > %d', self::table(), $event_id, $token, $now, $now ) ); } /** Keep the exact event on transient failure, with capped automatic backoff. */ public static function retry( string $event_id, string $token, string $code, int $now ) : bool { global $wpdb; if ( ! self::ready() || ! ABTestKit_Connected_Event::valid_id( $event_id ) || ! ABTestKit_Connected_Event::valid_id( $token ) || ! in_array( $code, array( 'transport', 'rate_limited', 'service_unavailable' ), true ) ) { return false; } $attempts = $wpdb->get_var( $wpdb->prepare( 'SELECT attempts FROM %i WHERE event_id = %s AND claim_token = %s', self::table(), $event_id, $token ) ); if ( null === $attempts || '' !== $wpdb->last_error ) { return false; } $delay = min( DAY_IN_SECONDS, 60 * ( 2 ** min( 11, max( 0, (int) $attempts - 1 ) ) ) ); return 1 === $wpdb->query( $wpdb->prepare( "UPDATE %i SET available_at = %d, claim_token = '', lease_expires_at = 0, last_error_code = %s WHERE event_id = %s AND claim_token = %s AND lease_expires_at > %d AND expires_at > %d", self::table(), $now + $delay, $code, $event_id, $token, $now, $now ) ); } /** Disconnect removes pending platform work, independently of identity. */ public static function clear() : void { global $wpdb; if ( self::ready() ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', self::table() ) ); } } /** Called only from the existing explicit clean-uninstall boundary. */ public static function uninstall() : void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', self::table() ) ); delete_option( self::SCHEMA_OPTION ); delete_option( ABTestKit_Connected_Installation::OPTION ); } } // phpcs:enable