*/ private array $pending = array(); /** * Whether the shutdown hook has been registered. * * @var bool */ private bool $shutdown_registered = false; /** * Notification types that have already recorded that the store holds no tokens. * * A bulk stock write fires a notification per product and the answer is the * same for all of them, so one line per type answers the whole request. * Keyed by type rather than a single flag, because each type writes to its * own log source and a type that recorded nothing would read as never * having been triggered. * * @var array */ private array $logged_no_tokens = array(); /** * The step logger. * * @var NotificationStepLogger */ private NotificationStepLogger $step_logger; /** * Initialize dependencies. * * @internal * * @param InternalNotificationDispatcher $dispatcher The dispatcher to use on shutdown. * @param PushTokensDataStore $data_store The push tokens data store. * @param NotificationStepLogger $step_logger The step logger. * * @since 10.7.0 */ final public function init( InternalNotificationDispatcher $dispatcher, PushTokensDataStore $data_store, NotificationStepLogger $step_logger ): void { $this->dispatcher = $dispatcher; $this->data_store = $data_store; $this->step_logger = $step_logger; } /** * Enables the store so it accepts notifications. * * Called from PushNotifications::on_init() after enablement checks pass. * * @return void * * @since 10.7.0 */ public function register(): void { $this->enabled = true; } /** * Adds a notification to the pending store. * * A duplicate (same type and resource ID) within a single request is not * added again, and is recorded as `triggered: duplicate_in_request` so a * reader can tell it apart from one that never arrived. The shutdown hook * is registered on the first call. * * Notifications are dropped when no push token is registered, so neither the safety net job nor the loopback request is created for a send that has no recipient. * * @param Notification $notification The notification to add. * @return void * * @since 10.7.0 */ public function add( Notification $notification ): void { if ( ! $this->enabled ) { return; } if ( ! $this->data_store->has_tokens() ) { $type = $notification->get_type(); if ( ! isset( $this->logged_no_tokens[ $type ] ) ) { $this->step_logger->log_notification_step( $notification, 'triggered', 'no_tokens' ); $this->logged_no_tokens[ $type ] = true; } return; } $key = $notification->get_identifier(); if ( isset( $this->pending[ $key ] ) ) { $this->step_logger->log_notification_step( $notification, 'triggered', 'duplicate_in_request' ); return; } $this->pending[ $key ] = $notification; // Capture the trigger time now, but persist it on shutdown (see // dispatch_all()). Writing here would land inside the order-creation // call stack, where an HPOS meta save can escalate into a full // $order->save() on an order whose line items have not been written yet. $notification->set_triggered_at( time() ); $fallback = $this->schedule_safety_net( $notification ); $this->step_logger->log_notification_step( $notification, 'triggered', 'queued', array( 'fallback' => $fallback ) ); if ( ! $this->shutdown_registered ) { add_action( 'shutdown', array( $this, 'dispatch_all' ) ); $this->shutdown_registered = true; } } /** * Schedules an ActionScheduler safety net job for the notification. * * If the shutdown hook never fires (OOM, SIGKILL, etc.), this job * guarantees the notification is still processed. * * @param Notification $notification The notification to schedule. * @return string `scheduled`, `already_scheduled`, or `schedule_failed` when Action Scheduler refused the job. * * @since 10.7.0 */ private function schedule_safety_net( Notification $notification ): string { // Canonical, identity-keyed args shared with NotificationProcessor::cancel_safety_net(). // Action Scheduler matches stored args by exact equality, so both sides must derive // them from the same place; see Notification::get_safety_net_args(). $args = $notification->get_safety_net_args(); if ( ActionSchedulerUtil::has_scheduled_action( NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP ) ) { return 'already_scheduled'; } // Action Scheduler returns 0 rather than throwing when it cannot store // the job, and a notification whose fallback was never scheduled has // nothing to catch it if the loopback does not arrive. $action_id = as_schedule_single_action( time() + NotificationProcessor::SAFETY_NET_DELAY, NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP, true ); return $action_id > 0 ? 'scheduled' : 'schedule_failed'; } /** * Dispatches all pending notifications via InternalNotificationDispatcher. * * Called on shutdown. Records each notification's trigger time, sends all * pending notifications through the InternalNotificationDispatcher, then * clears the store. * * @return void * * @since 10.7.0 */ public function dispatch_all(): void { if ( empty( $this->pending ) ) { return; } $this->record_trigger_times(); $this->dispatcher->dispatch( $this->sort_with_new_orders_first( array_values( $this->pending ) ) ); $this->enabled = false; $this->pending = array(); } /** * Persists each pending notification's trigger time to its resource. * * Runs on shutdown rather than at trigger time so the write stays out of * the order-creation call stack. WordPress fires `shutdown` on fatal errors * too, so the only requests that lose the value are those killed outright * (OOM, SIGKILL), the same requests the safety net exists for, and where * the payload falls back to the current time. * * The recorded time stays that of the first trigger. Core fires the stock * hooks on every reduction past the threshold, not only on the crossing, so * a product that keeps selling while its notification is in flight would * otherwise drag its own recorded time forward and understate its delivery * age. The sent marker is checked as well because a send clears the trigger * time, so a repeat trigger after one would recreate a value that nothing * clears again. * * Failures are caught so that a third-party handler on the resource's save * hooks cannot stop the dispatch below from running. * * @return void * * @since 11.2.0 */ private function record_trigger_times(): void { foreach ( $this->pending as $notification ) { try { if ( $notification->has_meta( NotificationProcessor::SENT_META_KEY ) || $notification->has_meta( NotificationProcessor::TRIGGERED_META_KEY ) ) { continue; } $notification->write_meta( NotificationProcessor::TRIGGERED_META_KEY, $notification->get_triggered_at() ); } catch ( Throwable $e ) { wc_get_logger()->warning( sprintf( 'Failed to record push notification trigger time (type=%s, resource_id=%d): %s', $notification->get_type(), $notification->get_resource_id(), $e->getMessage() ), array( 'source' => PushNotifications::FEATURE_NAME ) ); } } } /** * Moves new order notifications to the front of the batch. * * Android only plays a sound for the first notification of a burst, and * stock is reduced before the order notification is queued, so the order * must go first for its sound to play. * * @param Notification[] $notifications The notifications about to be dispatched. * @return Notification[] * * @since 11.3.0 */ private function sort_with_new_orders_first( array $notifications ): array { $new_orders = array(); $others = array(); foreach ( $notifications as $notification ) { if ( NewOrderNotification::TYPE === $notification->get_type() ) { $new_orders[] = $notification; } else { $others[] = $notification; } } return array_merge( $new_orders, $others ); } /** * Returns the number of pending notifications. * * @return int * * @since 10.7.0 */ public function count(): int { return count( $this->pending ); } /** * Returns all pending notifications. * * @return Notification[] * * @since 10.7.0 */ public function get_all(): array { return array_values( $this->pending ); } }