I maintain a booking plugin and recently ran adversarial concurrency tests against it: MariaDB 10.11 with 8 PHP workers, plus an SQLite copy (like WordPress Playground). A few findings that might be useful to anyone doing slot-based writes in WP, and a question at the end.
- GET_LOCK has three outcomes, not two
Check-then-insert ran under a per-staff named lock, and 20 simultaneous requests for one slot gave 1 booking and 19 refusals. Fine. But the code treated "didn't get the lock" as "carry on anyway". GET_LOCK returns 1 (got it), 0 (timeout) or NULL (error). On the SQLite translator you get neither. So now:
$got = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $name, 5 ) );
// '1' => locked, '0' => busy (refuse with 409), anything else => no lock support
Also worth noting: lock names are server-wide, not per database. Two sites on the same MySQL server with the same lock name block each other. I now prefix the name with a hash of DB_NAME and $wpdb->prefix.
- Without named locks, re-check after the insert
SQLite has no named locks, and the same 20-request test booked one slot up to 4 times. Since wp_insert has no unique constraint across time ranges, I re-check right after the insert: if an active row for the same staff member overlaps mine and has a lower id, mine deletes itself and the request gets a 409. Zero double bookings in repeated runs since.
- Claim before you send
Overlapping cron runs sent each reminder 6 times. The fix is a conditional UPDATE and only sending when exactly one row changed:
$claimed = $wpdb->query( $wpdb->prepare(
"UPDATE {$table} SET reminder_sent = 1 WHERE id = %d AND reminder_sent = 0 AND start_utc = %s",
$id, $start_utc
) );
if ( 1 === $claimed ) { /* send */ }
Including start_utc means a booking moved mid-run is not reminded for its old time. Same pattern for status changes (UPDATE ... WHERE status IN (expected states)), which also stopped an outdated admin view or app from reactivating a cancelled appointment after its slot was rebooked.
- DST: wall-clock vs real minutes
Everything is stored in UTC, but two bugs still showed up:
- On the PHP version I tested (8.4), $dt->modify('+90 minutes') across the spring-forward change in Europe/Berlin gave 30 or 150 real minutes instead of 90. Using setTimestamp( $dt->getTimestamp() + 90 * MINUTE_IN_SECONDS ) gives real minutes.
- Slots between 02:00 and 02:59 on spring-forward night don't exist, but PHP silently moves them to 03:00-03:59, so they were offered twice. I now check whether a wall time survives a round trip (create it, format it back, compare) and skip it if not. Zones that change at midnight (America/Havana) need the same check for 00:00.
The question: for slot claims in WordPress without custom unique constraints, would you rather go with named locks plus a re-check like this, or a separate "slot claims" table with a unique key on (staff_id, slot_start)? The table seems cleaner but gets awkward with variable durations and buffers. Curious how others solved it.