scheduler = $scheduler; $this->repo = null !== $repo ? $repo : new FindingsRepository(); } /** * @param int $coupon_id Coupon ID. * @return array{success:bool,scan_id?:int,error?:string} */ public function enqueue( $coupon_id ) { $coupon_id = (int) $coupon_id; $coupon = function_exists( 'wc_get_coupon' ) ? wc_get_coupon( $coupon_id ) : new \WC_Coupon( $coupon_id ); if ( ! $coupon || ! $coupon->get_id() ) { return array( 'success' => false, 'error' => __( 'Coupon not found.', 'marginbeacon' ), ); } $scan_id = (int) $this->repo->create_scan( array( 'type' => 'selected', 'status' => 'queued', 'evaluation_scope' => 'selected_free', 'input_revision' => CouponMaterialHasher::hash_coupon( $coupon ), 'cursor_json' => wp_json_encode( array( 'coupon_id' => $coupon_id, 'coupon_code' => $coupon->get_code(), ) ), 'started_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ) ); // Fail-closed: require valid scan_id from persistence. if ( $scan_id <= 0 ) { return array( 'success' => false, 'error' => __( 'Failed to create scan row.', 'marginbeacon' ), ); } $action_id = $this->scheduler->enqueue( Constants::ACTION_RUN_SELECTED_PREFLIGHT, array( 'scan_id' => (int) $scan_id, 'coupon_id' => $coupon_id, ), Constants::SCHEDULER_GROUP, null, 'preflight_' . $coupon_id . '_' . $scan_id ); if ( ! SchedulerResult::is_enqueued( $action_id ) ) { $outcome = $this->repo->persist_scan( (int) $scan_id, array( 'status' => 'failed' ), array( 'queued' ) ); if ( 'fail' === $outcome ) { return array( 'success' => false, 'error' => __( 'Could not queue preflight and failed to mark scan as failed.', 'marginbeacon' ), ); } return array( 'success' => false, 'error' => __( 'Could not queue preflight (Action Scheduler unavailable).', 'marginbeacon' ), ); } return array( 'success' => true, 'scan_id' => (int) $scan_id, ); } /** * Background handler. * * @param int $scan_id Scan ID. * @param int $coupon_id Coupon ID. * @return void */ public function run( $scan_id, $coupon_id ) { $scan_id = (int) $scan_id; $coupon_id = (int) $coupon_id; if ( $scan_id <= 0 || $coupon_id <= 0 ) { return; } // CAS: ok → continue; noop → stop quietly (paused/cancelled/terminal won race); fail → retry via AS. // Accept queued + running so AS retries after a crash mid-run remain idempotent. $mark = $this->repo->persist_scan( $scan_id, array( 'status' => 'running' ), array( 'queued', 'running' ) ); if ( 'noop' === $mark ) { return; } if ( 'fail' === $mark ) { throw new \RuntimeException( esc_html( sprintf( 'MarginBeacon preflight CAS failed for scan_id=%d (expected queued|running→running).', $scan_id ) ) ); } if ( 'ok' !== $mark ) { return; } try { $result = ( new SelectedCouponPreflightService() )->run( $coupon_id, array( 'scan_id' => $scan_id, ) ); if ( empty( $result['success'] ) ) { $outcome = $this->repo->persist_scan( (int) $scan_id, array( 'status' => 'failed' ), array( 'running' ) ); if ( 'fail' === $outcome ) { throw new \RuntimeException( esc_html( sprintf( 'MarginBeacon failed to persist failed status for scan_id=%d', $scan_id ) ) ); } } } catch ( \Throwable $e ) { if ( $e instanceof \RuntimeException && false !== strpos( $e->getMessage(), 'failed to persist failed status' ) ) { throw $e; } $outcome = $this->repo->persist_scan( (int) $scan_id, array( 'status' => 'failed' ), array( 'running' ) ); if ( 'fail' === $outcome ) { throw new \RuntimeException( esc_html( sprintf( 'MarginBeacon failed to persist failed status for scan_id=%d after exception', $scan_id ) ), 0, $e // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Throwable chaining, never rendered here. ); } throw $e; } } }