Files
opensourcepos/tests/Support/ConcurrentDbRaceTrait.php
objecttothis 29a9b1a7e7 Bugfix: Resolve Race Condition in Rewards and Gift Card Spending (#4640)
* Implement atomic updates for gift card and reward point decrements, enhance error handling for insufficient balances, and add regression tests for concurrency safety.

* Add translations for insufficient gift card balance and reward points error messages across all supported languages.

* Reorder `clear_suspended_sale_detail` call to ensure transactional consistency.

* Reorder `clear_all` call to align with success and error handling logic.

* Ensure soft-deleted gift cards are excluded in balance updates.

* Refactor change_quantity logic with atomic upserts, improve error handling for insufficient stock, and update related tests and constants.

* Added check for NEW_ENTRY

* Added unit tests to test changes.

* Fix class name casing in ItemQuantityTest for consistency.

* Fix Bulgarian translations for insufficient balance error messages in Sales module.

* Fix Greek translations for insufficient balance error messages in Sales module.

* Fix Armenian translations for insufficient balance error messages in Sales module.

* Fix Tamil translations for insufficient balance error messages in Sales module.

* Implement race condition testing for database methods with concurrent process support.

* Fix class name casing in ItemTest for consistency.

* Improve concurrent process handling in race condition tests; add readiness and synchronization barriers.

* Improve handling of process I/O streams and timeout management in race condition tests.

* Add test for decrementing gift card value when marked as deleted

* Add `finally` block to ensure proper cleanup in async database race condition tests

* Improve error handling and timeout management in async database race condition tests.

* Refactor test utilities to use shared `EmployeeFixtureTrait` and `ItemFixtureTrait`.

* Track process exit codes explicitly in race condition tests for improved error detection and debugging.

* Improve error handling in `ConcurrentDbRaceTrait` by adding exceptions for `mysqli_poll` and `mysqli_reap_async_query`.

Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>

---------

Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-08-20 02:24:23 +04:00

237 lines
8.3 KiB
PHP

<?php
namespace Tests\Support;
use RuntimeException;
/**
* Races two raw SQL statements against each other over two independent
* mysqli connections, using MYSQLI_ASYNC so both are in flight before either
* resolves. This exercises real database row-level locking instead of
* simulating concurrency with two sequential calls on one connection.
*/
trait ConcurrentDbRaceTrait
{
/**
* @return array{0: int, 1: int} affected_rows for [sql1, sql2]
*/
protected function raceTwoUpdates(string $sql1, string $sql2): array
{
$config = config('Database')->tests;
$link1 = mysqli_connect($config['hostname'], $config['username'], $config['password'], $config['database'], $config['port']);
$link2 = mysqli_connect($config['hostname'], $config['username'], $config['password'], $config['database'], $config['port']);
mysqli_query($link1, $sql1, MYSQLI_ASYNC);
mysqli_query($link2, $sql2, MYSQLI_ASYNC);
try {
$pending = [$link1, $link2];
$deadline = hrtime(true) + 10_000_000_000;
while ($pending !== []) {
if (hrtime(true) >= $deadline) {
throw new RuntimeException('mysqli_poll failed to resolve pending queries before deadline');
}
$read = $pending;
$error = $pending;
$reject = $pending;
$ready = mysqli_poll($read, $error, $reject, 5);
if ($ready === false) {
throw new RuntimeException('mysqli_poll returned false');
}
foreach (array_merge($read, $error, $reject) as $link) {
if (mysqli_reap_async_query($link) === false) {
throw new RuntimeException('mysqli_reap_async_query failed: ' . mysqli_error($link));
}
$pending = array_filter($pending, static fn ($pendingLink) => $pendingLink !== $link);
}
}
$affectedRows1 = mysqli_affected_rows($link1);
$affectedRows2 = mysqli_affected_rows($link2);
} finally {
mysqli_close($link1);
mysqli_close($link2);
}
return [$affectedRows1, $affectedRows2];
}
/**
* Races two model-method invocations against each other by running each
* in its own PHP CLI process (tests/Support/RaceWorker.php), so the two
* calls hit the database over genuinely separate connections at the same
* time instead of running sequentially on one connection.
*
* @return array{0: bool, 1: bool} return value of the model method for [call1, call2]
*/
protected function raceTwoProcesses(string $method, array $args1, array $args2): array
{
$workerScript = __DIR__ . '/RaceWorker.php';
$php = PHP_BINARY;
$descriptorSpec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$command1 = array_merge([$php, $workerScript, $method], array_map('strval', $args1));
$command2 = array_merge([$php, $workerScript, $method], array_map('strval', $args2));
$readyFile1 = tempnam(sys_get_temp_dir(), 'race_ready_');
$readyFile2 = tempnam(sys_get_temp_dir(), 'race_ready_');
$goFile = tempnam(sys_get_temp_dir(), 'race_go_');
unlink($readyFile1);
unlink($readyFile2);
unlink($goFile);
$baseEnv = array_filter($_SERVER, static fn ($value) => is_scalar($value));
$env1 = array_merge($baseEnv, ['RACE_READY_FILE' => $readyFile1, 'RACE_GO_FILE' => $goFile]);
$env2 = array_merge($baseEnv, ['RACE_READY_FILE' => $readyFile2, 'RACE_GO_FILE' => $goFile]);
$process1 = proc_open($command1, $descriptorSpec, $pipes1, ROOTPATH, $env1);
$process2 = proc_open($command2, $descriptorSpec, $pipes2, ROOTPATH, $env2);
fclose($pipes1[0]);
fclose($pipes2[0]);
stream_set_blocking($pipes1[1], false);
stream_set_blocking($pipes1[2], false);
stream_set_blocking($pipes2[1], false);
stream_set_blocking($pipes2[2], false);
try {
$deadline = microtime(true) + 5.0;
while (!file_exists($readyFile1) || !file_exists($readyFile2)) {
if (microtime(true) >= $deadline) {
$status1 = proc_get_status($process1);
$status2 = proc_get_status($process2);
if ($status1['running']) {
proc_terminate($process1);
}
if ($status2['running']) {
proc_terminate($process2);
}
fclose($pipes1[1]);
fclose($pipes1[2]);
fclose($pipes2[1]);
fclose($pipes2[2]);
proc_close($process1);
proc_close($process2);
$waiting = array_filter([
!file_exists($readyFile1) ? 'call 1' : null,
!file_exists($readyFile2) ? 'call 2' : null,
]);
throw new RuntimeException('race_worker.php failed to reach readiness barrier: ' . implode(', ', $waiting));
}
usleep(1000);
}
file_put_contents($goFile, '1');
$output1 = '';
$error1 = '';
$output2 = '';
$error2 = '';
$drain = static function () use ($pipes1, $pipes2, &$output1, &$error1, &$output2, &$error2): void {
$output1 .= stream_get_contents($pipes1[1]);
$error1 .= stream_get_contents($pipes1[2]);
$output2 .= stream_get_contents($pipes2[1]);
$error2 .= stream_get_contents($pipes2[2]);
};
$completionDeadline = microtime(true) + 10.0;
$exitCode1 = null;
$exitCode2 = null;
while (true) {
$drain();
$status1 = proc_get_status($process1);
$status2 = proc_get_status($process2);
if (!$status1['running'] && $exitCode1 === null) {
$exitCode1 = $status1['exitcode'];
}
if (!$status2['running'] && $exitCode2 === null) {
$exitCode2 = $status2['exitcode'];
}
if (!$status1['running'] && !$status2['running']) {
$drain();
break;
}
if (microtime(true) >= $completionDeadline) {
if ($status1['running']) {
proc_terminate($process1);
}
if ($status2['running']) {
proc_terminate($process2);
}
fclose($pipes1[1]);
fclose($pipes1[2]);
fclose($pipes2[1]);
fclose($pipes2[2]);
proc_close($process1);
proc_close($process2);
$exitCode1 ??= -1;
$exitCode2 ??= -1;
$stillRunning = array_filter([
$status1['running'] ? 'call 1' : null,
$status2['running'] ? 'call 2' : null,
]);
throw new RuntimeException('race_worker.php failed to complete before deadline: ' . implode(', ', $stillRunning));
}
usleep(1000);
}
fclose($pipes1[1]);
fclose($pipes1[2]);
proc_close($process1);
fclose($pipes2[1]);
fclose($pipes2[2]);
proc_close($process2);
} finally {
foreach ([$readyFile1, $readyFile2, $goFile] as $file) {
if (file_exists($file)) {
unlink($file);
}
}
}
if ($exitCode1 !== 0) {
throw new RuntimeException("race_worker.php (call 1) exited with {$exitCode1}: {$error1}");
}
if ($exitCode2 !== 0) {
throw new RuntimeException("race_worker.php (call 2) exited with {$exitCode2}: {$error2}");
}
return [trim($output1) === '1', trim($output2) === '1'];
}
}