*/ private $locks = array(); /** * Runs a callback while holding a site's lock. * * @param string $site_id Site ID. * @param callable $callback Callback. * @return mixed|null Callback result, or null when another process holds the lock. */ public function locked( $site_id, callable $callback ) { $key = get_current_blog_id() . '|' . $site_id; if ( ! $this->lock( $site_id ) ) { return null; } $owned = $this->locks[ $key ]; try { return $callback(); } finally { $this->unlock( $site_id, $key, $owned ); } } /** * Takes a site's send lock. Atomic: see OptionLock::acquire() for the INSERT * and the conditional takeover of an expired lock. * * @param string $site_id Site ID. * @return bool */ public function lock( $site_id ) { // Expiry is measured from real time: a long sweep's logical $now is already in the past. $lock = OptionLock::acquire( 'turtlereport_mail_lock_' . $site_id, self::TTL, time() ); if ( null === $lock ) { return false; } $this->locks[ get_current_blog_id() . '|' . $site_id ] = $lock; return true; } /** * Releases a site's send lock. * * @param string $site_id Site ID. * @param string|null $key Original blog/site key. * @param OptionLock|null $owned Original owner, even if a callback changed blogs. * @return void */ public function unlock( $site_id, $key = null, ?OptionLock $owned = null ) { $key = null === $key ? get_current_blog_id() . '|' . $site_id : $key; $owned = null === $owned && isset( $this->locks[ $key ] ) ? $this->locks[ $key ] : $owned; if ( null === $owned ) { return; } $owned->release(); if ( isset( $this->locks[ $key ] ) && $this->locks[ $key ] === $owned ) { unset( $this->locks[ $key ] ); } } /** * Stops a replaced worker before sending another recipient's message. * * @param string $site_id Site ID. * @return void * @throws \RuntimeException When this worker lost ownership. */ public function renew_lock( $site_id ) { $key = get_current_blog_id() . '|' . $site_id; if ( isset( $this->locks[ $key ] ) && ! $this->locks[ $key ]->renew( self::TTL ) ) { throw new \RuntimeException( __( 'Another background job took over sending this report. Sending continues from each recipient\'s recorded status.', 'turtlereport' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } } }