| @@ -18,8 +18,9 @@ | ||
| 18 | 18 | const META_ORDER_ID = '_wcpos_pj_order_id'; |
| 19 | 19 | const META_FORMAT = '_wcpos_pj_format'; |
| 20 | 20 | const META_TEMPLATE = '_wcpos_pj_template_id'; |
| 21 | 21 | const META_ERROR = '_wcpos_pj_error'; |
| 22 | + const META_UNCONFIRMED = '_wcpos_pj_unconfirmed'; | |
| 22 | 23 | const META_RETRIED_TO = '_wcpos_pj_retried_to'; |
| 23 | 24 | const META_CLAIMED_AT = '_wcpos_pj_claimed_at'; |
| 24 | 25 | const META_PN_KIND = '_wcpos_pj_pn_kind'; |
| 25 | 26 | const META_EXTERNAL_PROVIDER = '_wcpos_pj_external_provider'; |
| @@ -44,11 +45,22 @@ | ||
| 44 | 45 | |
| 45 | 46 | /** Unix time a job reached a terminal status — the retention clock. */ |
| 46 | 47 | const META_TERMINAL_AT = '_wcpos_pj_terminal_at'; |
| 47 | 48 | |
| 48 | - /** Seconds a claimed job stays in-flight before it is treated as stale and re-queued. */ | |
| 49 | + /** | |
| 50 | + * Seconds a claimed job stays in-flight before it is treated as stale. A stale | |
| 51 | + * claim is failed as "unconfirmed", never re-queued: the printer may have | |
| 52 | + * printed it and simply be unable to report yet (see find_unconfirmed()). | |
| 53 | + */ | |
| 49 | 54 | const CLAIM_TTL = 120; |
| 50 | 55 | |
| 56 | + /** | |
| 57 | + * Seconds after a claim was failed as unconfirmed during which a printer's | |
| 58 | + * late result is still attributed to it. Printers report within minutes of | |
| 59 | + * a link coming back; anything older is a stray post, not a late result. | |
| 60 | + */ | |
| 61 | + const UNCONFIRMED_RESULT_WINDOW = HOUR_IN_SECONDS; | |
| 62 | + | |
| 51 | 63 | const STATUS_PENDING = 'pending'; |
| 52 | 64 | const STATUS_CLAIMED = 'claimed'; |
| 53 | 65 | const STATUS_PRINTED = 'printed'; |
| 54 | 66 | const STATUS_FAILED = 'failed'; |
| @@ -54,12 +66,19 @@ | ||
| 54 | 66 | const STATUS_FAILED = 'failed'; |
| 55 | 67 | const STATUS_CANCELLED = 'cancelled'; |
| 56 | 68 | |
| 57 | 69 | /** |
| 58 | - * Constructor — register the CPT on init. | |
| 70 | + * Constructor — register the CPT on init, or at once when init has passed. | |
| 71 | + * | |
| 72 | + * On a storefront request this service is constructed lazily by the first | |
| 73 | + * order write, long after `init`; a hook added then would never fire. | |
| 59 | 74 | */ |
| 60 | 75 | public function __construct() { |
| 61 | - add_action( 'init', array( $this, 'register_post_type' ) ); | |
| 76 | + if ( did_action( 'init' ) ) { | |
| 77 | + $this->register_post_type(); | |
| 78 | + } else { | |
| 79 | + add_action( 'init', array( $this, 'register_post_type' ) ); | |
| 80 | + } | |
| 62 | 81 | // A static callback: several services construct Print_Job_Service on |
| 63 | 82 | // every request, and WordPress dedupes identical static callbacks, so |
| 64 | 83 | // the purge runs exactly once per cron event. |
| 65 | 84 | add_action( self::PURGE_HOOK, array( __CLASS__, 'run_purge' ) ); |
| @@ -174,8 +193,11 @@ | ||
| 174 | 193 | 'auto_open_drawer' => 'yes' === (string) get_post_meta( $id, self::META_AUTO_OPEN_DRAWER, true ), |
| 175 | 194 | 'drawer_connector' => self::normalize_drawer_connector( (string) get_post_meta( $id, self::META_DRAWER_CONNECTOR, true ) ), |
| 176 | 195 | 'drawer_error' => (string) get_post_meta( $id, self::META_DRAWER_ERROR, true ), |
| 177 | 196 | 'retried_to' => (int) get_post_meta( $id, self::META_RETRIED_TO, true ), |
| 197 | + 'error' => (string) get_post_meta( $id, self::META_ERROR, true ), | |
| 198 | + 'unconfirmed' => '1' === (string) get_post_meta( $id, self::META_UNCONFIRMED, true ), | |
| 199 | + 'terminal_at' => (int) get_post_meta( $id, self::META_TERMINAL_AT, true ), | |
| 178 | 200 | ); |
| 179 | 201 | } |
| 180 | 202 | |
| 181 | 203 | /** |
| @@ -453,8 +475,10 @@ | ||
| 453 | 475 | */ |
| 454 | 476 | public function query_rows( array $filters = array() ): array { |
| 455 | 477 | global $wpdb; |
| 456 | 478 | |
| 479 | + $order = isset( $filters['order'] ) && 'DESC' === strtoupper( (string) $filters['order'] ) ? 'DESC' : 'ASC'; | |
| 480 | + | |
| 457 | 481 | $query = new \WP_Query( |
| 458 | 482 | array( |
| 459 | 483 | 'post_type' => self::POST_TYPE, |
| 460 | 484 | 'post_status' => 'publish', |
| @@ -459,11 +483,14 @@ | ||
| 459 | 483 | 'post_type' => self::POST_TYPE, |
| 460 | 484 | 'post_status' => 'publish', |
| 461 | 485 | 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50, |
| 462 | 486 | 'paged' => isset( $filters['page'] ) ? max( 1, (int) $filters['page'] ) : 1, |
| 487 | + // Oldest-first by default: oldest_pending_gmt() reads row zero to | |
| 488 | + // find a printer's longest-waiting job. The queue *view* asks for | |
| 489 | + // DESC instead, where the newest job is the one being looked for. | |
| 463 | 490 | 'orderby' => array( |
| 464 | - 'date' => 'ASC', | |
| 465 | - 'ID' => 'ASC', | |
| 491 | + 'date' => $order, | |
| 492 | + 'ID' => $order, | |
| 466 | 493 | ), |
| 467 | 494 | 'fields' => 'ids', |
| 468 | 495 | 'no_found_rows' => true, |
| 469 | 496 | 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| @@ -495,8 +522,11 @@ | ||
| 495 | 522 | 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ), |
| 496 | 523 | 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ), |
| 497 | 524 | 'template_id' => (string) get_post_meta( $id, self::META_TEMPLATE, true ), |
| 498 | 525 | 'retried_to' => (int) get_post_meta( $id, self::META_RETRIED_TO, true ), |
| 526 | + 'error' => (string) get_post_meta( $id, self::META_ERROR, true ), | |
| 527 | + 'unconfirmed' => '1' === (string) get_post_meta( $id, self::META_UNCONFIRMED, true ), | |
| 528 | + 'terminal_at' => (int) get_post_meta( $id, self::META_TERMINAL_AT, true ), | |
| 499 | 529 | ); |
| 500 | 530 | }, |
| 501 | 531 | $ids |
| 502 | 532 | ); |
| @@ -653,8 +683,33 @@ | ||
| 653 | 683 | return $cancelled; |
| 654 | 684 | } |
| 655 | 685 | |
| 656 | 686 | /** |
| 687 | + * Permanently remove a job row. | |
| 688 | + * | |
| 689 | + * The retention purge clears terminal jobs on its own schedule; this is the | |
| 690 | + * admin's manual escape hatch for a queue full of noise they do not want to | |
| 691 | + * wait out. A still-waiting job is cancelled first so a printer that is | |
| 692 | + * mid-poll cannot claim a row that is about to vanish. | |
| 693 | + * | |
| 694 | + * @param int $id Job ID. | |
| 695 | + * | |
| 696 | + * @return bool True when the row was deleted. | |
| 697 | + */ | |
| 698 | + public function delete( int $id ): bool { | |
| 699 | + if ( self::POST_TYPE !== get_post_type( $id ) ) { | |
| 700 | + return false; | |
| 701 | + } | |
| 702 | + | |
| 703 | + $status = (string) get_post_meta( $id, self::META_STATUS, true ); | |
| 704 | + if ( \in_array( $status, array( self::STATUS_PENDING, self::STATUS_CLAIMED ), true ) && ! $this->cancel_if_waiting( $id ) ) { | |
| 705 | + return false; | |
| 706 | + } | |
| 707 | + | |
| 708 | + return (bool) wp_delete_post( $id, true ); | |
| 709 | + } | |
| 710 | + | |
| 711 | + /** | |
| 657 | 712 | * Atomically cancel a waiting job while excluding provider submission. |
| 658 | 713 | * |
| 659 | 714 | * @param int $id Job ID. |
| 660 | 715 | * |
| @@ -1043,11 +1098,77 @@ | ||
| 1043 | 1098 | return $claimed[0]; |
| 1044 | 1099 | } |
| 1045 | 1100 | |
| 1046 | 1101 | /** |
| 1047 | - * Re-queue stale claims for a printer (crashed/aborted prints). | |
| 1102 | + * The printer's newest unconfirmed, unresolved job within | |
| 1103 | + * UNCONFIRMED_RESULT_WINDOW, or null. | |
| 1048 | 1104 | * |
| 1049 | 1105 | * @param string $printer_id Printer ID. |
| 1106 | + * | |
| 1107 | + * @return array|null | |
| 1108 | + */ | |
| 1109 | + public function find_unconfirmed( string $printer_id ): ?array { | |
| 1110 | + $posts = get_posts( | |
| 1111 | + array( | |
| 1112 | + 'post_type' => self::POST_TYPE, | |
| 1113 | + 'post_status' => 'publish', | |
| 1114 | + 'posts_per_page' => 1, | |
| 1115 | + // Newest by the time it actually failed, not by creation: two jobs for | |
| 1116 | + // one printer can go terminal in a different order than they were | |
| 1117 | + // queued, and it is the most recently failed one a late result | |
| 1118 | + // belongs to. | |
| 1119 | + 'orderby' => array( | |
| 1120 | + 'terminal_at' => 'DESC', | |
| 1121 | + 'ID' => 'DESC', | |
| 1122 | + ), | |
| 1123 | + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query | |
| 1124 | + array( | |
| 1125 | + 'key' => self::META_PRINTER, | |
| 1126 | + 'value' => sanitize_text_field( $printer_id ), | |
| 1127 | + ), | |
| 1128 | + array( | |
| 1129 | + 'key' => self::META_STATUS, | |
| 1130 | + 'value' => self::STATUS_FAILED, | |
| 1131 | + ), | |
| 1132 | + array( | |
| 1133 | + 'key' => self::META_UNCONFIRMED, | |
| 1134 | + 'value' => '1', | |
| 1135 | + ), | |
| 1136 | + array( | |
| 1137 | + 'key' => self::META_RETRIED_TO, | |
| 1138 | + 'compare' => 'NOT EXISTS', | |
| 1139 | + ), | |
| 1140 | + 'terminal_at' => array( | |
| 1141 | + 'key' => self::META_TERMINAL_AT, | |
| 1142 | + 'value' => time() - self::UNCONFIRMED_RESULT_WINDOW, | |
| 1143 | + 'compare' => '>=', | |
| 1144 | + 'type' => 'NUMERIC', | |
| 1145 | + ), | |
| 1146 | + ), | |
| 1147 | + ) | |
| 1148 | + ); | |
| 1149 | + return empty( $posts ) ? null : $this->get( (int) $posts[0]->ID ); | |
| 1150 | + } | |
| 1151 | + | |
| 1152 | + /** | |
| 1153 | + * Record the printer's own result for a job — the claim it holds, or one | |
| 1154 | + * failed as unconfirmed whose result arrived late. Success clears the | |
| 1155 | + * unconfirmed flag and its explanatory text; a failure's code is recorded by | |
| 1156 | + * the caller after this. | |
| 1157 | + * | |
| 1158 | + * @param int $id Job ID. | |
| 1159 | + * @param bool $ok Whether the printer reported success. | |
| 1160 | + */ | |
| 1161 | + public function record_printer_result( int $id, bool $ok ): void { | |
| 1162 | + $this->set_status( $id, $ok ? self::STATUS_PRINTED : self::STATUS_FAILED ); | |
| 1163 | + delete_post_meta( $id, self::META_UNCONFIRMED ); | |
| 1164 | + delete_post_meta( $id, self::META_ERROR ); | |
| 1165 | + } | |
| 1166 | + | |
| 1167 | + /** | |
| 1168 | + * Fail stale claims without risking an automatic duplicate print. | |
| 1169 | + * | |
| 1170 | + * @param string $printer_id Printer ID. | |
| 1050 | 1171 | * @param int $ttl Claim TTL in seconds. |
| 1051 | 1172 | */ |
| 1052 | 1173 | public function release_stale_claims( string $printer_id, int $ttl = self::CLAIM_TTL ): void { |
| 1053 | 1174 | $claimed = $this->query( |
| @@ -1061,13 +1182,25 @@ | ||
| 1061 | 1182 | if ( 0 === $claimed_at || ( time() - $claimed_at ) > $ttl ) { |
| 1062 | 1183 | // Drop the timestamp while the job is still claimed — nothing |
| 1063 | 1184 | // can re-claim it until the status flips, so a fresh claim's |
| 1064 | 1185 | // timestamp can never be erased by this cleanup. Then the |
| 1065 | - // requeue is conditional on still-claimed: same race as | |
| 1186 | + // failure is conditional on still-claimed: same race as | |
| 1066 | 1187 | // try_claim() — a cancellation landing after the query above |
| 1067 | - // must not be overwritten back to pending. | |
| 1188 | + // must not be overwritten as failed. | |
| 1068 | 1189 | delete_post_meta( $job['id'], self::META_CLAIMED_AT ); |
| 1069 | - update_post_meta( $job['id'], self::META_STATUS, self::STATUS_PENDING, self::STATUS_CLAIMED ); | |
| 1190 | + if ( update_post_meta( $job['id'], self::META_STATUS, self::STATUS_FAILED, self::STATUS_CLAIMED ) ) { | |
| 1191 | + // A machine code, like every other META_ERROR writer; the queue UI | |
| 1192 | + // turns the unconfirmed flag into the merchant-facing explanation. | |
| 1193 | + update_post_meta( $job['id'], self::META_ERROR, 'claim_timeout' ); | |
| 1194 | + // The compare-and-swap above is the only status write; a second, | |
| 1195 | + // unconditional one would clobber a result or cancellation that | |
| 1196 | + // landed in between. Only the terminal side effects are wanted. | |
| 1197 | + $this->finalize_status_change( (int) $job['id'], self::STATUS_FAILED ); | |
| 1198 | + // Flag last: it is what makes the row visible to find_unconfirmed(), | |
| 1199 | + // so nothing above can race a late result that lands once it is set. | |
| 1200 | + update_post_meta( $job['id'], self::META_UNCONFIRMED, '1' ); | |
| 1201 | + \WCPOS\WooCommercePOS\Logger::warning( sprintf( 'Printer "%s" did not report a result for print job %d before the claim timeout.', $printer_id, (int) $job['id'] ) ); | |
| 1202 | + } | |
| 1070 | 1203 | } |
| 1071 | 1204 | } |
| 1072 | 1205 | } |
| 1073 | 1206 | |