Deweloperzy pluginów WordPress często sięgają po set_transient(), gdy zadanie nie może uruchomić się dwa razy. Ustaw klucz, sprawdź go na wejściu, usuń na wyjściu. Kod wygląda jak blokada i może zachowywać się jak ona przez wiele miesięcy. Potem żądanie crona nakłada się na kliknięcie administratora, startują dwa importy, a stany magazynowe lub ceny zaczynają się rozjeżdżać.
Błąd wynika z traktowania dwóch problemów jak jednego. Długie zadanie potrzebuje atomowej blokady startu, aby dwa żądania nie mogły uruchomić go równocześnie. Potrzebuje też trwałego stanu uruchomienia, aby późniejsze żądanie widziało pracę kontynuowaną przez procesy Action Scheduler. Blokada doradcza MySQL może zapewnić pierwszą właściwość. Rekord w wp_options może zapewnić drugą. Nie wystarczy jednak mieć oba mechanizmy. Trwały stan musi zostać zmieniony w czasie, gdy blokada atomowa jest trzymana.
Iteracja pierwsza: transient jest stanem cache, nie blokadą
Typowa pierwsza próba wygląda tak:
if ( get_transient( 'plugin_import_lock' ) ) {
return;
}
set_transient( 'plugin_import_lock', 1, 2 * HOUR_IN_SECONDS );
start_import();
To wyścig typu check-then-act. Żądania A i B mogą odczytać „brak transientu”, zanim którekolwiek zapisze klucz. Oba przejdą dalej.
Opakowanie tych wywołań w helper nie zmienia gwarancji. Przerwa może się zmniejszyć, ale nadal istnieje. Poprawność nie może zależeć od tego, czy jedno żądanie PHP dotrze do zapisu kilka milisekund przed drugim.
Jest też drugi problem: WordPress definiuje transienty jako wpisy cache. Mogą znajdować się w wp_options albo zewnętrznym object cache i mogą zniknąć przed zadeklarowanym terminem wygaśnięcia. Ta elastyczność jest przydatna dla danych podręcznych. Nie jest właściwym kontraktem dla autorytatywnego stanu zadania.
Część pluginów usuwa transient po zakończeniu pierwszej partii, choć kolejne partie nadal trwają. Blokada znika, a zadanie nie. Drugi start wygląda wtedy na dozwolony, mimo że praca wciąż trwa.
Iteracja druga: GET_LOCK() jest atomowy, ale ograniczony do sesji
Blokada doradcza MySQL jest realnym ulepszeniem:
SELECT GET_LOCK('plugin_import_start', 0);
Baza danych szereguje dostęp do nazwy blokady. Dwie sesje nie mogą trzymać jej jednocześnie, więc może chronić krótką sekcję decydującą, czy wolno rozpocząć nowe uruchomienie.
Nie może jednak reprezentować całego importu. MySQL zwalnia nazwaną blokadę jawnie albo po zakończeniu sesji. W typowym żądaniu WordPress handler startowy zdobywa blokadę, planuje pracę Action Scheduler, zwraca odpowiedź i zamyka połączenie z bazą. Partie wykonywane przez następne dwadzieścia lub sześćdziesiąt minut działają w innych procesach i sesjach.
To prawidłowe zachowanie mutexu. Błędem jest rozszerzenie jego zakresu w naszym modelu myślowym z jednej sesji bazy danych na całą operację logiczną.
Drugie żądanie administratora przychodzące dwadzieścia minut później zastanie wolną blokadę doradczą. Bez trwałego stanu może dodać do kolejki kolejny import tych samych danych.
Iteracja trzecia: zablokuj, sprawdź, zapisz, zaplanuj
Bezpieczna sekwencja wygląda tak:
GET_LOCK()- zablokuj sekcję startu.- Odczytaj stan z
wp_optionsw tej samej sekcji krytycznej. - Wyjdź, gdy
plugin_run_is_active()zwraca true. - Zapisz nowy rekord z
run_id. as_enqueue_async_action()na pierwszą partię z tymrun_id.RELEASE_LOCK()wfinally.
Kolejność ma znaczenie. Sprawdzenie flagi przed GET_LOCK() może być szybką ścieżką, ale nie może być kontrolą rozstrzygającą. Żądanie może odczytać stan „bezczynny”, zatrzymać się, a następnie zdobyć blokadę już po zapisaniu nowego uruchomienia przez inne żądanie. Stan trzeba sprawdzić ponownie wewnątrz sekcji krytycznej.
Zakładając, że plugin_run_is_active() zwraca true, gdy status to running, a heartbeat_at jest nowszy niż przyjęty w projekcie próg nieaktualności, zwarta implementacja może wyglądać tak:
function plugin_start_import() {
global $wpdb;
$lock_name = $wpdb->prefix . 'plugin_import_start';
$got_lock = (int) $wpdb->get_var(
$wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock_name )
);
if ( 1 !== $got_lock ) {
return new WP_Error( 'import_busy', 'Another start request is in progress.' );
}
try {
$state = get_option( 'plugin_import_state', [] );
if ( plugin_run_is_active( $state ) ) {
return new WP_Error( 'import_running', 'Import already running.' );
}
$run_id = wp_generate_uuid4();
$state = [
'run_id' => $run_id,
'status' => 'running',
'started_at' => time(),
'heartbeat_at' => time(),
];
update_option( 'plugin_import_state', $state, false );
$written = get_option( 'plugin_import_state', [] );
if ( ( $written['run_id'] ?? '' ) !== $run_id ) {
return new WP_Error( 'state_write_failed', 'Could not record import state.' );
}
$action_id = as_enqueue_async_action(
'plugin_import_batch',
[ 'run_id' => $run_id ],
'plugin-import'
);
if ( 0 === $action_id ) {
delete_option( 'plugin_import_state' );
return new WP_Error( 'enqueue_failed', 'Could not enqueue import.' );
}
return $run_id;
} finally {
$wpdb->get_var(
$wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name )
);
}
}
Blokada doradcza ma jedno wąskie zadanie: szereguje przejście ze stanu bezczynnego do aktywnego. Opcja ma inne: opisuje logiczne uruchomienie po zakończeniu żądania i sesji bazy danych. Nazywanie obu mechanizmów „blokadami” zaciera tę różnicę.
Action Scheduler ma też argument $unique w as_enqueue_async_action(). Gdy jest true, pomija planowanie, jeśli inna oczekująca lub wykonywana akcja ma ten sam hook i grupę. To łapie część podwójnych prób dodania do kolejki, ale nie śledzi wielopartiiowego uruchomienia, nie udostępnia heartbeatu ani odzyskiwania w panelu. To strażnik kolejki, a nie własność uruchomienia.
W kodzie produkcyjnym nazwa blokady powinna być specyficzna dla aplikacji i mieścić się w limicie długości MySQL. Trzeba też upewnić się, że każde konkurujące żądanie trafia do tego samego serwera bazy. Blokada doradcza działa na poziomie serwera, a nie globalnie między niezależnymi głównymi instancjami bazy. Przy rozdziale odczytu i zapisu odczytuj stan uruchomienia z tego samego połączenia, które trzyma GET_LOCK(), a nie z opóźnionej repliki. Jeśli opcje są cache’owane w Redis albo Memcached, pomiń cache dla autorytatywnego stanu albo trzymaj uruchomienia w osobnej tabeli.
Trwały stan potrzebuje właściciela i procedury odzyskiwania
Boolean w rodzaju plugin_import_in_progress = 1 jest lepszy niż brak stanu, ale nie wystarcza do niezawodnego odzyskiwania. Jeśli worker umrze, flaga może pozostać na zawsze. Jeśli administrator ją wyczyści i uruchomi nowy import, opóźniony worker starego uruchomienia może później zakończyć pracę i usunąć stan nowego.
Identyfikator uruchomienia zapobiega temu błędowi własności. Każda zaplanowana partia przenosi identyfikator, a każda zmiana stanu sprawdza, czy zapisana wartość nadal do niego pasuje. Stary worker może zgłosić błąd, ale nie może zakończyć ani odblokować nowszego uruchomienia. Handlery partii powinny być też idempotentne: Action Scheduler może ponowić nieudaną partię, a ponowienie nie może dwa razy zastosować tej samej części pracy.
Stan powinien zawierać również informacje czasowe pozwalające odróżnić wolną pracę od porzuconej:
started_atzapisuje początek operacji logicznej.heartbeat_atjest odświeżany po każdej udanej partii.statusrozróżniarunning,failed,completedi ewentualniecancelling.- Akcja odzyskiwania w panelu może oznaczyć nieaktualne uruchomienie jako zakończone błędem przed dopuszczeniem kolejnego.
Nie traktuj automatycznie każdego starego znacznika czasu jako zgody na ponowny start. Partia może być wolna, a nie martwa. Próg nieaktualności powinien wynikać z obserwowanego czasu wykonania partii. Stan warto pokazać w panelu i zapisywać w logu, kto wymusił odzyskanie.
To ta sama zasada, która stoi za bramkami jakości egzekwującymi regułę zamiast jedynie ją dokumentować. „Nie klikaj dwa razy” jest punktem listy kontrolnej. Atomowa zmiana stanu jest wykonywalnym ograniczeniem.
Ten wzorzec wykracza poza WordPress
Każdy system działający w dwóch skalach czasowych ma ten sam problem projektowy: rywalizację na poziomie milisekund podczas dopuszczenia i widoczność stanu przez minuty wykonywania. Krótki mutex rozwiązuje pierwszy problem. Trwały stan rozwiązuje drugi.
Redis, osobna tabela zadań albo kolejka z gwarancją unikalności mogą inaczej rozdzielać te odpowiedzialności. Architektura nadal musi odpowiedzieć na te same pytania:
- Kto jest właścicielem bieżącego uruchomienia?
- Która operacja atomowo zmienia stan z bezczynnego na aktywny?
- Jak kolejne workery dowodzą, że należą do tego uruchomienia?
- Jak wykrywany i odzyskiwany jest porzucony stan?
Implementacja może korzystać z jednego lub kilku systemów przechowywania danych. Gwarancje pozostają oddzielne.
Co sprawdzić przy przeglądzie zadań w tle w pluginie
Gdy plugin WordPress planuje długą pracę, traktuj get_transient() połączony z set_transient() jako ostrzeżenie dotyczące współbieżności. Traktuj samodzielny GET_LOCK() jako rozwiązanie niepełne, jeśli zadanie trwa dłużej niż żądanie, które zdobyło blokadę.
Następnie sprawdź przejście stanu, a nie tylko użyte składniki. Trwały stan musi zostać odczytany i zapisany podczas trzymania blokady atomowej. Workery muszą przenosić tożsamość uruchomienia. Zakończenie i odzyskiwanie mogą modyfikować tylko stan, którego nadal są właścicielami.
To różnica między kodem, który wygląda, jakby miał blokadę, a procesem w tle zachowującym poprawność przy nakładającym się cronie, ponowieniach, awariach workerów i niecierpliwych kliknięciach administratora. Ten sam wzorzec błędu widać w importach produktów WooCommerce, które wyglądają na udane, choć stany magazynowe i ceny się rozjeżdżają. Zakoduj te warunki przed startem pierwszej partii, a nie w skrypcie naprawczym po zapisaniu zduplikowanych danych.