The problem

Every booking saved invalidates the same row of core's {cachetags} table: yoyaku_booking_list. Core writes it after the transaction commits rather than inside it, so no hold keeps that row while it works. What forms instead is a convoy. Each invalidation is a short single row update, and at a hundred holds a second they queue for their turn, so a rush serializes on one row in the database whatever the house looks like and however many parts it has. Asked which transaction held the row, the database named one that was itself waiting, with its own update pending and nothing held longer than a moment.

Measured on a load harness against a house of eighty thousand seats in eighty parts, with every booker naming its own part, so the parts should have been independent: at a hundred and sixty bookers, performance_schema.data_locks reported 107 to 154 transactions waiting on cachetags PRIMARY at every sample, against six granted locks on the booking rows and the anchors and none waiting. Nothing was short of processor or memory: the web container ran at six to nine cores of fourteen, the database at about one, and the harness itself at twelve per cent of one.

What it costs is mostly the tail. Moving cache tag checksums off the database and onto memcache, which changes nothing else, was measured on the same house at the same rung with the arms alternated, and with the {cachetags} counter read around each run to prove which arm ran: it moved by sixteen and twenty three thousand on the database arm and by nothing at all on the memcache one. The median hold fell from 244 to 249ms down to 154 to 160ms, and the ninety fifth percentile from about 795ms to about 283ms. Throughput went from 184 and 256 holds a second to 282 and 295, which is somewhere between ten and sixty per cent depending which rounds are paired, because the database arm itself swings that much on this machine.

An earlier version of this issue said the swap took the house from 117 to 233 holds a second. That was wrong, and it is worth saying why: the two figures came from different load drivers, one walking a quantity stepper and one clicking seats, and the settings override I believed I had applied had gone into bootstrap_container_definition, which builds only the bootstrap container and never touches cache_tags.invalidator.checksum. Asking the container which class it had would have caught it in one line, and now does, in the harness.

Whose fault it is

The row is hot because yoyaku_booking_list is invalidated by every hold: 17508 invalidations of that one tag in a single ninety second run, one per request that books. That is core's doing, since an entity save invalidates its type's list tag.

An earlier version of this issue said availability figures depend on that tag, so a rush on one session discarded every session's availability. That was wrong, and worth correcting in full: AvailabilityCacheTags::CORE_TYPES lists slots, tariffs and allotments and deliberately no bookings, and says so in its own docblock, because bookings move with every hold and a surface absorbs them with a short lifetime instead. Availability was never invalidated by a booking.

What does depend on the tag is anything caching a list of bookings: the shipped views.view.yoyaku_bookings, AllotmentAccessControlHandler, and the bookings form in yoyaku_ui. So the cost of the tag is the queue, not a cache being thrown away.

Proposed

Three parts, each worth having on its own, in this order.

Narrow what a booking invalidates, which is what would split the queue. A hold would invalidate the session it is in rather than every booking on the site, through Booking::getListCacheTagsToInvalidate(), so two sessions on sale at once stop sharing a row. It does nothing for a rush on a single session, where every booker is on that session anyway, and a single session is the worst case and the one this project's sizing guide calls the most common mistake.

It is not being done, and that is the conclusion of this issue rather than a task left on it. A rush is a rush on one session, so narrowing by session splits nothing where it counts, and the price is real: the three consumers above lose a tag they rely on, one of them an access check on whether an allotment may be deleted, where a stale answer is dangerous rather than merely wrong. Paying that to relieve contention on sessions that are not the busy one is the wrong trade.

What is left for a single session's rush, then, is the deployment note: the row stops being contended by leaving the database. That is the change this issue ships.

Not on the list, so that nobody spends a day on it: moving the invalidation after the commit. Core already does that, in CacheTagsChecksumTrait::invalidateTags(), through the post transaction callback. The queue is not a lock held across a hold; it is many short writes to one row.

Also not on the list, and for the same reason: batching the invalidation per party. Core keeps an invalidatedTags map for the length of a request, so a party of eight lines saved in one request already invalidates the tag once, not eight times. The write is one per request that books, and that is already the floor.

Deployment, which is the part that needs saying today

Cache tag checksums on memcache remove the row from the database altogether, and docs/sizing-for-a-rush.md currently tells the reader the opposite: that it is unproven and the default should be kept. It is no longer unproven. The documentation is corrected in the merge request on this issue, so anybody sizing for a rush is told what the setting is worth and in which currency: the tail, mostly.

Remaining tasks

Done, in the merge request on this issue: the sizing documentation corrected with the measurement, and the recount that was making a claim count the hall again removed. Narrowing the tag was considered and dropped for the reason above.

AI-Generated: Yes (Claude Code was used to run the load measurements, to find this in the lock tables and to draft this issue summary. I reviewed it before posting; there is no code on this issue yet.)

Issue fork yoyaku-3618572

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mably created an issue. See original summary.

mably’s picture

Issue summary: View changes

mably’s picture

Status: Active » Needs review
mably’s picture

Issue summary: View changes
mably’s picture

Issue summary: View changes
mably’s picture

Issue summary: View changes
mably’s picture

Title: Every hold invalidates the same cache tag row, so a rush serializes in the database » A claim counts the whole hall again whenever it has held a seat

  • mably committed 4d5c52cf on 1.x
    fix: #3618572 A claim counts the whole hall again whenever it has held a...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.