class-wcmq-db.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. <?php
  2. /**
  3. * Schema, migrations and row access.
  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_DB
  13. */
  14. class Studiou_WCMQ_DB {
  15. const DB_VERSION_OPTION = 'studiou_wcmq_db_version';
  16. const DB_VERSION = '1.0.0';
  17. const STATE_PENDING = 'pending';
  18. const STATE_SENDING = 'sending';
  19. const STATE_SENT = 'sent';
  20. const STATE_FAILED = 'failed';
  21. /**
  22. * Queue table name.
  23. *
  24. * @return string
  25. */
  26. public static function queue_table() {
  27. global $wpdb;
  28. return $wpdb->prefix . 'studiou_wcmq_queue';
  29. }
  30. /**
  31. * Log table name.
  32. *
  33. * @return string
  34. */
  35. public static function log_table() {
  36. global $wpdb;
  37. return $wpdb->prefix . 'studiou_wcmq_log';
  38. }
  39. /**
  40. * Create/upgrade tables when the stored schema version lags the constant.
  41. *
  42. * mu-plugins get no activation hook, so this runs on load. dbDelta is
  43. * idempotent; the option check keeps it to one comparison per request.
  44. */
  45. public static function maybe_upgrade_db() {
  46. if ( get_option( self::DB_VERSION_OPTION ) === self::DB_VERSION ) {
  47. return;
  48. }
  49. self::create_tables();
  50. update_option( self::DB_VERSION_OPTION, self::DB_VERSION, true );
  51. }
  52. /**
  53. * Run dbDelta for both tables.
  54. *
  55. * All timestamp columns are SIGNED bigint. Unsigned subtraction underflows
  56. * into MySQL error 1690 inside the slot allocator's range comparison.
  57. */
  58. private static function create_tables() {
  59. global $wpdb;
  60. require_once ABSPATH . 'wp-admin/includes/upgrade.php';
  61. $charset_collate = $wpdb->get_charset_collate();
  62. $queue = self::queue_table();
  63. $log = self::log_table();
  64. $sql_queue = "CREATE TABLE {$queue} (
  65. id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  66. context varchar(100) NOT NULL DEFAULT '',
  67. order_id bigint(20) unsigned NOT NULL DEFAULT 0,
  68. recipient varchar(320) NOT NULL DEFAULT '',
  69. subject text NOT NULL,
  70. payload longtext NOT NULL,
  71. attachment_dir char(32) NULL,
  72. priority smallint(6) NOT NULL DEFAULT 10,
  73. state varchar(20) NOT NULL DEFAULT 'pending',
  74. attempts tinyint(3) unsigned NOT NULL DEFAULT 0,
  75. reclaims tinyint(3) unsigned NOT NULL DEFAULT 0,
  76. last_error text NULL,
  77. scheduled_at bigint(20) NOT NULL DEFAULT 0,
  78. claimed_at bigint(20) NULL,
  79. sent_at bigint(20) NULL,
  80. created_at bigint(20) NOT NULL DEFAULT 0,
  81. PRIMARY KEY (id),
  82. KEY state_sched (state,scheduled_at),
  83. KEY state_claim (state,claimed_at),
  84. KEY created_at (created_at),
  85. KEY order_id (order_id)
  86. ) {$charset_collate};";
  87. $sql_log = "CREATE TABLE {$log} (
  88. id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  89. level varchar(10) NOT NULL DEFAULT 'info',
  90. queue_id bigint(20) unsigned NULL,
  91. message text NOT NULL,
  92. created_at bigint(20) NOT NULL DEFAULT 0,
  93. PRIMARY KEY (id),
  94. KEY created_at (created_at),
  95. KEY level (level)
  96. ) {$charset_collate};";
  97. dbDelta( $sql_queue );
  98. dbDelta( $sql_log );
  99. }
  100. /* ---------------------------------------------------------------------
  101. * Queue rows
  102. * ------------------------------------------------------------------ */
  103. /**
  104. * Insert a queue row.
  105. *
  106. * @param array $data Column => value.
  107. * @return int Row id, or 0 on failure.
  108. */
  109. public static function insert_row( array $data ) {
  110. global $wpdb;
  111. $ok = $wpdb->insert( self::queue_table(), $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
  112. if ( false === $ok ) {
  113. return 0;
  114. }
  115. return (int) $wpdb->insert_id;
  116. }
  117. /**
  118. * Fetch one queue row.
  119. *
  120. * @param int $row_id Row id.
  121. * @return object|null
  122. */
  123. public static function get_row( $row_id ) {
  124. global $wpdb;
  125. $table = self::queue_table();
  126. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  127. return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $row_id ) );
  128. }
  129. /**
  130. * Update a queue row. Null values are written as SQL NULL by $wpdb.
  131. *
  132. * Returns $wpdb->update()'s raw result — rows CHANGED, or false on a DB
  133. * error. Callers must decide what they need, because the two are different
  134. * questions and conflating them hides real failures:
  135. *
  136. * false === $r a DB error occurred
  137. * 0 === $r no row changed: the WHERE matched nothing, or the values
  138. * were already what we asked for (WordPress does not set
  139. * CLIENT_FOUND_ROWS, so matched-but-unchanged reads as 0)
  140. * >0 rows changed
  141. *
  142. * Do not "fix" a caller by testing `> 0` on an update that may legitimately
  143. * be a no-op. Where a caller needs to know the row is still in the state it
  144. * expects, use update_row_if_state() instead.
  145. *
  146. * @param int $row_id Row id.
  147. * @param array $data Column => value.
  148. * @return int|false
  149. */
  150. public static function update_row( $row_id, array $data ) {
  151. global $wpdb;
  152. // phpcs:ignore WordPress.DB.DirectDatabaseQuery
  153. return $wpdb->update( self::queue_table(), $data, array( 'id' => (int) $row_id ) );
  154. }
  155. /**
  156. * Update a queue row only while it is still in the expected state.
  157. *
  158. * The state is part of the WHERE clause, so a row deleted from the admin,
  159. * or reclaimed by the reaper, or already resolved by another worker, comes
  160. * back as 0 rows rather than as a silent success. Every terminal write in
  161. * the worker and the reaper uses this: the alternative is deleting a row's
  162. * attachments and writing its order note after the row has ceased to exist.
  163. *
  164. * All these callers change `state` (or `scheduled_at` to a future slot), so
  165. * a matched row always reports >= 1 changed. 0 unambiguously means "not in
  166. * the state I expected".
  167. *
  168. * @param int $row_id Row id.
  169. * @param array $data Column => value.
  170. * @param string $expected_state State the row must currently be in.
  171. * @return int|false Rows changed, or false on DB error.
  172. */
  173. public static function update_row_if_state( $row_id, array $data, $expected_state ) {
  174. global $wpdb;
  175. // phpcs:ignore WordPress.DB.DirectDatabaseQuery
  176. return $wpdb->update(
  177. self::queue_table(),
  178. $data,
  179. array(
  180. 'id' => (int) $row_id,
  181. 'state' => (string) $expected_state,
  182. )
  183. );
  184. }
  185. /**
  186. * Atomically claim a pending row for sending.
  187. *
  188. * This is the duplicate-send guard. Never replace it with SELECT-then-UPDATE.
  189. *
  190. * It does NOT touch `attempts`. The worker folds `attempts` into the same
  191. * UPDATE that commits the outcome, so no row is ever "attempted but not
  192. * resolved". Crashes are counted separately, in `reclaims`, by the reaper.
  193. *
  194. * @param int $row_id Row id.
  195. * @return bool True when this process won the claim.
  196. */
  197. public static function claim( $row_id ) {
  198. global $wpdb;
  199. $table = self::queue_table();
  200. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  201. $affected = $wpdb->query(
  202. $wpdb->prepare(
  203. "UPDATE {$table} SET state = %s, claimed_at = %d WHERE id = %d AND state = %s",
  204. self::STATE_SENDING,
  205. time(),
  206. (int) $row_id,
  207. self::STATE_PENDING
  208. )
  209. );
  210. return 1 === (int) $affected;
  211. }
  212. /**
  213. * Count rows still in flight.
  214. *
  215. * @return int
  216. */
  217. public static function count_unfinished() {
  218. global $wpdb;
  219. $table = self::queue_table();
  220. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  221. return (int) $wpdb->get_var(
  222. $wpdb->prepare(
  223. "SELECT COUNT(*) FROM {$table} WHERE state IN (%s, %s)",
  224. self::STATE_PENDING,
  225. self::STATE_SENDING
  226. )
  227. );
  228. }
  229. /**
  230. * Row counts keyed by state.
  231. *
  232. * @return array
  233. */
  234. public static function counts_by_state() {
  235. global $wpdb;
  236. $table = self::queue_table();
  237. $out = array(
  238. self::STATE_PENDING => 0,
  239. self::STATE_SENDING => 0,
  240. self::STATE_SENT => 0,
  241. self::STATE_FAILED => 0,
  242. );
  243. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  244. $rows = $wpdb->get_results( "SELECT state, COUNT(*) AS c FROM {$table} GROUP BY state" );
  245. if ( is_array( $rows ) ) {
  246. foreach ( $rows as $row ) {
  247. $out[ $row->state ] = (int) $row->c;
  248. }
  249. }
  250. return $out;
  251. }
  252. /**
  253. * Paginated queue rows for the admin view.
  254. *
  255. * @param string $state State filter, or '' for all.
  256. * @param int $per_page Rows per page.
  257. * @param int $offset Offset.
  258. * @return array
  259. */
  260. public static function get_rows( $state, $per_page, $offset ) {
  261. global $wpdb;
  262. $table = self::queue_table();
  263. $per_page = max( 1, (int) $per_page );
  264. $offset = max( 0, (int) $offset );
  265. if ( $state ) {
  266. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  267. return (array) $wpdb->get_results(
  268. $wpdb->prepare(
  269. "SELECT * FROM {$table} WHERE state = %s ORDER BY scheduled_at ASC, id ASC LIMIT %d OFFSET %d",
  270. $state,
  271. $per_page,
  272. $offset
  273. )
  274. );
  275. }
  276. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  277. return (array) $wpdb->get_results(
  278. $wpdb->prepare(
  279. "SELECT * FROM {$table} ORDER BY scheduled_at ASC, id ASC LIMIT %d OFFSET %d",
  280. $per_page,
  281. $offset
  282. )
  283. );
  284. }
  285. /**
  286. * Count rows, optionally filtered by state.
  287. *
  288. * @param string $state State filter, or '' for all.
  289. * @return int
  290. */
  291. public static function count_rows( $state = '' ) {
  292. global $wpdb;
  293. $table = self::queue_table();
  294. if ( $state ) {
  295. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  296. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE state = %s", $state ) );
  297. }
  298. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  299. return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
  300. }
  301. /**
  302. * Delete one queue row.
  303. *
  304. * @param int $row_id Row id.
  305. * @return bool
  306. */
  307. public static function delete_row( $row_id ) {
  308. global $wpdb;
  309. // phpcs:ignore WordPress.DB.DirectDatabaseQuery
  310. return false !== $wpdb->delete( self::queue_table(), array( 'id' => (int) $row_id ), array( '%d' ) );
  311. }
  312. /* ---------------------------------------------------------------------
  313. * Log rows
  314. * ------------------------------------------------------------------ */
  315. /**
  316. * Append a log row.
  317. *
  318. * `debug` rows are written only when the debug setting is on.
  319. *
  320. * @param string $level debug|info|warning|error.
  321. * @param string $message Message.
  322. * @param int|null $queue_id Related queue row.
  323. */
  324. public static function log( $level, $message, $queue_id = null ) {
  325. if ( ! in_array( $level, array( 'debug', 'info', 'warning', 'error' ), true ) ) {
  326. $level = 'info';
  327. }
  328. if ( 'debug' === $level && ! Studiou_WCMQ_Settings::debug() ) {
  329. return;
  330. }
  331. global $wpdb;
  332. // phpcs:ignore WordPress.DB.DirectDatabaseQuery
  333. $wpdb->insert(
  334. self::log_table(),
  335. array(
  336. 'level' => $level,
  337. 'queue_id' => $queue_id ? (int) $queue_id : null,
  338. 'message' => (string) $message,
  339. 'created_at' => time(),
  340. )
  341. );
  342. if ( in_array( $level, array( 'warning', 'error' ), true ) ) {
  343. Studiou_WCMQ_Utils_Log::log( strtoupper( $level ) . ': ' . $message );
  344. }
  345. }
  346. /**
  347. * Paginated log rows.
  348. *
  349. * @param string $level Level filter, or '' for all.
  350. * @param int $per_page Rows per page.
  351. * @param int $offset Offset.
  352. * @return array
  353. */
  354. public static function get_log_rows( $level, $per_page, $offset ) {
  355. global $wpdb;
  356. $table = self::log_table();
  357. $per_page = max( 1, (int) $per_page );
  358. $offset = max( 0, (int) $offset );
  359. if ( $level ) {
  360. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  361. return (array) $wpdb->get_results(
  362. $wpdb->prepare(
  363. "SELECT * FROM {$table} WHERE level = %s ORDER BY id DESC LIMIT %d OFFSET %d",
  364. $level,
  365. $per_page,
  366. $offset
  367. )
  368. );
  369. }
  370. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  371. return (array) $wpdb->get_results(
  372. $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", $per_page, $offset )
  373. );
  374. }
  375. /**
  376. * Count log rows.
  377. *
  378. * @param string $level Level filter, or '' for all.
  379. * @return int
  380. */
  381. public static function count_log_rows( $level = '' ) {
  382. global $wpdb;
  383. $table = self::log_table();
  384. if ( $level ) {
  385. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  386. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE level = %s", $level ) );
  387. }
  388. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  389. return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
  390. }
  391. /**
  392. * Empty the log.
  393. *
  394. * Batched DELETE, never TRUNCATE: TRUNCATE requires the DROP privilege,
  395. * which the shared hosts this plugin targets frequently withhold.
  396. *
  397. * @return int Rows deleted.
  398. */
  399. public static function clear_log() {
  400. global $wpdb;
  401. $table = self::log_table();
  402. $deleted = 0;
  403. for ( $i = 0; $i < 200; $i++ ) {
  404. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
  405. $n = $wpdb->query( "DELETE FROM {$table} LIMIT 5000" );
  406. if ( ! $n ) {
  407. break;
  408. }
  409. $deleted += (int) $n;
  410. }
  411. return $deleted;
  412. }
  413. }