Fix self-serve start relay race
This commit is contained in:
@@ -38,6 +38,49 @@ use objects\department_variables_o;
|
||||
|
||||
trait selfserve_lane_command_t
|
||||
{
|
||||
|
||||
/**
|
||||
* Acquire an atomic per-lane START lock before performing physical side effects.
|
||||
*/
|
||||
protected function acquireLaneStartCommandLock(): string
|
||||
{
|
||||
if (!defined('redis') || !method_exists(redis, 'set_if_absent_with_expiration')) {
|
||||
throw new \RuntimeException('Cannot start lane: START lock is unavailable.');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$lock_key = $this->getLaneStartCommandLockKey();
|
||||
if (!redis->set_if_absent_with_expiration($lock_key, $token, 30)) {
|
||||
throw new \RuntimeException("Cannot start lane: Lane is not available.");
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
protected function releaseLaneStartCommandLock(string $token): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock_key = $this->getLaneStartCommandLockKey();
|
||||
try {
|
||||
if (method_exists(redis, 'get') && redis->get($lock_key) !== $token) {
|
||||
return;
|
||||
}
|
||||
if (method_exists(redis, 'delete')) {
|
||||
redis->delete($lock_key);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// The lock has a short TTL, so release failures must not mask START results.
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLaneStartCommandLockKey(): string
|
||||
{
|
||||
return 'selfserve_lane_start_command_lock_' . (int)$this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the lane and its department have self-serve enabled.
|
||||
* This method is intentionally protected to allow tests to override
|
||||
@@ -509,38 +552,48 @@ trait selfserve_lane_command_t
|
||||
// Validate customer number
|
||||
if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number);
|
||||
if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number);
|
||||
$previous_customer_number = $this->getCustomerNumber();
|
||||
$previous_license_plate = $this->getLicensePlate();
|
||||
// Set the customer number and license plate
|
||||
$this->setCustomerNumber($customer_number);
|
||||
$this->setLicensePlate($license_plate);
|
||||
$start_lock_token = $this->acquireLaneStartCommandLock();
|
||||
try {
|
||||
// Open the entrance port before marking the lane occupied. Gateway timeouts are
|
||||
// ambiguous because the relay may already have received the pulse.
|
||||
$this->openEntrancePortForWashStart();
|
||||
} catch (\Throwable $e) {
|
||||
$this->setCustomerNumber($previous_customer_number);
|
||||
$this->setLicensePlate($previous_license_plate);
|
||||
$this->setLaneState(selfserve_lane_state::IDLE);
|
||||
throw $e;
|
||||
// Re-check availability after taking the START lock so concurrent requests cannot
|
||||
// both pass the preflight check and trigger the physical entrance relay.
|
||||
if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available.");
|
||||
$previous_customer_number = $this->getCustomerNumber();
|
||||
$previous_license_plate = $this->getLicensePlate();
|
||||
$previous_status = $this->getLaneStatus();
|
||||
// Set the customer number and license plate
|
||||
$this->setCustomerNumber($customer_number);
|
||||
$this->setLicensePlate($license_plate);
|
||||
// Mark the lane occupied before any physical entrance relay side effects.
|
||||
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||
try {
|
||||
// Gateway timeouts are ambiguous because the relay may already have received
|
||||
// the pulse, so openEntrancePortForWashStart() reports them and continues.
|
||||
$this->openEntrancePortForWashStart();
|
||||
} catch (\Throwable $e) {
|
||||
$this->setCustomerNumber($previous_customer_number);
|
||||
$this->setLicensePlate($previous_license_plate);
|
||||
$this->setLaneStatus($previous_status);
|
||||
$this->setLaneState(selfserve_lane_state::IDLE);
|
||||
throw $e;
|
||||
}
|
||||
// Set the lane state to IN_WASH
|
||||
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
||||
// Start the wash timer
|
||||
$this->setWashStartTime(time());
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||
$this->resolveSelfServeActionWashModeForStart(),
|
||||
[
|
||||
'customer_number' => (int)$customer_number,
|
||||
'reg' => $license_plate,
|
||||
]
|
||||
);
|
||||
$this->runRelaySideEffectsForWashStart($arguments);
|
||||
// Log the lane start event
|
||||
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
||||
} finally {
|
||||
$this->releaseLaneStartCommandLock($start_lock_token);
|
||||
}
|
||||
// Set the lane status to OCCUPIED when started
|
||||
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||
// Set the lane state to IN_WASH
|
||||
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
||||
// Start the wash timer
|
||||
$this->setWashStartTime(time());
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||
$this->resolveSelfServeActionWashModeForStart(),
|
||||
[
|
||||
'customer_number' => (int)$customer_number,
|
||||
'reg' => $license_plate,
|
||||
]
|
||||
);
|
||||
$this->runRelaySideEffectsForWashStart($arguments);
|
||||
// Log the lane start event
|
||||
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
||||
break;
|
||||
case selfserve_lane_command::STOP:
|
||||
// Require lane to be occupied before stopping
|
||||
|
||||
Reference in New Issue
Block a user