42ebbe769c
Разговор обзвона, начатый при пустом счёте, теперь списывается целиком, счёт уходит в минус, и долг остаётся за клиентом — решение владельца Р38, требование Т61а. Числа из спеки: было 12 рублей, списали 102 — на счету минус 90, а в летописи одна строка на 102. Разрешение выдано поимённо, белым списком: только ai_call. У яндекса, СМС и телеграма поведение не изменилось ни на копейку — списание по-прежнему упирается в ноль. Доказано A/B на одном дереве: 424, 425 и 330 тестов трёх каналов дали ровно те же числа и с правкой, и без неё. Проверка платёжеспособности научилась называть причину остановки: долг по обзвону и непокрытые заморозки — разные вещи. Сам вердикт isSolvent не изменён ни байтом: по решению Р56 минус гасит клиенту всю рекламу, и письмо задачи З-1.7 обязано назвать обе остановки сразу. Различать нужно, чтобы назвать, а не чтобы смягчить приговор. Попутно, и это касается уже работающих денег рекламы: возврат refund был единственным из пяти методов, кто не ставил контекст клиента. Замерено запуском, а не чтением — под боевой ролью без контекста метод падал ModelNotFoundException, а не молчал. Живым деньгам не грозило: единственный зовущий, PollClientSmsDelivery Command, ставит контекст сам во внешней транзакции. Теперь ставит и сам метод. Обрезка нулём для трёх старых каналов сохранена намеренно, но перестала быть молчаливой: на ней пишется предупреждение ad_wallet.charge_clamped_to_zero. До сих пор съеденная разница между летописью и остатком пропадала без следа. Сторожа: tests/Feature/Obzvon/AdWalletMinusTest.php и AdWalletMinusRaceTest.php. Все показаны красными пятью поломками. Гонка проверена параллельным прогоном шести и четырёх настоящих процессов, а не рассуждением: без замка по строке кошелька теряется пять списаний из шести. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
6.9 KiB
PHP
163 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AdWallet;
|
|
use App\Models\AdWalletTransaction;
|
|
use App\Models\Tenant;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* З-1.2, проверка 6: два одновременных списания по одному клиенту не задваивают и
|
|
* не теряют. 🔴 Гонка проверяется ПАРАЛЛЕЛЬНЫМ ПРОГОНОМ, а не рассуждением.
|
|
*
|
|
* Здесь НЕТ `DatabaseTransactions`: настоящим процессам нужны настоящие,
|
|
* закоммиченные данные — из чужой незавершённой транзакции они не увидят ничего.
|
|
* Поэтому за собой убираем руками, в finally.
|
|
*
|
|
* `pcntl_fork` на Windows нет (см. AuditChainRaceConditionTest), поэтому работников
|
|
* запускаем как отдельные процессы `php`, каждый со своим соединением к базе, и
|
|
* сводим их к общему мгновению старта: без общего мгновения второй успевал бы
|
|
* закончить раньше, чем стартовал первый, и гонки бы не было вовсе.
|
|
*/
|
|
function z12RabotnikSkript(): string
|
|
{
|
|
$base = str_replace('\\', '/', base_path());
|
|
$skript = <<<PHP
|
|
<?php
|
|
// argv: 1=база 2=мгновение старта 3=тенант 4=сумма 5=ключ 6=номер звонка
|
|
putenv('DB_DATABASE='.\$argv[1]);
|
|
\$_ENV['DB_DATABASE'] = \$argv[1];
|
|
\$_SERVER['DB_DATABASE'] = \$argv[1];
|
|
|
|
require '{$base}/vendor/autoload.php';
|
|
\$app = require '{$base}/bootstrap/app.php';
|
|
\$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
|
|
// Общее мгновение старта: все работники ждут одну и ту же отметку времени.
|
|
\$ozhidanie = ((float) \$argv[2]) - microtime(true);
|
|
if (\$ozhidanie > 0) {
|
|
usleep((int) (\$ozhidanie * 1000000));
|
|
}
|
|
|
|
try {
|
|
app(App\Services\Advertising\AdWalletService::class)->charge(
|
|
(int) \$argv[3], 'ai_call', 'obzvon_call', (int) \$argv[6], \$argv[4], \$argv[5],
|
|
);
|
|
\$ishod = 'OK';
|
|
} catch (Throwable \$e) {
|
|
\$ishod = 'УПАЛ: '.get_class(\$e).' '.\$e->getMessage();
|
|
}
|
|
|
|
// Отчитываемся, к КАКОЙ базе сходили: иначе работник мог бы молча уйти в чужую.
|
|
echo config('database.connections.pgsql.database').'|'.\$ishod."\\n";
|
|
PHP;
|
|
|
|
$put = sys_get_temp_dir().'/z12_rabotnik_'.getmypid().'.php';
|
|
file_put_contents($put, $skript);
|
|
|
|
return $put;
|
|
}
|
|
|
|
/**
|
|
* Запускает работников ОДНОВРЕМЕННО и возвращает их отчёты.
|
|
*
|
|
* @param list<array{0:string,1:string,2:int}> $zadaniya [сумма, ключ, номер звонка]
|
|
* @return list<string>
|
|
*/
|
|
function z12ParallelnyyProgon(string $skript, int $tenantId, array $zadaniya): array
|
|
{
|
|
$baza = config('database.connections.pgsql.database');
|
|
$start = microtime(true) + 6.0; // запас на раскрутку Laravel в каждом процессе
|
|
$php = PHP_BINARY;
|
|
|
|
$processy = [];
|
|
foreach ($zadaniya as $z) {
|
|
$cmd = escapeshellarg($php).' '.escapeshellarg($skript).' '
|
|
.escapeshellarg((string) $baza).' '.escapeshellarg((string) $start).' '
|
|
.escapeshellarg((string) $tenantId).' '.escapeshellarg($z[0]).' '
|
|
.escapeshellarg($z[1]).' '.escapeshellarg((string) $z[2]);
|
|
|
|
$pipes = [];
|
|
$p = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
$processy[] = [$p, $pipes];
|
|
}
|
|
|
|
$otchety = [];
|
|
foreach ($processy as [$p, $pipes]) {
|
|
$out = trim((string) stream_get_contents($pipes[1]));
|
|
$err = trim((string) stream_get_contents($pipes[2]));
|
|
fclose($pipes[1]);
|
|
fclose($pipes[2]);
|
|
proc_close($p);
|
|
$otchety[] = $out !== '' ? $out : ('ПУСТО, stderr: '.$err);
|
|
}
|
|
|
|
return $otchety;
|
|
}
|
|
|
|
function z12UbratZaSoboy(int $tenantId): void
|
|
{
|
|
DB::table('ad_wallet_transactions')->where('tenant_id', $tenantId)->delete();
|
|
DB::table('ad_wallet_holds')->where('tenant_id', $tenantId)->delete();
|
|
DB::table('ad_wallets')->where('tenant_id', $tenantId)->delete();
|
|
DB::table('tenants')->where('id', $tenantId)->delete();
|
|
}
|
|
|
|
it('шесть одновременных списаний разными ключами не теряют ни копейки и доводят счёт до минуса', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3.00', 'frozen_rub' => '0.00']);
|
|
$skript = z12RabotnikSkript();
|
|
|
|
try {
|
|
$zadaniya = [];
|
|
for ($i = 1; $i <= 6; $i++) {
|
|
$zadaniya[] = ['1.00', 'z12:race:raznye:'.$i, $i];
|
|
}
|
|
|
|
$otchety = z12ParallelnyyProgon($skript, $tenant->id, $zadaniya);
|
|
|
|
// Работники обязаны отчитаться ИМЕНЕМ базы: чужая база обесценила бы замер.
|
|
$baza = config('database.connections.pgsql.database');
|
|
foreach ($otchety as $o) {
|
|
expect($o)->toBe($baza.'|OK');
|
|
}
|
|
|
|
// 3 ₽ было, 6 ₽ списали шестью независимыми событиями → ровно минус 3 ₽.
|
|
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('-3.00')
|
|
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
|
|
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(6);
|
|
} finally {
|
|
z12UbratZaSoboy($tenant->id);
|
|
@unlink($skript);
|
|
}
|
|
});
|
|
|
|
it('четыре одновременных списания с ОДНИМ ключом двигают деньги ровно один раз', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '12.00', 'frozen_rub' => '0.00']);
|
|
$skript = z12RabotnikSkript();
|
|
|
|
try {
|
|
$zadaniya = [];
|
|
for ($i = 1; $i <= 4; $i++) {
|
|
$zadaniya[] = ['102.00', 'z12:race:odin-klyuch', 1];
|
|
}
|
|
|
|
$otchety = z12ParallelnyyProgon($skript, $tenant->id, $zadaniya);
|
|
|
|
$baza = config('database.connections.pgsql.database');
|
|
foreach ($otchety as $o) {
|
|
expect($o)->toBe($baza.'|OK');
|
|
}
|
|
|
|
// Числа из спеки, но списанные вчетвером разом: минус 90 ₽, а не минус 396 ₽.
|
|
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('-90.00')
|
|
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
|
|
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
|
|
} finally {
|
|
z12UbratZaSoboy($tenant->id);
|
|
@unlink($skript);
|
|
}
|
|
});
|