ReservationService
in package
uses
SourceLockTransactionTrait
Synchronous, atomic stock reservations, idempotent per `SourceRef`.
No cron writes here (a reservation cron oversells). Everything is raw DBAL with bcmath scale 3 and Tracy channel 'store'.
Every source-scoped operation (reserve / release / confirm) runs the same frame:
SELECT GET_LOCK(source.lockName, 5)beforebeginTransaction; a miss returns an operation-specific 'sourceLocked' error, andRELEASE_LOCKruns in afinally.- Then the transaction, wrapped in a deadlock retry (
SourceLockTransactionTrait), with the idempotence check always under the lock. Check-then-act without serialization only looks idempotent (a retried webhook would create duplicate rows or documents).
Lock order: level rows by ascending stock_item_id, tiebreak ascending warehouse_id;
the level row is the mutex for a stock item and warehouse. reserve() takes levels via
the conditional UPDATE; release and confirm lock the reservation rows FOR UPDATE first
(item, then warehouse ascending) and then the levels in the same order. Same-source
operations never race (GET_LOCK serializes them); cross-source contention on a shared
level row is retried on deadlock.
The FEFO/batch consumption of confirm() is not duplicated here: it reuses
DocumentService::issueFromReservationsInTransaction() inside this transaction, but
the call arrives as a callable from
StockReservationManager, because a service must
not inject another service.
Table of Contents
Constants
- DOCUMENTS : string = 'cms_mod_store_stock_documents'
- ITEMS : string = 'cms_mod_store_stock_items'
- LEVELS : string = 'cms_mod_store_stock_levels'
- LOCK_MAX_ATTEMPTS : int = 3
- LOCK_SCALE : int = 3
- LOCK_TIMEOUT : int = 5
- RESERVATIONS : string = 'cms_mod_store_stock_reservations'
- SETTING_MODULE : int = 10
- SETTINGS : string = 'cms_system_settings'
- WAREHOUSES : string = 'cms_mod_store_warehouses'
Properties
- $em : EntityManagerInterface
- $settingContext : SettingContext
Methods
- __construct() : mixed
- confirm() : ConfirmResult
- Confirms the active reservations of a source into a posted issue document.
- release() : Result
- reserve() : ReservationResult
- acquireSourceLock() : bool
- Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
- connection() : Connection
- lockLogChannel() : string
- Tracy log channel for a failed `RELEASE_LOCK`. Override per module (Eshop: `eshop.general`).
- releaseSourceLock() : void
- Releases the advisory lock.
- runInSourceTransaction() : T
- Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
- activeRequestedSums() : array<int, string>
- Returns the source's original requested quantity per item, aggregated from the active rows. `requested_quantity` is persisted per row (equal across an item's rows, carried by zero-quantity markers too). A null `requested_quantity` (legacy row) falls back to the allocated quantity, which behaves like the old summed-allocation comparison ('full').
- allocateAtWarehouse() : string
- Allocates as much of `$remaining` as possible at one warehouse and returns the quantity taken (0 up to remaining).
- buildResultFromExisting() : ReservationResult
- Rebuilds the `ReservationResult` of an already-existing reservation from its active rows on an idempotent retry: `perLine` from the allocation rows (zero-quantity markers skipped) and `notInStock` as requested minus summed allocated per item. Status is 'full' when nothing is short, else 'partial', the same shape the original `reserve()` returned.
- expireOwnLapsed() : void
- Expires this source's own lapsed active rows under the lock (status becomes expired, `reserved` gets a bounded decrement), mirroring the lapsed idiom of `confirm()`, so the idempotence compare never treats a lapsed hold as still live. Runs first in the reserve transaction. Zero-quantity marker rows decrement 0, which is harmless.
- findConfirmedIssue() : array{id: int, number: ?string}|null
- inactiveItems() : array<int, true>
- Deactivated stock items among the requested ones.
- inlineExpire() : bool
- Expires lapsed active reservations of one stock item and warehouse (any source) inline: rows are locked `FOR UPDATE`, status becomes expired, and `reserved` is decremented by the summed quantity (`GREATEST(0, ...)`). Returns whether anything was expired, so the caller retries the conditional UPDATE. Under our own source lock we never hold active rows of our own source (the idempotence check returned early otherwise), so this only reclaims other sources' stale holds.
- insertReservationRow() : void
- ladd() : numeric-string
- lcmp() : int
- ldec() : numeric-string
- ldiv() : numeric-string
- Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
- lInt() : int
- lmul() : numeric-string
- ln() : numeric-string
- lStr() : string
- lStrN() : string|null
- lsub() : numeric-string
- normalizeSalePrices() : array<int, array{unitPrice: string|null, currencyId: int|null}>
- Normalizes the raw sale-price payload. A provided `unitPrice` is kept only when it is numeric and greater than 0; anything else (non-numeric, zero, negative) is dropped to null with a warning, because a bogus price must not silently become a real revenue snapshot. Missing prices for issued items are reported separately by `confirm()` once it knows which items are actually issued.
- priorityWarehouses() : array<int, array{warehouseId: int, deliveryDays: int}>
- resolveExpires() : string|null
- Resolves the expiry timestamp: explicit `$expiresAt`, else the settings TTL from DB `NOW()` (avoids app/DB clock skew), else null (no expiry). `reservationTtlMinutes = 0` disables the default TTL.
- sameLines() : bool
- settingValue() : string|null
- Reads a module-10 setting; prefers the request `SettingContext` and falls back to raw DBAL (harness and cron contexts where the context is uninitialized; accessing it throws, hence the guard).
- tryReserveFull() : bool
- Conditional full allocation: `reserved += q WHERE quantity - reserved >= q`.
Constants
DOCUMENTS
private
string
DOCUMENTS
= 'cms_mod_store_stock_documents'
ITEMS
private
string
ITEMS
= 'cms_mod_store_stock_items'
LEVELS
private
string
LEVELS
= 'cms_mod_store_stock_levels'
LOCK_MAX_ATTEMPTS
private
int
LOCK_MAX_ATTEMPTS
= 3
LOCK_SCALE
private
int
LOCK_SCALE
= 3
LOCK_TIMEOUT
private
int
LOCK_TIMEOUT
= 5
RESERVATIONS
private
string
RESERVATIONS
= 'cms_mod_store_stock_reservations'
SETTING_MODULE
private
int
SETTING_MODULE
= 10
SETTINGS
private
string
SETTINGS
= 'cms_system_settings'
WAREHOUSES
private
string
WAREHOUSES
= 'cms_mod_store_warehouses'
Properties
$em read-only
private
EntityManagerInterface
$em
$settingContext read-only
private
SettingContext
$settingContext
Methods
__construct()
public
__construct(EntityManagerInterface $em, SettingContext $settingContext) : mixed
Parameters
- $em : EntityManagerInterface
- $settingContext : SettingContext
confirm()
Confirms the active reservations of a source into a posted issue document.
public
confirm(SourceRef $source, array<int|string, mixed> $salePrices, int|null $userId, callable(Connection, SourceRef, array<int, array{itemId: int, warehouseId: int, quantity: string}>, array<int, array{unitPrice: string|null, currencyId: int|null}>, Array, Array, array<int, string>): int $issueFromReservations) : ConfirmResult
$issueFromReservations is a required callable supplied only by the facade
StockReservationManager::confirm() (a manager may
call other managers, a service may not). It runs on the same connection inside this
transaction, so the transaction contract of issueFromReservationsInTransaction()
holds unchanged.
Parameters
- $source : SourceRef
- $salePrices : array<int|string, mixed>
-
stockItemId => ['unitPrice','currencyId'] (raw payload)
- $userId : int|null
- $issueFromReservations : callable(Connection, SourceRef, array<int, array{itemId: int, warehouseId: int, quantity: string}>, array<int, array{unitPrice: string|null, currencyId: int|null}>, Array, Array, array<int, string>): int
Return values
ConfirmResultrelease()
public
release(SourceRef $source) : Result
Parameters
- $source : SourceRef
Return values
Resultreserve()
public
reserve(SourceRef $source, array<int, ReservationLine> $lines[, bool|null $allowPartial = null ][, DateTimeImmutable|null $expiresAt = null ]) : ReservationResult
Parameters
- $source : SourceRef
- $lines : array<int, ReservationLine>
- $allowPartial : bool|null = null
- $expiresAt : DateTimeImmutable|null = null
Return values
ReservationResultacquireSourceLock()
Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
protected
acquireSourceLock(string $name, int $timeout) : bool
Parameters
- $name : string
- $timeout : int
Return values
boolconnection()
protected
connection() : Connection
Return values
ConnectionlockLogChannel()
Tracy log channel for a failed `RELEASE_LOCK`. Override per module (Eshop: `eshop.general`).
protected
lockLogChannel() : string
Return values
stringreleaseSourceLock()
Releases the advisory lock.
protected
releaseSourceLock(string $name) : void
IMPORTANT: it is called from a finally, so a failure here (a gone-away connection after a
fatal query) is swallowed and logged; rethrowing would mask the original exception being
unwound. MySQL drops session locks when the connection dies anyway, so nothing leaks past
the process.
Parameters
- $name : string
runInSourceTransaction()
Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
protected
runInSourceTransaction(callable(Connection): T $fn) : T
The advisory lock (held by the caller across this call) is not released by a rollback, so every attempt runs under the same serialization.
Parameters
- $fn : callable(Connection): T
Tags
Return values
TactiveRequestedSums()
Returns the source's original requested quantity per item, aggregated from the active rows. `requested_quantity` is persisted per row (equal across an item's rows, carried by zero-quantity markers too). A null `requested_quantity` (legacy row) falls back to the allocated quantity, which behaves like the old summed-allocation comparison ('full').
private
activeRequestedSums(Connection $conn, SourceRef $source) : array<int, string>
Parameters
- $conn : Connection
- $source : SourceRef
Return values
array<int, string> —stockItemId => original requested quantity
allocateAtWarehouse()
Allocates as much of `$remaining` as possible at one warehouse and returns the quantity taken (0 up to remaining).
private
allocateAtWarehouse(Connection $conn, int $itemId, int $warehouseId, string $remaining) : string
(a) A conditional full UPDATE (WHERE quantity - reserved >= q) is both lock and guard.
(b) On 0 affected rows it expires the lapsed reservations of this stock item and
warehouse inline, then retries once. (c) If still 0, a locking read of the available
quantity (FOR UPDATE) takes min(available, remaining).
Parameters
- $conn : Connection
- $itemId : int
- $warehouseId : int
- $remaining : string
Return values
stringbuildResultFromExisting()
Rebuilds the `ReservationResult` of an already-existing reservation from its active rows on an idempotent retry: `perLine` from the allocation rows (zero-quantity markers skipped) and `notInStock` as requested minus summed allocated per item. Status is 'full' when nothing is short, else 'partial', the same shape the original `reserve()` returned.
private
buildResultFromExisting(Connection $conn, SourceRef $source, array<int, string> $requested) : ReservationResult
Parameters
- $conn : Connection
- $source : SourceRef
- $requested : array<int, string>
-
item => original requested quantity (from
activeRequestedSums())
Return values
ReservationResultexpireOwnLapsed()
Expires this source's own lapsed active rows under the lock (status becomes expired, `reserved` gets a bounded decrement), mirroring the lapsed idiom of `confirm()`, so the idempotence compare never treats a lapsed hold as still live. Runs first in the reserve transaction. Zero-quantity marker rows decrement 0, which is harmless.
private
expireOwnLapsed(Connection $conn, SourceRef $source) : void
Parameters
- $conn : Connection
- $source : SourceRef
findConfirmedIssue()
private
findConfirmedIssue(Connection $conn, SourceRef $source) : array{id: int, number: ?string}|null
Parameters
- $conn : Connection
- $source : SourceRef
Return values
array{id: int, number: ?string}|nullinactiveItems()
Deactivated stock items among the requested ones.
private
inactiveItems(Connection $conn, array<int, int> $itemIds) : array<int, true>
Parameters
- $conn : Connection
- $itemIds : array<int, int>
Return values
array<int, true>inlineExpire()
Expires lapsed active reservations of one stock item and warehouse (any source) inline: rows are locked `FOR UPDATE`, status becomes expired, and `reserved` is decremented by the summed quantity (`GREATEST(0, ...)`). Returns whether anything was expired, so the caller retries the conditional UPDATE. Under our own source lock we never hold active rows of our own source (the idempotence check returned early otherwise), so this only reclaims other sources' stale holds.
private
inlineExpire(Connection $conn, int $itemId, int $warehouseId) : bool
Parameters
- $conn : Connection
- $itemId : int
- $warehouseId : int
Return values
boolinsertReservationRow()
private
insertReservationRow(Connection $conn, SourceRef $source, int $itemId, int $warehouseId, string $quantity, string $requestedQuantity, string|null $expires) : void
Parameters
- $conn : Connection
- $source : SourceRef
- $itemId : int
- $warehouseId : int
- $quantity : string
- $requestedQuantity : string
- $expires : string|null
ladd()
private
ladd(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringlcmp()
private
lcmp(string $a, string $b) : int
Parameters
- $a : string
- $b : string
Return values
intldec()
private
ldec(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringldiv()
Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
private
ldiv(string $a, string $b) : numeric-string
IMPORTANT: truncation would systematically drop up to 0.001 from every unit on large quantities, so the invariant sum(unitPrice x quantity) ~ sum(rowBase) would drift in one direction (an issue document undervalued against the order).
Parameters
- $a : string
- $b : string
Return values
numeric-stringlInt()
private
lInt(mixed $value) : int
Parameters
- $value : mixed
Return values
intlmul()
private
lmul(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringln()
private
ln(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringlStr()
private
lStr(mixed $value) : string
Parameters
- $value : mixed
Return values
stringlStrN()
private
lStrN(mixed $value) : string|null
Parameters
- $value : mixed
Return values
string|nulllsub()
private
lsub(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringnormalizeSalePrices()
Normalizes the raw sale-price payload. A provided `unitPrice` is kept only when it is numeric and greater than 0; anything else (non-numeric, zero, negative) is dropped to null with a warning, because a bogus price must not silently become a real revenue snapshot. Missing prices for issued items are reported separately by `confirm()` once it knows which items are actually issued.
private
normalizeSalePrices(array<int|string, mixed> $salePrices[, array<int, string> &$warnings = [] ]) : array<int, array{unitPrice: string|null, currencyId: int|null}>
Parameters
- $salePrices : array<int|string, mixed>
- $warnings : array<int, string> = []
-
out-param
Return values
array<int, array{unitPrice: string|null, currencyId: int|null}>priorityWarehouses()
private
priorityWarehouses(Connection $conn) : array<int, array{warehouseId: int, deliveryDays: int}>
Parameters
- $conn : Connection
Return values
array<int, array{warehouseId: int, deliveryDays: int}> —active warehouses in draw priority
resolveExpires()
Resolves the expiry timestamp: explicit `$expiresAt`, else the settings TTL from DB `NOW()` (avoids app/DB clock skew), else null (no expiry). `reservationTtlMinutes = 0` disables the default TTL.
private
resolveExpires(Connection $conn, DateTimeImmutable|null $expiresAt) : string|null
IMPORTANT: the app runs in Europe/Prague while the DB session runs in UTC (named time
zone tables are unavailable on dev, so SET time_zone does not work). $expiresAt
arrives either from OrderStockManager::reserve() (PHP Prague now + TTL) or from the
external bridge string (StockReservationPresenter::toExpiresAt()), both Prague wall
clock. ReservationExpirer and the inline expiration compare expires_datetime < NOW()
on the DB side (UTC), so writing the value as is would be systematically 1-2 hours
ahead. The conversion to UTC happens here, the only place where $expiresAt is
formatted for writing, which fixes both paths at once.
Parameters
- $conn : Connection
- $expiresAt : DateTimeImmutable|null
Return values
string|nullsameLines()
private
sameLines(array<int, string> $existing, array<int, string> $requested) : bool
Parameters
- $existing : array<int, string>
- $requested : array<int, string>
Return values
boolsettingValue()
Reads a module-10 setting; prefers the request `SettingContext` and falls back to raw DBAL (harness and cron contexts where the context is uninitialized; accessing it throws, hence the guard).
private
settingValue(string $key) : string|null
Parameters
- $key : string
Return values
string|nulltryReserveFull()
Conditional full allocation: `reserved += q WHERE quantity - reserved >= q`.
private
tryReserveFull(Connection $conn, int $itemId, int $warehouseId, string $quantity) : bool
Parameters
- $conn : Connection
- $itemId : int
- $warehouseId : int
- $quantity : string