Implement session mutation locking and enhance session management methods
This commit is contained in:
@@ -103,53 +103,76 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
||||
{
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$createSession = (bool)($options['create_session'] ?? true);
|
||||
$mutationResult = $this->withSessionMutationLock(
|
||||
$laneId,
|
||||
$snapshot['reg'],
|
||||
$snapshot['customer_number'],
|
||||
function () use ($laneId, $snapshot, $options): array {
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$createSession = (bool)($options['create_session'] ?? true);
|
||||
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
return $session->exists()
|
||||
? $this->getSessionSummary((int)$session->id)
|
||||
: $this->formatBlockedSessionSummary($snapshot);
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
return [
|
||||
'session' => $session,
|
||||
'response' => $session->exists()
|
||||
? $this->getSessionSummary((int)$session->id)
|
||||
: $this->formatBlockedSessionSummary($snapshot),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$session->exists() && !$createSession) {
|
||||
return [
|
||||
'session' => $session,
|
||||
'response' => $this->formatSnapshotResponse($snapshot, null),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$session->exists()) {
|
||||
$session = (new selfserve_wash_sessions_o())->add(
|
||||
$laneId,
|
||||
(int)$snapshot['lane']['department'],
|
||||
$snapshot['machine_type']['id'] ?? null,
|
||||
$snapshot['customer_number'],
|
||||
$snapshot['reg'],
|
||||
$snapshot['vehicle']['id'] ?? null,
|
||||
$snapshot['vehicle']['type'] ?? null,
|
||||
$this->deriveBaseStatus($snapshot),
|
||||
(bool)$snapshot['allowed'],
|
||||
$this->buildSessionMetadata($snapshot),
|
||||
);
|
||||
} else {
|
||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||
$session->customer_number->set($snapshot['customer_number']);
|
||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||
$session->reg->set($snapshot['reg']);
|
||||
$session->allowed->set((bool)$snapshot['allowed']);
|
||||
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
|
||||
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
||||
}
|
||||
|
||||
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
|
||||
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
|
||||
'allowed' => (bool)$snapshot['allowed'],
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||
]);
|
||||
|
||||
return [
|
||||
'session' => $session,
|
||||
'response' => null,
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
$session = $mutationResult['session'];
|
||||
if ($mutationResult['response'] !== null) {
|
||||
return $mutationResult['response'];
|
||||
}
|
||||
|
||||
if (!$session->exists() && !$createSession) {
|
||||
return $this->formatSnapshotResponse($snapshot, null);
|
||||
}
|
||||
|
||||
if (!$session->exists()) {
|
||||
$session = (new selfserve_wash_sessions_o())->add(
|
||||
$laneId,
|
||||
(int)$snapshot['lane']['department'],
|
||||
$snapshot['machine_type']['id'] ?? null,
|
||||
$snapshot['customer_number'],
|
||||
$snapshot['reg'],
|
||||
$snapshot['vehicle']['id'] ?? null,
|
||||
$snapshot['vehicle']['type'] ?? null,
|
||||
$this->deriveBaseStatus($snapshot),
|
||||
(bool)$snapshot['allowed'],
|
||||
$this->buildSessionMetadata($snapshot),
|
||||
);
|
||||
} else {
|
||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||
$session->customer_number->set($snapshot['customer_number']);
|
||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||
$session->reg->set($snapshot['reg']);
|
||||
$session->allowed->set((bool)$snapshot['allowed']);
|
||||
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
|
||||
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
||||
}
|
||||
|
||||
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
|
||||
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
|
||||
'allowed' => (bool)$snapshot['allowed'],
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||
]);
|
||||
|
||||
if ($syncRelayState) {
|
||||
if ($syncRelayState && $session->exists()) {
|
||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||
}
|
||||
|
||||
@@ -410,7 +433,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->markCompleted($orderId);
|
||||
if (!$session->markCompletedIfOpen($orderId)) {
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||
'lane_id' => $laneId,
|
||||
@@ -455,9 +480,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'runtime_before_reset' => $runtimeSnapshot,
|
||||
'forced_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
$session->markForceStopped($orderId, $eventPayload);
|
||||
if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
|
||||
$summary = $this->getSessionSummary((int)$session->id);
|
||||
} else {
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
||||
$summary = $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
}
|
||||
|
||||
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
||||
@@ -3193,6 +3221,86 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable():array<string,mixed> $callback
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
|
||||
{
|
||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
|
||||
$lock = $this->acquireSessionMutationLock($lockKey);
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->releaseSessionMutationLock($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{driver:string,key:string,token:?string}
|
||||
*/
|
||||
protected function acquireSessionMutationLock(string $lockKey): array
|
||||
{
|
||||
if (defined('redis') && method_exists(redis, 'set_if_absent_with_expiration')) {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
if (!redis->set_if_absent_with_expiration($lockKey, $token, 15)) {
|
||||
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
|
||||
}
|
||||
|
||||
return [
|
||||
'driver' => 'redis',
|
||||
'key' => $lockKey,
|
||||
'token' => $token,
|
||||
];
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query("SELECT GET_LOCK('" . $db->escape_string($lockKey) . "', 5) AS acquired");
|
||||
$row = $db->fetch_assoc($result);
|
||||
if ((int)($row['acquired'] ?? 0) !== 1) {
|
||||
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
|
||||
}
|
||||
|
||||
return [
|
||||
'driver' => 'mysql',
|
||||
'key' => $lockKey,
|
||||
'token' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{driver:string,key:string,token:?string} $lock
|
||||
*/
|
||||
protected function releaseSessionMutationLock(array $lock): void
|
||||
{
|
||||
try {
|
||||
if ($lock['driver'] === 'redis' && defined('redis')) {
|
||||
if (method_exists(redis, 'get') && redis->get($lock['key']) !== $lock['token']) {
|
||||
return;
|
||||
}
|
||||
if (method_exists(redis, 'delete')) {
|
||||
redis->delete($lock['key']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($lock['driver'] === 'mysql') {
|
||||
global $db;
|
||||
$db->query("SELECT RELEASE_LOCK('" . $db->escape_string($lock['key']) . "')");
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Locks have TTLs or connection scope; release failures must not mask API results.
|
||||
}
|
||||
}
|
||||
|
||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
|
||||
{
|
||||
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
|
||||
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildSessionMetadata(array $snapshot): array
|
||||
{
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user