Files
vkv/app/Models/EvaluationLock.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

149 lines
4.9 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon;
/**
* Model: EvaluationLock
*
* Účel:
* - Reprezentuje aplikační zámek (lock) pro vyhodnocovací procesy.
* - Slouží k zabránění souběžného spuštění více vyhodnocovacích běhů
* nad stejným rozsahem dat (scope).
*
* Kontext v architektuře:
* - Používá se při spuštění vyhodnocení (StartEvaluationRunJob)
* a během celé evaluation pipeline.
* - Je součástí mechanismu ochrany proti race condition a kolizím
* mezi paralelně běžícími background joby.
*
* Princip fungování:
* - Každý lock je identifikován unikátním klíčem (`key`), který
* reprezentuje zamčený rozsah dat, typicky např.:
* - `round:{round_id}`
* - `round:{round_id}:band:{band_id}`
* - Při pokusu o vytvoření locku se spoléhá na unikátní index v DB.
* Pokud záznam s daným klíčem již existuje, znamená to, že jiný
* vyhodnocovací běh nad stejným scope již probíhá.
*
* Pole modelu:
* - key:
* Jednoznačný identifikátor zamčeného scope (unikátní v DB).
* - evaluation_run_id:
* Reference na EvaluationRun, který lock vlastní.
* - locked_at:
* Čas, kdy byl lock získán.
* - expires_at:
* Volitelný čas expirace locku (slouží jako bezpečnostní pojistka
* proti "visícím" lockům při havárii procesu).
*
* Co model NEDĚLÁ:
* - neřeší automatické obnovování nebo expirování locků
* - neobsahuje byznys logiku vyhodnocení
* - nesupluje databázové transakce
*
* Zásady návrhu:
* - Lock musí být získáván a uvolňován atomicky.
* - Locky musí být vždy uvolněny ve finálním kroku vyhodnocení
* (FinalizeRunJob), případně při chybě.
* - Existence locku je autoritativní zdroj informace o tom,
* zda je daný scope právě zpracováván.
*/
class EvaluationLock extends Model
{
protected $table = 'evaluation_locks';
protected $fillable = [
'key',
'evaluation_run_id',
'locked_at',
'expires_at',
];
protected $casts = [
'evaluation_run_id' => 'integer',
'locked_at' => 'datetime',
'expires_at' => 'datetime',
];
public function evaluationRun(): BelongsTo
{
return $this->belongsTo(EvaluationRun::class);
}
/**
* Pokusí se získat lock pro daný klíč.
*
* - Pokud existuje expirovaný lock, před pokusem ho smaže.
* - Pokud už aktivní lock existuje, vrátí null.
*
* @param string $key Jedinečný identifikátor scope (např. "round:5").
* @param \App\Models\EvaluationRun|null $run Volitelný běh, ke kterému se lock váže.
* @param \DateInterval|int|\Carbon\Carbon|null $ttl Doba platnosti (sekundy nebo DateInterval nebo konkrétní expirace).
*/
public static function acquire(string $key, ?EvaluationRun $run = null, \DateInterval|int|Carbon|null $ttl = null): ?self
{
$now = Carbon::now();
// uklidíme expirovaný lock se stejným klíčem, aby neblokoval unikátní index
self::where('key', $key)
->whereNotNull('expires_at')
->where('expires_at', '<', $now)
->delete();
$expiresAt = null;
if ($ttl instanceof Carbon) {
$expiresAt = $ttl;
} elseif ($ttl instanceof \DateInterval) {
$expiresAt = (clone $now)->add($ttl);
} elseif (is_int($ttl)) {
$expiresAt = (clone $now)->addSeconds($ttl);
}
try {
return self::create([
'key' => $key,
'evaluation_run_id' => $run?->id,
'locked_at' => $now,
'expires_at' => $expiresAt,
]);
} catch (QueryException $e) {
// unikátní klíč porušen -> lock už drží někdo jiný
if (str_contains(strtolower($e->getMessage()), 'duplicate') || str_contains(strtolower($e->getMessage()), 'unique')) {
return null;
}
throw $e;
}
}
/**
* Uvolní lock podle klíče (a volitelně evaluation_run_id).
*/
public static function release(string $key, ?EvaluationRun $run = null): int
{
$query = self::where('key', $key);
if ($run) {
$query->where('evaluation_run_id', $run->id);
}
return $query->delete();
}
/**
* Zjistí, zda lock existuje a neexpiroval.
*/
public static function isLocked(string $key): bool
{
$now = Carbon::now();
return self::where('key', $key)
->where(function ($q) use ($now) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', $now);
})
->exists();
}
}