coupon_refund_mysql.php 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. <?php
  2. // Opt-in: use a private, network-disabled MySQL instance. Never loads either project's .env.
  3. $socket = getenv('COUPON_TEST_MYSQL_SOCKET');
  4. if (!$socket) { echo "SKIP: set COUPON_TEST_MYSQL_SOCKET to an isolated test server\n"; exit(0); }
  5. if (!preg_match('#^/tmp/b28coupon-fix-[a-z0-9]+\.sock$#D', $socket)) throw new RuntimeException('Use an isolated /tmp/b28coupon-fix-*.sock.');
  6. $root = dirname(__DIR__, 2);
  7. $memberRoot = getenv('COUPON_MEMBER_ROOT') ?: dirname($root) . '/melbet_sport-api';
  8. $family = $argv[1] ?? 'parent';
  9. $dir = $argv[2] ?? sys_get_temp_dir() . '/coupon-refund-barriers-' . bin2hex(random_bytes(5));
  10. $number = (int)($argv[3] ?? 0);
  11. if ($family === 'parent') {
  12. mkdir($dir, 0700, true);
  13. $pdo = new PDO('mysql:unix_socket=' . $socket, 'root', '');
  14. $pdo->exec('CREATE DATABASE IF NOT EXISTS coupon_refund_regression');
  15. }
  16. if ($family !== 'member') {
  17. $loader = require $root . '/vendor/autoload.php';
  18. if ($source = getenv('COUPON_BOT_SOURCE')) $loader->addClassMap(['App\Services\PromotionCouponService' => $source . '/app/Services/PromotionCouponService.php']);
  19. $app = new Illuminate\Foundation\Application($root);
  20. $app->instance('config', new Illuminate\Config\Repository());
  21. $app->instance('log', new Psr\Log\NullLogger());
  22. Illuminate\Support\Facades\Facade::setFacadeApplication($app);
  23. $db = new Illuminate\Database\Capsule\Manager($app);
  24. $db->addConnection(['driver' => 'mysql', 'unix_socket' => $socket, 'database' => 'coupon_refund_regression', 'username' => 'root', 'password' => '', 'prefix' => 'bot_']);
  25. $db->setAsGlobal(); $db->bootEloquent();
  26. $app->instance('db', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->schema());
  27. $DB = Illuminate\Support\Facades\DB::class;
  28. $DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
  29. $DB::statement('SET SESSION innodb_lock_wait_timeout = 8');
  30. } else {
  31. $loader = require $memberRoot . '/vendor/autoload.php';
  32. if ($source = getenv('COUPON_MEMBER_SOURCE')) $loader->addClassMap(['app\common\service\coupon\CouponService' => $source . '/app/common/service/coupon/CouponService.php']);
  33. $app = new think\App($memberRoot . '/'); think\Container::setInstance($app);
  34. $db = new think\DbManager();
  35. $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
  36. 'type' => 'mysql', 'hostname' => 'localhost', 'database' => 'coupon_refund_regression', 'username' => 'root', 'password' => '',
  37. 'dsn' => 'mysql:unix_socket=' . $socket . ';dbname=coupon_refund_regression;charset=utf8mb4', 'prefix' => 'bot_',
  38. ]]]);
  39. $app->instance(think\DbManager::class, $db); $app->instance('log', new Psr\Log\NullLogger());
  40. $DB = think\facade\Db::class;
  41. $DB::execute('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
  42. $DB::execute('SET SESSION innodb_lock_wait_timeout = 8');
  43. }
  44. function awaitFile(string $file): void {
  45. $deadline = microtime(true) + 10;
  46. while (!file_exists($file)) {
  47. if (microtime(true) > $deadline) throw new RuntimeException('Timeout: ' . basename($file));
  48. usleep(10000); clearstatcache(true, $file);
  49. }
  50. }
  51. function same($actual, $expected, string $message): void {
  52. if ($actual !== $expected) throw new RuntimeException($message . ': ' . json_encode($actual));
  53. }
  54. if ($family !== 'parent') {
  55. if ($family === 'bot') {
  56. $DB::beginTransaction();
  57. } else {
  58. $DB::startTrans();
  59. }
  60. $refund = function () use ($family, $number) {
  61. if ($family === 'bot') App\Services\PromotionCouponService::release('sport_order', $number);
  62. else app\common\service\coupon\CouponService::release('sport_order', $number);
  63. };
  64. if ($number === 1) $refund(); // Leave this real refund uncommitted while the second transaction starts.
  65. elseif ($family === 'bot') $DB::table('promotion_coupon_uses')->count();
  66. else $DB::table('bot_promotion_coupon_uses')->count(); // Old RR view includes the first use as not refunded.
  67. touch($dir . '/' . $number . '.ready');
  68. if ($number === 1) awaitFile($dir . '/release');
  69. else $refund();
  70. $DB::commit(); touch($dir . '/' . $number . '.done');
  71. exit(0);
  72. }
  73. foreach ([['bot', 'bot'], ['member', 'member'], ['bot', 'member'], ['member', 'bot']] as [$first, $second]) {
  74. $barrier = $dir . '/' . $first . '-' . $second; mkdir($barrier, 0700);
  75. $DB::statement('DROP TABLE IF EXISTS bot_promotion_coupon_uses');
  76. $DB::statement('DROP TABLE IF EXISTS bot_promotion_coupons');
  77. $DB::statement('CREATE TABLE bot_promotion_coupons (id BIGINT PRIMARY KEY,status VARCHAR(16),used_source VARCHAR(32),used_stake DECIMAL(24,2),used_at DATETIME,updated_at DATETIME) ENGINE=InnoDB');
  78. $DB::statement('CREATE TABLE bot_promotion_coupon_uses (id BIGINT PRIMARY KEY,coupon_id BIGINT,source VARCHAR(32),order_id BIGINT,refunded_at DATETIME,updated_at DATETIME,UNIQUE(source,order_id),INDEX(coupon_id)) ENGINE=InnoDB');
  79. $DB::table('promotion_coupons')->insert(['id' => 1, 'status' => 'used']);
  80. foreach ([1, 2] as $id) $DB::table('promotion_coupon_uses')->insert(['id' => $id, 'coupon_id' => 1, 'source' => 'sport_order', 'order_id' => $id]);
  81. $start = function ($family, $number) use ($barrier) {
  82. $process = proc_open([PHP_BINARY, __FILE__, $family, $barrier, (string)$number], [
  83. 0 => ['pipe', 'r'], 1 => ['file', $barrier . '/' . $number . '.log', 'a'], 2 => ['file', $barrier . '/' . $number . '.log', 'a'],
  84. ], $pipes);
  85. if (!is_resource($process)) throw new RuntimeException('Cannot start worker');
  86. fclose($pipes[0]); return $process;
  87. };
  88. $workers = [];
  89. try {
  90. $workers[] = $start($first, 1); awaitFile($barrier . '/1.ready');
  91. $workers[] = $start($second, 2); awaitFile($barrier . '/2.ready');
  92. usleep(100000); // Both transactions overlap; only the final business state is asserted.
  93. } finally {
  94. touch($barrier . '/release');
  95. foreach ($workers as $worker) same(proc_close($worker), 0, 'Worker failed; see ' . $barrier);
  96. }
  97. same($DB::table('promotion_coupon_uses')->whereNull('refunded_at')->count(), 0, 'Both uses are refunded');
  98. same($DB::table('promotion_coupons')->value('status'), 'unused', 'The last refund restores the coupon despite an old RR snapshot');
  99. echo "PASS {$first}/{$second} concurrent coupon refunds\n";
  100. }