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"; }