A destruct() Flush Turns Thousands of Redundant Saves Into Hundreds

Submitted by charles on

The problem

An event subscriber that does real work on every event it sees looks correct right up until the events arrive in a batch. This one saved a shared record once per event. One event at a time, that was fine. Then a sync fired a hundred at once and it became thousands of redundant saves. The fix was to accumulate the work as it comes in and do it once at shutdown. It costs the events nothing.

The fix

We made each save cheaper too, but that wasn't the fix. The fix was to stop saving on every event. The handler no longer touches storage. It records which ids changed and returns:

public function onRecordDeactivated(RecordChangedEvent $event): void {
  $id = $event->getRecord()->id();
  $this->pending[$id] = $id; // repeated ids in the same run dedupe for free
}

The real work is removing each pending id from the groups that contain it. That runs once, at the end of the request or command. Most runtimes give you a hook for it. In PHP I implemented the "needs destruction" interface and tagged the service, so the container calls the method on shutdown:

public function destruct(): void {
  if (!$this->pending) {
    return;
  }
  $ids = array_values($this->pending);
  $this->pending = [];
  // Re-check status now, not when the event fired. A record deactivated
  // and reactivated within the same run should be left alone.
  $still_inactive = $this->lookUpCurrentlyInactive($ids);
  foreach ($this->findGroupsContaining($still_inactive) as $group) {
    $group->remove(array_intersect($still_inactive, $group->memberIds()));
    $group->save(); // once per group, no matter how many ids it lost
  }
}

Now the cost tracks the number of groups that lost a member, not people times groups. In the batch that used to fire several thousand saves, only a couple of hundred groups had changed. Save calls dropped roughly 30x.

Two things kept this correct rather than just fast. First, re-check state at flush time, not when the event fired. What you accumulate is a list of candidates, not a list of facts. Someone deactivated early in the batch might be reactivated before the flush runs, so I look up current status inside destruct() and skip anyone who is no longer inactive.

The second is easier to miss. Iterating every affected group and calling remove() blindly re-serializes groups that never held the person, which drags the cost straight back up. Intersect the pending ids against each group's membership first, and you only save the groups that changed.

The takeaway

This is just accumulate-then-flush, and it turns up well beyond one event subscriber. Any time a handler's real job is "update a shared thing that a lot of events point at," doing that update inline becomes a save per event. Your most popular records take a save for every event that so much as touches them. Move the update to an end-of-life hook instead, whether that's a destructor, an atexit handler, or a finally wrapped around your dispatch loop. Save-per-event becomes save-per-container, and nothing about how or when the events fire has to change.

Two caveats before you reach for it.

The pending state lives in memory until the flush runs, so a hard kill mid-run loses whatever hasn't flushed. If the event that would re-trigger the work won't fire again for something you already swallowed, that's a real gap, not a delay. Decide up front whether you need a reconciliation job to catch what a lost flush drops, or whether the event is rare and cheap enough to live with the risk. I added a drush command and had a script run it hourly.

And collapsing N saves into one doesn't make that one save free. Suppose updating a single container is expensive on its own, because it has to decode and re-encode a large document to drop one entry. Funnelling hundreds of those into a single deferred pass can turn a clean win into a new bottleneck once the volume climbs. The redundant fan-out and the cost of a single update are two separate problems, and fixing the first doesn't touch the second.

So when a lot of small events all want to touch the same few records, collect the intent and flush it once. Re-check before you act, and don't assume the flush itself is cheap.