Files
huntingclubroster/tests/transaction_retries.php

25 lines
3.8 KiB
PHP

<?php
if(PHP_SAPI!=='cli')exit;
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
require __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();$ids=json_decode(getenv('HCR_TENANT_IDS'),true,512,JSON_THROW_ON_ERROR);$checks=0;
function retryCheck(bool $ok,string $label):void{global $checks;if(!$ok)throw new RuntimeException($label);$checks++;echo 'PASS: '.$label."\n";}
$insert=static function(PDO $db,string $subject)use($ids):void{$s=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');$s->execute([$ids['clubA'],$ids['membera'],$subject]);};
$attempts=0;$value=DatabaseTransaction::run($db,function(PDO $db)use(&$attempts,$insert){$attempts++;$insert($db,'Retried transaction');if($attempts<3)$db->exec("SIGNAL SQLSTATE '40001' SET MYSQL_ERRNO=1213,MESSAGE_TEXT='Controlled deadlock'");return 'committed';});
retryCheck($attempts===3&&$value==='committed','Deadlock retries return the successful whole-transaction result');
retryCheck((int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Retried transaction'")->fetchColumn()===1,'Retry rollbacks prevent duplicate ticket writes');
$attempts=0;try{DatabaseTransaction::run($db,function(PDO $db)use(&$attempts,$insert){$attempts++;$insert($db,'Exhausted transaction');$db->exec("SIGNAL SQLSTATE 'HY000' SET MYSQL_ERRNO=1205,MESSAGE_TEXT='Controlled timeout'");});throw new RuntimeException('Expected timeout');}catch(PDOException $e){retryCheck($attempts===3&&(int)$e->errorInfo[1]===1205,'Lock timeout retries stop after three attempts');}
retryCheck(!$db->inTransaction()&&!(int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Exhausted transaction'")->fetchColumn(),'Exhausted retries leave no open transaction or partial write');
$attempts=0;try{DatabaseTransaction::run($db,function(PDO $db)use(&$attempts,$insert){$attempts++;$insert($db,'Rejected transaction');throw new AppException('Denied',403);});}catch(AppException $e){retryCheck($attempts===1&&$e->status===403,'Business and permission errors are never retried');}
retryCheck(!(int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Rejected transaction'")->fetchColumn(),'Permission failure rolls back its writes');
// Simulate a grant changing after the first attempt rolls back, before retry.
class RevocationPDO extends PDO {
public $afterRollback;
public function rollBack():bool{$ok=parent::rollBack();if($this->afterRollback){$call=$this->afterRollback;$this->afterRollback=null;$call();}return $ok;}
}
$settings=(new Base())->getSettings();$retryDb=new RevocationPDO('mysql:host='.$settings->DatabaseServer.';port='.$settings->DatabasePort.';dbname='.$settings->DatabaseName.';charset=utf8mb4',(string)$settings->DatabaseUser,(string)$settings->DatabasePassword,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC]);
$_SESSION=[Base::USER_SERIAL=>$ids['admina2']];$retryDb->afterRollback=function()use($db,$ids){$db->exec("UPDATE club_memberships SET membership_role='member' WHERE membership_serial=".$ids['clubA_admina2']);};$attempts=0;
try{DatabaseTransaction::run($retryDb,function(PDO $db)use(&$attempts,$ids,$insert){$attempts++;(new TenantAccess())->requireManager($ids['clubA'],$db);$insert($db,'Revoked transaction');$db->exec("SIGNAL SQLSTATE '40001' SET MYSQL_ERRNO=1213,MESSAGE_TEXT='Controlled deadlock'");});throw new RuntimeException('Revoked write was allowed');}catch(AppException $e){retryCheck($attempts===2&&$e->status===403,'Authorization is checked again after a concurrent revocation');}
retryCheck(!(int)$db->query("SELECT COUNT(*) FROM club_tickets WHERE subject='Revoked transaction'")->fetchColumn(),'Revoked authority cannot commit a retried write');
echo "$checks transaction retry checks passed.\n";