mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-09-13 05:47:23 -04:00
* 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>
237 lines
8.3 KiB
PHP
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'];
|
|
}
|
|
}
|