register_post_type(); } else { add_action( 'init', array( $this, 'register_post_type' ) ); } // A static callback: several services construct Print_Job_Service on // every request, and WordPress dedupes identical static callbacks, so // the purge runs exactly once per cron event. add_action( self::PURGE_HOOK, array( __CLASS__, 'run_purge' ) ); } /** * Cron entry point for the retention purge. */ public static function run_purge(): void { ( new self() )->purge_expired(); } /** * Register the print job post type. Internal, not publicly queryable. */ public function register_post_type(): void { register_post_type( self::POST_TYPE, array( 'label' => 'WCPOS Print Jobs', 'public' => false, 'show_ui' => false, 'show_in_rest' => false, 'exclude_from_search' => true, 'supports' => array( 'title', 'editor' ), ) ); if ( ! wp_next_scheduled( self::PURGE_HOOK ) ) { wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::PURGE_HOOK ); } } /** * Create a print job. * * @param array $args printer_id (required), content_type, payload (base64), order_id, format, template_id, pn_kind, trigger. * * @return int Job post ID. */ public function create( array $args ): int { $id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'post_title' => 'print-job', 'post_content' => isset( $args['payload'] ) ? (string) $args['payload'] : '', ), true ); if ( is_wp_error( $id ) ) { return 0; } update_post_meta( $id, self::META_PRINTER, sanitize_text_field( $args['printer_id'] ) ); update_post_meta( $id, self::META_STATUS, self::STATUS_PENDING ); update_post_meta( $id, self::META_CTYPE, sanitize_text_field( $args['content_type'] ?? 'application/octet-stream' ) ); if ( ! empty( $args['order_id'] ) ) { update_post_meta( $id, self::META_ORDER_ID, (int) $args['order_id'] ); } if ( ! empty( $args['format'] ) ) { update_post_meta( $id, self::META_FORMAT, sanitize_text_field( $args['format'] ) ); } if ( ! empty( $args['template_id'] ) ) { update_post_meta( $id, self::META_TEMPLATE, sanitize_text_field( (string) $args['template_id'] ) ); } if ( ! empty( $args['pn_kind'] ) ) { update_post_meta( $id, self::META_PN_KIND, sanitize_text_field( (string) $args['pn_kind'] ) ); } if ( ! empty( $args['trigger'] ) ) { update_post_meta( $id, self::META_TRIGGER, sanitize_text_field( (string) $args['trigger'] ) ); } if ( array_key_exists( 'auto_open_drawer', $args ) ) { update_post_meta( $id, self::META_AUTO_OPEN_DRAWER, ! empty( $args['auto_open_drawer'] ) ? 'yes' : 'no' ); } if ( ! empty( $args['drawer_connector'] ) ) { update_post_meta( $id, self::META_DRAWER_CONNECTOR, self::normalize_drawer_connector( (string) $args['drawer_connector'] ) ); } do_action( 'woocommerce_pos_print_job_created', (int) $id, (string) $args['printer_id'] ); return (int) $id; } /** * Get a single job as an array, or null. * * @param int $id Job ID. * * @return array|null */ public function get( int $id ): ?array { $post = get_post( $id ); if ( ! $post || self::POST_TYPE !== $post->post_type ) { return null; } return array( 'id' => (int) $post->ID, 'created_gmt' => (string) $post->post_date_gmt, 'printer_id' => (string) get_post_meta( $id, self::META_PRINTER, true ), 'status' => (string) get_post_meta( $id, self::META_STATUS, true ), 'content_type' => (string) get_post_meta( $id, self::META_CTYPE, true ), 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ), 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ), 'template_id' => (string) get_post_meta( $id, self::META_TEMPLATE, true ), 'pn_kind' => (string) get_post_meta( $id, self::META_PN_KIND, true ), 'external_provider' => (string) get_post_meta( $id, self::META_EXTERNAL_PROVIDER, true ), 'external_job_id' => (string) get_post_meta( $id, self::META_EXTERNAL_JOB_ID, true ), 'external_state' => (string) get_post_meta( $id, self::META_EXTERNAL_STATE, true ), 'payload' => (string) $post->post_content, 'auto_open_drawer' => 'yes' === (string) get_post_meta( $id, self::META_AUTO_OPEN_DRAWER, true ), 'drawer_connector' => self::normalize_drawer_connector( (string) get_post_meta( $id, self::META_DRAWER_CONNECTOR, true ) ), 'drawer_error' => (string) get_post_meta( $id, self::META_DRAWER_ERROR, true ), 'retried_to' => (int) get_post_meta( $id, self::META_RETRIED_TO, true ), 'error' => (string) get_post_meta( $id, self::META_ERROR, true ), 'unconfirmed' => '1' === (string) get_post_meta( $id, self::META_UNCONFIRMED, true ), 'terminal_at' => (int) get_post_meta( $id, self::META_TERMINAL_AT, true ), ); } /** * Record a successful external (push-provider) submission against a job. * * @param int $id Job ID. * @param string $provider Provider key (e.g. 'printnode', 'star-online'). * @param string $job_id External job id (opaque string). * @param string $state Submission state (e.g. 'submitted'). */ public function record_external_submission( int $id, string $provider, string $job_id, string $state ): void { update_post_meta( $id, self::META_EXTERNAL_PROVIDER, sanitize_text_field( $provider ) ); update_post_meta( $id, self::META_EXTERNAL_JOB_ID, sanitize_text_field( $job_id ) ); update_post_meta( $id, self::META_EXTERNAL_STATE, sanitize_text_field( $state ) ); } /** * Normalize a cash-drawer connector identifier to the server contract. * * @param string $connector Incoming connector value. * * @return string pin2 or pin5. */ public static function normalize_drawer_connector( string $connector ): string { $connector = strtolower( trim( $connector ) ); if ( in_array( $connector, array( 'pin5', 'drawer_2', '1' ), true ) ) { return 'pin5'; } return 'pin2'; } /** * Load a receipt template by id (numeric stored template or virtual slug). * * Single source of truth for template resolution shared by render_payload(), * the auto-print trigger, and the manual print-jobs endpoint. * * @param string $template_id Template id (numeric) or virtual slug. * * @return array|null Template array, or null when not found. */ public static function load_template( string $template_id ): ?array { return is_numeric( $template_id ) ? \WCPOS\WooCommercePOS\Templates::get_template( (int) $template_id ) : \WCPOS\WooCommercePOS\Templates::get_virtual_template( $template_id, 'receipt' ); } /** * Render the bytes a printer should fetch for a job. * * @param array $job Job array returned by get(). * @param string $media_type Negotiated media type, when the transport chose one. * * @return string */ public function render_payload( array $job, string $media_type = '' ): string { return $this->render_job( $job, $media_type )['body']; } /** * Render a job, reporting peripherals its payload cannot carry. * * `$media_type` is the format a CloudPRNT printer picked out of the poll * response's offer. When it names a format the thermal pipeline can produce, * it overrides the provider's default wire format — this is what makes the * offer real rather than decorative. An empty string keeps the provider * default, which is what every non-negotiating caller passes. * * The `cut` and `drawer` keys are non-null only for command-free formats, * where the peripherals have to be requested out-of-band; see * Thermal_Renderer::render_with_control(). * * @param array $job Job array returned by get(). * @param string $media_type Negotiated media type, when the transport chose one. * * @return array{body:string, cut:string|null, drawer:string|null} */ public function render_job( array $job, string $media_type = '' ): array { if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) && ! empty( $job['pn_kind'] ) ) { $template = self::load_template( (string) $job['template_id'] ); if ( null === $template ) { return self::nothing_to_print(); } $order = wc_get_order( (int) $job['order_id'] ); if ( ! $order ) { return self::nothing_to_print(); } if ( 'pdf' === $job['pn_kind'] ) { try { return self::in_band( ( new Template_Pdf_Service() )->render( $template, $order ) ); } catch ( \Throwable $e ) { \WCPOS\WooCommercePOS\Logger::log( sprintf( 'Cloud print: PrintNode PDF render failed for job %d: %s', (int) $job['id'], $e->getMessage() ) ); return self::nothing_to_print(); } } if ( 'escpos' === $job['pn_kind'] ) { try { return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render_with_control( $template, $order, 'escpos', $this->drawer_render_options( $job ) ); } catch ( \Throwable $e ) { \WCPOS\WooCommercePOS\Logger::log( sprintf( 'Cloud print: PrintNode ESC/POS render failed for job %d: %s', (int) $job['id'], $e->getMessage() ) ); return self::nothing_to_print(); } } return self::nothing_to_print(); } if ( ! empty( $job['order_id'] ) && ! empty( $job['template_id'] ) ) { $template = self::load_template( (string) $job['template_id'] ); if ( null === $template ) { return self::nothing_to_print(); } $printer = ( new Cloud_Print_Registry() )->get_printer( (string) $job['printer_id'] ); $provider = Provider::normalize( \is_string( $printer['provider'] ?? null ) ? $printer['provider'] : null ); $wire = Provider::wire_format( $provider, (string) ( $template['engine'] ?? '' ) ); if ( null === $wire ) { return self::nothing_to_print(); } $negotiated = '' === $media_type ? '' : Cloud_Print_Media_Types::wire_format( $media_type ); if ( '' !== $negotiated ) { $wire = $negotiated; } $order = wc_get_order( (int) $job['order_id'] ); if ( ! $order ) { return self::nothing_to_print(); } try { return ( new \WCPOS\WooCommercePOS\Templates\Thermal\Thermal_Renderer() )->render_with_control( $template, $order, $wire, $this->drawer_render_options( $job ) ); } catch ( \Throwable $e ) { // Defense in depth: never let a malformed template/payload bubble up // as a 500 and leave the poll's claimed job stuck. Returning empty // lets the caller treat the job as having nothing to print. \WCPOS\WooCommercePOS\Logger::log( sprintf( 'Cloud print: thermal render failed for job %d: %s', (int) $job['id'], $e->getMessage() ) ); return self::nothing_to_print(); } } if ( ! empty( $job['order_id'] ) && ! empty( $job['format'] ) ) { $order = wc_get_order( (int) $job['order_id'] ); if ( ! $order ) { return self::nothing_to_print(); } try { $data = ( new Receipt_Data_Builder() )->build( $order, 'live' ); $adapter = ( new Receipt_Output_Adapter_Factory() )->create( (string) $job['format'] ); return self::in_band( $adapter->transform( $data ) ); } catch ( \Throwable $e ) { // A stored job can carry a format the factory no longer supports // (e.g. the removed fixed-layout starprnt placeholder). Fail closed // like the thermal branch above: log and print nothing rather than // letting the poll 500 with a claimed job stuck. \WCPOS\WooCommercePOS\Logger::log( sprintf( 'Cloud print: fixed-layout render failed for job %d: %s', (int) $job['id'], $e->getMessage() ) ); return self::nothing_to_print(); } } $payload = base64_decode( (string) $job['payload'], true ); return self::in_band( false === $payload ? '' : $payload ); } /** * A render result whose payload carries its own cut and drawer commands. * * @param string $body The rendered payload. * * @return array{body:string, cut:string|null, drawer:string|null} */ private static function in_band( string $body ): array { return array( 'body' => $body, 'cut' => null, 'drawer' => null, ); } /** * The render result for a job that produced nothing. * * @return array{body:string, cut:string|null, drawer:string|null} */ private static function nothing_to_print(): array { return self::in_band( '' ); } /** * Build drawer options for thermal rendering. * * @param array $job Job array. * * @return array{auto_open_drawer:bool, drawer_connector:string} */ private function drawer_render_options( array $job ): array { return array( 'auto_open_drawer' => ! empty( $job['auto_open_drawer'] ), 'drawer_connector' => (string) ( $job['drawer_connector'] ?? 'pin2' ), ); } /** * Query jobs by printer, status and/or order (oldest first). * * @param array $filters printer_id, status, order_id, limit. * * @return array */ public function query( array $filters = array() ): array { $meta_query = $this->filters_to_meta_query( $filters ); $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50, 'paged' => isset( $filters['page'] ) ? max( 1, (int) $filters['page'] ) : 1, // ID breaks date ties: jobs created in the same second must // keep a stable order or offset pagination duplicates rows. 'orderby' => array( 'date' => 'ASC', 'ID' => 'ASC', ), 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query ) ); return array_map( function ( $post ) { return $this->get( (int) $post->ID ); }, $posts ); } /** * Queue-view rows: like query(), but never hydrates post_content — a * raster receipt payload is megabytes the queue table doesn't need, and * a page of them would be loaded into memory on every refresh. * * @param array $filters printer_id / status / limit / page. * * @return array */ public function query_rows( array $filters = array() ): array { global $wpdb; $order = isset( $filters['order'] ) && 'DESC' === strtoupper( (string) $filters['order'] ) ? 'DESC' : 'ASC'; $query = new \WP_Query( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => isset( $filters['limit'] ) ? (int) $filters['limit'] : 50, 'paged' => isset( $filters['page'] ) ? max( 1, (int) $filters['page'] ) : 1, // Oldest-first by default: oldest_pending_gmt() reads row zero to // find a printer's longest-waiting job. The queue *view* asks for // DESC instead, where the newest job is the one being looked for. 'orderby' => array( 'date' => $order, 'ID' => $order, ), 'fields' => 'ids', 'no_found_rows' => true, 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query ) ); $ids = array_map( 'intval', $query->posts ); if ( empty( $ids ) ) { return array(); } update_meta_cache( 'post', $ids ); $placeholders = implode( ',', array_fill( 0, \count( $ids ), '%d' ) ); // Direct, content-free date lookup: get_post() would pull the full // row (payload included) into the object cache, defeating the point. $dates = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a %d list. $wpdb->prepare( "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE ID IN ($placeholders)", $ids ), OBJECT_K ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return array_map( function ( int $id ) use ( $dates ): array { return array( 'id' => $id, 'created_gmt' => isset( $dates[ $id ] ) ? (string) $dates[ $id ]->post_date_gmt : '', 'printer_id' => (string) get_post_meta( $id, self::META_PRINTER, true ), 'status' => (string) get_post_meta( $id, self::META_STATUS, true ), 'content_type' => (string) get_post_meta( $id, self::META_CTYPE, true ), 'order_id' => (int) get_post_meta( $id, self::META_ORDER_ID, true ), 'format' => (string) get_post_meta( $id, self::META_FORMAT, true ), 'template_id' => (string) get_post_meta( $id, self::META_TEMPLATE, true ), 'retried_to' => (int) get_post_meta( $id, self::META_RETRIED_TO, true ), 'error' => (string) get_post_meta( $id, self::META_ERROR, true ), 'unconfirmed' => '1' === (string) get_post_meta( $id, self::META_UNCONFIRMED, true ), 'terminal_at' => (int) get_post_meta( $id, self::META_TERMINAL_AT, true ), ); }, $ids ); } /** * Count jobs matching the same filters query() accepts. * * @param array $filters printer_id / status / order_id / template_id / trigger / exclude_retried. * * @return int */ public function count( array $filters = array() ): int { $query = new \WP_Query( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_query' => $this->filters_to_meta_query( $filters ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query ) ); return (int) $query->found_posts; } /** * One grouped pass over every job: per printer and status, the job count * and the oldest creation time (GMT, MySQL format). * * Replaces a per-printer count/oldest query fan-out — the queue view * refreshes every 30 seconds, so its summary must cost one query no * matter how many printers are registered. * * @return array> printer_id => status => stats. */ public function status_summary(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one aggregate pass; WP_Query would need 2 queries per printer. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT printer.meta_value AS printer_id, status.meta_value AS job_status, COUNT(DISTINCT p.ID) AS jobs, COUNT(DISTINCT CASE WHEN status.meta_value = %s AND retried.post_id IS NULL THEN p.ID END) AS unresolved_jobs, MIN(p.post_date_gmt) AS oldest_gmt FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} printer ON printer.post_id = p.ID AND printer.meta_key = %s INNER JOIN {$wpdb->postmeta} status ON status.post_id = p.ID AND status.meta_key = %s LEFT JOIN {$wpdb->postmeta} retried ON retried.post_id = p.ID AND retried.meta_key = %s WHERE p.post_type = %s AND p.post_status = 'publish' GROUP BY printer.meta_value, status.meta_value", self::STATUS_FAILED, self::META_PRINTER, self::META_STATUS, self::META_RETRIED_TO, self::POST_TYPE ) ); $summary = array(); foreach ( (array) $rows as $row ) { $summary[ (string) $row->printer_id ][ (string) $row->job_status ] = array( 'count' => (int) $row->jobs, 'unresolved_count' => (int) $row->unresolved_jobs, 'oldest_gmt' => (string) $row->oldest_gmt, ); } return $summary; } /** * The creation time (GMT, MySQL format) of a printer's oldest waiting job. * * Waiting means pending or claimed: a printer that fetched a job and then * died leaves it claimed forever, and that backlog must still surface. * * @param string $printer_id Printer id. * * @return string Empty when the printer has no waiting jobs. */ public function oldest_pending_gmt( string $printer_id ): string { $oldest = ''; foreach ( array( self::STATUS_PENDING, self::STATUS_CLAIMED ) as $status ) { $rows = $this->query_rows( array( 'printer_id' => $printer_id, 'status' => $status, 'limit' => 1, ) ); if ( ! empty( $rows ) && '' !== (string) $rows[0]['created_gmt'] ) { $created = (string) $rows[0]['created_gmt']; if ( '' === $oldest || $created < $oldest ) { $oldest = $created; } } } return $oldest; } /** * Cancel every waiting (pending or claimed) job matching the filter. * * Printed, failed, and already-cancelled jobs are never touched — this * exists to clear a backlog, not to rewrite history. * * @param array $filters ids (array of job ids) and/or printer_id. * * @return int Number of jobs cancelled. */ public function cancel_waiting( array $filters ): int { $cancellable = array( self::STATUS_PENDING, self::STATUS_CLAIMED ); $cancelled = 0; if ( ! empty( $filters['ids'] ) ) { foreach ( array_map( 'intval', (array) $filters['ids'] ) as $id ) { if ( $this->cancel_if_waiting( $id ) ) { ++$cancelled; } } return $cancelled; } if ( empty( $filters['printer_id'] ) ) { return 0; } foreach ( $cancellable as $status ) { // Batched: query() pages from the front and cancelling removes // jobs from the result set, so repeat until the queue is drained. do { $jobs = $this->query( array( 'printer_id' => (string) $filters['printer_id'], 'status' => $status, 'limit' => 100, ) ); $batch = \count( $jobs ); $batch_cancelled = 0; foreach ( $jobs as $job ) { if ( $this->cancel_if_waiting( (int) $job['id'] ) ) { ++$cancelled; ++$batch_cancelled; } } } while ( 100 === $batch && $batch_cancelled > 0 ); } return $cancelled; } /** * Permanently remove a job row. * * The retention purge clears terminal jobs on its own schedule; this is the * admin's manual escape hatch for a queue full of noise they do not want to * wait out. A still-waiting job is cancelled first so a printer that is * mid-poll cannot claim a row that is about to vanish. * * @param int $id Job ID. * * @return bool True when the row was deleted. */ public function delete( int $id ): bool { if ( self::POST_TYPE !== get_post_type( $id ) ) { return false; } $status = (string) get_post_meta( $id, self::META_STATUS, true ); if ( \in_array( $status, array( self::STATUS_PENDING, self::STATUS_CLAIMED ), true ) && ! $this->cancel_if_waiting( $id ) ) { return false; } return (bool) wp_delete_post( $id, true ); } /** * Atomically cancel a waiting job while excluding provider submission. * * @param int $id Job ID. * * @return bool True when the job was cancelled. */ public function cancel_if_waiting( int $id ): bool { if ( self::POST_TYPE !== get_post_type( $id ) ) { return false; } if ( ! $this->acquire_lifecycle_lock( $id ) ) { return false; } try { foreach ( array( self::STATUS_PENDING, self::STATUS_CLAIMED ) as $status ) { if ( update_post_meta( $id, self::META_STATUS, self::STATUS_CANCELLED, $status ) ) { $this->finalize_status_change( $id, self::STATUS_CANCELLED ); return true; } } return false; } finally { $this->release_lifecycle_lock( $id ); } } /** * A meta_query clause matching a set of job statuses. * * @param array $statuses Status values. * * @return array */ private function status_clause( array $statuses ): array { return array( 'key' => self::META_STATUS, 'value' => $statuses, 'compare' => 'IN', ); } /** * Translate public filters into a meta_query array. * * @param array $filters printer_id / status / order_id / template_id. * * @return array */ private function filters_to_meta_query( array $filters ): array { $meta_query = array(); if ( ! empty( $filters['printer_id'] ) ) { // Same contract as status below: one printer matches exactly, a // list becomes an IN clause. sanitize_text_field() flattens an // array to '', so without this a printer_id list matched nothing. $printer_id = \is_array( $filters['printer_id'] ) ? array_map( 'sanitize_text_field', $filters['printer_id'] ) : sanitize_text_field( $filters['printer_id'] ); $meta_query[] = array( 'key' => self::META_PRINTER, 'value' => $printer_id, 'compare' => \is_array( $printer_id ) ? 'IN' : '=', ); } if ( ! empty( $filters['status'] ) ) { // A single status matches exactly; a list becomes an IN clause // (the queue's default "active" view is pending + claimed + failed). $status = \is_array( $filters['status'] ) ? array_map( 'sanitize_text_field', $filters['status'] ) : sanitize_text_field( $filters['status'] ); if ( ! empty( $filters['exclude_retried'] ) && \in_array( self::STATUS_FAILED, (array) $status, true ) ) { $active_statuses = array_values( array_diff( (array) $status, array( self::STATUS_FAILED ) ) ); $status_query = array( 'relation' => 'OR' ); if ( ! empty( $active_statuses ) ) { $status_query[] = $this->status_clause( $active_statuses ); } $status_query[] = array( 'relation' => 'AND', array( 'key' => self::META_STATUS, 'value' => self::STATUS_FAILED, ), array( 'key' => self::META_RETRIED_TO, 'compare' => 'NOT EXISTS', ), ); $meta_query[] = $status_query; } else { $meta_query[] = array( 'key' => self::META_STATUS, 'value' => $status, 'compare' => \is_array( $status ) ? 'IN' : '=', ); } } if ( ! empty( $filters['order_id'] ) ) { $meta_query[] = array( 'key' => self::META_ORDER_ID, 'value' => (int) $filters['order_id'], 'type' => 'NUMERIC', ); } if ( ! empty( $filters['template_id'] ) ) { $meta_query[] = array( 'key' => self::META_TEMPLATE, 'value' => sanitize_text_field( (string) $filters['template_id'] ), ); } if ( ! empty( $filters['trigger'] ) ) { // Jobs attributable to this trigger: the same recorded trigger, or // no trigger at all — manual prints and pre-trigger jobs count // toward every rule so they keep suppressing auto reprints. $meta_query[] = array( 'relation' => 'OR', array( 'key' => self::META_TRIGGER, 'value' => sanitize_text_field( (string) $filters['trigger'] ), ), array( 'key' => self::META_TRIGGER, 'compare' => 'NOT EXISTS', ), ); } return $meta_query; } /** * Set a job's status. * * @param int $id Job ID. * @param string $status One of the STATUS_* constants. */ public function set_status( int $id, string $status ): void { update_post_meta( $id, self::META_STATUS, sanitize_text_field( $status ) ); $this->finalize_status_change( $id, $status ); } /** * Mark a source job as retried and discard its dead payload. * * @param int $id Source job ID. * @param int $replacement_id Replacement job ID. * * @return bool Whether the retry was recorded. */ public function mark_retried( int $id, int $replacement_id ): bool { if ( ! update_post_meta( $id, self::META_RETRIED_TO, $replacement_id ) ) { return false; } $this->strip_payload( $id ); return true; } /** * Apply side effects for a status change. * * @param int $id Job ID. * @param string $status New status. */ private function finalize_status_change( int $id, string $status ): void { if ( \in_array( $status, array( self::STATUS_PRINTED, self::STATUS_CANCELLED, self::STATUS_FAILED ), true ) ) { // The retention clock starts when the job *ends*, not when it was // created — a receipt that waited a week and then printed still // deserves its full retention window. update_post_meta( $id, self::META_TERMINAL_AT, time() ); } if ( \in_array( $status, array( self::STATUS_PRINTED, self::STATUS_CANCELLED ), true ) ) { // Terminal success (or abandonment): the payload has done its // job, and a raster receipt is hundreds of KB. The row survives // with metadata only — that's all the duplicate-trigger guard // and the queue's history view need. Failed jobs keep their // payload so Retry can copy it until a replacement is created. $this->strip_payload( $id ); } } /** * Strip a job's stored payload while retaining its metadata. * * @param int $id Job ID. */ private function strip_payload( int $id ): void { wp_update_post( array( 'ID' => $id, 'post_content' => '', ) ); } /** * Acquire the atomic per-job lifecycle lock. * * @param int $id Job ID. * * @return bool True when the lock was acquired. */ public function acquire_lifecycle_lock( int $id ): bool { $option = self::LIFECYCLE_LOCK_PREFIX . $id; $now = time(); if ( add_option( $option, (string) $now, '', false ) ) { return true; } $locked_at = (int) get_option( $option, 0 ); if ( $locked_at > 0 && ( $now - $locked_at ) > self::LIFECYCLE_LOCK_TTL ) { delete_option( $option ); return add_option( $option, (string) $now, '', false ); } return false; } /** * Release the per-job lifecycle lock. * * @param int $id Job ID. */ public function release_lifecycle_lock( int $id ): void { delete_option( self::LIFECYCLE_LOCK_PREFIX . $id ); } /** * Delete terminal jobs past their retention window. * * Runs daily via PURGE_HOOK. Printed/cancelled jobs are kept for * `woocommerce_pos_print_job_retention_days` (default 7 — long enough * for the duplicate-trigger guard and "did it print?" questions); * failed jobs for `woocommerce_pos_print_job_failed_retention_days` * (default 30 — they represent unresolved problems). A filter * returning 0 or less keeps that class of job forever. Waiting jobs * (pending/claimed) are never purged. */ public function purge_expired(): void { $windows = array( array( 'statuses' => array( self::STATUS_PRINTED, self::STATUS_CANCELLED ), 'days' => (int) apply_filters( 'woocommerce_pos_print_job_retention_days', 7 ), ), array( 'statuses' => array( self::STATUS_FAILED ), 'days' => (int) apply_filters( 'woocommerce_pos_print_job_failed_retention_days', 30 ), ), ); foreach ( $windows as $window ) { if ( $window['days'] <= 0 ) { continue; } $cutoff = time() - $window['days'] * DAY_IN_SECONDS; // The retention clock is the moment the job went terminal. Rows // from before this meta existed fall back to their creation date. $expired_queries = array( array( 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $this->status_clause( $window['statuses'] ), array( 'key' => self::META_TERMINAL_AT, 'value' => $cutoff, 'compare' => '<', 'type' => 'NUMERIC', ), ), ), array( 'date_query' => array( array( 'column' => 'post_date_gmt', 'before' => gmdate( 'Y-m-d H:i:s', $cutoff ), ), ), 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $this->status_clause( $window['statuses'] ), array( 'key' => self::META_TERMINAL_AT, 'compare' => 'NOT EXISTS', ), ), ), ); $deleted = 0; foreach ( $expired_queries as $args ) { do { $query = new \WP_Query( array_merge( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 200, 'fields' => 'ids', 'no_found_rows' => true, ), $args ) ); $batch = \count( $query->posts ); foreach ( $query->posts as $post_id ) { wp_delete_post( (int) $post_id, true ); ++$deleted; } // Bounded per run — tomorrow's cron finishes any remainder. } while ( 200 === $batch && $deleted < 2000 ); } } } /** * Claim a job for printing (one in-flight job per printer). * * @param int $id Job ID. */ public function claim( int $id ): void { $this->try_claim( $id ); } /** * Attempt to claim a job while preserving one active claim per printer. * * @param int $id Job ID. * * @return bool True when the job was claimed. */ public function try_claim( int $id ): bool { $job = $this->get( $id ); if ( null === $job || self::STATUS_PENDING !== $job['status'] || '' === $job['printer_id'] ) { return false; } $printer_id = sanitize_text_field( $job['printer_id'] ); if ( ! $this->acquire_claim_lock( $printer_id ) ) { return false; } try { if ( null !== $this->find_active_claim( $printer_id ) ) { return false; } // Conditional on still-pending: a cancellation that lands between // the eligibility read above and this write must win — an // unconditional write would flip a just-cancelled job back to // claimed and hand it to the printer. if ( ! update_post_meta( $id, self::META_STATUS, self::STATUS_CLAIMED, self::STATUS_PENDING ) ) { return false; } update_post_meta( $id, self::META_CLAIMED_AT, time() ); return true; } finally { $this->release_claim_lock( $printer_id ); } } /** * The printer's current, non-stale in-flight claim, or null. * * @param string $printer_id Printer ID. * @param int $ttl Claim TTL in seconds. * * @return array|null */ public function find_active_claim( string $printer_id, int $ttl = self::CLAIM_TTL ): ?array { $claimed = $this->query( array( 'printer_id' => $printer_id, 'status' => self::STATUS_CLAIMED, 'limit' => 1, ) ); if ( empty( $claimed ) ) { return null; } $claimed_at = (int) get_post_meta( $claimed[0]['id'], self::META_CLAIMED_AT, true ); if ( $claimed_at > 0 && ( time() - $claimed_at ) > $ttl ) { return null; } return $claimed[0]; } /** * The printer's newest unconfirmed, unresolved job within * UNCONFIRMED_RESULT_WINDOW, or null. * * @param string $printer_id Printer ID. * * @return array|null */ public function find_unconfirmed( string $printer_id ): ?array { $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, // Newest by the time it actually failed, not by creation: two jobs for // one printer can go terminal in a different order than they were // queued, and it is the most recently failed one a late result // belongs to. 'orderby' => array( 'terminal_at' => 'DESC', 'ID' => 'DESC', ), 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query array( 'key' => self::META_PRINTER, 'value' => sanitize_text_field( $printer_id ), ), array( 'key' => self::META_STATUS, 'value' => self::STATUS_FAILED, ), array( 'key' => self::META_UNCONFIRMED, 'value' => '1', ), array( 'key' => self::META_RETRIED_TO, 'compare' => 'NOT EXISTS', ), 'terminal_at' => array( 'key' => self::META_TERMINAL_AT, 'value' => time() - self::UNCONFIRMED_RESULT_WINDOW, 'compare' => '>=', 'type' => 'NUMERIC', ), ), ) ); return empty( $posts ) ? null : $this->get( (int) $posts[0]->ID ); } /** * Record the printer's own result for a job — the claim it holds, or one * failed as unconfirmed whose result arrived late. Success clears the * unconfirmed flag and its explanatory text; a failure's code is recorded by * the caller after this. * * @param int $id Job ID. * @param bool $ok Whether the printer reported success. */ public function record_printer_result( int $id, bool $ok ): void { $this->set_status( $id, $ok ? self::STATUS_PRINTED : self::STATUS_FAILED ); delete_post_meta( $id, self::META_UNCONFIRMED ); delete_post_meta( $id, self::META_ERROR ); } /** * Fail stale claims without risking an automatic duplicate print. * * @param string $printer_id Printer ID. * @param int $ttl Claim TTL in seconds. */ public function release_stale_claims( string $printer_id, int $ttl = self::CLAIM_TTL ): void { $claimed = $this->query( array( 'printer_id' => $printer_id, 'status' => self::STATUS_CLAIMED, ) ); foreach ( $claimed as $job ) { $claimed_at = (int) get_post_meta( $job['id'], self::META_CLAIMED_AT, true ); if ( 0 === $claimed_at || ( time() - $claimed_at ) > $ttl ) { // Drop the timestamp while the job is still claimed — nothing // can re-claim it until the status flips, so a fresh claim's // timestamp can never be erased by this cleanup. Then the // failure is conditional on still-claimed: same race as // try_claim() — a cancellation landing after the query above // must not be overwritten as failed. delete_post_meta( $job['id'], self::META_CLAIMED_AT ); if ( update_post_meta( $job['id'], self::META_STATUS, self::STATUS_FAILED, self::STATUS_CLAIMED ) ) { // A machine code, like every other META_ERROR writer; the queue UI // turns the unconfirmed flag into the merchant-facing explanation. update_post_meta( $job['id'], self::META_ERROR, 'claim_timeout' ); // The compare-and-swap above is the only status write; a second, // unconditional one would clobber a result or cancellation that // landed in between. Only the terminal side effects are wanted. $this->finalize_status_change( (int) $job['id'], self::STATUS_FAILED ); // Flag last: it is what makes the row visible to find_unconfirmed(), // so nothing above can race a late result that lands once it is set. update_post_meta( $job['id'], self::META_UNCONFIRMED, '1' ); \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'] ) ); } } } } /** * Acquire a short per-printer claim lock. * * @param string $printer_id Printer ID. * * @return bool True when the lock was acquired. */ private function acquire_claim_lock( string $printer_id ): bool { $option = $this->claim_lock_option( $printer_id ); $now = time(); if ( add_option( $option, (string) $now, '', false ) ) { return true; } $locked_at = (int) get_option( $option, 0 ); if ( $locked_at > 0 && ( $now - $locked_at ) > self::CLAIM_TTL ) { delete_option( $option ); return add_option( $option, (string) $now, '', false ); } return false; } /** * Release the per-printer claim lock. * * @param string $printer_id Printer ID. */ private function release_claim_lock( string $printer_id ): void { delete_option( $this->claim_lock_option( $printer_id ) ); } /** * Build the per-printer claim lock option name. * * @param string $printer_id Printer ID. * * @return string */ private function claim_lock_option( string $printer_id ): string { return self::CLAIM_LOCK_PREFIX . md5( $printer_id ); } /** * The next pending job for a printer, or null. * * @param string $printer_id Printer ID. * * @return array|null */ public function next_pending( string $printer_id ): ?array { $pending = $this->query( array( 'printer_id' => $printer_id, 'status' => self::STATUS_PENDING, 'limit' => 1, ) ); return empty( $pending ) ? null : $pending[0]; } }