Flush and run, using Kernel::TERMINATE to improve page speed performance

At DrupalCon Dublin I caught Fabianx’s presentation on streaming and other awesome performance techniques. His presentation explained how BigPipe worked to me, finally. It also made me aware of the fact that, in Drupal, we have mechanisms to do expensive procedures after output has been flushed to the browser. That means the end user sees all their markup but PHP can chug along doing some work without the page slowing down.

What is the Kernel::TERMINATE event

The Kernel::TERMINATE event is provided by the Symfony HttpKernel. As documented in Symfony\Component\HttpKernel\KernelEvents:

The TERMINATE event occurs once a response was sent. This event allows you to run expensive post-response jobs.

Thumbnail

Drupal core uses this event, usually to write caches.

  • path_subscriber writes the alias cache.
  • request_close_subscriber write the module handler’s cache.
  • automated_cron.subscriber runs cron
  • user_last_access_subscriber updates the user’s last access timestamp

If you worked with Drupal 7, consider this to be like hook_exit()

You can read more about the event on the Symfony HttpKernel documentation https://symfony.com/doc/current/components/http_kernel.html#the-kernel-…. As noted on Reddit, this event will not work as described in this blog unless you are using PHP FPM. For most hosting providers this is the default setup. So, if you are using Apache with mod_php you are out of luck, it seems. 

A practical real-life (client) use case

I use this in a client project to work with Salesforce. The Salesforce API is pretty slow (no, it’s extremely slow.) Especially when you need to do a lot of operations:

  • create an account
  • create contact references
  • generate an opportunity
  • provide the line items for that opportunity

You don’t want the customer to sit on a forever loading page while they wait for the checkout complete page to load. People expect their eCommerce experience to be fast. The Kernel::TERMINATE event is a very special trick. It can allow you to do remote API calls or other length procedures without slowing down the user.

The checkout process we have ends up working something like this, thanks to the Kernel::TERMINATE event.

  1. The customer enters payment information.
  2. Upon validation, the user is brought to a provisioning step
  3. API requests are made to provision their product account and subscription. The order is queued for Salesforce
  4. Upon success, the user is brought to the checkout complete page
  5. The Customer sees their confirmation page, account information, and a link to log into the product.
  6. The server begins running Salesforce API integration calls on the same PHP FPM worker that flushed the content for the checkout complete page.

Watching the logs stream the entire event is pretty amazing. The end user experience is only blocked on business critical validations and not internal business operations (CRM and reporting.)

Here’s how we make the magic happen

We have a service called provision_handler. This is a class which helps us run various provisioning calls for each scenario. The service allows us to set the order which should be queued and provisioned after the checkout complete page has been flushed to the end user.

  /**
   * Sets the queued order to run on Kernel::TERMINATE.
   */
  public function setQueuedOrder(OrderInterface $order) {
    $this->queuedOrder = $order;
  }

  /**
   * Get the queued order to finish provisioning.
   */
  public function getQueuedOrder() {
    return $this->queuedOrder;
  }

The queued order is just a property on our provision handler. At no time during any single request will we be handling multiple orders, a known operation constraint. And thanks to the way that PHP works, we know that this data remains stable during our request. After we have executed our business critical validations and provisioning we set the queued order from our checkout flow code.

$provision_handler->setQueuedOrder($this->order);

Then our event subscriber picks up the order and ensures all that order information is synchronized into Salesforce.

  /**
   * Ensures system paths for the request get cached.
   */
  public function onKernelTerminate(PostResponseEvent $event) {
    $order = $this->handler->getQueuedOrder();
    if ($order) {
      $this->handler->finalizeProvisioning($order);
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    return [
      KernelEvents::TERMINATE => ['onKernelTerminate'],
    ];
  }

Other use cases

Drupal Commerce has many uses for this event. Many times when an order has completed checkout many different things need to happen. Send emails, send the order to an ERP, update stock, change promotion usage stats, etc. Or, what if you are generating order reports?

In Commerce Reports 8.x–1.x I decided to use the Kernel::TERMINATE event to generate order reports. This offloads an expensive procedure to, essentially, process itself in the background after the user has seen that their order was paid and completed.

The module listens on two events: the order to go through the placed transition and then for Kernel::TERIMATE

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events = [
      'commerce_order.place.pre_transition' => 'flagOrder',
      KernelEvents::TERMINATE => 'generateReports',
    ];
    return $events;
  }

When the place transition runs, the flagOrder method is called to identify the order for later processing. Note: the current method isn’t great, hence the @todo.

  /**
   * Flags the order to have a report generated.
   *
   * @todo come up with better flagging.
   *
   * @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event
   *   The workflow transition event.
   */
  public function flagOrder(WorkflowTransitionEvent $event) {
    $order = $event->getEntity();
    $existing = $this->state->get('commerce_order_reports', []);
    $existing[] = $order->id();
    $this->state->set('commerce_order_reports', $existing);
  }

This adds the order to be processed in Drupal’s state along with any other queued orders. When the time comes, we load all the queued orders and run the reporting plugins against them.

  /**
   * Generates order reports once output flushed.
   *
   * This creates the base order report populated with the bundle plugin ID,
   * order ID, and created timestamp from when the order was placed. Each
   * plugin then sets its values.
   *
   * @param \Symfony\Component\HttpKernel\Event\PostResponseEvent $event
   *   The post response event.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function generateReports(PostResponseEvent $event) {
    $order_ids = $this->state->get('commerce_order_reports', []);
    $orders = $this->orderStorage->loadMultiple($order_ids);
    $plugin_types = $this->reportTypeManager->getDefinitions();
    /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
    foreach ($orders as $order) {
      foreach ($plugin_types as $plugin_type) {
        /** @var \Drupal\commerce_reports\Plugin\Commerce\ReportType\ReportTypeInterface $instance */
        $instance = $this->reportTypeManager->createInstance($plugin_type['id'], []);
        $order_report = $this->orderReportStorage->create([
          'type' => $plugin_type['id'],
          'order_id' => $order->id(),
          'created' => $order->getPlacedTime(),
        ]);
        $instance->generateReport($order_report, $order);
        // @todo Fire an event allowing modification of report entity.
        // @todo Above may not be needed with storage events.
        $order_report->save();
      }
    }

    // @todo this could lose data, possibly as its global state.
    $this->state->set('commerce_order_reports', []);
  }

Making this easier

I have been wanting to expose this pattern within Drupal Commerce. My vision is that Drupal Commerce will flag orders on the place transition and provide a subscriber on Kernel::TERMINATE. Then all custom and contributed code can subscribe to our own even which files in Kernel::TERMINATE that provides the order to be processed.

This would make Drupal Commerce more performant out of the box and remove operations performed before the output is flushed to the browser. This would mitigate performance bottlenecks for operations non-essential to the customer’s client-side experience. For instance:

  • Generating log entry for order completion
  • The order receipt email
  • Registering promotion and coupon usage

When it comes down to the code execution, the process is merely shifted to runtime milliseconds later. But the end user is guaranteed to see their page’s content and not be blocked by these operations.

It is an easy win to boost perceived performance within Drupal.