SharingMysqlConcurrencyTest.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Services\Sharing\ShareSettlementService;
  4. use Illuminate\Support\Facades\DB;
  5. use PHPUnit\Framework\TestCase;
  6. use Tests\Fixtures\SharingMysqlSupport;
  7. require_once dirname(__DIR__) . '/fixtures/sharing_mysql_support.php';
  8. /**
  9. * Opt-in, real InnoDB row-lock tests. Never loads either project's .env or connects over TCP.
  10. * SHARING_MYSQL_SOCKET must belong to a new --skip-networking server under the guarded tmp prefix.
  11. * The runner starts and stops its own server; this suite owns only randomly named test databases.
  12. */
  13. class SharingMysqlConcurrencyTest extends TestCase
  14. {
  15. private ?\PDO $server = null;
  16. private string $socket;
  17. private string $database;
  18. private string $runDir;
  19. private array $processes = [];
  20. protected function setUp(): void
  21. {
  22. $socket = getenv('SHARING_MYSQL_SOCKET');
  23. if (!$socket) $this->markTestSkipped('Set SHARING_MYSQL_SOCKET to a new isolated, socket-only MySQL fixture; no existing database is allowed');
  24. if (!in_array('mysql', \PDO::getAvailableDrivers(), true)) $this->markTestSkipped('The isolated PHP runtime has no pdo_mysql driver');
  25. $this->socket = $socket;
  26. $this->server = SharingMysqlSupport::guardedPdo($socket);
  27. $this->database = 'share_concurrency_' . bin2hex(random_bytes(6));
  28. $this->server->exec('CREATE DATABASE `' . $this->database . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
  29. $this->runDir = dirname($socket) . '/workers-' . bin2hex(random_bytes(6));
  30. mkdir($this->runDir, 0700);
  31. SharingMysqlSupport::laravel($socket, $this->database);
  32. SharingMysqlSupport::schema();
  33. SharingMysqlSupport::seed();
  34. }
  35. protected function tearDown(): void
  36. {
  37. foreach ($this->processes as $worker) {
  38. if (is_resource($worker['process'])) {
  39. if (proc_get_status($worker['process'])['running']) proc_terminate($worker['process']);
  40. foreach ($worker['pipes'] as $pipe) if (is_resource($pipe)) fclose($pipe);
  41. proc_close($worker['process']);
  42. }
  43. }
  44. $this->processes = [];
  45. if ($this->server !== null) {
  46. DB::disconnect();
  47. $this->server->exec('DROP DATABASE IF EXISTS `' . $this->database . '`');
  48. foreach (glob($this->runDir . '/*') ?: [] as $file) unlink($file);
  49. rmdir($this->runDir);
  50. $this->server = null;
  51. \Carbon\Carbon::setTestNow();
  52. \Illuminate\Support\Facades\Facade::clearResolvedInstances();
  53. }
  54. parent::tearDown();
  55. }
  56. public function test_same_tp_request_in_two_processes_reserves_once_and_returns_the_same_claim(): void
  57. {
  58. [$a, $b] = $this->race(['framework' => 'tp', 'request_id' => 'same-request'], ['framework' => 'tp', 'request_id' => 'same-request']);
  59. $this->assertTrue($a['ok']); $this->assertTrue($b['ok']);
  60. $this->assertSame($a['result']['id'], $b['result']['id']);
  61. $this->assertSame('10.01', $a['result']['amount']);
  62. $this->assertSame(1, DB::table('share_claims')->count());
  63. $this->assertSame('10.0100000000', $this->amount('reserved_amount'));
  64. $this->assertSame('20.0000000000', $this->wallet());
  65. }
  66. public function test_different_tp_requests_in_two_processes_leave_only_one_pending_claim(): void
  67. {
  68. [$a, $b] = $this->race(['framework' => 'tp', 'request_id' => 'request-a'], ['framework' => 'tp', 'request_id' => 'request-b']);
  69. $this->assertTrue($a['ok']); $this->assertFalse($b['ok']);
  70. $this->assertStringContainsString('已有待审核', $b['message']);
  71. $this->assertSame(1, DB::table('share_claims')->where('active_user_id', 1)->count());
  72. $this->assertSame('10.0100000000', $this->amount('reserved_amount'));
  73. $this->assertSame('0.0000000000', $this->amount('paid_amount'));
  74. }
  75. public function test_concurrent_laravel_approvals_credit_one_wallet_log_and_audit(): void
  76. {
  77. $claim = $this->single(['framework' => 'tp', 'request_id' => 'approve-once']);
  78. $this->assertTrue($claim['ok']);
  79. $args = ['framework' => 'laravel', 'action' => 'audit', 'claim_id' => $claim['result']['id']];
  80. [$a, $b] = $this->race($args, $args);
  81. $this->assertTrue($a['ok']); $this->assertTrue($b['ok']);
  82. $this->assertSame('approved', $a['result']['status']);
  83. $this->assertSame($a['result']['credited_log_id'], $b['result']['credited_log_id']);
  84. $this->assertSame('30.0100000000', $this->wallet());
  85. $this->assertSame('10.0100000000', $this->amount('paid_amount'));
  86. $this->assertSame('0.0000000000', $this->amount('reserved_amount'));
  87. $this->assertSame(1, DB::table('balance_logs')->where('change_type', '分享佣金领取')->count());
  88. $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'share_claim')->count());
  89. }
  90. public function test_correction_holding_account_blocks_approval_and_preserves_the_original_pending_request(): void
  91. {
  92. $id = $this->settledClaim();
  93. DB::table('share_bet_facts')->update(['effective_amount' => '50', 'net_profit' => '-50']);
  94. [$corrected, $approval] = $this->race(['framework' => 'laravel', 'action' => 'settle'], ['framework' => 'laravel', 'action' => 'audit', 'claim_id' => $id]);
  95. $this->assertTrue($corrected['ok']); $this->assertFalse($approval['ok']);
  96. $this->assertStringContainsString('重新申请', $approval['message']);
  97. $this->assertSame('5.0000000000', $this->amount('earned_amount'));
  98. $this->assertSame('0.0000000000', $this->amount('paid_amount'));
  99. $this->assertSame('10.0000000000', $this->amount('reserved_amount'));
  100. $this->assertSame('pending', DB::table('share_claims')->where('id', $id)->value('status'));
  101. $this->assertSame('20.0000000000', $this->wallet());
  102. $this->assertSame(0, DB::table('balance_logs')->where('change_type', '分享佣金领取')->count());
  103. $before = DB::table('share_ledger')->count();
  104. $this->assertSame(0, (new ShareSettlementService())->settle('2026-10-24')['changed']);
  105. $this->assertSame($before, DB::table('share_ledger')->count());
  106. }
  107. public function test_approval_holding_account_credits_once_then_correction_becomes_debt_without_wallet_debit(): void
  108. {
  109. $id = $this->settledClaim();
  110. DB::table('share_bet_facts')->update(['effective_amount' => '50', 'net_profit' => '-50']);
  111. [$approval, $correction] = $this->race(['framework' => 'laravel', 'action' => 'audit', 'claim_id' => $id], ['framework' => 'laravel', 'action' => 'settle']);
  112. $this->assertTrue($approval['ok']); $this->assertTrue($correction['ok']);
  113. $this->assertSame('30.0000000000', $this->wallet());
  114. $this->assertSame('10.0000000000', $this->amount('paid_amount'));
  115. $this->assertSame('5.0000000000', $this->amount('earned_amount'));
  116. $this->assertSame('0.0000000000', $this->amount('reserved_amount'));
  117. $this->assertSame('approved', DB::table('share_claims')->where('id', $id)->value('status'));
  118. $this->assertSame(1, DB::table('balance_logs')->where('change_type', '分享佣金领取')->count());
  119. $this->assertSame(2, DB::table('share_ledger')->count());
  120. $this->assertSame(0, (new ShareSettlementService())->settle('2026-10-24')['changed']);
  121. $this->assertSame('30.0000000000', $this->wallet());
  122. }
  123. public function test_two_settlement_processes_serialize_one_revision_and_one_ledger_delta(): void
  124. {
  125. DB::table('share_accounts')->where('user_id', 1)->update(['earned_amount' => 0]);
  126. $args = ['framework' => 'laravel', 'action' => 'settle'];
  127. [$a, $b] = $this->race($args, $args);
  128. $this->assertTrue($a['ok']); $this->assertTrue($b['ok']);
  129. $this->assertSame(1, $a['result']['changed']); $this->assertSame(0, $b['result']['changed']);
  130. $this->assertSame('10.0000000000', $this->amount('earned_amount'));
  131. $this->assertSame(1, DB::table('share_ledger')->count());
  132. $this->assertSame(1, DB::table('share_settlements')->count());
  133. $this->assertSame(1, (int)DB::table('share_settlements')->value('revision'));
  134. }
  135. public function test_lottery_fact_swap_is_one_publication_and_settlement_waits_for_the_complete_parent_order(): void
  136. {
  137. DB::table('share_bet_facts')->delete();
  138. DB::table('share_accounts')->where('user_id', 1)->update(['earned_amount' => 0]);
  139. $schema = DB::connection()->getSchemaBuilder();
  140. $schema->create('lhc_order', function ($t) {
  141. $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('type'); $t->integer('lottery_status');
  142. $t->decimal('amount', 24, 10); $t->decimal('total_amount', 24, 10); $t->bigInteger('created_at'); $t->bigInteger('updated_at');
  143. });
  144. $schema->create('lhc_order_item', function ($t) {
  145. $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('lottery_status');
  146. $t->decimal('win_amount', 24, 10); $t->decimal('profit_and_loss', 24, 10); $t->bigInteger('updated_at');
  147. });
  148. $time = \Carbon\Carbon::parse('2026-10-24 12:00:00', 'Asia/Shanghai')->timestamp;
  149. DB::table('lhc_order')->insert(['id' => 1, 'member_id' => 'M2', 'ordernum' => 'atomic-swap', 'type' => 5,
  150. 'lottery_status' => 2, 'amount' => 100, 'total_amount' => 200, 'created_at' => $time, 'updated_at' => $time]);
  151. DB::table('lhc_order_item')->insert([
  152. ['id' => 101, 'member_id' => 'M2', 'ordernum' => 'atomic-swap', 'lottery_status' => 2,
  153. 'win_amount' => 150, 'profit_and_loss' => 50, 'updated_at' => $time],
  154. ['id' => 102, 'member_id' => 'M2', 'ordernum' => 'atomic-swap', 'lottery_status' => 1,
  155. 'win_amount' => 0, 'profit_and_loss' => -100, 'updated_at' => $time],
  156. ]);
  157. $baseline = (new \App\Services\Sharing\BetFactSynchronizer())->sync('2026-10-24', '2026-10-24', true);
  158. $this->assertSame([], $baseline['errors']); $this->assertSame(2, $baseline['created']);
  159. $this->assertSame('150.0000000000', bcadd((string)DB::table('share_bet_facts')->sum('effective_amount'), '0', 10));
  160. $this->assertSame(1, (new ShareSettlementService())->settle('2026-10-24')['changed']);
  161. $this->assertSame('15.0000000000', $this->amount('earned_amount'));
  162. DB::table('lhc_order_item')->where('id', 101)->update(['lottery_status' => 1, 'win_amount' => 0, 'profit_and_loss' => -100, 'updated_at' => $time + 1]);
  163. DB::table('lhc_order_item')->where('id', 102)->update(['lottery_status' => 2, 'win_amount' => 150, 'profit_and_loss' => 50, 'updated_at' => $time + 1]);
  164. [$sync, $settled] = $this->race(['framework' => 'laravel', 'action' => 'sync'], ['framework' => 'laravel', 'action' => 'settle'], function ($held) {
  165. // Inside the source-order transaction the first changed child temporarily makes 200.
  166. // Another connection must still see the old, committed 150 and settlement must wait.
  167. $this->assertSame('200.0000000000', file_get_contents($held['partial']));
  168. $this->assertSame('150.0000000000', bcadd((string)DB::table('share_bet_facts')->sum('effective_amount'), '0', 10));
  169. });
  170. $this->assertTrue($sync['ok']); $this->assertSame([], $sync['result']['errors']); $this->assertSame(2, $sync['result']['updated']);
  171. $this->assertTrue($settled['ok']); $this->assertSame(0, $settled['result']['changed']);
  172. $this->assertSame('150.0000000000', bcadd((string)DB::table('share_settlements')->value('direct_performance'), '0', 10));
  173. $this->assertSame('150.0000000000', bcadd((string)DB::table('share_bet_facts')->sum('effective_amount'), '0', 10));
  174. $this->assertSame('15.0000000000', $this->amount('earned_amount'));
  175. $this->assertSame(1, DB::table('share_ledger')->count());
  176. $this->assertSame(1, (int)DB::table('share_settlements')->value('revision'));
  177. }
  178. private function settledClaim(): int
  179. {
  180. DB::table('share_accounts')->where('user_id', 1)->update(['earned_amount' => 0]);
  181. $this->assertSame(1, (new ShareSettlementService())->settle('2026-10-24')['changed']);
  182. $claim = $this->single(['framework' => 'tp', 'request_id' => 'corrected-claim']);
  183. $this->assertTrue($claim['ok']); $this->assertSame('10.00', $claim['result']['amount']);
  184. return (int)$claim['result']['id'];
  185. }
  186. private function race(array $first, array $second, ?callable $whileHeld = null): array
  187. {
  188. $a = $this->spawn($first + ['hold' => true]);
  189. try {
  190. $this->waitFile($a['locked']);
  191. if ($whileHeld) $whileHeld($a);
  192. $b = $this->spawn($second);
  193. $this->waitFile($b['ready']);
  194. $deadline = microtime(true) + 5;
  195. do {
  196. $waiting = (int)$this->server->query('SELECT COUNT(*) FROM performance_schema.data_lock_waits')->fetchColumn();
  197. if ($waiting > 0) break;
  198. usleep(10000);
  199. } while (microtime(true) < $deadline);
  200. $this->assertGreaterThan(0, $waiting, 'The second real database transaction must contend on an InnoDB row lock');
  201. } finally {
  202. touch($a['release']);
  203. }
  204. return [$this->collect($a), $this->collect($b)];
  205. }
  206. private function single(array $args): array { return $this->collect($this->spawn($args)); }
  207. private function spawn(array $args): array
  208. {
  209. $token = bin2hex(random_bytes(6));
  210. $params = $args + ['socket' => $this->socket, 'database' => $this->database,
  211. 'tp_root' => getenv('SHARING_TP_ROOT') ?: dirname(__DIR__, 3) . '/melbet_sport-api',
  212. 'laravel_autoload' => getenv('AGENT_TEST_AUTOLOAD') ?: dirname(__DIR__, 2) . '/vendor/autoload.php',
  213. 'ready' => $this->runDir . '/' . $token . '.ready', 'locked' => $this->runDir . '/' . $token . '.locked',
  214. 'partial' => $this->runDir . '/' . $token . '.partial',
  215. 'release' => $this->runDir . '/' . $token . '.release'];
  216. $process = proc_open([PHP_BINARY, '-d', 'xdebug.mode=off', '-d', 'error_reporting=8191',
  217. dirname(__DIR__) . '/fixtures/sharing_mysql_worker.php', json_encode($params, JSON_THROW_ON_ERROR)],
  218. [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
  219. if (!is_resource($process)) throw new \RuntimeException('Cannot start an isolated worker process');
  220. fclose($pipes[0]); unset($pipes[0]);
  221. foreach ($pipes as $pipe) stream_set_blocking($pipe, false);
  222. $worker = $params + ['process' => $process, 'pipes' => $pipes, 'token' => $token];
  223. $this->processes[$token] = $worker;
  224. return $worker;
  225. }
  226. private function collect(array $worker): array
  227. {
  228. $deadline = microtime(true) + 20;
  229. while (proc_get_status($worker['process'])['running']) {
  230. if (microtime(true) > $deadline) throw new \RuntimeException('A fixture worker did not finish');
  231. usleep(10000);
  232. }
  233. $stdout = stream_get_contents($worker['pipes'][1]); $stderr = stream_get_contents($worker['pipes'][2]);
  234. foreach ($worker['pipes'] as $pipe) fclose($pipe);
  235. proc_close($worker['process']); unset($this->processes[$worker['token']]);
  236. $decoded = json_decode($stdout, true);
  237. if (!is_array($decoded)) throw new \RuntimeException('Worker produced no JSON result: ' . $stdout . $stderr);
  238. return $decoded;
  239. }
  240. private function waitFile(string $path): void
  241. {
  242. $deadline = microtime(true) + 15;
  243. while (!is_file($path)) {
  244. if (microtime(true) > $deadline) throw new \RuntimeException('Worker did not reach expected synchronization point: ' . basename($path));
  245. usleep(10000);
  246. }
  247. }
  248. private function amount(string $field): string { return bcadd((string)DB::table('share_accounts')->where('user_id', 1)->value($field), '0', 10); }
  249. private function wallet(): string { return bcadd((string)DB::table('wallets')->where('member_id', 'M1')->value('available_balance'), '0', 10); }
  250. }