Initial commit
This commit is contained in:
148
app/Models/EvaluationLock.php
Normal file
148
app/Models/EvaluationLock.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user