find_pending( $action, $dedupe_key ); if ( null !== $existing ) { return $existing; } $args['_dedupe'] = $dedupe_key; } if ( ! function_exists( 'as_enqueue_async_action' ) && ! function_exists( 'as_schedule_single_action' ) ) { return false; } if ( null === $run_at ) { $id = as_enqueue_async_action( $action, $args, $group ); return is_int( $id ) && $id > 0 ? $id : false; } $id = as_schedule_single_action( $run_at, $action, $args, $group ); return is_int( $id ) && $id > 0 ? $id : false; } /** * {@inheritdoc} */ public function cancel( $action_id ) { if ( function_exists( 'as_unschedule_action' ) ) { // Prefer cancel by ID when available via Action Scheduler store. } if ( class_exists( '\ActionScheduler' ) ) { try { \ActionScheduler::store()->cancel_action( (int) $action_id ); } catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Best-effort cancel. } } } /** * {@inheritdoc} */ public function find_pending( $action, $dedupe_key ) { if ( ! function_exists( 'as_get_scheduled_actions' ) ) { return null; } $per_page = 100; $offset = 0; $max = 1000; while ( $offset < $max ) { $actions = as_get_scheduled_actions( array( 'hook' => $action, 'status' => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ), 'per_page' => $per_page, 'offset' => $offset, 'group' => Constants::SCHEDULER_GROUP, 'orderby' => 'ID', 'order' => 'ASC', ), 'ids' ); if ( empty( $actions ) || ! is_array( $actions ) ) { return null; } foreach ( $actions as $id ) { $scheduled = \ActionScheduler::store()->fetch_action( $id ); $args = $scheduled->get_args(); if ( isset( $args['_dedupe'] ) && $args['_dedupe'] === $dedupe_key ) { return (int) $id > 0 ? (int) $id : null; } } if ( count( $actions ) < $per_page ) { return null; } $offset += $per_page; } return null; } /** * {@inheritdoc} * * @return string|false Ownership token or false. */ public function claim_lock( $resource_key, $ttl ) { $ttl = max( 1, (int) $ttl ); $option = 'aksmb_adv_lock_' . md5( (string) $resource_key ); $token = function_exists( 'wp_generate_password' ) ? wp_generate_password( 20, false, false ) : bin2hex( random_bytes( 10 ) ); $payload = wp_json_encode( array( 'until' => time() + $ttl, 'token' => $token, ) ); $existing = get_option( $option, null ); if ( null !== $existing && false !== $existing ) { $decoded = is_string( $existing ) ? json_decode( $existing, true ) : null; $until = 0; if ( is_array( $decoded ) && isset( $decoded['until'] ) ) { $until = (int) $decoded['until']; } elseif ( is_numeric( $existing ) ) { // Legacy numeric expiry. $until = (int) $existing; } if ( $until >= time() ) { return false; } // Replace only the expired value we read; a rival may already own it. if ( ! is_scalar( $existing ) ) { return false; } global $wpdb; $affected = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s", (string) $payload, $option, (string) $existing ) ); wp_cache_delete( $option, 'options' ); if ( 1 !== $affected ) { return false; } } elseif ( ! add_option( $option, (string) $payload, '', 'no' ) ) { return false; } set_transient( 'aksmb_lock_' . md5( (string) $resource_key ), $token, $ttl ); return $token; } /** * Whether a stored lock option may be released with the given token (owner-only). * * @param mixed $stored Option value. * @param string|null $token Claim token. * @return bool */ public static function may_release_lock( $stored, $token ) { if ( null === $token || '' === (string) $token ) { return false; } $decoded = is_string( $stored ) ? json_decode( $stored, true ) : null; if ( ! is_array( $decoded ) || ! isset( $decoded['token'] ) ) { return false; } return hash_equals( (string) $decoded['token'], (string) $token ); } /** * Build compare-and-delete target for an owned lock (null when token may not release). * * @param string $resource_key Resource. * @param mixed $stored Option value. * @param string|null $token Owner token. * @return array{option:string,value:string}|null */ public static function compare_delete_target( $resource_key, $stored, $token ) { if ( ! self::may_release_lock( $stored, $token ) ) { return null; } return array( 'option' => 'aksmb_adv_lock_' . md5( (string) $resource_key ), 'value' => (string) $stored, ); } /** * {@inheritdoc} * * Owner-only: a missing/empty token never releases the lock. */ public function release_lock( $resource_key, $token = null ) { $option = 'aksmb_adv_lock_' . md5( (string) $resource_key ); $existing = get_option( $option, null ); $target = self::compare_delete_target( $resource_key, $existing, $token ); if ( null === $target ) { return; } global $wpdb; $affected = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND BINARY option_value = BINARY %s", $target['option'], $target['value'] ) ); wp_cache_delete( $option, 'options' ); if ( 1 === $affected ) { delete_transient( 'aksmb_lock_' . md5( (string) $resource_key ) ); } } /** * {@inheritdoc} */ public function inspect_health( $group ) { $pending = 0; $running = 0; $failed = 0; $oldest = null; if ( function_exists( 'as_get_scheduled_actions' ) && class_exists( '\ActionScheduler_Store' ) ) { $pending_ids = as_get_scheduled_actions( array( 'group' => $group, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => 1, 'orderby' => 'date', 'order' => 'ASC', ), 'ids' ); $pending = is_array( $pending_ids ) ? count( as_get_scheduled_actions( array( 'group' => $group, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => 100, ), 'ids' ) ) : 0; if ( ! empty( $pending_ids ) ) { $action = \ActionScheduler::store()->fetch_action( $pending_ids[0] ); $date = $action->get_schedule()->get_date(); if ( $date ) { $oldest = $date->getTimestamp(); } } $running = count( as_get_scheduled_actions( array( 'group' => $group, 'status' => \ActionScheduler_Store::STATUS_RUNNING, 'per_page' => 100, ), 'ids' ) ); $failed = count( as_get_scheduled_actions( array( 'group' => $group, 'status' => \ActionScheduler_Store::STATUS_FAILED, 'per_page' => 100, ), 'ids' ) ); } return array( 'group' => $group, 'pending' => $pending, 'running' => $running, 'failed' => $failed, 'pending_capped' => true, 'retry' => 0, // AS does not expose a stable retry status; reserved for future. 'oldest_pending_ts' => $oldest, 'wp_cron_delayed' => $this->is_cron_delayed( $oldest ), ); } /** * Handle evaluate order job. * * Action Scheduler expands args with array_values(), so parameters are * positional (order_id, revision, origin) — not a single associative array. * * @param int|array $order_id Order ID or legacy args array. * @param string $revision Unused revision hash. * @param string $origin evaluation_origin (realtime|recent_preview). * @return void */ public function handle_evaluate_order( $order_id = 0, $revision = '', $origin = 'realtime' ) { if ( is_array( $order_id ) ) { $origin = isset( $order_id['origin'] ) ? (string) $order_id['origin'] : 'realtime'; $revision = isset( $order_id['revision'] ) ? (string) $order_id['revision'] : ''; $order_id = isset( $order_id['order_id'] ) ? (int) $order_id['order_id'] : 0; } else { $order_id = (int) $order_id; $origin = is_string( $origin ) ? $origin : 'realtime'; $revision = is_string( $revision ) ? $revision : ''; } if ( $order_id <= 0 ) { return; } ( new OrderRiskEvaluatorRunner() )->run( $order_id, $origin, $revision ); } /** * Bounded price revision seed (activation). * * @param int|array $batch Batch index or legacy args. * @return void */ public function handle_seed_price_revisions( $batch = 0 ) { if ( is_array( $batch ) ) { $batch = isset( $batch['batch'] ) ? (int) $batch['batch'] : 0; } else { $batch = (int) $batch; } $outcome = ( new PriceRevisionSeeder( $this ) )->run_batch( $batch ); if ( empty( $outcome['done'] ) && empty( $outcome['continuation_enqueued'] ) ) { throw new \RuntimeException( esc_html( 'MarginBeacon price seed continuation failed: ' . (string) ( $outcome['continuation_error'] ?? 'unknown' ) ) ); } } /** * Daily reconciliation batch. * * @param int|array $batch Batch index or legacy args. * @return void */ public function handle_reconcile_price_revisions( $batch = 0 ) { if ( is_array( $batch ) ) { $batch = isset( $batch['batch'] ) ? (int) $batch['batch'] : 0; } else { $batch = (int) $batch; } $outcome = ( new PriceRevisionReconciler( $this ) )->run_batch( $batch ); if ( empty( $outcome['done'] ) && empty( $outcome['continuation_enqueued'] ) ) { throw new \RuntimeException( esc_html( 'MarginBeacon price reconcile continuation failed: ' . (string) ( $outcome['continuation_error'] ?? 'unknown' ) ) ); } } /** * Background selected-coupon preflight. * * @param int|array $scan_id Scan ID or args array. * @param int $coupon_id Coupon ID when positional. * @return void */ public function handle_run_selected_preflight( $scan_id = 0, $coupon_id = 0 ) { if ( is_array( $scan_id ) ) { $coupon_id = isset( $scan_id['coupon_id'] ) ? (int) $scan_id['coupon_id'] : 0; $scan_id = isset( $scan_id['scan_id'] ) ? (int) $scan_id['scan_id'] : 0; } else { $scan_id = (int) $scan_id; $coupon_id = (int) $coupon_id; } ( new PreflightJobService( $this ) )->run( $scan_id, $coupon_id ); } /** * Background coupon acknowledgement sync batch. * * @param int|array $coupon_id Coupon ID or args array. * @param int|string $before_id Keyset upper bound when positional. * @return void */ public function handle_sync_ack_coupon( $coupon_id = 0, $before_id = null ) { if ( is_array( $coupon_id ) ) { $before_id = array_key_exists( 'before_id', $coupon_id ) ? $coupon_id['before_id'] : null; $coupon_id = isset( $coupon_id['coupon_id'] ) ? (int) $coupon_id['coupon_id'] : 0; } else { $coupon_id = (int) $coupon_id; } if ( $coupon_id <= 0 ) { return; } $before = ( null === $before_id || '' === $before_id ) ? null : (int) $before_id; ( new OrderRiskAcknowledgementSync() )->process_coupon_sync_batch( $coupon_id, $before ); } /** * Background COGS migration batch (copy MarginBeacon → Woo, then switch). * * @param int|array $batch Batch index or args. * @return void */ public function handle_cogs_migration_batch( $batch = 0 ) { if ( is_array( $batch ) ) { $batch = isset( $batch['batch'] ) ? (int) $batch['batch'] : 0; } else { $batch = (int) $batch; } ( new CogsMigrationService( null, null, $this ) )->run_batch( $batch, false ); } /** * @param int|null $oldest_ts Oldest pending timestamp. * @return bool */ private function is_cron_delayed( $oldest_ts ) { if ( null === $oldest_ts ) { return false; } return ( time() - (int) $oldest_ts ) > ( 60 * MINUTE_IN_SECONDS ); } }