149 lines
4.9 KiB
PHP
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();
|
|
}
|
|
}
|