mini_api_address_concurrency_test.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. <?php
  2. /** Two independent MySQL connections, deterministically interleaved at the enterprise lock. */
  3. require dirname(__DIR__) . '/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. date_default_timezone_set('Asia/Shanghai');
  7. use app\miniapi\auth\Principal;
  8. use app\miniapi\exception\ApiException;
  9. use app\miniapi\model\MiniAudit;
  10. use app\miniapi\model\MiniCommand;
  11. use app\miniapi\model\MiniGrant;
  12. use app\miniapi\model\MiniUser;
  13. use app\miniapi\service\Access;
  14. use app\miniapi\service\EnterpriseCommands;
  15. use app\miniapi\service\WaterPresenter;
  16. use Illuminate\Container\Container;
  17. use Illuminate\Database\ConnectionResolver;
  18. use Illuminate\Database\Connectors\ConnectionFactory;
  19. use plugin\saiadmin\app\model\water\WaterAccount;
  20. use plugin\saiadmin\app\model\water\WaterDelivery;
  21. use plugin\saiadmin\app\model\water\WaterEnterprise;
  22. use plugin\saiadmin\app\model\water\WaterLedger;
  23. use plugin\saiadmin\app\model\water\WaterSku;
  24. use plugin\saiadmin\app\service\WaterBusiness;
  25. use support\Model;
  26. $checks = 0;
  27. function check(bool $ok, string $message): void
  28. {
  29. global $checks;
  30. $checks++;
  31. if (!$ok) {
  32. throw new RuntimeException($message);
  33. }
  34. }
  35. $db = (new MiniUser())->getConnection();
  36. $originalResolver = Model::getConnectionResolver();
  37. $peer = (new ConnectionFactory(new Container()))->make($db->getConfig(), 'mini_concurrency_peer');
  38. $peerResolver = new ConnectionResolver(['mini_concurrency_peer' => $peer]);
  39. $peerResolver->setDefaultConnection('mini_concurrency_peer');
  40. $enterprise = $sku = $user = 0;
  41. $armed = false;
  42. $tag = 'MINI-RACE-' . bin2hex(random_bytes(8));
  43. $now = date('Y-m-d H:i:s');
  44. try {
  45. check($peer->getPdo() !== $db->getPdo(), 'test requires independent connections');
  46. // Commit only isolated fixtures so the second connection can see them.
  47. $db->transaction(function () use (&$enterprise, &$sku, &$user, $tag, $now) {
  48. $enterprise = WaterEnterprise::query()->insertGetId([
  49. 'name' => $tag, 'contact' => 'Concurrency test', 'phone' => '13812345678',
  50. 'address' => $tag . '-original', 'created_at' => $now, 'updated_at' => $now,
  51. ]);
  52. $sku = WaterSku::query()->insertGetId([
  53. 'name' => $tag, 'liters' => 18.9, 'available' => 10, 'created_at' => $now, 'updated_at' => $now,
  54. ]);
  55. WaterAccount::query()->insert([
  56. 'enterprise_id' => $enterprise, 'sku_id' => $sku, 'entitlement' => 10, 'updated_at' => $now,
  57. ]);
  58. $user = MiniUser::query()->insertGetId([
  59. 'appid' => $tag, 'openid' => $tag, 'created_at' => $now, 'updated_at' => $now,
  60. ]);
  61. MiniGrant::query()->insert([
  62. 'user_id' => $user, 'role' => 'enterprise_admin', 'resource_id' => $enterprise,
  63. 'created_by' => 0, 'created_at' => $now, 'updated_at' => $now,
  64. ]);
  65. });
  66. $revision = WaterPresenter::revision(WaterEnterprise::query()->findOrFail($enterprise));
  67. $interleaved = false;
  68. $armed = true;
  69. $db->beforeExecuting(function (string $sql) use (&$armed, &$interleaved, $db, $peerResolver, $originalResolver, $enterprise, $sku) {
  70. if (!$armed || !str_contains($sql, 'sa_water_enterprise') || !str_contains(strtolower($sql), 'for update')) {
  71. return;
  72. }
  73. $armed = false;
  74. check($db->transactionLevel() > 0, 'address transaction already started');
  75. check(!WaterDelivery::query()->where('enterprise_id', $enterprise)->exists(), 'address snapshot predates booking');
  76. try {
  77. Model::setConnectionResolver($peerResolver);
  78. (new WaterBusiness())->createDelivery(['enterprise_id' => $enterprise, 'sku_id' => $sku, 'quantity' => 2], 0);
  79. } finally {
  80. Model::setConnectionResolver($originalResolver);
  81. }
  82. check(!WaterDelivery::query()->where('enterprise_id', $enterprise)->exists(), 'plain read still misses committed booking under REPEATABLE READ');
  83. $interleaved = true;
  84. });
  85. try {
  86. (new EnterpriseCommands(new Access(new Principal((string) $user))))->address($enterprise, [
  87. 'request_key' => $tag . '-address', 'revision' => $revision,
  88. 'address' => $tag . '-moved', 'contact' => 'Concurrency test', 'phone' => '13812345678',
  89. ]);
  90. throw new RuntimeException('Address change must reject the concurrent booking');
  91. } catch (ApiException $error) {
  92. check($error->getCode() === 40902, 'current read rejects active task');
  93. }
  94. check($interleaved, 'booking committed before enterprise lock');
  95. check(WaterEnterprise::query()->findOrFail($enterprise)->address === $tag . '-original', 'address preserved');
  96. check(WaterDelivery::query()->where('enterprise_id', $enterprise)->count() === 1, 'concurrent booking remains committed');
  97. check(!MiniCommand::query()->where('user_id', $user)->exists(), 'rejected command not persisted');
  98. } finally {
  99. $armed = false;
  100. Model::setConnectionResolver($originalResolver);
  101. while ($db->transactionLevel() > 0) {
  102. $db->rollBack();
  103. }
  104. $peer->disconnect();
  105. // Exact fixture IDs only; existing enterprises, inventory and users are never modified.
  106. $db->transaction(function () use ($enterprise, $sku, $user) {
  107. if ($enterprise) {
  108. WaterLedger::query()->where('enterprise_id', $enterprise)->delete();
  109. WaterDelivery::query()->where('enterprise_id', $enterprise)->delete();
  110. WaterAccount::query()->where('enterprise_id', $enterprise)->delete();
  111. WaterEnterprise::query()->where('id', $enterprise)->delete();
  112. }
  113. if ($sku) {
  114. WaterSku::query()->where('id', $sku)->delete();
  115. }
  116. if ($user) {
  117. MiniAudit::query()->where('actor_type', 'mini')->where('actor_id', $user)->delete();
  118. MiniCommand::query()->where('user_id', $user)->delete();
  119. MiniGrant::query()->where('user_id', $user)->delete();
  120. MiniUser::query()->where('id', $user)->delete();
  121. }
  122. });
  123. }
  124. check(!WaterEnterprise::query()->where('id', $enterprise)->exists(), 'enterprise fixture cleaned');
  125. check(!WaterSku::query()->where('id', $sku)->exists(), 'inventory fixture cleaned');
  126. check(!MiniUser::query()->where('id', $user)->exists(), 'identity fixture cleaned');
  127. echo "PASS: $checks two-connection address checks; isolated fixtures cleaned.\n";