Quartz czyli scheduling ze śmietnika historii

Quartz to narzędzie, które jest niestety popularnym wynikiem przy wyszukiwaniu haseł „scheduler”, „job”, „Java”. Strona domowa projektu tytułuje się „Enterprise Job Scheduler”. „Enterprise” ma sugerować, że to rozwiązanie poważne, więcej: skalowalne na duże projekty. W ramach wzbogacenia dyskusji chciałbym dorzucić inną opinię: że Quartz to niebezpieczny szrot, którego nieusuwalne wady mogą zarżnąć waszą produktywność i spowodować incydenty na produkcji.

Przejdziemy od kodu z błędami N+1 zapytań, przez alarmujący stan projektu, do architektury z single point of failure.

Napisać można dowolne bzdury, nawet że Quartz jest dobry

Smutnym faktem jest, że ciągle powstają nowe artykuły traktujące Quartz jako coś normalnego. Znalazłem artykuł na Baeldung z 11 maja tego roku. Na Medium ktoś dzieli się swoimi poradami o Quartzu, data to 19 sierpnia. Nie czytałem, szkoda mi pieniędzy.

Co byście pomyśleli, gdyby ktoś teraz zaproponował wam schowany za paywallem tutorial z wdrożenia na produkcji Javy 8? Pewnie że upadł na głowę. Otóż najnowsza generacja Quartza powstawała mniej więcej w czasach Javy 8.

Również smutne jest, że hasło „Quartz” można znaleźć w aktualnej dokumentacji Spring Boota i że ta marka w pewien sposób firmuje i poleca złom.

Nie enterprise, lecz legacy scheduler

Pierwszy powód, żeby nie używać Quartza, to jego historia i perspektywy na przyszłość.

Wersja 2.2.0 wyszła w 2013 roku. 77% aktywnych teraz programistów nie pracowała jeszcze wtedy w zawodzie (według JetBrains Developer Ecosystem 2023). Nie była jeszcze gotowa Java 8. Nie było Spring Boota! Immutable types, lambdy – to były jakieś wymysły zmarginalizowanych scalowców, do javowego mainstreamu wtedy się nie przebiły. W modzie były takie potworki jak Java Serialization, Enterprise Java Beans itd. Te potworki też zainspirowały pewnie twórców Quartza.

W roku 2017 wyszedł Quartz 2.3.0. Dopiero planowano Javę 11. Spring Boot był w wersji 1.5.

Rok 2019 przyniósł Quartz 2.3.2. Ostatni. Nowszych wydań stabilnych nie ma.

W roku 2023 coś zaczęło się dziać, pojawiła się wersja release candidate, potem jeszcze cztery, ale nic stabilnego z tego się nie urodziło.

Rdzewiejący złom

Co to znaczy, że projekt przez 5 lat nie wypuścił żadnej stabilnej wersji, nawet patch version?

Ktoś może powiedzieć, że dlatego, że w kwestii schedulingu wymyślono już wszystko. Powie, że kod robi co trzeba, więc nie potrzebuje ulepszeń.

Ja mówię, że to znaczy, że nie wolno używać takiego narzędzia. Nie ma opiekunów projektu, który byliby w stanie zrobić nawet najprostsze zmiany w kodzie. Projekt oderwał się od rzeczywistości i nie ma widoków na zmianę. Nie można liczyć, że błędy a nawet luki bezpieczeństwa będą naprawiane.

Wrzucenie nieutrzymywanego kodu na produkcję to rosyjska ruletka. Moim zdaniem, jeśli ktoś wstawia Quartza do pisanych od zera projektów, w roku 2024, to jest po prostu nieodpowiedzialny.

Kod spaghetti

Na wypadek, gdyby ktoś upierał się, że kod jest tak dobry, że już nie ma co w nim zmieniać, polecam zajrzeć do klasy org.quartz.impl.jdbcjobstore.JobStoreSupport. Ponad 4000 linii. Metody po 100 linijek. 9 todosów. Zakomentowany kod. Złamana chyba każda zasada clean code.

W świecie zawodowego programowania, do którego jestem przyzwyczajony, za pisanie kodu w ten sposób wylatuje się z hukiem z rozmowy kwalifikacyjnej. Nie dziwię się, że ciężko robić najmniejsze nawet zmiany w takim projekcie.

Możesz powiedzieć, że nieważne jaki kod, skoro działa, dopisywać niczego w nim nie będziesz. Nie zgadzam się, bo gdy miałem incydent produkcyjny z bardzo dziwnie zachowującym się Quartzem, tylko kod źródłowy mógł wyjaśnić, co się stało. Biblioteki muszą mieć czytelny kod, choćby po to, żeby dało się go łatwo czytać przy analizie problemów.

Tymczasem Quartz to rozbuchane warstwy abstrakcji, jak to było w modzie 20 lat temu. Trzeba robić 4 skoki nawigacji, żeby przejść od źle ponazywanej, niezrozumiałej logiki do konkretnej operacji na bazie danych. Z tym „konkretnym” to nie do końca prawda, bo trafiamy na lepienie zapytania ze stałych zdefiniowanych w innym pliku. Zrobienie modelu mentalnego, jak coś działa, wymaga zbudowania ogromnego kontekstu. Czytanie w takim spaghetti, który wątek jak zakłada lock, naprawdę nie jest łatwe.

Single point of failure

Jedną z podstawowych zasad nowoczesnej (czyli powstałej po czasach Quartza) architektury jest unikanie Single point of failure.

Jeśli błąd występuje w jednym REST-owym endpoincie, nie do pomyślenia jest, że powoduje to niedostępność innego endpontu.

Jeśli kod obsługujący jeden topic w Kafce jest błędny, nie ma prawa to mieć wpływu na sprawność obsługi innego topicu.

Tymczasem w Quartzu wystarczy jeden job, którego ładowanie powoduje błąd, żeby całkowicie przestał działać mechanism misfire handler. Żaden inny job nie będzie tam już przetwarzany. Koniec gry. Nie ma żadnych mechanizmów naprawczych (self-healing). Trzeba ręcznie usunąć dany wiersz z bazy danych, a póki tego nie zrobimy, duża część schedulera utraci dostępność.

Na ile poważna jest taka awaria? Misfire to sytuacja, kiedy scheduler nie zdążył w zaplanowany czas uruchomić zadania. Nie zdarza się za każdym razem, ale też nie jest czymś nienormalnym. Normalnie działający system potrzebuje do poprawnego działania obsługi misfires. Nie może pozwolić sobie na totalną awarię tej części Quartza.

Scheduler może uniemożliwić refaktoring

Co może spowodować błąd ładowania joba? Nawet niewinna zmiana w kodzie. Quartz zapisuje w bazie danych klasy Javy za pomocą przedpotopowej serializacji (tej z java.io.Serializable). Która nie radzi sobie z modyfikacjami tak prostymi, jak zmiana pakietu.

Scenariusz z życia wzięty: ktoś usuwa niepotrzebną klasę. To był naprawdę nikomu niepotrzebny kod, ale 100 dni później następuje incydent produkcyjny w Quartzu. Co się stało?

Był job uruchamiany co 100 dni. W bazie była przechowywania serializowana klasa. Klasa została usunięta, więc deserializacja nie udała się. Jak pisałem w „Single point of failure”, jeden job, którego nie da się odczytać, blokuje jakiekolwiek odczytywanie w mechanizmie misfires. Co gorsza, nie były wykonywane nawet zwykłe zadania, nieklasyfikowane jako misfires. Czemu? Powodem jest „skalowalność” Quartza, o której za chwilę.

Czy można Quartzowi „powiedzieć”, żeby użył nowego pakietu? Nie, bo przechowywana jest zserializowana klasa, jako blob, nie da się tego „zaktualizować”. Jedyne, co pozostaje, to skasować job z bazy danych. Co nie jest łatwe do zrobienia z kodu, ze względu na rażące błędy w implementacji tej operacji. Najpewniej będziecie musieli usuwać „na piechotę”.

Jeśli ktoś zajrzy do implementacji QuartzScheduler#deleteJob, zobaczy (po ponad 6 skokach w nawigacji), że kod zaczyna od pobrania wszystkich triggerów. Robi to za pomocą błędu N+1 zapytań: najpierw wyciąga listę identyfikatorów triggerów. Potem w pętli pobiera każdy trigger jednym zapytaniem, zaciągając wszystkie możliwe pola (jest ich 16, w tym jeden blob) i wczytując dla każdego triggera klasę Javy z blobu. Co wybuchnie wyjątkiem, jeśli klasa zniknęła. Czyli tym API nie da się usunąć nieprawidłowego joba. Najgorsze jest to, że kod robi błąd za błędem pobierając dane, których w ogóle nie potrzebuje. Po dostaniu listy triggerów wyciąga tylko ich klucze:

List<Trigger> triggers = getTriggersOfJob(jobKey);
for (Trigger trigger : triggers) {
    if (!unscheduleJob(trigger.getKey())) {
        throw new SchedulerException(sb.toString());
    }
 }

Może gdyby zastosowali większe cohesion, a nie rozsmarowali logikę po 4-5 plikach, to łatwiej by zauważyli, że operacja usuwania nie powinna wołać operacji pobierania triggera ze wszelkimi możliwymi szczegółami.

Skalowalność

Na papierze, Quartz jest rozwiązaniem skalowalnym, klasy „enterprise”. Możemy skonfigurować liczbę wątków, więc nie powinien martwić nas wzrost liczby zadań. Można by napisać „pełna skalowalność wertykalna i horyzontalna” w folderze reklamowym dla „klientów klasy enterprise”.

Tyle, że jak już pisałem, ta architektura ma single point of failure.

Nie jest łatwo dokopać się do mało chwalebnej informacji, że wewnątrz Quartza jest założony global lock i ma miejsce walka o zasoby. Jest wątek pobierający z bazy „normalne” zadania, jest inny wątek, pobierający z bazy zadania „misfire”. Oba wątki walczą o jeden lock regulujący dostęp do triggerów. Jeśli załadowanie triggera się nie uda, następują jeszcze 2 próby i dopiero potem zwalniany jest lock. Nieważne, że w przypadku błędu serializacji nie ma najmniejszego sensu próbować ponownie! Taki pomysł twórców Quartza powoduje, że wątek „misfire” jest w stanie zagłodzić wątek normalnej pracy, spędzając czas na busy-waitingu podczas trzymania blokady.

Wtedy nie ma znaczenia, że przeznaczyliśmy 100 wątków na 100 maszynach do wykonywania zadań. Zagłodzony został jeden jedyny wątek, który te zadania wyciąga z bazy, więc wszystkie pozostałe będę bezużyteczne, bo nie otrzymają pracy. Brawo.

Zakończenie

Łatwość znalezienia w wyszukiwarce informacji o Quartzu, do tego aktualnych, powoduje zgodnie z availability bias, że programiści będą uważali Quartz za coś godnego użycia. Być może starszy kolega będzie na hasło „scheduler” od razu rzucał „Quartz”. Nie dlatego, że rozumie konsekwencje stosowania, ale dlatego, że tylko o takiej opcji słyszał, nigdy nie użył czegoś innego. Albo dlatego, że przekonują go rozwiązania z „enterprise” w nazwie, a co stoi za nazwą, tego już nie sprawdza zbyt dokładnie.

Quartz może służyć za pouczający przykład. Fakt, że ludzie od lat piszą o jakiejś technologii, wcale nie znaczy, że ta technologia żyje. Nie znaczy też, że dobrze działa. Może w gorszym przypadku znaczyć tylko tyle, że ludzie nie mają o czym pisać (więc piszą o byle czym, byle łapać zasięgi) i nie uczą się na błędach.

Dla Quartza nie ma nadzieli na poprawę. Kod jest fatalny, pełen błędów, zagmatwany w sposób utrudniający zrozumienie, co się dzieje, a co do dopiero poprawienie czegokolwiek. Architektoniczne podstawy są niezdrowe, oparte o single point of failure. Uzdrowienie sytuacji wymaga zainwestowania dużych wysiłków, a do tego dojdzie, bo to projekt chory, upadły, porzucony, w wieloletniej anarchii.

Brak perspektyw na powrót projektu do życia jest przemilczany chyba we wszystkich artykułach na temat Quartza, co jest rzeczą haniebną, nieuczciwą. Nie tak ma wyglądać udzielanie informacji. Przemilczanie tej kwestii jest chyba gorsze niż skupianie się tylko na happy path.

Nie mam miejsca w tym artykule na omawianie alternatyw. Jeśli macie jakieś sprawdzone – możecie podrzucić w komentarzach, jestem ciekawy waszych obserwacji.


Photo by Graddes 📸 on Unsplash

Creative Commons License
Except where otherwise noted, the content by Piotr Kubowicz is licensed under a Creative Commons Attribution 4.0 International License.