| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- <?php
- // Opt-in: use a private, network-disabled MySQL instance. Never loads either project's .env.
- $socket = getenv('COUPON_TEST_MYSQL_SOCKET');
- if (!$socket) { echo "SKIP: set COUPON_TEST_MYSQL_SOCKET to an isolated test server\n"; exit(0); }
- if (!preg_match('#^/tmp/b28coupon-fix-[a-z0-9]+\.sock$#D', $socket)) throw new RuntimeException('Use an isolated /tmp/b28coupon-fix-*.sock.');
- $root = dirname(__DIR__, 2);
- $memberRoot = getenv('COUPON_MEMBER_ROOT') ?: dirname($root) . '/melbet_sport-api';
- $family = $argv[1] ?? 'parent';
- $dir = $argv[2] ?? sys_get_temp_dir() . '/coupon-refund-barriers-' . bin2hex(random_bytes(5));
- $number = (int)($argv[3] ?? 0);
- if ($family === 'parent') {
- mkdir($dir, 0700, true);
- $pdo = new PDO('mysql:unix_socket=' . $socket, 'root', '');
- $pdo->exec('CREATE DATABASE IF NOT EXISTS coupon_refund_regression');
- }
- if ($family !== 'member') {
- $loader = require $root . '/vendor/autoload.php';
- if ($source = getenv('COUPON_BOT_SOURCE')) $loader->addClassMap(['App\Services\PromotionCouponService' => $source . '/app/Services/PromotionCouponService.php']);
- $app = new Illuminate\Foundation\Application($root);
- $app->instance('config', new Illuminate\Config\Repository());
- $app->instance('log', new Psr\Log\NullLogger());
- Illuminate\Support\Facades\Facade::setFacadeApplication($app);
- $db = new Illuminate\Database\Capsule\Manager($app);
- $db->addConnection(['driver' => 'mysql', 'unix_socket' => $socket, 'database' => 'coupon_refund_regression', 'username' => 'root', 'password' => '', 'prefix' => 'bot_']);
- $db->setAsGlobal(); $db->bootEloquent();
- $app->instance('db', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->schema());
- $DB = Illuminate\Support\Facades\DB::class;
- $DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
- $DB::statement('SET SESSION innodb_lock_wait_timeout = 8');
- } else {
- $loader = require $memberRoot . '/vendor/autoload.php';
- if ($source = getenv('COUPON_MEMBER_SOURCE')) $loader->addClassMap(['app\common\service\coupon\CouponService' => $source . '/app/common/service/coupon/CouponService.php']);
- $app = new think\App($memberRoot . '/'); think\Container::setInstance($app);
- $db = new think\DbManager();
- $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
- 'type' => 'mysql', 'hostname' => 'localhost', 'database' => 'coupon_refund_regression', 'username' => 'root', 'password' => '',
- 'dsn' => 'mysql:unix_socket=' . $socket . ';dbname=coupon_refund_regression;charset=utf8mb4', 'prefix' => 'bot_',
- ]]]);
- $app->instance(think\DbManager::class, $db); $app->instance('log', new Psr\Log\NullLogger());
- $DB = think\facade\Db::class;
- $DB::execute('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
- $DB::execute('SET SESSION innodb_lock_wait_timeout = 8');
- }
- function awaitFile(string $file): void {
- $deadline = microtime(true) + 10;
- while (!file_exists($file)) {
- if (microtime(true) > $deadline) throw new RuntimeException('Timeout: ' . basename($file));
- usleep(10000); clearstatcache(true, $file);
- }
- }
- function same($actual, $expected, string $message): void {
- if ($actual !== $expected) throw new RuntimeException($message . ': ' . json_encode($actual));
- }
- if ($family !== 'parent') {
- if ($family === 'bot') {
- $DB::beginTransaction();
- } else {
- $DB::startTrans();
- }
- $refund = function () use ($family, $number) {
- if ($family === 'bot') App\Services\PromotionCouponService::release('sport_order', $number);
- else app\common\service\coupon\CouponService::release('sport_order', $number);
- };
- if ($number === 1) $refund(); // Leave this real refund uncommitted while the second transaction starts.
- elseif ($family === 'bot') $DB::table('promotion_coupon_uses')->count();
- else $DB::table('bot_promotion_coupon_uses')->count(); // Old RR view includes the first use as not refunded.
- touch($dir . '/' . $number . '.ready');
- if ($number === 1) awaitFile($dir . '/release');
- else $refund();
- $DB::commit(); touch($dir . '/' . $number . '.done');
- exit(0);
- }
- foreach ([['bot', 'bot'], ['member', 'member'], ['bot', 'member'], ['member', 'bot']] as [$first, $second]) {
- $barrier = $dir . '/' . $first . '-' . $second; mkdir($barrier, 0700);
- $DB::statement('DROP TABLE IF EXISTS bot_promotion_coupon_uses');
- $DB::statement('DROP TABLE IF EXISTS bot_promotion_coupons');
- $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');
- $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');
- $DB::table('promotion_coupons')->insert(['id' => 1, 'status' => 'used']);
- foreach ([1, 2] as $id) $DB::table('promotion_coupon_uses')->insert(['id' => $id, 'coupon_id' => 1, 'source' => 'sport_order', 'order_id' => $id]);
- $start = function ($family, $number) use ($barrier) {
- $process = proc_open([PHP_BINARY, __FILE__, $family, $barrier, (string)$number], [
- 0 => ['pipe', 'r'], 1 => ['file', $barrier . '/' . $number . '.log', 'a'], 2 => ['file', $barrier . '/' . $number . '.log', 'a'],
- ], $pipes);
- if (!is_resource($process)) throw new RuntimeException('Cannot start worker');
- fclose($pipes[0]); return $process;
- };
- $workers = [];
- try {
- $workers[] = $start($first, 1); awaitFile($barrier . '/1.ready');
- $workers[] = $start($second, 2); awaitFile($barrier . '/2.ready');
- usleep(100000); // Both transactions overlap; only the final business state is asserted.
- } finally {
- touch($barrier . '/release');
- foreach ($workers as $worker) same(proc_close($worker), 0, 'Worker failed; see ' . $barrier);
- }
- same($DB::table('promotion_coupon_uses')->whereNull('refunded_at')->count(), 0, 'Both uses are refunded');
- same($DB::table('promotion_coupons')->value('status'), 'unused', 'The last refund restores the coupon despite an old RR snapshot');
- echo "PASS {$first}/{$second} concurrent coupon refunds\n";
- }
|