Files
SGF/scripts/settlement-read-proof.php
T
2026-11-04 09:37:20 +02:00

112 lines
12 KiB
PHP

<?php
// Only entered after the existing owned QA database, role and cluster guards.
if($phase==='settlement-read-http'){
auth()->forgetGuards();app(App\Shared\Context\CompanyContext::class)->clear();
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/read-enter',function(Illuminate\Http\Request $r)use($f){
$u=App\Models\User::findOrFail($f['actors']['read']);auth()->login($u);$r->session()->regenerate();$token=bin2hex(random_bytes(24));
$r->session()->put('auth_version',$u->auth_version);$r->session()->put('company_context',$f['scope']+['token'=>$token,'module_id'=>'purchasing']);
return response()->json(['csrf'=>csrf_token(),'context'=>$token])->header('Cache-Control','no-store');
});
$sql=[];$state=[];
DB::listen(function($event)use(&$sql,&$state){$sql[]=$event->sql;if(!$state&&str_contains($event->sql,'"ps_payments"')){$pdo=$event->connection->getPdo();$state=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}});
$app->handleRequest(Illuminate\Http\Request::capture());
$key=$_SERVER['HTTP_X_QA_READ_PROOF']??'';
if(($_SERVER['REQUEST_METHOD']??'')==='GET'&&preg_match('/^[a-z]{1,20}$/D',$key))file_put_contents($s['root'].'/read-http-'.$key.'.json',json_encode(['status'=>http_response_code(),'transaction'=>$state,'queries'=>count($sql),'writer_lock_queries'=>count(array_filter($sql,fn($q)=>str_contains(strtolower($q),'for update')))],JSON_PRETTY_PRINT));
exit;
}
if($phase==='settlement-read-worker'){
$input=realpath($argv[3]??'');
if(!$input||!str_starts_with($input,realpath($s['root']).DIRECTORY_SEPARATOR))throw new LogicException('Private worker input required');
$w=json_decode(file_get_contents($input),true,64,JSON_THROW_ON_ERROR);
if(!in_array($w['job'],['read','legacy','command'],true)||!preg_match('/^settlement_read_[a-z]+_[a-f0-9]{12}$/D',$w['label']))throw new LogicException('Unknown worker');
DB::select('select set_config(?, ?, false)',['application_name',$w['label']]);DB::statement("SET lock_timeout='40s'");DB::statement("SET statement_timeout='45s'");
$pid=(int)DB::selectOne('select pg_backend_pid() as pid')->pid;file_put_contents($input.'.started',json_encode(['pid'=>$pid]));
$start=hrtime(true);$out=[];
try{
if($w['job']==='command'){
DB::beginTransaction();try{
$validator=$enter('validate');$key=(string)Illuminate\Support\Str::uuid();$body=['proposal_id'=>$g['pending_proposal_id'],'decision'=>'confirmed','reason'=>'Current version revalidated after writer release.'];
$engine=$service->engine($validator)->settlements();$a=$engine->command('allocation-decide',$body,$key);$b=$engine->command('allocation-decide',$body,$key);
$out=['success'=>true,'replayed'=>$b['replayed'],'same_result'=>$a['decision_id']===$b['decision_id'],
'decisions'=>DB::table('ps_allocation_decisions')->where('proposal_id',$g['pending_proposal_id'])->count(),
'allocations_for_target'=>DB::table('ps_allocations')->where(['payment_id'=>$g['payment_id'],'obligation_id'=>end($g['obligation_ids'])])->count(),'worker_changes_rolled_back'=>true];
}finally{while(DB::transactionLevel()>0)DB::rollBack();}
}else{
$sql=[];$state=[];
DB::listen(function($event)use(&$sql,&$state){$sql[]=$event->sql;if(!$state&&str_contains($event->sql,'"ps_payments"')){$pdo=$event->connection->getPdo();$state=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}});
if($w['job']==='legacy'){
$data=$service->engine($u)->settlements()->listing();$original=array_values(array_filter($data['payments'],fn($p)=>$p['id']===$g['payment_id']));
$out=['success'=>true,'unallocated_minor'=>$original[0]['unallocated_minor'],'transaction'=>$state,'writer_lock_queries'=>count(array_filter($sql,fn($q)=>str_contains(strtolower($q),'for update')))];
}else{
$r=Illuminate\Http\Request::create('/api/supplier-payments?'.http_build_query(['payment'=>$g['payment_id'],'source'=>$g['source_ids'][0]]),'GET');$r->setUserResolver(fn()=>$u);$r->setLaravelSession(app('session')->driver());
$data=app(App\Modules\Purchasing\SupplierPaymentsController::class)->index($r)->getData(true);
$out=['success'=>true,'unallocated_minor'=>$data['payments'][0]['unallocated_minor'],'events_total'=>$data['settlement_pagination']['payment_events']['total'],
'transaction'=>$state,'writer_lock_queries'=>count(array_filter($sql,fn($q)=>str_contains(strtolower($q),'for update'))),'queries'=>count($sql)];
}
}
}catch(Throwable $e){$out=['success'=>false,'error_class'=>get_class($e)];}
$out['time_ms']=round((hrtime(true)-$start)/1e6,3);file_put_contents($input.'.result',json_encode($out,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));exit;
}
if($phase==='settlement-read-race'){
if(is_file($s['root'].'/settlement-read-race.json'))throw new LogicException('Completed race proof preserved');
$baseline=json_decode(file_get_contents($s['root'].'/settlement-volume.json'),true,64,JSON_THROW_ON_ERROR);
if($fingerprint()!==$baseline['baseline_fingerprint'])throw new LogicException('Existing fixture changed');
$workers=[];$files=[];
$launch=function(string $job)use(&$workers,&$files,$s,$file){
$input=$s['root'].'/read-race-'.$job.'-'.bin2hex(random_bytes(5)).'.json';
file_put_contents($input,json_encode(['job'=>$job,'label'=>'settlement_read_'.$job.'_'.$s['tag']]));
$log=$input.'.log';$p=proc_open([PHP_BINARY,__DIR__.'/payable-history-sgf-proof.php',$file,'settlement-read-worker',$input],[0=>['file','NUL','r'],1=>['file',$log,'a'],2=>['file',$log,'a']],$pipes,dirname(__DIR__),null,['bypass_shell'=>true]);
if(!is_resource($p))throw new RuntimeException('Could not start owned worker');$workers[$job]=$p;$files[$job]=$input;
};
$waitResult=function(string $job,int $seconds=15)use(&$files){$until=microtime(true)+$seconds;while(!is_file($files[$job].'.result')&&microtime(true)<$until)usleep(20000);if(!is_file($files[$job].'.result'))throw new RuntimeException('Owned worker did not complete: '.$job);return json_decode(file_get_contents($files[$job].'.result'),true,64,JSON_THROW_ON_ERROR);};
DB::beginTransaction();
try{
$parent=(int)DB::selectOne('select pg_backend_pid() as pid')->pid;
DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();
$a=DB::table('ps_allocations')->where('payment_id',$g['payment_id'])->orderBy('id')->skip(1)->first();
$proposal=$command('prepare','reversal-prepare',['allocation_id'=>$a->id,'source_revision'=>$a->source_revision,'reason'=>'Uncommitted correction for isolated read concurrency.']);
$command('validate','reversal-confirm',['proposal_id'=>$proposal['proposal_id'],'reason'=>'Uncommitted independent review for concurrency.']);
$enter('read');
$launch('legacy');$launch('command');
config(['database.connections.read_race_monitor'=>DB::connection()->getConfig()]);$monitor=DB::connection('read_race_monitor');
$blocked=[];$observed=[];$until=microtime(true)+12;
do{
$monitor->select('select pg_stat_clear_snapshot()');
foreach(['legacy','command'] as $job){
$row=$monitor->selectOne('SELECT pid, wait_event_type, wait_event, query, pg_blocking_pids(pid)::text AS blockers FROM pg_stat_activity WHERE datname=current_database() AND application_name=?',['settlement_read_'.$job.'_'.$s['tag']]);
if($row)$observed[$job]=['pid'=>$row->pid,'wait_type'=>$row->wait_event_type,'wait'=>$row->wait_event,'blockers'=>$row->blockers,'company_statement'=>str_contains($row->query,'"companies"'),'for_update'=>str_contains(strtolower($row->query),'for update')];
}
foreach($observed as $job=>$row){
$direct=json_decode(str_replace(['{','}'],['[',']'],$row['blockers']));$reachesParent=in_array($parent,$direct,true);
foreach($observed as $other)if(in_array((int)$other['pid'],$direct,true)&&in_array($parent,json_decode(str_replace(['{','}'],['[',']'],$other['blockers'])),true))$reachesParent=true;
if($row['wait_type']==='Lock'&&$row['company_statement']&&$row['for_update']&&$reachesParent)$blocked[$job]=['blocking_chain_reaches_parent'=>true,'direct_blockers'=>$direct,'statement'=>'companies FOR UPDATE','wait_event'=>$row['wait']];
}
if(count($blocked)===2)break;usleep(20000);
}while(microtime(true)<$until);
if(count($blocked)!==2){file_put_contents($s['root'].'/read-race-diagnostic-'.time().'.json',json_encode(['parent'=>$parent,'observed'=>$observed,'detected'=>$blocked],JSON_PRETTY_PRINT));throw new RuntimeException('Legacy read and confirmation must both wait on the parent company lock');}
$launch('read');$read=$waitResult('read',10);
if(!$read['success']||$read['unallocated_minor']!==41||$read['events_total']!==9||$read['writer_lock_queries']!==0||$read['transaction']!==['isolation'=>'repeatable read','read_only'=>'on']||DB::transactionLevel()!==1)throw new RuntimeException('New reader did not preserve committed read-only snapshot');
$stillBlocked=[];foreach(['legacy','command'] as $job)$stillBlocked[$job]=!is_file($files[$job].'.result');
if(in_array(false,$stillBlocked,true))throw new RuntimeException('Writer lock was not retained during the reader');
DB::rollBack();
$legacy=$waitResult('legacy');$confirmed=$waitResult('command');
if(!$legacy['success']||$legacy['unallocated_minor']!==41||$legacy['writer_lock_queries']<2||!$confirmed['success']||!$confirmed['replayed']||!$confirmed['same_result']||$confirmed['decisions']!==1||$confirmed['allocations_for_target']!==1)throw new RuntimeException('Post-release command/read result differs');
if($fingerprint()!==$baseline['baseline_fingerprint'])throw new RuntimeException('Race left financial writes behind');
file_put_contents($s['root'].'/settlement-read-race.json',json_encode(['passed'=>true,'blocker'=>'parent transaction on companies row','waiting'=>$blocked,'reader_completed_before_release'=>true,'uncommitted_reversal_not_seen'=>true,'read'=>$read,'legacy_read_after_release'=>$legacy,'confirmation_after_release'=>$confirmed,'financial_fingerprint_equal'=>true,'not_throughput_capacity'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo "Reader finished before writer release; legacy reader and confirmation waited, then completed with one decision/allocation. All writes rolled back.\n";
}finally{
while(DB::transactionLevel()>0)DB::rollBack();
foreach($workers as $p){$status=proc_get_status($p);if($status['running'])proc_terminate($p);proc_close($p);}DB::purge('read_race_monitor');
}exit;
}
if($phase==='settlement-read-cases'){require __DIR__.'/settlement-read-cases.php';exit;}
if($phase==='settlement-read-post'){
if(is_file($s['root'].'/settlement-read-post.json'))throw new LogicException('Completed post-check preserved');
DB::beginTransaction();DB::statement('SET TRANSACTION READ ONLY');try{
$baseline=json_decode(file_get_contents($s['root'].'/settlement-volume.json'),true,64,JSON_THROW_ON_ERROR);$hash=$fingerprint();
if($hash!==$baseline['baseline_fingerprint'])throw new LogicException('Financial facts changed during read-isolation proof');
file_put_contents($s['root'].'/settlement-read-post.json',json_encode(['passed'=>true,'read_only'=>true,'financial_fingerprint_equal'=>true,'business_fingerprint'=>$hash,'payment_count'=>DB::table('ps_payments')->count(),'pending_decision_absent'=>!DB::table('ps_allocation_decisions')->where('proposal_id',$g['pending_proposal_id'])->exists(),'old_post_file_sha256'=>hash_file('sha256',$s['root'].'/settlement-post.json')],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}finally{DB::rollBack();}echo "Financial baseline and original pending decision preserved after concurrency and HTTP.\n";exit;
}
throw new LogicException('Unknown read isolation proof phase');