class-wcmq-queue.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. <?php
  2. /**
  3. * Slot allocation, enqueue, attachment custody.
  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_Queue
  13. */
  14. class Studiou_WCMQ_Queue {
  15. const HOOK = 'studiou_wcmq_send';
  16. const GROUP = 'studiou-wcmq';
  17. const LOCK_NAME = 'studiou_wcmq_slot';
  18. const LOCK_WAIT = 5;
  19. const ATTACH_DIR = 'studiou-wcmq-attachments';
  20. /**
  21. * Allocate a slot and publish it, under the advisory lock.
  22. *
  23. * Returns [ $slot, $publish_result ]. Callers need the slot AFTER the lock is
  24. * released, to hand to as_schedule_single_action(); a helper that returned
  25. * only $publish's result would leave them with no timestamp, and
  26. * as_schedule_single_action(null, ...) coerces to 0, which Action Scheduler
  27. * treats as "run immediately" — draining the whole queue at once.
  28. *
  29. * The publish callback must INSERT or UPDATE the row: with the slot derived
  30. * from the table, the write is what publishes the allocation. A lock that
  31. * spans only the SELECT closes no race.
  32. *
  33. * @param int $earliest Floor timestamp.
  34. * @param callable $publish fn( int $slot ): mixed.
  35. * @return array [ int $slot, mixed $result ]
  36. */
  37. public static function with_slot_lock( $earliest, callable $publish ) {
  38. global $wpdb;
  39. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
  40. $locked = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', self::LOCK_NAME, self::LOCK_WAIT ) );
  41. if ( 1 !== $locked ) {
  42. // Overshooting the rate limit is bad; losing a customer's order
  43. // confirmation is worse. Proceed unlocked and say so.
  44. Studiou_WCMQ_DB::log( 'warning', 'Slot lock unavailable; allocating without it.' );
  45. }
  46. try {
  47. $slot = self::allocate_slot( (int) $earliest );
  48. $result = call_user_func( $publish, $slot );
  49. return array( $slot, $result );
  50. } finally {
  51. if ( 1 === $locked ) {
  52. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
  53. $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', self::LOCK_NAME ) );
  54. }
  55. }
  56. }
  57. /**
  58. * First free slot at or after $earliest.
  59. *
  60. * The smallest t >= max(now, $earliest) such that no pending/sending row sits
  61. * within `interval` of t. A monotonic "next slot" counter cannot express this
  62. * once retries exist: a row backed off an hour would drag every subsequent
  63. * enqueue behind it.
  64. *
  65. * The collision test uses a two-sided range comparison rather than
  66. * ABS(scheduled_at - candidate). MySQL evaluates a subtraction with any
  67. * UNSIGNED operand as unsigned, so the moment an occupied row precedes the
  68. * candidate — mail #2 of every bulk — it underflows into error 1690. The
  69. * timestamp columns are signed for the same reason.
  70. *
  71. * @param int $earliest Floor timestamp.
  72. * @return int
  73. */
  74. public static function allocate_slot( $earliest ) {
  75. global $wpdb;
  76. $table = Studiou_WCMQ_DB::queue_table();
  77. $interval = Studiou_WCMQ_Settings::interval();
  78. $earliest = (int) $earliest;
  79. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  80. $slot = $wpdb->get_var(
  81. $wpdb->prepare(
  82. "SELECT c.candidate FROM (
  83. SELECT GREATEST(%d, UNIX_TIMESTAMP()) AS candidate
  84. UNION ALL
  85. SELECT q.scheduled_at + %d
  86. FROM {$table} q
  87. WHERE q.state IN ('pending','sending')
  88. AND q.scheduled_at + %d >= GREATEST(%d, UNIX_TIMESTAMP())
  89. ) AS c
  90. WHERE NOT EXISTS (
  91. SELECT 1 FROM {$table} q2
  92. WHERE q2.state IN ('pending','sending')
  93. AND q2.scheduled_at > c.candidate - %d
  94. AND q2.scheduled_at < c.candidate + %d
  95. )
  96. ORDER BY c.candidate ASC
  97. LIMIT 1",
  98. $earliest,
  99. $interval,
  100. $interval,
  101. $earliest,
  102. $interval,
  103. $interval
  104. )
  105. );
  106. // phpcs:enable
  107. // Cannot happen: the floor is always a candidate, and the largest
  108. // candidate always survives NOT EXISTS. Defensive only.
  109. if ( null === $slot ) {
  110. return max( time(), $earliest );
  111. }
  112. return (int) $slot;
  113. }
  114. /**
  115. * Persist a mail and schedule its send.
  116. *
  117. * Returns the row id, or false on ANY failure. The interceptor treats false
  118. * as "not handled" and lets wp_mail() send synchronously.
  119. *
  120. * @param array $ctx Context from the probe.
  121. * @param array $payload Mail snapshot.
  122. * @return int|false
  123. */
  124. public static function enqueue( array $ctx, array $payload ) {
  125. try {
  126. $dir_token = bin2hex( random_bytes( 16 ) );
  127. } catch ( Throwable $e ) {
  128. Studiou_WCMQ_DB::log( 'error', 'Could not generate attachment token: ' . $e->getMessage() );
  129. return false;
  130. }
  131. // Copy attachments BEFORE the row becomes durable.
  132. //
  133. // Attachments are file paths. Invoice plugins write them to temp dirs and
  134. // delete them at end of request; by send time they are gone. If the row
  135. // were inserted first and the paths rewritten afterwards, a PHP fatal in
  136. // between would leave a permanent `pending` row whose payload points at
  137. // files that no longer exist — and rollback() cannot run after a fatal.
  138. // The reaper would then faithfully resurrect a mail that can never send.
  139. //
  140. // Copying first means the row is never durable with paths we do not own.
  141. // The only cost is an orphaned directory if we bail before the INSERT,
  142. // which the retention sweep collects.
  143. $copied = self::copy_attachments( $dir_token, $payload['attachments'] );
  144. if ( null === $copied ) {
  145. self::delete_attachment_dir( $dir_token );
  146. Studiou_WCMQ_DB::log( 'error', 'Could not copy mail attachments; sending synchronously.' );
  147. return false;
  148. }
  149. $payload['attachments'] = $copied;
  150. // wp_json_encode returns false on unencodable input (e.g. invalid UTF-8
  151. // in a rendered order email) WITHOUT throwing. An exception-only guard
  152. // would miss it and the mail would be lost.
  153. $json = wp_json_encode( $payload );
  154. if ( ! is_string( $json ) || '' === $json ) {
  155. self::delete_attachment_dir( $dir_token );
  156. Studiou_WCMQ_DB::log( 'error', 'Could not encode mail payload; sending synchronously.' );
  157. return false;
  158. }
  159. $recipient = is_array( $payload['to'] ) ? implode( ',', $payload['to'] ) : (string) $payload['to'];
  160. $now = time();
  161. $row = array(
  162. 'context' => (string) $ctx['id'],
  163. 'order_id' => (int) $ctx['order_id'],
  164. 'recipient' => substr( $recipient, 0, 320 ),
  165. 'subject' => (string) $payload['subject'],
  166. 'payload' => $json,
  167. 'attachment_dir' => $dir_token,
  168. 'priority' => 10,
  169. 'state' => Studiou_WCMQ_DB::STATE_PENDING,
  170. 'attempts' => 0,
  171. 'reclaims' => 0,
  172. 'scheduled_at' => 0,
  173. 'created_at' => $now,
  174. );
  175. try {
  176. list( $slot, $row_id ) = self::with_slot_lock(
  177. $now,
  178. function ( $slot ) use ( $row ) {
  179. $row['scheduled_at'] = $slot;
  180. return Studiou_WCMQ_DB::insert_row( $row );
  181. }
  182. );
  183. } catch ( Throwable $e ) {
  184. self::delete_attachment_dir( $dir_token );
  185. Studiou_WCMQ_DB::log( 'error', 'Enqueue insert failed: ' . $e->getMessage() );
  186. return false;
  187. }
  188. $row_id = (int) $row_id;
  189. if ( ! $row_id ) {
  190. self::delete_attachment_dir( $dir_token );
  191. Studiou_WCMQ_DB::log( 'error', 'Could not insert queue row; sending synchronously.' );
  192. return false;
  193. }
  194. // Scheduled last, and checked: as_schedule_single_action() returns 0 on
  195. // failure without throwing. A fatal between the INSERT and here leaves a
  196. // `pending` row with no action — but with a payload that is already
  197. // self-contained, so the reaper can safely reschedule it.
  198. $action_id = as_schedule_single_action( $slot, self::HOOK, array( (int) $row_id ), self::GROUP );
  199. if ( ! $action_id ) {
  200. self::rollback( $row_id, $dir_token );
  201. Studiou_WCMQ_DB::log( 'error', 'Could not schedule send action; sending synchronously.' );
  202. return false;
  203. }
  204. Studiou_WCMQ_DB::log(
  205. 'debug',
  206. sprintf( 'Queued %s for %s at %d.', $ctx['id'], $recipient, $slot ),
  207. $row_id
  208. );
  209. return $row_id;
  210. }
  211. /**
  212. * Put an existing row back into `pending` on a fresh future slot.
  213. *
  214. * Used by the admin Retry action. Takes the same lock as enqueue, because
  215. * the UPDATE publishes the allocation exactly as the INSERT does, and guards
  216. * the schedule call the same way.
  217. *
  218. * **$expected_state is mandatory and closes a double-send race.** The admin
  219. * reads the row, then calls this — and in that window a worker's Action
  220. * Scheduler action can fire and claim the row (`pending → sending`). An
  221. * unguarded UPDATE would then force the mid-send row back to `pending`: the
  222. * worker's own state-guarded commit fails (0 rows), so it keeps the
  223. * attachments and writes no note — but the mail already went out via
  224. * `wp_mail()`, and the fresh action we schedule here delivers a SECOND copy.
  225. * Guarding on the state the caller observed means a row a worker has since
  226. * claimed comes back as 0-changed → we do not schedule, and the worker's send
  227. * stands as the single delivery.
  228. *
  229. * @param int $row_id Row id.
  230. * @param int $earliest Floor timestamp, 0 for now.
  231. * @param array $extra Extra columns to set.
  232. * @param string $expected_state State the row must still be in (what the
  233. * caller observed at read time).
  234. * @return bool
  235. */
  236. public static function reschedule( $row_id, $earliest, array $extra, $expected_state ) {
  237. $row_id = (int) $row_id;
  238. if ( ! $row_id || ! function_exists( 'as_schedule_single_action' ) ) {
  239. return false;
  240. }
  241. $earliest = $earliest ? (int) $earliest : time();
  242. list( $slot, $updated ) = self::with_slot_lock(
  243. $earliest,
  244. function ( $slot ) use ( $row_id, $extra, $expected_state ) {
  245. $data = array_merge(
  246. $extra,
  247. array(
  248. 'state' => Studiou_WCMQ_DB::STATE_PENDING,
  249. 'claimed_at' => null,
  250. 'scheduled_at' => $slot,
  251. )
  252. );
  253. return Studiou_WCMQ_DB::update_row_if_state( $row_id, $data, $expected_state );
  254. }
  255. );
  256. // 0 (or false) means the row was no longer in $expected_state — most
  257. // importantly, a worker claimed it out from under us. Do NOT schedule an
  258. // action: the worker is mid-send and a fresh action would double-deliver.
  259. // Report failure so the admin sees "couldn't process", not "re-queued".
  260. if ( ! $updated ) {
  261. Studiou_WCMQ_DB::log( 'error', 'Reschedule changed no row (state moved under us); not scheduling.', $row_id );
  262. return false;
  263. }
  264. // The row is now ours in `pending`. Drop any stale action for it before
  265. // adding the new one, so AS never holds two actions for one row (a row
  266. // retried while still pending already owns an action at its old slot; a
  267. // running action cannot be unscheduled, but that path is closed by the
  268. // state guard above). Both fire → the claim guard makes it a single send,
  269. // but unscheduling keeps it on the intended new slot.
  270. if ( function_exists( 'as_unschedule_all_actions' ) ) {
  271. as_unschedule_all_actions( self::HOOK, array( (int) $row_id ), self::GROUP );
  272. }
  273. $action_id = as_schedule_single_action( $slot, self::HOOK, array( (int) $row_id ), self::GROUP );
  274. if ( ! $action_id ) {
  275. Studiou_WCMQ_DB::log( 'error', 'Could not schedule action while rescheduling; reaper will repair.', $row_id );
  276. return false;
  277. }
  278. return true;
  279. }
  280. /**
  281. * Undo a partial enqueue.
  282. *
  283. * Cannot run after a PHP fatal — that is what the reaper is for. This only
  284. * covers the failures we can actually catch.
  285. *
  286. * @param int $row_id Row id.
  287. * @param string $dir_token Attachment directory token.
  288. */
  289. private static function rollback( $row_id, $dir_token ) {
  290. self::delete_attachment_dir( $dir_token );
  291. Studiou_WCMQ_DB::delete_row( $row_id );
  292. }
  293. /* ---------------------------------------------------------------------
  294. * Attachments
  295. * ------------------------------------------------------------------ */
  296. /**
  297. * Base directory for attachment copies, created and guarded on first use.
  298. *
  299. * @return string Absolute path, or '' on failure.
  300. */
  301. public static function attachments_basedir() {
  302. $uploads = wp_upload_dir();
  303. if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) {
  304. return '';
  305. }
  306. $base = trailingslashit( $uploads['basedir'] ) . self::ATTACH_DIR;
  307. if ( ! file_exists( $base ) && ! wp_mkdir_p( $base ) ) {
  308. return '';
  309. }
  310. // Write the guards whenever they are missing, not only when we just
  311. // created the directory — a backup restore or a security scanner can
  312. // remove them while the directory survives, and an unguarded directory
  313. // full of customer invoices is exactly what these prevent on Apache.
  314. // Nginx ignores .htaccess entirely; there the random 32-hex directory
  315. // token is the real protection, and the readme documents the location deny.
  316. $index = $base . '/index.php';
  317. if ( ! file_exists( $index ) ) {
  318. @file_put_contents( $index, '<?php // Silence is golden.' ); // phpcs:ignore
  319. }
  320. $htaccess = $base . '/.htaccess';
  321. if ( ! file_exists( $htaccess ) ) {
  322. // Both syntaxes: Apache 2.4 dropped Order/Deny.
  323. $rules = "<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n"
  324. . "<IfModule !mod_authz_core.c>\n\tOrder deny,allow\n\tDeny from all\n</IfModule>\n";
  325. @file_put_contents( $htaccess, $rules ); // phpcs:ignore
  326. }
  327. return $base;
  328. }
  329. /**
  330. * Copy attachments into this row's private directory.
  331. *
  332. * Array keys are preserved: wp_mail() treats a string key as the attachment
  333. * filename.
  334. *
  335. * @param string $dir_token Directory token.
  336. * @param array $attachments Original paths.
  337. * @return array|null Copied paths, or null on failure.
  338. */
  339. private static function copy_attachments( $dir_token, $attachments ) {
  340. $attachments = (array) $attachments;
  341. if ( empty( $attachments ) ) {
  342. return array();
  343. }
  344. $base = self::attachments_basedir();
  345. if ( '' === $base ) {
  346. return null;
  347. }
  348. $dir = $base . '/' . $dir_token;
  349. if ( ! wp_mkdir_p( $dir ) ) {
  350. return null;
  351. }
  352. $out = array();
  353. foreach ( $attachments as $key => $path ) {
  354. if ( ! is_string( $path ) || '' === $path || ! is_readable( $path ) ) {
  355. return null;
  356. }
  357. $dest = $dir . '/' . wp_unique_filename( $dir, basename( $path ) );
  358. if ( ! @copy( $path, $dest ) ) { // phpcs:ignore
  359. return null;
  360. }
  361. $out[ $key ] = $dest;
  362. }
  363. return $out;
  364. }
  365. /**
  366. * Remove a row's attachment directory.
  367. *
  368. * @param string $dir_token Directory token.
  369. */
  370. public static function delete_attachment_dir( $dir_token ) {
  371. if ( ! is_string( $dir_token ) || ! preg_match( '/^[a-f0-9]{32}$/', $dir_token ) ) {
  372. return;
  373. }
  374. $base = self::attachments_basedir();
  375. if ( '' === $base ) {
  376. return;
  377. }
  378. self::rmdir_recursive( $base . '/' . $dir_token );
  379. }
  380. /**
  381. * Delete a directory and its files, one level deep.
  382. *
  383. * @param string $dir Absolute path.
  384. */
  385. public static function rmdir_recursive( $dir ) {
  386. if ( ! is_dir( $dir ) ) {
  387. return;
  388. }
  389. $items = @scandir( $dir ); // phpcs:ignore
  390. if ( is_array( $items ) ) {
  391. foreach ( $items as $item ) {
  392. if ( '.' === $item || '..' === $item ) {
  393. continue;
  394. }
  395. $path = $dir . '/' . $item;
  396. if ( is_dir( $path ) ) {
  397. self::rmdir_recursive( $path );
  398. } else {
  399. @unlink( $path ); // phpcs:ignore
  400. }
  401. }
  402. }
  403. @rmdir( $dir ); // phpcs:ignore
  404. }
  405. }