15df5b4a46
Two tests: 1. pcntl_fork concurrent-INSERT test (skipped on Windows/no pcntl) — demonstrates chain branch when 5 workers insert into the same partition simultaneously; passes after advisory-lock migration. 2. pg_locks advisory lock presence test (Windows-compatible) — verifies that pg_advisory_xact_lock is actually held in pg_locks during an INSERT transaction, proving the migration works. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
127 lines
4.8 KiB
PHP
127 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Tenant;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
/**
|
|
* Race-condition reproduction test for audit_chain_hash() trigger.
|
|
*
|
|
* Two tests:
|
|
* 1. pcntl_fork-based concurrent INSERT test — skipped on Windows (no pcntl).
|
|
* Expected: FAIL before migration (concurrent inserts branch the chain),
|
|
* PASS after migration (advisory lock serialises inserts).
|
|
*
|
|
* 2. pg_locks advisory lock presence test — runs on Windows.
|
|
* Asserts that within an INSERT transaction the advisory lock key derived
|
|
* from the partition OID is held (proves the lock is actually acquired).
|
|
*/
|
|
|
|
it(
|
|
'audit_chain_hash trigger preserves sequential chain under concurrent INSERTs',
|
|
function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
|
|
|
$startCount = DB::table('activity_log')
|
|
->where('tenant_id', $tenant->id)
|
|
->count();
|
|
|
|
// Spawn 5 concurrent processes each inserting into activity_log for the same tenant.
|
|
// Without advisory lock, concurrent reads of prev_hash return the same value
|
|
// → multiple rows hash to the same prev → chain branch → validator fails.
|
|
$pids = [];
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$pid = pcntl_fork();
|
|
if ($pid === 0) {
|
|
// Child: own DB connection, own transaction
|
|
DB::reconnect();
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
|
DB::table('activity_log')->insert([
|
|
'tenant_id' => $tenant->id,
|
|
'event' => 'deal.created',
|
|
'context' => json_encode(['worker' => $i]),
|
|
'created_at' => now(),
|
|
]);
|
|
exit(0);
|
|
}
|
|
$pids[] = $pid;
|
|
}
|
|
foreach ($pids as $pid) {
|
|
pcntl_waitpid($pid, $status);
|
|
}
|
|
|
|
$rows = DB::table('activity_log')
|
|
->where('tenant_id', $tenant->id)
|
|
->orderBy('id')
|
|
->get(['id', 'log_hash']);
|
|
|
|
expect($rows->count())->toBe($startCount + 5);
|
|
|
|
// Run the chain validator; it should find no mismatches (after migration).
|
|
$exitCode = $this->artisan('audit:verify-chains')->run();
|
|
expect($exitCode)->toBe(0);
|
|
}
|
|
)->skip(! function_exists('pcntl_fork'), 'pcntl required for race-condition test (not available on Windows)');
|
|
|
|
it('audit_chain_hash holds pg_advisory_xact_lock on the partition OID during INSERT', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
|
|
|
// Resolve the OID of the current-month activity_log partition (or parent).
|
|
$partitionName = 'activity_log_y'.date('Y').'_m'.date('m');
|
|
$oid = DB::selectOne(
|
|
"SELECT COALESCE(
|
|
(SELECT c.oid FROM pg_class c WHERE c.relname = ?),
|
|
(SELECT c.oid FROM pg_class c WHERE c.relname = 'activity_log')
|
|
) AS oid",
|
|
[$partitionName]
|
|
)?->oid;
|
|
|
|
expect($oid)->not->toBeNull('Could not resolve partition/parent OID');
|
|
|
|
// Compute the lock key using the same formula as the trigger:
|
|
// ('x' || lpad(to_hex(TG_RELID::int), 16, '0'))::bit(64)::bigint
|
|
$lockKeyRow = DB::selectOne(
|
|
"SELECT ('x' || lpad(to_hex(?::int), 16, '0'))::bit(64)::bigint AS lock_key",
|
|
[(int) $oid]
|
|
);
|
|
$lockKey = $lockKeyRow?->lock_key;
|
|
expect($lockKey)->not->toBeNull();
|
|
|
|
// Wrap an INSERT in a transaction and check pg_locks DURING that transaction.
|
|
$lockHeld = false;
|
|
DB::transaction(function () use ($tenant, $lockKey, &$lockHeld): void {
|
|
DB::table('activity_log')->insert([
|
|
'tenant_id' => $tenant->id,
|
|
'event' => 'deal.created',
|
|
'context' => json_encode(['test' => 'advisory_lock_check']),
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
// pg_advisory_xact_lock releases at END of transaction — still held here.
|
|
$held = DB::selectOne(
|
|
'SELECT EXISTS (
|
|
SELECT 1
|
|
FROM pg_locks
|
|
WHERE locktype = \'advisory\'
|
|
AND classid = (? >> 32)::int
|
|
AND objid = (? & x\'ffffffff\'::bigint)::int
|
|
AND granted = true
|
|
AND pid = pg_backend_pid()
|
|
) AS held',
|
|
[(int) $lockKey, (int) $lockKey]
|
|
);
|
|
$lockHeld = (bool) ($held->held ?? false);
|
|
});
|
|
|
|
expect($lockHeld)->toBeTrue(
|
|
'pg_advisory_xact_lock was not observed in pg_locks during the INSERT transaction. '
|
|
.'This means the migration has not been applied or the lock key formula is wrong.'
|
|
);
|
|
});
|