Files
2026-08-29 15:53:53 +03:00

62 lines
3.2 KiB
PHP

<?php
namespace App\Serp\Operations;
use App\Models\SerpBudgetEntry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class BudgetManager
{
public function __construct(private KillSwitch $kill, private OperationAuditLogger $audit, private RampUpPolicy $ramp) {}
public function reserve(string $key, string $mode, ?int $checkId = null): SerpBudgetEntry
{
return Cache::lock('serp-budget-global', 10)->block(5, function () use ($key, $mode, $checkId) {
$result = DB::transaction(function () use ($key, $mode, $checkId) {
if ($e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first()) {
return $e;
}
$this->ramp->assertAllowsNewPaid();
$estimate = Money::micros((string) config('serp.estimated_costs.'.$mode));
$day = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
$month = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
if ($day + $estimate > Money::micros((string) config('serp.daily_budget_usd')) || $month + $estimate > Money::micros((string) config('serp.monthly_budget_usd'))) {
return false;
}
return SerpBudgetEntry::create(['reservation_key' => $key, 'serp_check_id' => $checkId, 'mode' => $mode, 'reserved_micros' => $estimate, 'currency' => 'USD', 'reserved_at' => now('UTC')]);
});
if ($result === false) {
$this->kill->activate('budget_exceeded');
$this->audit->record('budget_blocked', ['cost_micros' => Money::micros((string) config('serp.estimated_costs.'.$mode))]);
throw new OperationBlockedException('budget_blocked');
}
$this->audit->record('budget_reserved', ['mode' => $mode, 'cost_micros' => $result->reserved_micros]);
return $result;
});
}
public function reconcile(string $key, string|int|float|null $actual, ?string $taskId = null): void
{
DB::transaction(function () use ($key, $actual, $taskId) {
$e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first();
if (! $e || $e->reconciled_at) {
return;
}$e->forceFill(['provider_task_id' => $taskId, 'actual_micros' => Money::micros($actual), 'reconciled_at' => now('UTC')])->save();
$this->audit->record('budget_reconciled', ['cost_micros' => $e->actual_micros]);
});
}
public function usage(): array
{
$daily = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
$monthly = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
return ['daily_micros' => $daily, 'monthly_micros' => $monthly];
}
}