Implement session mutation locking and enhance session management methods

This commit is contained in:
Jeppe Bundgaard
2026-06-08 16:49:41 +02:00
parent a19178a042
commit 49364864d2
2 changed files with 225 additions and 46 deletions
@@ -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 [
@@ -158,6 +158,18 @@ class selfserve_wash_sessions_o extends db
}
public function markCompleted(?int $orderId = null): void
{
if (!$this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId)) {
return;
}
}
public function markCompletedIfOpen(?int $orderId = null): bool
{
return $this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId);
}
private function markCompletedInMemory(?int $orderId = null): void
{
$this->completed_at->set(date('Y-m-d H:i:s'));
if ($orderId !== null) {
@@ -167,6 +179,18 @@ class selfserve_wash_sessions_o extends db
}
public function markForceStopped(?int $orderId = null, ?array $metadata = null): void
{
if (!$this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata)) {
return;
}
}
public function markForceStoppedIfOpen(?int $orderId = null, ?array $metadata = null): bool
{
return $this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata);
}
private function markForceStoppedInMemory(?int $orderId = null, ?array $metadata = null): void
{
$this->completed_at->set(date('Y-m-d H:i:s'));
if ($orderId !== null) {
@@ -181,6 +205,53 @@ class selfserve_wash_sessions_o extends db
$this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value);
}
private function closeIfOpen(selfserve_wash_session_status $status, ?int $orderId = null, ?array $metadata = null): bool
{
if (!$this->isPersistedSession()) {
if ($status === selfserve_wash_session_status::COMPLETED) {
$this->markCompletedInMemory($orderId);
} else {
$this->markForceStoppedInMemory($orderId, $metadata);
}
return true;
}
global $db;
$updates = [
"`completed_at` = NOW()",
"`status` = '" . $db->escape_string($status->value) . "'",
];
if ($orderId !== null) {
$updates[] = "`order_id` = " . (int)$orderId;
}
if ($metadata !== null) {
$existing = $this->metadata_json->value();
$existing = is_array($existing) ? $existing : [];
$existing['force_stop'] = $metadata;
$updates[] = "`metadata_json` = '" . $db->escape_string(json_encode($existing, JSON_THROW_ON_ERROR)) . "'";
}
$terminalStatuses = self::terminalStatusSqlList();
$db->query(
"UPDATE `selfserve_wash_sessions` SET " . implode(', ', $updates) .
" WHERE `id` = " . (int)$this->id .
" AND `completed_at` IS NULL" .
" AND UPPER(TRIM(`status`)) NOT IN ($terminalStatuses)"
);
$changed = $db->conn()->affected_rows > 0;
$this->select((int)$this->id);
return $changed;
}
private function isPersistedSession(): bool
{
return isset($this->id) && (int)$this->id > 0 && $this->exists();
}
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
{
$filters = [