Capacity and holds¶
This is the heart of the module: how capacity is counted, how the booking lifecycle works, and how concurrency is kept safe.
Availability formula¶
Availability is computed per tariff:
available(tariff) = tariff.quota − Σ quantity of bookings in {held, confirmed}
Three different numbers govern here, and the module keeps them apart by name. A quota is an allocation an operator typed on a tariff, and may be any number at all, including one larger than the room. A capacity is how many a space holds: a slot's overall cap, a resource's, a pooled area's. Calling both "capacity" once cost a debugging session, because a page reading "825 left" for one tariff in a hall of 813 places of that grade was not broken: it printed the lower of a typed quota and a physical count.
The third is the limit per order, and it is neither of the other two: it bounds what a single booker may take of a tariff, not what is bookable. A tariff of 200 reduced seats limited to 4 per order still has 200 bookable; it just has none of them for a booker who already holds 4. See One booker's share.
- A
NULLtariff quota means the tariff sets no limit of its own (the core reportsNULL); a venue may still bound it, see below. - A slot may also carry an overall capacity; when set, a hold must fit both the tariff's quota and the slot-wide total.
- Only
heldandconfirmedconsume either.released,expiredandcanceledfree it.
Part of a slot's capacity can be set aside under a name, for the schools or for guests, so that only the tariffs drawing on that name may take it. Those places leave the general figure the moment they are declared, with no booking behind them:
available(slot) = capacity − everything taken − what the allotments still hold
A tariff drawing on an allotment is bounded by its own quota and by the room, like any other; what changes is that it may reach the places its own allotment holds, while everybody else may not. See Allotments.
A module can add a further bound the core cannot see, and the lowest bound wins:
available(tariff) = min(tariff quota left, slot cap left, every provider bound)
A provider implements AvailabilityBoundProviderInterface and is registered with
the yoyaku.availability_bound tag. It answers NULL when it has nothing to say,
which is not the same as 0: NULL leaves the other bounds alone.
The one shipped provider is in yoyaku_placement: on a resource that runs in a
venue, a tariff is capped by the available places of the grades it prices. That
matters because several tariffs usually price the same grade (a full price and
a youth price on the same seats), and their quotas cannot each be believed on
their own: with 825 places in Catégorie 1, two tariffs quoting 825 each would
promise 1650. The bound also means a tariff with no quota reads the real number
of places left instead of "unlimited".
An area offered as a pool (free seating, or standing) has no places to count, so it contributes its remaining capacity instead: the capacity its configuration declares, else the number of places it holds, minus everything already consuming it. A tariff is capped by the pools it may book, meaning the ones whose grade it prices, and each pool counts once however many of its grades the tariff prices. So a venue can cap a tariff while holding no place at all, and an empty place count no longer means "nothing to say".
An assigned area is counted as its places and never as a section beside them. Its bookings name places, so they are already in the place counts above, and adding its capacity on top would promise every one of its seats twice.
Both are upper bounds, not reservations: two tariffs pricing one grade will read the same available number at the same moment, because a read holds nothing. The authoritative allocation is the hold, where each place is claimed once and each pool counted under the slot lock.
Where the limit lives¶
Because the limit can be a quota per tariff, a capacity on the slot, or both, the same two tariffs (say Adult and Child) support two common models.
A quota per tariff. Give each tariff its own quota and leave the slot's overall capacity empty:
| Setting | Value |
|---|---|
Adult tariff quota |
10 |
Child tariff quota |
20 |
| Slot capacity | (empty) |
Ten adult units and twenty child units, counted independently - filling the child units never touches the adult pool.
Shared pool, price per tariff. Leave the tariff quotas empty and set the slot's overall capacity. Both tariffs draw from one pool; the price difference lives in the pricing layer (the engine is money-agnostic), not in the limit:
| Setting | Value |
|---|---|
Adult tariff quota |
(empty) |
Child tariff quota |
(empty) |
| Slot capacity | 30 |
Thirty units total, split however they go - 30 adults and 0 children, or
any mix. A hold on either tariff is bounded by the shared 30.
Note
With a shared pool and no quotas, one tariff can take the whole pool. To cap a shared pool and a single type ("30 total, but at most 25 adults") set the slot capacity and that tariff's quota; a hold must then fit both.
Both models are enforced atomically under the slot-row lock (see Concurrency), so neither can overbook under concurrent requests.
One booker's share¶
Availability alone lets one booker take everything: without a further rule, the only ceiling on a quantity is what is available, so a single order can clear a reduced tariff. That rule is a policy, attached where it should reach, not a field on the tariff: see Constraint policies for how policies attach.
Attach Per-order limit per tariff to a tariff and one transaction may hold at most that many units of it, in every slot offering that tariff. Attach it to a single tariff instead and only that evening is capped; attach it to both and the tighter number decides, because each policy is asked separately and any of them may refuse.
| Bounds | Answer when it refuses | |
|---|---|---|
quota |
what everyone together may book | there are none left |
| the policy | what one transaction may hold | there are plenty, but not for you |
Those are different answers and surfaces should not confuse them: telling a
visitor a tariff is full when it is merely limited sends them away from seats
they could still have at another tariff, or in a second order. The engine throws
PolicyRefusedException rather than CapacityExceededException, so a surface
can say "you may not take more" instead of greying out a seat that is in requirement
available. The seat map does exactly that, and leaves the seat as it was.
A surface that draws a quantity control asks
ConstraintPolicyManager::getSlotTariffCeiling() for the number, and sets its maximum
from it. That figure is advisory: the refusal on hold is what decides, and it
re-checks under the slot lock, so a basket changed in another tab is still
caught.
It is checked once per group, not per booking. On a placed venue the composer turns one quantity into one booking per seat, so ten single-unit bookings are one ask for ten; checking one at a time would wave every one of them past a limit of four. It counts what the order being appended to already holds, so four now and one later is five.
Unlike capacity it is not re-checked under the slot lock. The lock serializes bookers competing for the same room, which is not what this is about: the only way to race it is to send two of your own orders at the same moment, and the worst that wins is a few units over the limit in one order rather than an overbooked slot.
A released or expired booking stops counting, so a visitor who gives units back gets their allowance back with them.
Booking lifecycle¶
stateDiagram-v2
[*] --> held : hold()
held --> confirmed : confirm()
held --> released : release()
held --> expired : expireBooking(), the order's window spent
confirmed --> canceled : cancel()
confirmed --> [*]
released --> [*]
expired --> [*]
canceled --> [*]
note right of held
Consumes capacity.
Carries no deadline: its order does.
end note
note right of confirmed
Consumes capacity.
Permanent until canceled.
end note
| From | Method | To | Frees capacity? |
|---|---|---|---|
held |
confirm() |
confirmed |
no (still consumes) |
held |
release() |
released |
yes |
held |
expireBooking() |
expired |
yes |
confirmed |
cancel() |
canceled |
yes |
A booking carries no clock of its own, so there is nothing on it to extend, renew or suspend. The window belongs to the order, and the verbs that move it are the order's: see Hold expiry.
confirm() on a booking that has already lapsed throws
HoldExpiredException - the caller must hold again, which
re-checks capacity. Nothing silently re-acquires capacity it no longer owns.
Concurrency: no overbooking¶
Every hold() runs inside a database transaction and takes a
SELECT … FOR UPDATE row lock before reading consumption and writing the
booking. The lock serializes competing holds so two requests can never both
pass the capacity check for the last unit.
sequenceDiagram
participant A as Request A
participant B as Request B
participant DB as Database
Note over A,B: 1 unit left, both want it
A->>DB: BEGIN
A->>DB: SELECT anchor FOR UPDATE 🔒
B->>DB: BEGIN
B->>DB: SELECT anchor FOR UPDATE (sections ⏳)
A->>DB: taken = 0, capacity 1 ✓
A->>DB: INSERT booking (held)
A->>DB: UPDATE anchor taken = 1
A->>DB: COMMIT 🔓
DB-->>B: lock acquired
B->>DB: taken = 1, capacity 1 ✗
B->>DB: ROLLBACK
Note over B: CapacityExceededException
What gets locked¶
The rows the rules in play name. A hold waits on an anchor row per thing that actually bounds it, taken in a stable order so two group holds touching the same slots cannot deadlock.
Which rows those are follows from what the hold reaches. An ask naming a seat waits on that seat's part of the house, because the rule against leaving a place on its own judges a row and a row never crosses one. One naming an area waits on that area. One that names neither is a party asking to be seated: its tariff prices grades, those grades live in particular parts of the house, and one of those parts is chosen for the party, so it waits on that one. Which one is a preference: the parts with room for it come first, and the parts without follow rather than being dropped. Dropping them would answer the choice and lie about the wait, and where NO part had room it would answer nothing at all, so a party too big for any one of them would wait on the slot, which is a row nobody taking a seat holds. A tariff that prices no grade in particular prices the whole house, and is given a section the same way: the house is choosing the seats either way, so choosing the section first is that same choice a moment earlier. Only a hall with no sections at all is waited on whole. A slot with a capacity is one number for everybody, so that too is the slot; a tariff with a quota adds the tariff's own row, and an allotment its own.
The section is chosen inside the locks, and so are the seats. This is the
order that matters rather than a detail of it. Choosing reads what is free, and
a read taken before the locks is the same read for every booker in flight: all
of them are offered the same seats, the first is served, and the rest are
refused for seats that were free when they asked. Under the locks each party
reads after the one before it committed, so being offered a seat and taking it
are one moment. PartiesAtOnceTest fires several parties at a hall with room
for all of them and insists every one is seated on places of its own; with the
placement moved back above the locks it fails on the first party.
Every anchor exists before a booker can want it. A slot writes its own when it is created, one for each part of the house it is played in, and one for each tariff and allotment its resource already carries; a tariff or an allotment given to a slot afterwards writes its own as it arrives. That bookkeeping is the whole reason the hold path never writes one, and it is not a tidiness: an anchor that is not there yet is locked with a gap lock over the range where it would be rather than a row lock, and two bookers meeting in that gap deadlock and lose their clicks.
The tariff anchors were the hole. Nothing created them, so every slot reached
its first booker without one and the first booker wrote it, which sounds like
once in a slot's life until you notice when that once is: the moment the
slot opens, with a rush arriving. Measured on the load harness, forty
bookers arriving together at a slot with no tariff anchor lost thirteen
claims of four hundred to SQLSTATE[40001] … 1213 Deadlock found on the
anchor read; the same volley against a slot born with its anchors lost
none of eight hundred
(#3618198).
The repair that writes a missing anchor is still there, because an anchor can still arrive late: a part of the house added to a venue after its slots exist is repaired on demand, deliberately. It writes in a fixed order for the same reason the read is ordered, so that two repairs cannot take the same rows the opposite way round. What ordering cannot rule out is two of them meeting over the gap itself, and the answer to that is to write the anchors before the hold opens its transaction rather than inside it, which is a change to where the repair is called from and has not been made.
That stable order is load bearing, and it is the one thing here proven by
switching it off. Two group holds naming the same two slots in opposite orders,
fired at the same moment, deadlock within a dozen rounds without it: MySQL kills
one with SQLSTATE[40001] … 1213 Deadlock found on the FOR UPDATE of the
second slot, and the booker loses the click. With the sort in place the same
pairs pass. TwoBookersAtOnceTest holds that comparison, so removing the sort
turns it red rather than turning it into an intermittent failure nobody can
place.
It is worth saying why the tariff is not the narrow anchor for seats, because it looks tempting and is wrong. A tariff is not a partition of anything: it prices one or more place grades, and two tariffs may price the same grade, which is how a full price and a reduced price for the same seats are offered. Two holds on different tariffs can therefore want the same seat, so a tariff row guards a number and never a seat. The overall slot cap spans every tariff for the same reason. What partitions the seats is the part of the house they sit in, which is why that is what a hold naming a seat waits on.
Grades do partition the seats, one grade to a place, but a grade belongs to the venue rather than to the slot, so locking one would serialize every slot in the hall against every other.
What that gains is measured rather than guessed: bookers in different parts of a house stop waiting for one another, and doubling the bookers on one slot doubles the seats it takes. See Performance results.
Locking reads
The anchor rows are taken with SELECT … FOR UPDATE inside the hold
transaction, so the figures they carry are the latest committed ones rather
than a stale snapshot under REPEATABLE READ. The counters are read in a
second statement rather than the locking one, which is safe for exactly the
scopes the hold locked and for no others: a hold may decide something from a
number only where it holds the row that number lives on.
This needs a database with row-level locking
The guarantee is the row lock, so it holds where SELECT … FOR UPDATE
does: MySQL and MariaDB, and PostgreSQL, where the test suite passes
though continuous integration runs on MySQL. SQLite has no row locks and
Drupal's SQLite driver drops the clause silently, which leaves the
locking read an ordinary read and nothing to stop two holds passing the
same check. Kernel tests run on SQLite, so they exercise the logic of this
design and not its locking; the evidence that the locking works is
measured against MySQL, under
Performance results.
What each scope has taken¶
Each anchor row carries how much of its scope is consumed, so deciding whether a ask fits reads a row instead of adding up the bookings. That matters because the addition happens inside the lock: a house part way through a rush has thousands of bookings, and every booker in the queue waits for however long it takes to read them.
The counter is kept by the booking entity itself, on presave and on delete, and not by the engine. The engine is the way capacity is meant to be taken, but it is not the only way a booking row changes: an operator edits one, a migration runs, a module nobody has written yet saves one. A counter only some writers maintain is worse than no counter, because it gets believed. Keying the maintenance on what the booking takes rather than on the operation means a state added later gives its quantity back without any code of its own.
A party moves each counter once rather than once per booking. A booker taking six seats at one price moves two numbers, the slot's and that tariff's, once each, inside the same transaction that writes the bookings.
A number is kept only where a rule reads it, and that is not an economy. A counter is a write, and a write holds its row until the transaction ends, so a counter kept on the slot's own row would be written by every hold in every part of the house at once. On a house whose holds wait per part that is the queue they were freed from: measured on the load harness, keeping it where nothing read it took a house from 62.7 holds a second to 22.0 and refused a third of the bookers. So the slot's number is kept where the slot declares a capacity, a tariff's where a quota governs it, an allotment's where the slot holds places under one, and nowhere else. A seated house bounded by its own seats keeps none of them.
The rows a hold locks are the rows whose numbers it reads, and also the rows it writes.
That is why a slot holding places under an allotment makes every hold there wait on every one of those allotments. What is left for a booking drawing on none is the room less all of their shares, so all of their numbers are read.
It is also why a hold that will move a tariff's counter waits on that tariff's row even where it already locks the whole slot. A row written without being locked is reached by one path while every locker reaches it by another, and two transactions then hold one half of it each.
An unkept number reads as unknown, never as nought. That is what makes the rule above safe: the day a capacity is put on a slot that has been taking bookings without one, the figure it needs was never kept, and nought would offer a house that is already half full. So the first rule that asks for an unknown number counts the bookings once, inside the locks, writes it down, and it is a kept number from then on. A booking saved while a number is unknown leaves it unknown rather than moving it, since a count that started in the middle looks exactly like a true one and is not.
One number is kept at a finer grain than the row it sits under. An area offered by the number is a part of the house or one slice of one, and a hall may pool a slice while offering the rest of that part its own way, so each area has a counter of its own. What serializes the bookers who could reach either of them is still the part they are both in: locking a part takes every slice row under it, in the same statement, and the slices are grains of a number rather than locks of their own. See Placement for what counts against an area.
Checking and repairing the counters
A counter can only ever be wrong by drifting from the bookings, and nothing
about a wrong one looks wrong. drush yoyaku:anchor-counters counts the
bookings the slow way and reports every scope whose kept number disagrees;
--fix writes the counted figures over them. A scope keeping no number is
not a finding and is not repaired: nothing reads it, which is why nobody
keeps it, and the day something does read it the first reader counts it
itself. A site arriving with bookings and no counters therefore needs
nothing run against it.
What does need saying is the other direction: a purge that empties the
bookings without going through them, a restore, a rig truncating its tables,
all leave numbers behind that no longer count anything. Such a caller has to
forget the counters as well, which is what LockAnchors::resetCounters() is
for.
A site that already has anchor rows
An anchor row is found by its scope, and the scope a partition is keyed
under changed from section to partition: it is the engine's own word for
the thing, and section was the word of the one module that had been
written when the row was first designed. Rows written under the old key are
not found by the new one.
Nothing breaks loudly. A hold that cannot find its anchor logs it, writes it, and carries on; the counter starts empty, so the first reader after that counts the bookings itself and is right from then on. But that repair is taken under load, once per partition per slot, which is the one situation this table exists to avoid. So a site carrying anchors from before this change reinstalls, which is the declared upgrade path before 1.0 and the reason there is no update hook.
When a hold is abandoned¶
A hold that cannot go through rolls its transaction back and raises what stopped
it, and the rollback goes through Rollbacks::discard() rather than being asked
of the transaction directly. The reason is that a rollback can itself fail: core
allows one only on the innermost item of the transaction stack, and a
transaction the server has already ended, which is what it does to a deadlock
victim, leaves core releasing savepoints that are gone. Either way the failure
comes from inside the catch block and would replace the exception being handled,
so the booker would see a failure of the site and the log would hold a line
about transaction plumbing with nothing about the refusal or the deadlock that
started it.
So a failed rollback is recorded, naming both what the work was abandoned for and what the rollback said, and the caller raises what it caught. Skipping the rollback is not an alternative: a transaction destroyed without an explicit commit is committed, not abandoned, so a path that merely raises keeps every write it had made.
What the booker is then told depends on whose failure it was. The database
ending a transaction to settle a conflict between two requests is not a fault:
nothing was written, they lost nothing, and asking again ordinarily works, so
they are told exactly that. Contention::hasEndedTheTransaction() recognizes it,
off the driver's codes rather than its sentences, and a hold turns it into a
refusal rather than an error. Everything else keeps the answer it had, which for
a failure nobody wrote a sentence for is the fallback in BookerRefusal.
What the lock costs, measured¶
Serializing is the point, and it has a price that has been measured rather than guessed. Under eighty simultaneous bookers on one slot, the sampler caught 48 transactions waiting at once, almost all of them on that slot's row, and spreading the same load over four slots was worth 65 percent more holds a second. In the same rig, sixty bookers on a hall of two hundred places ended with exactly two hundred holds, no place held twice and no failure: the guarantee, demonstrated under concurrency instead of argued.
The figures and their caveats are in Performance results, and the harness that produced them, which anybody can re-run per release, is in Load testing the booking path.
Slot identity¶
The lock protects a slot; what protects the identity of a slot is a unique
key on (resource, start). A resource has at most one slot per start.
Two rows for one time would not be cosmetic. Capacity lives on the slot and its tariffs, so availability for that time would read as the sum of the duplicates, and a hold would lock one of the rows rather than the time: two first-bookers arriving together would each take a different row's lock, each pass their own capacity check, and both take the last unit.
Two consequences for the surfaces that create slots:
- Generating slots is additive. The generator skips a time that already has a slot for that resource instead of creating a second one, and reports how many it skipped beside how many it created. Since one run spans at most a year, running it over adjoining ranges is the expected workflow and overlapping by a day is an easy mistake.
- Creating a slot by hand on a taken time is a validation error on the
Start field (the
SingleSlotPerStartconstraint), not a storage exception.
The pair is the right key for every shipped shape: tariffs hang off the slot as separate slot tariff rows rather than beside it, and a multi-day booking is a single slot with a single start.
Sites created before the key
The project is pre 1.0 and ships no update hooks, so the key has to be applied by hand on an existing site. Reconcile any duplicates first: the unique key cannot be created while two rows share a time.
SELECT resource, start, COUNT(*) AS rows_here, GROUP_CONCAT(id) AS slots
FROM yoyaku_slot
GROUP BY resource, start
HAVING rows_here > 1;
A duplicate holding bookings cannot simply be deleted. Per group, keep one
row (the one the bookings are on, else the lowest id), repoint what
references the others - yoyaku_booking.slot and yoyaku_slot_tariff.slot
are the only two - onto the survivor, reconcile the capacities so the
survivor carries what the time is really meant to offer, and delete the rest.
Then apply the key. Core does not diff index changes on an entity type update, so both the table and Drupal's record of it are updated explicitly:
$schema = \Drupal::database()->schema();
// Only present on a site whose slot table was installed with the plain
// composite index the unique key replaces.
if ($schema->indexExists('yoyaku_slot', 'yoyaku_slot__resource_start')) {
$schema->dropIndex('yoyaku_slot', 'yoyaku_slot__resource_start');
}
$schema->addUniqueKey('yoyaku_slot', 'yoyaku_slot__resource_start', ['resource', 'start']);
$store = \Drupal::keyValue('entity.storage_schema.sql');
$data = $store->get('yoyaku_slot.entity_schema_data', []);
unset($data['yoyaku_slot']['indexes']['yoyaku_slot__resource_start']);
$data['yoyaku_slot']['unique keys']['yoyaku_slot__resource_start'] = ['resource', 'start'];
$store->set('yoyaku_slot.entity_schema_data', $data);
A site installed after the change gets the key from the entity schema and needs none of this.
Hold expiry¶
A basket is held for a window, and the window belongs to the order. Every
booking in it lapses together, because there is one deadline and it is
yoyaku_transaction.hold_expires. A booking carries none.
How long that window is resolves channel → tenant → yoyaku.settings, each
key on its own, so a front that only wants a shorter window says just that and
inherits the rest from the realm it serves:
| Key | Meaning |
|---|---|
hold_ttl |
Seconds the basket is held for. Empty means it never lapses - see Manual validation. |
renew_hold_on_change |
Start the window again whenever what the basket holds changes. |
hold_max_lifetime |
Seconds renewal may not reach past, measured from when this run of holding began. Empty means uncapped. |
Renewal is min(now + hold_ttl, hold_started + hold_max_lifetime). Active
shopping keeps a basket alive; adding a cheap booking every fourteen minutes
does not hold capacity for ever.
hold_started is stamped when the order first takes a booking and cleared when it
holds none, so the ceiling is measured from this run of holding. Measuring
it from the order's creation would be wrong precisely because a lapsed order is
reused: a basket reopened three days later would be capped against a moment
already past and swept the instant it was filled.
Renewal writes are throttled¶
Renewing the window means writing the order, and on a seating map that is the hottest write in the module: a booker taking places one at a time would write their order on every click to move a deadline by a second or two.
So the write is throttled. A renewal is persisted once the window has moved by a twentieth of itself, and skipped below that. The guarantee that gains is stateable and holds at any setting: a basket never lapses more than 5% of its own window early. The tolerance is a proportion rather than a number of seconds on purpose, because the window is configurable, and a tolerance that is negligible against fifteen minutes is most of a ninety-second one.
The booker never sees the lag. The renewed deadline is put on the order in memory whatever happens, so the countdown their own screen draws is always the window they just earned; it is only the stored value that trails, and nothing reads that but the sweep.
A mass expiry is bounded by design, which matters because the case that
produces one is a booking opening: every basket is filled within minutes of
the others, so they all run out within minutes of each other. The sweep
matches one indexed row and enqueues one item per order rather than per
booking, each order is expired under its own row lock so they do not
contend, and the batch is capped per cron run. SweepCostTest pins what one
costs and, more importantly, that it stays linear in them: the order marks the
transition, so a basket of N bookings reconciles once rather than N times.
throttle_hold_renewal on yoyaku.settings turns it off, and every renewal is
then written wherever it moved. It is site-wide with no tenant or channel
override, because it is a decision about writes and not about how long a booker
may hold places. It is marked experimental: the saving is measured, but it is
the newest rule here and the one most likely to want revisiting. Removing the
write altogether, rather than rationing it, is
#3615509.
sequenceDiagram
participant Cron
participant Hook as #[Hook(cron)]
participant Q as yoyaku_expire_transaction_holds queue
participant W as ExpireTransactionHolds worker
participant TM as TransactionManager
Cron->>Hook: run
Hook->>Hook: query editable orders where hold_expires ≤ now
Hook->>Q: enqueue {yoyaku_transaction: id}
Cron->>W: processItem
W->>TM: expireTransaction(order)
TM->>TM: re-read under the row lock, expire every held booking
Note over TM: no-op if confirmed, emptied or renewed meanwhile
One row matched and one queue item per basket rather than one of each per booking, and the whole basket ends in one locked write.
expireTransaction() re-reads the state and the deadline from a fresh load
under the order's row lock, so a basket confirmed or renewed between enqueue and
processing is left alone, and two sweepers noticing the same lapse in one pass
cannot both do the work. It leaves the order empty, not canceled: it
lapsed, and nobody decided anything. The booker returns to a reusable basket.
A held place on a slot that has closed¶
A hold on a slot past its booking cutoff cannot be confirmed: the engine refuses the checkout, so nothing the booker does resolves it and nothing the operator does should have to. Without help it stays held for ever, and its order stays pending with it, out of reach of the abandonment sweep, which only sees baskets with no window, and of retention, which only sees orders that are over.
The reason to clear it is not the capacity. That is already lost: the seat went never taken and the slot has been and gone. It is that the basket is otherwise a lie. Open your cart next week and you are shown seats for last Tuesday's concert, above a checkout button that can only refuse them, when the whole point of keeping the basket is that it is one you can come back to.
Cleared per booking, not per order. A basket holding one slot that has
passed and two still to come keeps the two, since taking all three would mislead
the same booker this exists to stop misleading. The order then recomputes to
whatever it now is: empty when that was its only booking, still pending when
others stand.
The boundary is each resource's own booking cutoff rather than the slot start, because a resource set to close at the end is legitimately bookable while its slot runs. Slots are grouped by cutoff and asked about in one query per group, the same shape the settlement sweep uses.
Keeping orders, and deleting them¶
Nothing deleted an order before this, in any state, so a site accumulated every
basket anybody ever abandoned. order_retention says how long an order that is
no longer live is kept, one period per state, resolved tenant → site.
Deliberately not per channel, unlike the hold window. How long a booker's session lasts is a front's business; how long data is kept is the realm's, and an order must not be kept for longer or shorter because of the door the booker came through.
Two states ship with a period, and they are the two where nothing is at stake:
| State | Ships as | Why it is safe to delete |
|---|---|---|
empty |
P7D |
It holds nothing. This is the state a lapsed or emptied basket lands in, and the one this makes routine. |
canceled |
P30D |
The record of a booking that did not happen. |
A period is a number of seconds or an ISO 8601 duration, the same shape a resource type states its settlement grace in. Empty means never, and that is the reading everywhere: a state naming no period, a tenant overriding nothing, a duration nobody can parse. Unconfigured data is kept, because the failure this must never have is deleting an order an operator did not mean to lose.
Settled states are absent on purpose. Deleting a completed order destroys
the record that a booking happened and cascades into its bookings, its
payments and its tickets, and what usually wants removing is the booker's
personal data rather than the booking. That argues for anonymizing instead,
which is a different operation and is not this.
Nothing deletes an order something still needs¶
Retention says how long. It does not say whether, because what still references
an order lives in modules the engine knows nothing about. So the sweep asks,
through OrderDeletionGuardInterface: every guard is consulted, the first
refusal ends the question, and the refusal is logged with its reason so an
operator can see why an old order is still there.
Two guards ship:
yoyaku_orchestrarefuses while a workflow instance is still running against the order. An ended run does not refuse: it is a record of what happened rather than something waiting.yoyaku_paymentrefuses on any payment record at all, captured or not. A refused attempt is still the account of what happened, and it is exactly what gets asked about when a booker says they were charged.
A guard that throws is treated as a refusal. A module that cannot answer has not said the order is free.
The guards are asked in the queue worker, at the moment of deletion, never at the moment of queueing: time passes between the two, and it is exactly the time in which a payment lands or a run starts. The worker re-reads the state, the period and the age from a fresh load for the same reason.
The same guards decide whether an operator may delete an order: the Delete link of the orders list asks them. A page of fifty orders must not ask fifty times, so a guard answers for several orders at once, and the list asks about its whole page before it builds the rows. An order that is neither archived nor canceled cannot be deleted anyway, so it is refused before any guard is asked. The sweep never uses the answers of a page: it still asks at the moment of deletion.
Orders arrive in these states on their own: a lapsed basket recomputes to
empty, a released one likewise, an abandoned one is expired by
abandon_after and lands there too.
Retention never has to reach into a live state, which is why none of them are
deletable.
The sweep runs one query per configured state, oldest first, capped per pass, so a site turning retention on for the first time drains its backlog over several runs rather than in one. One caveat worth knowing: the search is bounded by the site period, so a tenant keeping orders for less than the site is not visited early. A tenant keeping them longer is handled, the worker resolving per order. Set the site value to the shortest any realm wants.
Manual validation (non-expiring holds)¶
Not every booking should confirm automatically. To model manual approval,
leave hold_ttl empty at the level that governs - the realm, usually - so
its baskets are held with no deadline at all:
- the order gets no deadline (
hold_expiresisNULL), andNULLnever satisfies<= now, so the sweep is structurally blind to it; - it keeps consuming capacity while pending, so the slot cannot be overbooked during review;
- an admin or workflow then approves it with
confirm()or rejects it withrelease().
Whether a realm auto-confirms (payment-driven, a finite window) or waits for
manual validation (no window) is purely a matter of how it is configured and who
calls confirm() - the engine never confirms on its own.
The cost of a hold that never lapses¶
A basket with no deadline consumes capacity until somebody acts on it, and
nobody always does. A visitor who opens one and wanders off holds those places
for ever, because the hold sweep matches on hold_expires <= now and NULL
never satisfies it. On a realm that leaves the window empty that is not an edge
case, it is every abandoned browse.
abandon_after ends them: tenant then site, reaching only orders that carry no
deadline of their own. It does not delete anything. It expires the basket
through the ordinary path, so the places go back, the events fire, a run
correlated to the order hears and ends, and the order lands on empty where
retention collects it on its own terms.
It ships as four hours, and the reasoning is that an abandoned basket is worth nothing. Nobody is coming back to it, and a booker who does can fill another in a moment; the places are the scarce thing, not the basket.
This is where booking parts company with the shop it superficially resembles. A shop can afford a generous basket because its stock is replenished: the item held for a fortnight will be back in the warehouse next week, so the cost of holding it is a little working capital. A seat at Thursday's concert is not replenished and does not come back. Every hour it sits in a basket nobody returns to is an hour it cannot be booked, and once Thursday passes it is not unsold stock, it is an empty seat. Yoyaku's inventory is perishable and fixed, which is why the number here is hours where a shop would reasonably say weeks.
It is worth stating the general form, because it governs more than this setting: everything yoyaku books is dated. A resource runs slots, a slot has a start, and every place in it stops existing when that start passes. Nothing here can be booked later, restocked, back-ordered or discounted at the end of the season. That is why the whole engine is a set of clocks rather than a set of counters: the hold window, the renewal ceiling, the checkout deadline, the settlement grace, this, and retention are all answers to the same question of how long something may be held before somebody else should be allowed to have it.
What the period does have to allow for is somebody still filling a basket, which is why it is hours rather than minutes: a large group booked seat by seat takes a while, and being cut off part way through is a real failure where losing an abandoned basket is not.
Nothing is waiting on a pending order. A booking submitted for approval is
placed, not pending: the booker's part is done, it is frozen, it waits for
the confirm step, and it is never auto-released. This sweep only ever sees
pending, so an operator's queue is untouched by it however short the period
is set.
A realm whose baskets do have a window is untouched by any of this. Its baskets already lapse, and the sweep excludes them by requiring the deadline to be absent, so no basket is ever governed by two clocks at once.
stateDiagram-v2
[*] --> held : held into an order whose realm states no window
held --> confirmed : confirm() · admin approves
held --> released : release() · admin rejects
note right of held
Its order has no deadline.
Consumes capacity
while pending review.
end note
Booking mode¶
A resource books a requested quantity in one of two modes, resolved from the resource (its own override) then its type, defaulting to collective:
- Collective: a quantity of N is one booking of N. The group is a single capacity consumer; one ticket admits all N. Suits a group booked as a unit.
- Individual: a quantity of N is N bookings of 1. Each attendee is their own booking, with its own ticket and its own check-in state. This is what a per-attendee event (a conference pass, a concert admission) needs.
The mode is a booking_mode property on the resource type (editable on the
resource type form) that a resource may override. The BookingComposer reads
it to turn a quantity into bookings: one for collective, one per
unit for individual. Capacity is unchanged either way: N individual bookings
consume the same N units as one collective booking of N.
Individual bookings are the basis for named places and standing zones (the placement module), where each individual booking is additionally given a place or a zone; but individual mode needs no venue on its own (a plain conference pass is simply individual plus a capacity).
Placement outranks the mode. The mode is the resource's to choose only while
nothing contradicts it, and a provider that puts each unit somewhere of its own
does: three units cannot share one seat, nor one unit of a pool's capacity. Such
a provider says so through UnitAssignmentProviderInterface::isIndividualBookingRequired(),
and the composer then places the units whatever the resolved mode says. Without
that, a venue-placed resource left on the collective default would produce a
single place-less booking, which no place bound protects. The core anonymous
provider claims nothing here, so a resource with no venue keeps deciding for
itself.
One slot tariff for each slot and tariff¶
A slot tariff says how one slot departs from one tariff, and a booking names
the slot and the tariff rather than the override, so (slot, tariff) is the
only way back to it. A second row for the same pair would leave which quota and
price apply down to whichever row a query returned first, and a quota typed on
one of them would silently not be the one enforced. So the slot tariff table
carries a unique key on the pair, and the UniqueSlotTariff constraint reports
a collision on the Tariff field so a form can name the value to change.
Sites created before that key
Pre 1.0 ships no update hooks here either. Reconcile any duplicates first, then add the key and let Drupal record it:
SELECT slot, tariff, COUNT(*) AS rows_here, GROUP_CONCAT(id) AS ids
FROM yoyaku_slot_tariff
GROUP BY slot, tariff
HAVING rows_here > 1;
\Drupal::database()->query(
'ALTER TABLE {yoyaku_slot_tariff}
ADD UNIQUE KEY yoyaku_slot_tariff__slot_tariff (slot, tariff)'
);
$manager = \Drupal::entityDefinitionUpdateManager();
$manager->updateEntityType($manager->getEntityType('yoyaku_slot_tariff'));