class-wcmq-worker.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. <?php
  2. /**
  3. * Action Scheduler worker: claims a row and sends it.
  4. *
  5. * @package studiou-wc-mail-queue
  6. * @copyright 2026 QUADARAX
  7. * @author Dalibor Votruba <dvotruba@quadarax.com>
  8. * @license GPL-2.0-or-later
  9. */
  10. defined( 'ABSPATH' ) || exit;
  11. /**
  12. * Class Studiou_WCMQ_Worker
  13. */
  14. class Studiou_WCMQ_Worker {
  15. /**
  16. * Last WP_Error captured from wp_mail_failed, for the row in flight.
  17. *
  18. * @var string|null
  19. */
  20. private $last_error = null;
  21. /**
  22. * Constructor.
  23. *
  24. * The wp_mail_failed listener is registered ONCE here, never per send.
  25. * Under Action Scheduler many send() calls run in one PHP process; a
  26. * per-send add_action leaks a closure per row, and an early `return` that
  27. * skips the paired remove_action leaves a stale WP_Error in the buffer to be
  28. * misattributed to the next row. Resetting the buffer before each send (in
  29. * send(), step 3) makes both failure modes structurally impossible.
  30. */
  31. public function __construct() {
  32. add_action( Studiou_WCMQ_Queue::HOOK, array( $this, 'send' ), 10, 1 );
  33. add_action( 'wp_mail_failed', array( $this, 'capture' ), 10, 1 );
  34. }
  35. /**
  36. * Capture a mail error.
  37. *
  38. * @param WP_Error $error Error.
  39. */
  40. public function capture( $error ) {
  41. if ( is_wp_error( $error ) ) {
  42. $this->last_error = $error->get_error_message();
  43. }
  44. }
  45. /**
  46. * Send one queued mail.
  47. *
  48. * @param int $row_id Queue row id.
  49. */
  50. public function send( $row_id ) {
  51. $row_id = (int) $row_id;
  52. if ( ! $row_id ) {
  53. return;
  54. }
  55. // The reaper runs before we claim OUR row, which looks unsafe: it hunts
  56. // pending rows with no scheduled action, and our row is pending and about
  57. // to run. It does not misfire, but only by a coincidence of Action
  58. // Scheduler internals — as_has_scheduled_action() matches STATUS_RUNNING
  59. // as well as STATUS_PENDING (packages/action-scheduler/functions.php:404),
  60. // and AS calls log_execution() (which sets RUNNING) before invoking this
  61. // hook (ActionScheduler_Abstract_QueueRunner.php:102). So our own row is
  62. // seen as running, not as an orphan. If AS ever changes that ordering,
  63. // this reaper call would duplicate the running row's action — revisit here.
  64. Studiou_WCMQ_Reaper::run();
  65. // Atomic claim. Zero affected rows means someone else has it, or it is
  66. // already sent. This is the duplicate-send guard.
  67. if ( ! Studiou_WCMQ_DB::claim( $row_id ) ) {
  68. return;
  69. }
  70. $row = Studiou_WCMQ_DB::get_row( $row_id );
  71. if ( ! $row ) {
  72. return;
  73. }
  74. $payload = json_decode( (string) $row->payload, true );
  75. if ( ! is_array( $payload ) || JSON_ERROR_NONE !== json_last_error()
  76. || ! isset( $payload['to'], $payload['subject'], $payload['message'] ) ) {
  77. // Not retriable. Do not burn an attempt on it.
  78. $affected = Studiou_WCMQ_DB::update_row_if_state(
  79. $row_id,
  80. array(
  81. 'state' => Studiou_WCMQ_DB::STATE_FAILED,
  82. 'claimed_at' => null,
  83. 'last_error' => 'corrupt payload',
  84. ),
  85. Studiou_WCMQ_DB::STATE_SENDING
  86. );
  87. Studiou_WCMQ_DB::log( 'error', 'Corrupt payload; row failed without sending.', $row_id );
  88. // This is a terminal state. Without the note the order records nothing
  89. // at all: the customer got no email and the audit trail is silent.
  90. if ( 1 === (int) $affected ) {
  91. Studiou_WCMQ_Notifier::annotate( $row, false, 'corrupt payload' );
  92. }
  93. Studiou_WCMQ_State::maybe_settle();
  94. return;
  95. }
  96. $this->last_error = null;
  97. $sent = false;
  98. try {
  99. $sent = $this->dispatch( $payload );
  100. } catch ( Throwable $e ) {
  101. $this->last_error = $e->getMessage();
  102. $sent = false;
  103. }
  104. $error = $this->last_error;
  105. $this->last_error = null;
  106. // Completed send attempts, not crashes. Never bumped at claim, and folded
  107. // into the same UPDATE that commits the outcome so there is no window in
  108. // which the row has been attempted but not resolved. We hold the claim, so
  109. // nobody else is writing this column.
  110. $attempts = (int) $row->attempts + 1;
  111. if ( $sent ) {
  112. $this->on_success( $row, $row_id, $attempts );
  113. } else {
  114. $this->on_failure( $row, $row_id, $attempts, $error );
  115. }
  116. Studiou_WCMQ_State::maybe_settle();
  117. }
  118. /**
  119. * Call wp_mail() with the stored payload, exactly once.
  120. *
  121. * Two hooks have to be neutralised for the duration:
  122. *
  123. * 1. `pre_wp_mail` — our own interceptor, or the mail would be re-queued
  124. * forever. Handled by the $sending flag.
  125. *
  126. * 2. `wp_mail` — WordPress applies this filter to $atts *before* it applies
  127. * `pre_wp_mail`, so the payload we snapshotted at enqueue has already been
  128. * through it. Calling wp_mail() again here would run every `wp_mail` filter
  129. * on the site a second time, against its own prior output: an unsubscribe
  130. * footer appended twice, a BCC-archive header duplicated. Queued mail would
  131. * differ from synchronous mail in ways nobody would connect to this plugin.
  132. *
  133. * Detaching the whole `wp_mail` hook and restoring it in `finally` is the only
  134. * way to suppress third-party callbacks we do not control. `wp_mail_from` and
  135. * friends need no such treatment — they are idempotent, and the resolved
  136. * sender is already baked into the stored headers.
  137. *
  138. * @param array $payload Decoded mail payload.
  139. * @return bool
  140. */
  141. private function dispatch( array $payload ) {
  142. global $wp_filter;
  143. $headers = isset( $payload['headers'] ) ? $payload['headers'] : '';
  144. $attachments = isset( $payload['attachments'] ) ? (array) $payload['attachments'] : array();
  145. $saved_wp_mail = isset( $wp_filter['wp_mail'] ) ? $wp_filter['wp_mail'] : null;
  146. if ( null !== $saved_wp_mail ) {
  147. unset( $wp_filter['wp_mail'] );
  148. }
  149. Studiou_WCMQ_Interceptor::$sending = true;
  150. try {
  151. return (bool) wp_mail( $payload['to'], $payload['subject'], $payload['message'], $headers, $attachments );
  152. } finally {
  153. // Never leave either of these in place: under Action Scheduler many
  154. // sends share one PHP process, and a stuck flag or a missing hook
  155. // would corrupt every later mail in the batch.
  156. Studiou_WCMQ_Interceptor::$sending = false;
  157. if ( null !== $saved_wp_mail ) {
  158. $wp_filter['wp_mail'] = $saved_wp_mail;
  159. }
  160. }
  161. }
  162. /**
  163. * Commit the send, then annotate.
  164. *
  165. * Order matters. Once wp_mail() has returned true, nothing may throw back
  166. * into the send path: a fatal here would leave the row in `sending` for the
  167. * reaper to rescue and send a second time.
  168. *
  169. * @param object $row Row snapshot from before the send.
  170. * @param int $row_id Row id.
  171. * @param int $attempts Attempts including this one.
  172. */
  173. private function on_success( $row, $row_id, $attempts ) {
  174. // State-guarded: if the row was deleted from the admin, or reclaimed by
  175. // the reaper, between our claim and now, this reports 0 rather than
  176. // silently "succeeding" and letting us delete attachments and write an
  177. // order note for a row that no longer exists.
  178. $affected = Studiou_WCMQ_DB::update_row_if_state(
  179. $row_id,
  180. array(
  181. 'state' => Studiou_WCMQ_DB::STATE_SENT,
  182. 'sent_at' => time(),
  183. 'claimed_at' => null,
  184. 'last_error' => null,
  185. 'attempts' => (int) $attempts,
  186. ),
  187. Studiou_WCMQ_DB::STATE_SENDING
  188. );
  189. if ( 1 !== (int) $affected ) {
  190. // The mail went out but we could not record it. Either the DB errored
  191. // (row stays `sending`, the reaper will reclaim and resend — a
  192. // duplicate we cannot prevent from here) or the row vanished under us.
  193. // Either way: do not delete the attachment directory, so a resend
  194. // still carries its invoice, and write no note claiming success.
  195. Studiou_WCMQ_DB::log(
  196. 'error',
  197. 'Mail was sent but the row could not be committed to `sent` (it may have been deleted or reclaimed). It may be re-sent.',
  198. $row_id
  199. );
  200. return;
  201. }
  202. Studiou_WCMQ_Queue::delete_attachment_dir( $row->attachment_dir );
  203. Studiou_WCMQ_Notifier::annotate( $row, true, null );
  204. Studiou_WCMQ_DB::log(
  205. 'info',
  206. sprintf( 'Sent %s to %s.', $row->context, $row->recipient ),
  207. $row_id
  208. );
  209. }
  210. /**
  211. * Retry with backoff, or give up.
  212. *
  213. * @param object $row Row snapshot.
  214. * @param int $row_id Row id.
  215. * @param int $attempts Attempts after this one.
  216. * @param string|null $error Captured error.
  217. */
  218. private function on_failure( $row, $row_id, $attempts, $error ) {
  219. $max_attempts = (int) Studiou_WCMQ_Settings::get( 'max_attempts' );
  220. if ( $attempts >= $max_attempts ) {
  221. $affected = Studiou_WCMQ_DB::update_row_if_state(
  222. $row_id,
  223. array(
  224. 'state' => Studiou_WCMQ_DB::STATE_FAILED,
  225. 'claimed_at' => null,
  226. 'last_error' => $error,
  227. 'attempts' => (int) $attempts,
  228. ),
  229. Studiou_WCMQ_DB::STATE_SENDING
  230. );
  231. // The attachment directory is deliberately KEPT: failed rows are
  232. // manually retriable from the admin, and a retry without its
  233. // attachments sends a broken email that looks fine.
  234. if ( 1 === (int) $affected ) {
  235. Studiou_WCMQ_Notifier::annotate( $row, false, $error );
  236. }
  237. Studiou_WCMQ_DB::log(
  238. 'error',
  239. sprintf( 'Send failed permanently after %d attempts: %s', $attempts, (string) $error ),
  240. $row_id
  241. );
  242. return;
  243. }
  244. $interval = Studiou_WCMQ_Settings::interval();
  245. // `**`, not `^`. In PHP `^` is bitwise XOR and binds looser than `*`.
  246. $backoff = min( HOUR_IN_SECONDS, $interval * ( 2 ** $attempts ) );
  247. list( $slot, $affected ) = Studiou_WCMQ_Queue::with_slot_lock(
  248. time() + $backoff,
  249. function ( $slot ) use ( $row_id, $error, $attempts ) {
  250. return Studiou_WCMQ_DB::update_row_if_state(
  251. $row_id,
  252. array(
  253. 'state' => Studiou_WCMQ_DB::STATE_PENDING,
  254. 'claimed_at' => null,
  255. 'scheduled_at' => $slot,
  256. 'last_error' => $error,
  257. 'attempts' => (int) $attempts,
  258. ),
  259. Studiou_WCMQ_DB::STATE_SENDING
  260. );
  261. }
  262. );
  263. if ( 1 !== (int) $affected ) {
  264. // The row did not move back to `pending`. Scheduling an action for it
  265. // would wake a worker that finds nothing to claim. The row is still
  266. // `sending`, so the reaper will reclaim it after claim_timeout.
  267. Studiou_WCMQ_DB::log( 'error', 'Could not return row to pending for retry; leaving it to the reaper.', $row_id );
  268. return;
  269. }
  270. $action_id = as_schedule_single_action( $slot, Studiou_WCMQ_Queue::HOOK, array( (int) $row_id ), Studiou_WCMQ_Queue::GROUP );
  271. if ( ! $action_id ) {
  272. // The row is pending with no action — an invariant violation. The
  273. // reaper repairs it, so this degrades to "recovered late", not "lost".
  274. Studiou_WCMQ_DB::log( 'error', 'Could not schedule retry; reaper will repair.', $row_id );
  275. return;
  276. }
  277. Studiou_WCMQ_DB::log(
  278. 'warning',
  279. sprintf( 'Send failed (attempt %d/%d); retrying at %d. %s', $attempts, $max_attempts, $slot, (string) $error ),
  280. $row_id
  281. );
  282. }
  283. }