CoolCMS API

ReservationService
in package
uses SourceLockTransactionTrait

FinalYes

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) before beginTransaction; a miss returns an operation-specific 'sourceLocked' error, and RELEASE_LOCK runs in a finally.
  • 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

Properties

Methods

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
ConfirmResult

acquireSourceLock()

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
bool

lockLogChannel()

Tracy log channel for a failed `RELEASE_LOCK`. Override per module (Eshop: `eshop.general`).

protected lockLogChannel() : string
Return values
string

releaseSourceLock()

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
template
Return values
T

activeRequestedSums()

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
string

buildResultFromExisting()

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
ReservationResult

expireOwnLapsed()

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}|null

inactiveItems()

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
bool

insertReservationRow()

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-string

ldiv()

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-string

lmul()

private lmul(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

lsub()

private lsub(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

normalizeSalePrices()

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|null

sameLines()

private sameLines(array<int, string> $existing, array<int, string> $requested) : bool
Parameters
$existing : array<int, string>
$requested : array<int, string>
Return values
bool

settingValue()

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|null

tryReserveFull()

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
Tags
phpstan-impure

the affected-rows result changes between calls as the inline expiration (and concurrent transactions) mutate the level row; the second call after inlineExpire() is not guaranteed to repeat the first call's result.

Return values
bool
On this page

Search results