0 ? $feed_queue[0] : false; } /** * Empties the feed queue. */ public static function clear_feed_queue() { delete_option( 'wppfm_feed_queue' ); update_site_option( 'wppfm_feed_queue', array() ); } /** * Returns the transient key used to serialize feed startup for a single feed id. * * This short-lived guard closes the gap between "feed start requested" and * "background worker lock acquired", where a second request could otherwise * prepare the same feed again and create a duplicate batch key. * * @param string $feed_id The feed id that is starting. * * @return string */ private static function get_feed_startup_lock_key( $feed_id ) { return 'wppfm_feed_startup_lock_' . sanitize_key( (string) $feed_id ); } /** * Attempts to acquire the per-feed startup lock. * * @param string $feed_id The feed id that is starting. * * @return bool True when the startup lock was acquired. */ public static function acquire_feed_startup_lock( $feed_id ) { if ( '' === (string) $feed_id ) { return false; } $lock_key = self::get_feed_startup_lock_key( $feed_id ); $existing = get_site_transient( $lock_key ); if ( ! empty( $existing ) ) { return false; } $ttl = max( MINUTE_IN_SECONDS, intval( apply_filters( 'wppfm_feed_startup_lock_ttl', 5 * MINUTE_IN_SECONDS, $feed_id ) ) ); return (bool) set_site_transient( $lock_key, array( 'feed_id' => (string) $feed_id, 'ts' => time(), ), $ttl ); } /** * Releases the per-feed startup lock. * * @param string $feed_id The feed id whose startup lock should be cleared. */ public static function release_feed_startup_lock( $feed_id ) { if ( '' === (string) $feed_id ) { return; } delete_site_transient( self::get_feed_startup_lock_key( $feed_id ) ); } /** * Checks if the feed queue is empty. * * @return bool true if the feed is empty. */ public static function feed_queue_is_empty() { $queue = self::get_feed_queue(); return count( $queue ) < 1; } /** * Returns the number of product ids that are still in the product queue. * * @since 2.3.0 * @since 3.13.0 - Added a check if the $ids_in_product_queue is set. * @return int number of product ids still in the product queue. */ public static function nr_ids_remaining_in_product_queue() { $key = get_site_option( 'wppfm_background_process_key' ); $ids_in_product_queue = get_site_option( $key ); return $ids_in_product_queue ? count( $ids_in_product_queue ) - 1 : 0; // The last line in the product queue is the feed closure line, so it needs to be subtracted from the count. } /** * Sets the background_process_is_running option. * * @since 3.11.0 switched from using an option to using a transient to store the process status. * @since 3.18.0 records the process start timestamp to bridge the lock acquisition grace period. * @param bool $set required setting. Default false. */ public static function set_feed_processing_flag( $set = false ) { $status = false !== $set ? 'true' : 'false'; set_site_transient( 'wppfm_background_process_is_active', $status, DAY_IN_SECONDS ); if ( 'true' === $status ) { // @since 3.18.0 recorded process start time to bridge the lock acquisition grace period. set_site_transient( 'wppfm_background_process_started_at', time(), DAY_IN_SECONDS ); } else { // @since 3.18.0 ensure any previously recorded start timestamp is cleared when processing stops. delete_site_transient( 'wppfm_background_process_started_at' ); } } /** * Get the background_process_is_active status option. * * @since 3.11.0 switched from using an option to using a transient to store the process status. * @since 3.18.0 requires a valid background-process lock or a short grace period after startup. * @return bool true if the process is still running. */ public static function feed_is_processing() { $status = get_site_transient( 'wppfm_background_process_is_active' ); $use_lock = apply_filters( 'wppfm_use_lock_for_processing_flag', true ); if ( 'true' !== $status ) { return false; } if ( ! $use_lock ) { return true; } $process_lock = get_site_transient( 'wppfm_feed_generation_process_process_lock' ); if ( $process_lock ) { return true; } $start_timestamp = intval( get_site_transient( 'wppfm_background_process_started_at' ) ); $grace_period = apply_filters( 'wppfm_feed_processing_lock_grace_seconds', MINUTE_IN_SECONDS ); if ( $start_timestamp > 0 && ( time() - $start_timestamp ) <= max( 10, intval( $grace_period ) ) ) { // @since 3.18.0 apply a grace period after start to allow the background worker to obtain the lock. return true; } // @since 3.18.0+ Heartbeat fallback for environments where transients may be evicted early. // This helps prevent overlapping workers and watchdog restarts when a process is still active but the lock transient vanished. if ( self::background_process_heartbeat_is_fresh() ) { return true; } return false; } /** * Returns the durable background-process heartbeat payload (if present). * * @since 3.18.0 * * @return array|null */ public static function get_background_process_heartbeat() { $heartbeat = get_site_option( 'wppfm_feed_generation_process_process_heartbeat' ); return is_array( $heartbeat ) ? $heartbeat : null; } /** * Returns true when the durable heartbeat indicates a process is likely still active. * * @since 3.18.0 * * @return bool */ public static function background_process_heartbeat_is_fresh() { $heartbeat = self::get_background_process_heartbeat(); if ( ! $heartbeat || empty( $heartbeat['ts'] ) ) { return false; } $ttl = apply_filters( 'wppfm_feed_generation_process_heartbeat_ttl', 10 * MINUTE_IN_SECONDS ); $ttl = max( 60, intval( $ttl ) ); return ( time() - intval( $heartbeat['ts'] ) ) <= $ttl; } /** * Checks if a running feed size is still growing, in order to identify a failing feed process. * * @since 2.2.0. * * @param string $feed_file String with the full path and name of the feed file. * * @return boolean false if the feed still grows, true if it stopped growing for a certain time. */ public static function feed_processing_failed( $feed_file ) { if ( '' === $feed_file ) { return null; } // Retrieve the last known growth-monitor snapshot for this feed file. $monitor_data = self::get_feed_growth_monitor_data( $feed_file ); $prev_feed_size = $monitor_data['size']; $prev_feed_time_stamp = $monitor_data['timestamp']; $prev_handled_items = $monitor_data['processed']; // Now represents \"handled\" items (added or filtered). $bonus_delay = $monitor_data['bonus_delay']; $curr_feed_size = file_exists( $feed_file ) ? filesize( $feed_file ) : false; // If the file does not exist, treat as failure only when the worker is not // actively reporting progress (avoids races while the file is recreated). if ( false === $curr_feed_size ) { if ( self::background_process_heartbeat_is_fresh() ) { return false; } delete_transient( 'wppfm_feed_file_size' ); // Reset the counter. return true; } // Use the handled-items counter for stall detection. This counter is // increased for every queue entry that is processed, regardless of // whether the product is added to the feed or filtered out. This makes // the watchdog more robust on feeds with heavy filtering. $current_handled_items = self::get_handled_items_counter(); $feed_grew = $curr_feed_size > $prev_feed_size; $items_grew = $current_handled_items > $prev_handled_items; if ( $feed_grew || $items_grew ) { // When the process clearly made progress (either the file grew or more // items where handled), extend the allowed delay before we consider the // feed stalled. This keeps the behaviour compatible with the previous // implementation while using the more robust handled-items signal. $bonus = $items_grew ? apply_filters( 'wppfm_failed_detection_alpha', 60, $feed_file ) : 0; self::persist_feed_growth_monitor_data( array( 'size' => $curr_feed_size, 'timestamp' => time(), 'file' => $feed_file, 'processed' => $current_handled_items, 'bonus_delay' => $bonus, ) ); return false; } // Safety guard: when there are no ids left in the product queue, the // feed process has effectively finished and any lack of recent file // growth should not be treated as a hard failure. This prevents false // positives on hosts where the final growth check runs after the // queue has been drained or when the last part of the run only // contains filtered products. if ( 0 === self::nr_ids_remaining_in_product_queue() ) { return false; } $base_delay = apply_filters( 'wppfm_failed_detection_base_delay', WPPFM_DELAY_FAILED_LABEL, $feed_file ); $base_delay = max( 0, intval( $base_delay ) ); $bonus_delay = max( 0, intval( $bonus_delay ) ); $delay = $base_delay + $bonus_delay; // And the delay time has passed. if ( (int) $prev_feed_time_stamp + $delay < time() ) { // A fresh heartbeat means the background worker is still alive; do not // flag a stall while it may simply be between writes or batches. if ( self::background_process_heartbeat_is_fresh() ) { return false; } delete_transient( 'wppfm_feed_file_size' ); // Reset the counter. return true; } return false; } /** * Updates the timer that is used as reference to monitor if a file is growing during the feed production process. * * @since 2.11.0 */ public static function update_file_grow_monitoring_timer() { // Get the current monitor data. $grow_monitor_array = get_transient( 'wppfm_feed_file_size' ); if ( ! $grow_monitor_array ) { // The wppfm_feed_file_size is not set in the non-background mode. return; } $grow_monitor_data = explode( '|', $grow_monitor_array ); $prev_size = isset( $grow_monitor_data[0] ) ? intval( $grow_monitor_data[0] ) : 0; $tracked_file = $grow_monitor_data[2] ?? ''; $prev_processed = isset( $grow_monitor_data[3] ) ? intval( $grow_monitor_data[3] ) : self::get_processed_products_counter(); $bonus_delay = isset( $grow_monitor_data[4] ) ? intval( $grow_monitor_data[4] ) : 0; // Reset the timer part of the monitor while keeping the other data intact. self::persist_feed_growth_monitor_data( array( 'size' => $prev_size, 'timestamp' => time(), 'file' => $tracked_file, 'processed' => $prev_processed, 'bonus_delay' => $bonus_delay, ) ); } /** * Returns the current feed queue. * * @return array with feed ids in the queue or an empty array. */ protected static function get_feed_queue() { return get_site_option( 'wppfm_feed_queue', array() ); } /** * Returns the processed products counter from the transient store. * * @since 3.18.0 * @return int */ private static function get_processed_products_counter() { $processed_products = get_transient( 'wppfm_nr_of_processed_products' ); return false === $processed_products ? 0 : intval( $processed_products ); } /** * Returns the handled-items counter from the transient store. * * This counter tracks every queue entry that has been processed by the * background worker (added to the feed or filtered out). It is used by * the stalled-feed watchdog logic to distinguish \"no progress\" from * \"only filtered products left\" on large feeds. * * @since 3.19.x * @return int */ private static function get_handled_items_counter() { $handled_items = get_transient( 'wppfm_nr_of_handled_items' ); return false === $handled_items ? 0 : intval( $handled_items ); } /** * Retrieves or initializes the feed growth monitor data. * * @since 3.18.0 * * @param string $feed_file The feed file currently being processed. * * @return array */ private static function get_feed_growth_monitor_data( $feed_file ) { $transient_value = get_transient( 'wppfm_feed_file_size' ); if ( false === $transient_value ) { $data = array( 'size' => 0, 'timestamp' => time(), 'file' => $feed_file, // For new runs, initialise the processed field using the handled-items // counter so the growth monitor reflects overall queue progress. 'processed' => self::get_handled_items_counter(), 'bonus_delay' => 0, ); self::persist_feed_growth_monitor_data( $data ); return $data; } $stored = explode( '|', $transient_value ); // Prepare a normalized dataset (handles both legacy 3-part payloads and the new 5-part payloads). $data = array( 'size' => isset( $stored[0] ) ? intval( $stored[0] ) : 0, 'timestamp' => isset( $stored[1] ) ? intval( $stored[1] ) : time(), 'file' => $stored[2] ?? $feed_file, // The stored \"processed\" slot now represents handled items. For legacy // payloads that were still based on \"products added\", fall back to the // current handled-items counter so the monitor has a sane baseline. 'processed' => isset( $stored[3] ) ? intval( $stored[3] ) : self::get_handled_items_counter(), 'bonus_delay' => isset( $stored[4] ) ? intval( $stored[4] ) : 0, ); if ( $feed_file !== $data['file'] && '' !== $feed_file ) { $data['size'] = 0; $data['timestamp'] = time(); $data['file'] = $feed_file; $data['processed'] = self::get_handled_items_counter(); $data['bonus_delay'] = 0; self::persist_feed_growth_monitor_data( $data ); } return $data; } /** * Persists the feed growth monitor data structure into the transient store. * * @since 3.18.0 * * @param array $data Normalised data set. */ private static function persist_feed_growth_monitor_data( $data ) { $payload = sprintf( '%d|%d|%s|%d|%d', max( 0, intval( $data['size'] ?? 0 ) ), max( 0, intval( $data['timestamp'] ?? time() ) ), $data['file'] ?? '', max( 0, intval( $data['processed'] ?? 0 ) ), max( 0, intval( $data['bonus_delay'] ?? 0 ) ) ); set_transient( 'wppfm_feed_file_size', $payload, WPPFM_TRANSIENT_LIVE ); } } endif;