dispatcher = $dispatcher; $this->data_store = $data_store; $this->preferences_service = $preferences_service; $this->retry_handler = $retry_handler; $this->step_logger = $step_logger; } /** * Registers the ActionScheduler hook for the safety net job. * * @return void * * @since 10.7.0 */ public function register(): void { add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 3 ); } /** * Processes a single notification: checks meta, sends to WPCOM, marks sent. * * @param Notification $notification The notification to process. * @param bool $is_retry Whether this is a retry or safety net attempt. * @param int $attempt The current attempt number (0 = first attempt). * @return bool True if successfully sent (or already sent). * * @since 10.7.0 */ public function process( Notification $notification, bool $is_retry = false, int $attempt = 0 ): bool { $step_context = array( 'attempt' => $attempt, 'is_retry' => $is_retry, ); /** * This notification has already been sent - don't continue. */ if ( $notification->has_meta( self::SENT_META_KEY ) ) { $this->step_logger->log_notification_step( $notification, 'skipped', 'already_sent', $step_context ); return true; } if ( ! $is_retry ) { /** * This notification has already been claimed for sending, and since * this is not a retry, this is not expected and means some other * process is handling the notification (e.g. race condition) - * don't continue. */ if ( $notification->has_meta( self::CLAIMED_META_KEY ) ) { $this->step_logger->log_notification_step( $notification, 'skipped', 'already_claimed', $step_context ); return true; } $notification->write_meta( self::CLAIMED_META_KEY ); } /** * Non-paginated result from get_tokens_for_roles. * * @var PushToken[] $eligible_tokens */ $eligible_tokens = $this->data_store->get_tokens_for_roles( PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED ); /** * Filter out tokens whose owning user does not want this notification. * The decision is delegated to the notification itself via * {@see Notification::get_suppression_reason()} so per-type preference * shapes (simple bool today, parametrized arrays in the future) stay * encapsulated alongside the type's resource access. */ list( $tokens, $suppression_reasons ) = $this->filter_tokens_by_preferences( $eligible_tokens, $notification ); $this->log_recipients( $notification, $eligible_tokens, $tokens, $suppression_reasons, $step_context ); /** * There are no recipients to send to (either no tokens at all, or * every owning user opted out of this notification type). We don't * want to retry as this isn't a 'recoverable error', so mark as sent * and return. */ if ( empty( $tokens ) ) { $notification->write_meta( self::SENT_META_KEY ); $notification->reset_processing_meta(); $this->cancel_safety_net( $notification ); return true; } $result = $this->dispatcher->dispatch( $notification, $tokens ); $this->log_send_outcome( $notification, $tokens, $result, $step_context ); if ( ! empty( $result['success'] ) ) { // Success only, for the reason {@see PushToken::get_last_sent_at_gmt()} gives. $this->data_store->record_last_sent_at( $tokens ); $notification->write_meta( self::SENT_META_KEY ); $notification->reset_processing_meta(); $this->cancel_safety_net( $notification ); return true; } $this->retry_handler->schedule( $notification, $result['retry_after'] ?? null, $attempt ); $this->cancel_safety_net( $notification ); return false; } /** * Returns the subset of $tokens whose owning user wants $notification. * * The decision is delegated to {@see Notification::get_suppression_reason()} * so per-type preference shapes (simple bool today, parametrized arrays * in the future) stay encapsulated alongside the type's resource access. * Tokens with no owning user are dropped — there are no preferences to * consult. * * Decisions are memoized per user for the duration of one call, since * the same user can have several registered tokens (iOS, iPad, Android, * browser) and we don't want to re-read user meta or re-fetch the * resource for every token. * * Each dropped token is returned with the reason, so the step log can say * why a device did not receive the notification. * * @param PushToken[] $tokens The tokens to filter. * @param Notification $notification The notification being processed. * * @return array{0: PushToken[], 1: array} The tokens whose owner wants the notification, and the reason each dropped token was excluded, as token ID => reason. * * @since 10.9.0 */ private function filter_tokens_by_preferences( array $tokens, Notification $notification ): array { $type = $notification->get_type(); $decision_cache = array(); $kept = array(); $suppression_reasons = array(); foreach ( $tokens as $token ) { $user_id = $token->get_user_id(); if ( ! $user_id ) { $suppression_reasons[ (int) $token->get_id() ] = SuppressionReason::NO_ACCOUNT; continue; } if ( ! array_key_exists( $user_id, $decision_cache ) ) { $pref_value = $this->preferences_service->get_preferences( $user_id )[ $type ] ?? null; $decision_cache[ $user_id ] = $notification->get_suppression_reason( $pref_value ); } if ( null === $decision_cache[ $user_id ] ) { $kept[] = $token; } else { $suppression_reasons[ (int) $token->get_id() ] = $decision_cache[ $user_id ]; } } return array( $kept, $suppression_reasons ); } /** * Writes the recipient decision to the step log: one line naming the * recipients, and one line naming the tokens that were excluded. * * A store can hold thousands of tokens, and a log source has a daily size * ceiling past which its oldest lines are overwritten without a trace, so * both lines name their tokens in arrays. * * @param Notification $notification The notification being processed. * @param PushToken[] $eligible_tokens Tokens whose owner has a role that receives push notifications. * @param PushToken[] $recipients The tokens the notification will be sent to. * @param array $suppression_reasons The reason each dropped token was excluded, as token ID => reason. * @param array $step_context Fields shared by every line of this attempt. * @return void */ private function log_recipients( Notification $notification, array $eligible_tokens, array $recipients, array $suppression_reasons, array $step_context ): void { if ( ! $this->step_logger->is_active() ) { return; } $recipient_ids = array_map( fn( PushToken $token ) => (int) $token->get_id(), $recipients ); $context = array_merge( $step_context, array( 'tokens_total' => $this->data_store->count_tokens(), 'tokens_eligible' => count( $eligible_tokens ), 'recipients' => count( $recipients ), 'held_back' => count( $suppression_reasons ), 'held_back_reasons' => array_count_values( $suppression_reasons ), ) ); if ( empty( $eligible_tokens ) ) { $this->step_logger->log_notification_step( $notification, 'recipients', 'none', $context ); } elseif ( empty( $recipients ) ) { $this->step_logger->log_notification_step( $notification, 'recipients', 'all_excluded', $context ); } else { $context['token_ids'] = array_slice( $recipient_ids, 0, self::RECIPIENT_ID_CAP ); $this->step_logger->log_notification_step( $notification, 'recipients', 'resolved', $context ); } $this->log_excluded_tokens( $notification, $suppression_reasons, $step_context ); } /** * Writes one line naming every token the notification was not sent to, * grouped by the reason each was excluded. * * Grouping by reason rather than naming a reason per token keeps the line * small on a store where most tokens are excluded. * * @param Notification $notification The notification being processed. * @param array $suppression_reasons The reason each dropped token was excluded, as token ID => reason. * @param array $step_context Fields shared by every line of this attempt. * @return void */ private function log_excluded_tokens( Notification $notification, array $suppression_reasons, array $step_context ): void { if ( empty( $suppression_reasons ) ) { return; } $by_reason = array(); foreach ( $suppression_reasons as $token_id => $reason ) { $by_reason[ $reason ][] = (int) $token_id; } $outcome = 'various'; if ( 1 === count( $by_reason ) ) { $outcome = (string) array_key_first( $by_reason ); } $this->step_logger->log_suppressed_step( $notification, 'token_excluded', $outcome, array_merge( $step_context, array( 'excluded_tokens' => $by_reason ) ) ); } /** * Writes the send outcome WPCOM reported, naming the tokens it refused. * * Every recipient carries the notification's own outcome except the ones * WPCOM named as invalid, so the refused IDs are the whole difference. * * @param Notification $notification The notification being processed. * @param PushToken[] $recipients The tokens the notification was sent to. * @param array $result The dispatcher's return value. * @param array $step_context Fields shared by every line of this attempt. * @return void */ private function log_send_outcome( Notification $notification, array $recipients, array $result, array $step_context ): void { if ( ! $this->step_logger->is_active() ) { return; } $invalid_tokens = array_flip( $result['invalid_tokens'] ?? array() ); if ( empty( $invalid_tokens ) ) { return; } $invalid_ids = array(); foreach ( $recipients as $token ) { if ( isset( $invalid_tokens[ $token->get_token() ] ) ) { $invalid_ids[] = (int) $token->get_id(); } } $this->step_logger->log_notification_step( $notification, 'dispatched', 'invalid_token', array_merge( $step_context, array( 'invalid_token_ids' => $invalid_ids ) ) ); } /** * Cancels the pending safety net ActionScheduler job for a notification. * * Called after the processor handles the notification (whether success or * failure with retry scheduled) so the safety net doesn't fire redundantly. * * @param Notification $notification The notification whose safety net to cancel. * @return void * * @since 10.9.0 */ private function cancel_safety_net( Notification $notification ): void { // Must match the shape PendingNotificationStore::schedule_safety_net() used; // both derive the args from Notification::get_safety_net_args() so the // exact-equality match Action Scheduler performs succeeds. as_unschedule_all_actions( self::SAFETY_NET_HOOK, $notification->get_safety_net_args(), self::ACTION_SCHEDULER_GROUP ); } /** * ActionScheduler callback for the safety net job. This will be scheduled * for 60 seconds in the future when a notification is added to the * `PendingNotificationStore`. If the initial send succeeds, or fails and is * able to schedule a retry, this action will be unscheduled. If the initial * send does not occur, or fails and cannot schedule a retry (e.g. out of * memory, retry scheduling error) then this safety net will run. * * @param string $type The notification type. * @param int $resource_id The resource ID. * @param array $extra Identity fields from {@see Notification::get_identity_data()}. * Empty for notification types whose state is fully described by type + resource_id. * @return void * * @since 10.7.0 */ public function handle_safety_net( string $type, int $resource_id, array $extra = array() ): void { try { // Use the `+` array union operator (not array_merge) so the positional // $type and $resource_id always win over any colliding keys in $extra. // Defends against a malformed payload reconstructing the wrong target. $data = array( 'type' => $type, 'resource_id' => $resource_id, ) + $extra; $notification = Notification::from_array( $data ); } catch ( Exception $e ) { $this->step_logger->log_unattributed_failure( 'fallback', 'invalid_notification', 'error', sprintf( 'Fallback failed: %s', $e->getMessage() ), array( 'type' => $type, 'resource_id' => $resource_id, ) ); return; } $this->step_logger->log_notification_step( $notification, 'fallback', 'fired' ); try { $this->process( $notification, true ); } catch ( Exception $e ) { $this->step_logger->log_failure( $notification, 'fallback', 'exception', 'error', sprintf( 'Fallback failed: %s', $e->getMessage() ) ); $this->retry_handler->schedule( $notification, null, 0 ); } } }