[{"data":1,"prerenderedAt":13665},["ShallowReactive",2],{"article-pl-php-forgets-python-remembers":3,"articles-pl-sidebar":933},{"id":4,"title":5,"articleId":6,"body":7,"category":27,"codeLang":27,"date":913,"deploys":61,"description":13,"excerpt":914,"extension":915,"lang":916,"meta":917,"navigation":51,"path":918,"pos":919,"readMin":79,"related":922,"seo":925,"service":926,"stem":927,"tags":928,"updated":914,"version":931,"__hash__":932},"articles_pl\u002Fpl\u002Farticles\u002Fphp-forgets-python-remembers.md","PHP zapomina, Python pamięta. Chodzi o model uruchamiania","php-forgets-python-remembers",{"type":8,"value":9,"toc":899},"minimark",[10,14,19,22,100,103,163,166,177,181,184,187,194,199,202,205,209,216,222,237,241,248,251,255,286,316,320,323,423,437,447,513,520,527,542,553,557,567,570,592,672,675,679,690,885,889,892,895],[11,12,13],"p",{},"Ten sam licznik odwiedzin w PHP pokazuje 1 przy każdym odświeżeniu, a w Pythonie, przy jednym procesie, rośnie: 1, 2, 3. Powodem jest to, jak długo żyje stan programu, który obsługuje Twój request: w klasycznym PHP do końca requestu, w Pythonie do końca procesu. Od tego zależy, co przeżyje między jednym requestem a drugim, a dotyczy to także PHP w trybie worker.",[15,16,18],"h2",{"id":17},"licznik-który-nie-umie-liczyć","Licznik, który nie umie liczyć",[11,20,21],{},"Zrób mały eksperyment. Endpoint w PHP z licznikiem odwiedzin w zmiennej statycznej, za nginx i PHP-FPM:",[23,24,29],"pre",{"className":25,"code":26,"language":27,"meta":28,"style":28},"language-php shiki shiki-themes github-light github-dark","\u003C?php\n\u002F\u002F public\u002Findex.php, served by nginx + PHP-FPM\n\nfunction hits(): int\n{\n    static $count = 0;\n    return ++$count;\n}\n\nheader('Content-Type: text\u002Fplain');\necho hits(); \u002F\u002F 1. Refresh: 1. Refresh a hundred times: 1.\n","php","",[30,31,32,40,46,53,59,65,71,77,83,88,94],"code",{"__ignoreMap":28},[33,34,37],"span",{"class":35,"line":36},"line",1,[33,38,39],{},"\u003C?php\n",[33,41,43],{"class":35,"line":42},2,[33,44,45],{},"\u002F\u002F public\u002Findex.php, served by nginx + PHP-FPM\n",[33,47,49],{"class":35,"line":48},3,[33,50,52],{"emptyLinePlaceholder":51},true,"\n",[33,54,56],{"class":35,"line":55},4,[33,57,58],{},"function hits(): int\n",[33,60,62],{"class":35,"line":61},5,[33,63,64],{},"{\n",[33,66,68],{"class":35,"line":67},6,[33,69,70],{},"    static $count = 0;\n",[33,72,74],{"class":35,"line":73},7,[33,75,76],{},"    return ++$count;\n",[33,78,80],{"class":35,"line":79},8,[33,81,82],{},"}\n",[33,84,86],{"class":35,"line":85},9,[33,87,52],{"emptyLinePlaceholder":51},[33,89,91],{"class":35,"line":90},10,[33,92,93],{},"header('Content-Type: text\u002Fplain');\n",[33,95,97],{"class":35,"line":96},11,[33,98,99],{},"echo hits(); \u002F\u002F 1. Refresh: 1. Refresh a hundred times: 1.\n",[11,101,102],{},"Odśwież dziesięć razy. Dziesięć jedynek. Teraz to samo na FastAPI:",[23,104,108],{"className":105,"code":106,"language":107,"meta":28,"style":28},"language-python shiki shiki-themes github-light github-dark","# main.py, started with: uvicorn main:app\nfrom fastapi import FastAPI\n\napp = FastAPI()\ncount = 0\n\n@app.get(\"\u002F\")\ndef hits():\n    global count\n    count += 1\n    return {\"count\": count}  # 1, 2, 3... grows with every request\n","python",[30,109,110,115,120,124,129,134,138,143,148,153,158],{"__ignoreMap":28},[33,111,112],{"class":35,"line":36},[33,113,114],{},"# main.py, started with: uvicorn main:app\n",[33,116,117],{"class":35,"line":42},[33,118,119],{},"from fastapi import FastAPI\n",[33,121,122],{"class":35,"line":48},[33,123,52],{"emptyLinePlaceholder":51},[33,125,126],{"class":35,"line":55},[33,127,128],{},"app = FastAPI()\n",[33,130,131],{"class":35,"line":61},[33,132,133],{},"count = 0\n",[33,135,136],{"class":35,"line":67},[33,137,52],{"emptyLinePlaceholder":51},[33,139,140],{"class":35,"line":73},[33,141,142],{},"@app.get(\"\u002F\")\n",[33,144,145],{"class":35,"line":79},[33,146,147],{},"def hits():\n",[33,149,150],{"class":35,"line":85},[33,151,152],{},"    global count\n",[33,154,155],{"class":35,"line":90},[33,156,157],{},"    count += 1\n",[33,159,160],{"class":35,"line":96},[33,161,162],{},"    return {\"count\": count}  # 1, 2, 3... grows with every request\n",[11,164,165],{},"Przy jednym procesie uvicorna dziesięć odświeżeń daje 10. Logika jest ta sama, a różnica siedzi piętro niżej, w tym, co proces robi między jednym requestem a drugim.",[11,167,168,169,172,173,176],{},"To demonstracja na kolejnych, pojedynczych requestach. Jako licznik przy równoczesnym ruchu się nie nadaje: endpointy ze zwykłym ",[30,170,171],{},"def"," FastAPI uruchamia w puli wątków, a ",[30,174,175],{},"count += 1"," nie ma gwarancji atomowości, więc przy kilku requestach naraz część zliczeń może przepaść.",[15,178,180],{"id":179},"klasyczny-php-każdy-request-od-zera","Klasyczny PHP: każdy request od zera",[11,182,183],{},"PHP pod PHP-FPM pracuje w modelu shared-nothing, czyli każdy request dostaje świeże środowisko wykonania i stan skryptu z poprzedniego requestu do niego nie przechodzi. Zmienne, obiekty, statyczne właściwości klas, singletony: wszystko idzie do kosza w chwili, gdy skrypt kończy pracę.",[11,185,186],{},"Wyobraź sobie pracownika, który po każdym zadaniu idzie do domu i rano wraca z pustą głową. Dostaje kartkę (request), robi, co trzeba, oddaje wynik (response) i zapomina. Nie pamięta poprzedniego klienta ani tego, że wczoraj już liczył, więc licznik pokazuje 1.",[11,188,189,190,193],{},"Proces PHP-FPM wcale nie umiera po requeście. Worker FPM (proces potomny mastera) obsługuje setki requestów po kolei, aż ",[30,191,192],{},"pm.max_requests"," każe go wymienić. Domyślnie to 0, czyli limit wyłączony: worker nie jest wymieniany z powodu liczby requestów, choć reload, restart czy przycinanie puli i tak go kiedyś zakończą. Ale między requestami PHP robi pełny request shutdown: zwalnia pamięć skryptu, zamyka zwykłe połączenia, czyści tablicę symboli.",[195,196,198],"h3",{"id":197},"co-w-takim-razie-robi-opcache","Co w takim razie robi OPcache?",[11,200,201],{},"OPcache trzyma w pamięci współdzielonej skompilowany bytecode skryptów (postać pośrednią, którą silnik wykonuje zamiast parsować tekst pliku), żeby PHP nie kompilował tego samego przy każdym requeście. W OPcache jest skompilowana funkcja razem z wartością początkową zmiennej statycznej, a bieżąca wartość żyje w pamięci requestu.",[11,203,204],{},"Preloading (od PHP 7.4) idzie krok dalej i ładuje wskazane klasy do pamięci raz, przy starcie FPM. To też kod, z jednym skutkiem ubocznym: zmiany w preloadowanym pliku FPM nie zobaczy, dopóki master FPM nie wystartuje od nowa, niezależnie od sprawdzania dat plików.",[195,206,208],{"id":207},"gdzie-w-takim-razie-trzymać-stan","Gdzie w takim razie trzymać stan?",[11,210,211,212,215],{},"Poza procesem. Sesja (plik, Redis, baza) dla rzeczy przypiętych do użytkownika. Baza danych dla wszystkiego, co ma przeżyć restart. Redis albo Memcached dla cache'u współdzielonego między serwerami. APCu, jeśli wystarczy Ci cache lokalny na jednej maszynie: to pamięć współdzielona między workerami FPM, przeżywa koniec requestu, ale znika przy restarcie i reloadzie FPM. Licznik oparty na ",[30,213,214],{},"apcu_inc('hits')"," będzie rósł, dopóki FPM nie wystartuje od nowa albo APCu nie wyczyści cache z braku miejsca: brakujący klucz APCu założy sam, a zwiększanie jest atomowe.",[11,217,218,219,221],{},"Stan aplikacji się w tym modelu nie kumuluje. Zwykła pamięć skryptu wraca po każdym requeście, więc zapomniana tablica czy obiekt żyją najwyżej jeden request. Nie dotyczy to wycieków w rozszerzeniach i bibliotekach natywnych. Te zostają w procesie i właśnie na nie dokumentacja PHP podsuwa ",[30,220,192],{},".",[11,223,224,225,228,229,232,233,236],{},"Po zmianie kodu zwykle nie musisz też niczego restartować. OPcache domyślnie sprawdza znaczniki czasu plików (co 2 sekundy), więc nowa wersja wchodzi sama, choć niekoniecznie już przy następnym requeście. Przy wyłączonym ",[30,226,227],{},"opcache.validate_timestamps"," trzeba wyczyścić OPcache (na przykład ",[30,230,231],{},"opcache_reset()"," wywołanym w procesie FPM) albo zrobić reload FPM, a przy kodzie objętym preloadingiem potrzebny jest reload FPM (w Debianie i Ubuntu ",[30,234,235],{},"systemctl reload php8.5-fpm"," wysyła USR2, po którym master startuje od nowa i ładuje preload jeszcze raz). Jedno zastrzeżenie: kopiowanie plików po kolei na działającą aplikację potrafi przez chwilę wymieszać starą wersję z nową. Świeży stan requestu nie robi z tego atomowego deployu, do tego potrzebny jest osobny mechanizm przełączania wersji.",[15,238,240],{"id":239},"python-proces-żyje-długo","Python: proces żyje długo",[11,242,243,244,247],{},"Gunicorn i uvicorn działają inaczej. Startujesz proces, ten proces importuje Twój moduł raz, buduje obiekt aplikacji raz i od tej chwili tylko obsługuje requesty. Linia ",[30,245,246],{},"count = 0"," wykonała się dokładnie jeden raz, a każdy kolejny request dopisuje do tej samej zmiennej w tej samej pamięci.",[11,249,250],{},"W Pythonie proces po prostu zostaje: połączenie do bazy i każdy obiekt odłożony „na chwilę” w zmiennej globalnej są dalej w pamięci.",[195,252,254],{"id":253},"haczyk-workerów-jest-kilku","Haczyk: workerów jest kilku",[11,256,257,258,261,262,265,266,269,270,273,274,277,278,281,282,285],{},"Uruchom ",[30,259,260],{},"uvicorn main:app --workers 4"," (albo ",[30,263,264],{},"gunicorn -w 4 -k asgi main:app",") i wyślij kilka requestów z terminala: ",[30,267,268],{},"for i in 1 2 3 4 5 6; do curl -s localhost:8000\u002F; echo; done",". Każde wywołanie ",[30,271,272],{},"curl"," to osobne połączenie, więc może trafić do innego workera, a liczniki zaczynają przeskakiwać, na przykład 1, 1, 2, 1, 3, 2. Każdy worker to osobny proces z własną kopią modułu i własnym ",[30,275,276],{},"count",". Workery dzielą jeden socket i przyjmują połączenia, jak leci, więc trafiasz raz tu, raz tam. A po restarcie wszystkie cztery liczniki wracają do zera. Przeglądarka może tego nie pokazać. Trzyma połączenie otwarte (keep-alive), więc kolejne odświeżenia idą do tego samego workera i licznik po prostu rośnie. Uvicorn trzyma takie połączenie 5 sekund, gunicorn z workerem ASGI 2 sekundy (",[30,279,280],{},"keepalive","), a worker ",[30,283,284],{},"sync"," zamyka je po każdej odpowiedzi. Za reverse proxy dochodzi jeszcze jego własna warstwa połączeń.",[11,287,288,289,292,293,296,297,300,301,303,304,307,308,311,312,315],{},"Bez ",[30,290,291],{},"--workers",", zmiennej ",[30,294,295],{},"WEB_CONCURRENCY"," i własnego pliku konfiguracji gunicorn startuje jednego synchronicznego workera (",[30,298,299],{},"--workers 1",", klasa ",[30,302,284],{},"). Ten worker obsługuje WSGI, a FastAPI jest aplikacją ASGI, dlatego przy gunicornie potrzebny jest worker ASGI: od wersji 24 wbudowany ",[30,305,306],{},"-k asgi",", wcześniej ",[30,309,310],{},"-k uvicorn_worker.UvicornWorker"," z pakietu ",[30,313,314],{},"uvicorn-worker",". Uvicorn w tej samej sytuacji uruchamia jeden proces z pętlą asyncio. Gdy procesów jest kilka, każdy ma własną kopię zmiennej globalnej.",[15,317,319],{"id":318},"pułapki-długo-żyjącego-procesu","Pułapki długo żyjącego procesu",[11,321,322],{},"Licznik nikomu nie szkodzi. Ten sam mechanizm przy danych użytkownika wygląda tak:",[23,324,326],{"className":105,"code":325,"language":107,"meta":28,"style":28},"# auth.py: \"convenient\" access to the current user\n# (sketch: app, users and User come from the rest of the app;\n#  users.from_header() parses \"Bearer \u003Ctoken>\", returns None if invalid)\ncurrent_user = None\n\n@app.middleware(\"http\")\nasync def load_user(request, call_next):\n    global current_user\n    header = request.headers.get(\"authorization\")\n    if header:\n        current_user = users.from_header(header)  # no else branch: nobody clears it\n    return await call_next(request)\n\n@app.get(\"\u002Fme\")\ndef me():\n    return {\"email\": current_user.email}\n# Request with a token: fine. The next request WITHOUT a token on the\n# same worker gets the e-mail of the last user that worker served.\n",[30,327,328,333,338,343,348,352,357,362,367,372,377,382,388,393,399,405,411,417],{"__ignoreMap":28},[33,329,330],{"class":35,"line":36},[33,331,332],{},"# auth.py: \"convenient\" access to the current user\n",[33,334,335],{"class":35,"line":42},[33,336,337],{},"# (sketch: app, users and User come from the rest of the app;\n",[33,339,340],{"class":35,"line":48},[33,341,342],{},"#  users.from_header() parses \"Bearer \u003Ctoken>\", returns None if invalid)\n",[33,344,345],{"class":35,"line":55},[33,346,347],{},"current_user = None\n",[33,349,350],{"class":35,"line":61},[33,351,52],{"emptyLinePlaceholder":51},[33,353,354],{"class":35,"line":67},[33,355,356],{},"@app.middleware(\"http\")\n",[33,358,359],{"class":35,"line":73},[33,360,361],{},"async def load_user(request, call_next):\n",[33,363,364],{"class":35,"line":79},[33,365,366],{},"    global current_user\n",[33,368,369],{"class":35,"line":85},[33,370,371],{},"    header = request.headers.get(\"authorization\")\n",[33,373,374],{"class":35,"line":90},[33,375,376],{},"    if header:\n",[33,378,379],{"class":35,"line":96},[33,380,381],{},"        current_user = users.from_header(header)  # no else branch: nobody clears it\n",[33,383,385],{"class":35,"line":384},12,[33,386,387],{},"    return await call_next(request)\n",[33,389,391],{"class":35,"line":390},13,[33,392,52],{"emptyLinePlaceholder":51},[33,394,396],{"class":35,"line":395},14,[33,397,398],{},"@app.get(\"\u002Fme\")\n",[33,400,402],{"class":35,"line":401},15,[33,403,404],{},"def me():\n",[33,406,408],{"class":35,"line":407},16,[33,409,410],{},"    return {\"email\": current_user.email}\n",[33,412,414],{"class":35,"line":413},17,[33,415,416],{},"# Request with a token: fine. The next request WITHOUT a token on the\n",[33,418,420],{"class":35,"line":419},18,[33,421,422],{},"# same worker gets the e-mail of the last user that worker served.\n",[11,424,425,426,429,430,433,434,436],{},"W modelu „od zera” taki kod wysypałby się na pierwszym anonimowym requeście: ",[30,427,428],{},"None",", wyjątek, wpis w logu, wszystko widać od razu. Pod długo żyjącym procesem wyjątek poleci tylko wtedy, gdy ",[30,431,432],{},"current_user"," jest ",[30,435,428],{},": na świeżym workerze albo po requeście z nieprawidłowym tokenem. Gdy worker obsłużył już kogoś z tokenem, endpoint zwraca e-mail tej osoby. W logu błędów nie ma śladu, w access logu zwykłe 200.",[11,438,439,440,442,443,446],{},"Jeden worker nie znaczy przy tym jednego requestu naraz. Middleware async przeplata się z innymi requestami na pętli zdarzeń, a endpointy zadeklarowane zwykłym ",[30,441,171],{}," FastAPI uruchamia w puli wątków. Przychodzą dwa requesty z różnymi tokenami. Może się zdarzyć, że middleware pierwszego ustawi użytkownika A, middleware drugiego nadpisze go użytkownikiem B, zanim endpoint pierwszego go odczyta, i ten endpoint zwróci e-mail B. Dopisanie ",[30,444,445],{},"else: current_user = None"," zamyka tylko pierwszy przypadek. Wyścig znika dopiero po wyjęciu użytkownika z globala, na przykład przez zależność, która czyta token z bieżącego requestu:",[23,448,450],{"className":105,"code":449,"language":107,"meta":28,"style":28},"# sketch, same assumptions as above\nfrom fastapi import Depends, HTTPException, Request\n\ndef get_current_user(request: Request) -> User:\n    header = request.headers.get(\"authorization\")\n    user = users.from_header(header) if header else None\n    if user is None:\n        raise HTTPException(status_code=401, headers={\"WWW-Authenticate\": \"Bearer\"})\n    return user\n\n@app.get(\"\u002Fme\")\ndef me(user: User = Depends(get_current_user)):\n    return {\"email\": user.email}  # belongs to this request only\n",[30,451,452,457,462,466,471,475,480,485,490,495,499,503,508],{"__ignoreMap":28},[33,453,454],{"class":35,"line":36},[33,455,456],{},"# sketch, same assumptions as above\n",[33,458,459],{"class":35,"line":42},[33,460,461],{},"from fastapi import Depends, HTTPException, Request\n",[33,463,464],{"class":35,"line":48},[33,465,52],{"emptyLinePlaceholder":51},[33,467,468],{"class":35,"line":55},[33,469,470],{},"def get_current_user(request: Request) -> User:\n",[33,472,473],{"class":35,"line":61},[33,474,371],{},[33,476,477],{"class":35,"line":67},[33,478,479],{},"    user = users.from_header(header) if header else None\n",[33,481,482],{"class":35,"line":73},[33,483,484],{},"    if user is None:\n",[33,486,487],{"class":35,"line":79},[33,488,489],{},"        raise HTTPException(status_code=401, headers={\"WWW-Authenticate\": \"Bearer\"})\n",[33,491,492],{"class":35,"line":85},[33,493,494],{},"    return user\n",[33,496,497],{"class":35,"line":90},[33,498,52],{"emptyLinePlaceholder":51},[33,500,501],{"class":35,"line":96},[33,502,398],{},[33,504,505],{"class":35,"line":384},[33,506,507],{},"def me(user: User = Depends(get_current_user)):\n",[33,509,510],{"class":35,"line":390},[33,511,512],{},"    return {\"email\": user.email}  # belongs to this request only\n",[11,514,515,516,519],{},"Jeśli middleware ma go policzyć raz dla wszystkich endpointów, miejscem jest ",[30,517,518],{},"request.state",", nie zmienna modułu.",[11,521,522,523,526],{},"Pamięć to osobny problem. Lista, do której dopisujesz „tymczasowo”, cache w słowniku bez limitu, obiekt trzymany przez referencję w globalu. CPython zwalnia nieużywane obiekty także w trakcie działania procesu (zliczanie referencji plus garbage collector dla cykli), ale dopóki globalna lista albo słownik trzyma obiekty, są one osiągalne i nie mogą zostać zwolnione. Jeśli taka kolekcja rośnie bez limitu, rośnie z nią pamięć procesu. Dlatego gunicorn ma ",[30,524,525],{},"--max-requests"," (domyślnie 0, czyli wyłączone): po N requestach gunicorn restartuje workera. Wycieku to nie usuwa, ale co jakiś czas oddaje pamięć.",[11,528,529,530,533,534,537,538,541],{},"Z połączeniami do bazy jest podobnie. Pula utworzona przy starcie procesu żyje razem z nim, a jej połączenia obsługują kolejne requesty. W tym czasie baza może się zrestartować albo firewall zamknąć bezczynną sesję, a request, który pobierze takie połączenie, dostaje przy Postgresie na przykład ",[30,531,532],{},"server closed the connection unexpectedly",". Gdy firewall wytnie sesję po cichu, request może po prostu wisieć do timeoutu. Na pierwszy przypadek jest ",[30,535,536],{},"pool_pre_ping=True"," w SQLAlchemy: sprawdza połączenie przy pobraniu z puli, choć przed zerwaniem w trakcie trwającej transakcji nie chroni. Na cichy firewall ping nie pomoże, bo zawiśnie tak samo; tu pomaga ",[30,539,540],{},"pool_recycle"," krótszy niż jego timeout (wymienia stare połączenie przy następnym pobraniu z puli) albo keepalive TCP. Podobne mechanizmy mają inne ORM-y i pule połączeń.",[11,543,544,545,548,549,552],{},"Przy deployu wgrywasz nowy kod na dysk, a proces ma w pamięci stary. Dopóki nie wyślesz ",[30,546,547],{},"HUP"," do mastera gunicorna (master stawia nowe workery z nowym kodem i łagodnie wygasza stare, o ile aplikacja nie startuje z ",[30,550,551],{},"--preload",", bo wtedy dziedziczą stary kod z mastera) albo nie zrestartujesz usługi, produkcja serwuje wersję sprzed deployu.",[15,554,556],{"id":555},"php-też-tak-potrafi-i-wpada-w-te-same-dziury","PHP też tak potrafi (i wpada w te same dziury)",[11,558,559,560,563,564,221],{},"Długo żyjący proces da się mieć też w PHP. W PHP najczęściej spotkasz do tego trzy serwery albo rozszerzenia i jedną warstwę nad nimi. Swoole (i jego fork Open Swoole) to rozszerzenie PHP z własnym serwerem HTTP i pętlą zdarzeń. RoadRunner to serwer aplikacji napisany w Go, który trzyma pulę procesów PHP i podaje im requesty przez pipe'y. FrankenPHP to PHP wbudowany w serwer Caddy, od 2025 roku oficjalnie wspierany przez PHP Foundation, z trybem worker, w którym Twój skrypt sam kręci pętlę ",[30,561,562],{},"frankenphp_handle_request()",". Laravel Octane to warstwa, która stawia aplikację Laravel na dowolnym z tych trzech jedną komendą: ",[30,565,566],{},"php artisan octane:start --server=frankenphp",[11,568,569],{},"Mechanika jest taka sama jak w Pythonie: aplikacja bootuje się raz, potem obsługuje requesty w pętli. Kontener, konfiguracja, routing, połączenia, wszystko zostaje w pamięci. Stąd zwykle przyspieszenie, bo odpada koszt bootowania frameworka przy każdym requeście, choć ile to daje w konkretnej aplikacji, trzeba zmierzyć.",[11,571,572,573,576,577,580,581,584,585,588,589,221],{},"Z tego samego powodu pojawiają się te same pułapki. Statyczna właściwość z licznikiem zaczyna rosnąć. Singleton, do którego wstrzyknąłeś obiekt ",[30,574,575],{},"Request"," w konstruktorze, może trzymać request pierwszego użytkownika do końca życia workera, jeśli nikt tej zależności nie odświeża. Octane sam resetuje sporo stanu frameworka między requestami, ale dokumentacja zaznacza, że nie zawsze wie, jak wyczyścić stan zostawiony przez aplikację, a statyczne właściwości i singletony to właśnie taki stan. Dokumentacja ma na to osobny rozdział i osobny bezpiecznik: worker jest wymieniany co 500 requestów (",[30,578,579],{},"--max-requests=500",", domyślnie). RoadRunner ma ",[30,582,583],{},"pool.max_jobs"," (limit wykonań) i osobno ",[30,586,587],{},"pool.supervisor.max_worker_memory"," (limit pamięci), FrankenPHP w przykładowym skrypcie czyta ",[30,590,591],{},"MAX_REQUESTS",[23,593,595],{"className":25,"code":594,"language":27,"meta":28,"style":28},"\u002F\u002F Same bug as in Python, this time in Laravel under Octane\n\u002F\u002F (sketch: User::fromToken() stands for your own token lookup)\nfinal class CurrentUser\n{\n    public static ?User $user = null;\n}\n\n\u002F\u002F middleware: sets the user when a token is present, never clears it\nif ($token = $request->bearerToken()) {\n    CurrentUser::$user = User::fromToken($token);\n}\n\n\u002F\u002F controller\nreturn ['email' => CurrentUser::$user?->email];\n\u002F\u002F PHP-FPM: a request without a token gets null.\n\u002F\u002F Octane: it gets the last user served by the same worker.\n",[30,596,597,602,607,612,616,621,625,629,634,639,644,648,652,657,662,667],{"__ignoreMap":28},[33,598,599],{"class":35,"line":36},[33,600,601],{},"\u002F\u002F Same bug as in Python, this time in Laravel under Octane\n",[33,603,604],{"class":35,"line":42},[33,605,606],{},"\u002F\u002F (sketch: User::fromToken() stands for your own token lookup)\n",[33,608,609],{"class":35,"line":48},[33,610,611],{},"final class CurrentUser\n",[33,613,614],{"class":35,"line":55},[33,615,64],{},[33,617,618],{"class":35,"line":61},[33,619,620],{},"    public static ?User $user = null;\n",[33,622,623],{"class":35,"line":67},[33,624,82],{},[33,626,627],{"class":35,"line":73},[33,628,52],{"emptyLinePlaceholder":51},[33,630,631],{"class":35,"line":79},[33,632,633],{},"\u002F\u002F middleware: sets the user when a token is present, never clears it\n",[33,635,636],{"class":35,"line":85},[33,637,638],{},"if ($token = $request->bearerToken()) {\n",[33,640,641],{"class":35,"line":90},[33,642,643],{},"    CurrentUser::$user = User::fromToken($token);\n",[33,645,646],{"class":35,"line":96},[33,647,82],{},[33,649,650],{"class":35,"line":384},[33,651,52],{"emptyLinePlaceholder":51},[33,653,654],{"class":35,"line":390},[33,655,656],{},"\u002F\u002F controller\n",[33,658,659],{"class":35,"line":395},[33,660,661],{},"return ['email' => CurrentUser::$user?->email];\n",[33,663,664],{"class":35,"line":401},[33,665,666],{},"\u002F\u002F PHP-FPM: a request without a token gets null.\n",[33,668,669],{"class":35,"line":407},[33,670,671],{},"\u002F\u002F Octane: it gets the last user served by the same worker.\n",[11,673,674],{},"Pod Octane ten kod nie rzuca wyjątku, więc trzeba go znaleźć przeglądem: szukaj statycznych właściwości ustawianych w middleware.",[15,676,678],{"id":677},"od-strony-serwera","Od strony serwera",[11,680,681,682,685,686,689],{},"W konfiguracji nginx też widać różnicę. PHP-FPM mówi po FastCGI (protokół między serwerem WWW a procesem aplikacji, starszy niż większość Twoich zależności), więc nginx podaje mu request przez socket dyrektywą ",[30,683,684],{},"fastcgi_pass",". Gunicorn, uvicorn, RoadRunner i Swoole wystawiają zwykły HTTP, więc nginx jest dla nich reverse proxy z ",[30,687,688],{},"proxy_pass",". FrankenPHP jest serwerem sam w sobie (to Caddy), więc nginx przed nim jest opcją, nie koniecznością.",[691,692,693,711],"table",{},[694,695,696],"thead",{},[697,698,699,702,705,708],"tr",{},[700,701],"th",{},[700,703,704],{},"PHP-FPM",[700,706,707],{},"Python (gunicorn \u002F uvicorn)",[700,709,710],{},"PHP w trybie worker (Octane, FrankenPHP, RoadRunner, Swoole)",[712,713,714,750,770,806,824,838,867],"tbody",{},[697,715,716,720,726,738],{},[717,718,719],"td",{},"Serwer aplikacji",[717,721,722,725],{},[30,723,724],{},"php-fpm"," (master + workery)",[717,727,728,731,732,734,735],{},[30,729,730],{},"gunicorn"," (arbiter + workery, dla ASGI z ",[30,733,306],{},") albo ",[30,736,737],{},"uvicorn --workers N",[717,739,740,743,744,743,747],{},[30,741,742],{},"php artisan octane:start",", ",[30,745,746],{},"frankenphp php-server --worker public\u002Fworker.php",[30,748,749],{},"rr serve",[697,751,752,755,760,765],{},[717,753,754],{},"Nginx rozmawia przez",[717,756,757],{},[30,758,759],{},"fastcgi_pass unix:\u002Frun\u002Fphp\u002Fphp8.5-fpm.sock",[717,761,762],{},[30,763,764],{},"proxy_pass http:\u002F\u002F127.0.0.1:8000",[717,766,767,769],{},[30,768,764],{}," (FrankenPHP może stać bez nginx)",[697,771,772,775,785,797],{},[717,773,774],{},"Reload po deployu",[717,776,777,778,780,781,784],{},"zwykle zbędny; ",[30,779,235],{}," przy wyłączonym ",[30,782,783],{},"validate_timestamps",", też przy preloadingu (nazwa usługi jak w Debianie i Ubuntu)",[717,786,787,790,791,793,794,796],{},[30,788,789],{},"kill -HUP \u003Cmaster>"," (gunicorn bez ",[30,792,551],{},", uvicorn tylko z ",[30,795,291],{},") albo restart usługi, obowiązkowy",[717,798,799,743,802,805],{},[30,800,801],{},"php artisan octane:reload",[30,803,804],{},"rr reset",", endpoint admina FrankenPHP (przy włączonym API) albo restart, obowiązkowy",[697,807,808,811,818,821],{},[717,809,810],{},"Deploy",[717,812,813,814,817],{},"podmiana plików, OPcache łapie zmianę sam po kilku sekundach; przy przełączaniu wydań symlinkiem dochodzi realpath cache (reload FPM albo ",[30,815,816],{},"$realpath_root"," w nginx)",[717,819,820],{},"nowy kod na dysku nic nie zmienia do reloadu",[717,822,823],{},"jak w Pythonie",[697,825,826,829,832,835],{},[717,827,828],{},"Stan w pamięci między requestami",[717,830,831],{},"nie ma (poza APCu i połączeniami trwałymi)",[717,833,834],{},"jest, osobny w każdym procesie",[717,836,837],{},"jest, osobny w każdym workerze",[697,839,840,843,848,858],{},[717,841,842],{},"Bezpiecznik na wycieki",[717,844,845,847],{},[30,846,192],{}," (domyślnie 0)",[717,849,850,851,853,854,857],{},"gunicorn ",[30,852,525],{},", uvicorn ",[30,855,856],{},"--limit-max-requests"," (oba domyślnie wyłączone)",[717,859,860,861,863,864],{},"Octane ",[30,862,525],{}," (domyślnie 500), RoadRunner ",[30,865,866],{},"max_jobs",[697,868,869,872,875,878],{},[717,870,871],{},"Co siedzi w cache'u",[717,873,874],{},"bytecode (OPcache)",[717,876,877],{},"moduły w pamięci procesu",[717,879,880,881,884],{},"zbootowana aplikacja w pamięci workera; bytecode tylko przy włączonym OPcache dla danego SAPI (w CLI ",[30,882,883],{},"opcache.enable_cli=1",")",[15,886,888],{"id":887},"co-z-tego-wynika","Co z tego wynika",[11,890,891],{},"Pisz aplikację tak, jakby każdy request mógł trafić do innego procesu, na innej maszynie, tuż po restarcie. Stan użytkownika w sesji albo w obiekcie bieżącego requestu, stan aplikacji w bazie, cache współdzielony w Redisie. W zmiennych globalnych i statycznych właściwościach trzymaj to, co jest wspólne dla wszystkich requestów: konfigurację, skompilowane wyrażenie regularne, klienta HTTP. Jeśli trzymasz tam coś, co się zmienia, na przykład lokalny cache, odpowiedz sobie na trzy pytania: kto go czyści, kiedy się odświeża i co się stanie przy dwóch requestach naraz.",[11,893,894],{},"Pod PHP-FPM większość tej zasady dostajesz za darmo. W trybie worker dane zostawione w globalu mogą trafić do kolejnego requestu obsługiwanego przez ten sam worker.",[896,897,898],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":28,"searchDepth":42,"depth":42,"links":900},[901,902,906,909,910,911,912],{"id":17,"depth":42,"text":18},{"id":179,"depth":42,"text":180,"children":903},[904,905],{"id":197,"depth":48,"text":198},{"id":207,"depth":48,"text":208},{"id":239,"depth":42,"text":240,"children":907},[908],{"id":253,"depth":48,"text":254},{"id":318,"depth":42,"text":319},{"id":555,"depth":42,"text":556},{"id":677,"depth":42,"text":678},{"id":887,"depth":42,"text":888},"2026-10-17",null,"md","pl",{},"\u002Fpl\u002Farticles\u002Fphp-forgets-python-remembers",{"x":920,"y":921,"depth":36,"size":915},0.46,0.37,[923,924],"singleton-pattern","php-references",{"title":5,"description":13},"execution-model","pl\u002Farticles\u002Fphp-forgets-python-remembers",[27,107,724,929,930],"laravel-octane","deployment","v1.9.1","Oo85lkYq3s_DYlNnzp220NIzLKoz8zcZCmEjgXfzA7w",[934,1786,3360,4299,4832,5103,5875,6528,7852,8534,9429,10053,10610,11414,12054,13002],{"id":935,"title":936,"articleId":937,"body":938,"category":1763,"codeLang":107,"date":1764,"deploys":1132,"description":942,"excerpt":914,"extension":915,"lang":916,"meta":1765,"navigation":51,"path":1766,"pos":1767,"readMin":67,"related":1772,"seo":1775,"service":1776,"stem":1777,"tags":1778,"updated":1783,"version":1784,"__hash__":1785},"articles_pl\u002Fpl\u002Farticles\u002Fagent-graphs.md","Agent AI jako graf stanów w LangGraph: checkpointy, przerwania i odtwarzanie","agent-graphs",{"type":8,"value":939,"toc":1753},[940,943,946,950,953,956,959,962,966,1171,1187,1242,1246,1253,1305,1324,1342,1346,1355,1411,1424,1427,1431,1434,1448,1521,1524,1528,1542,1546,1549,1731,1741,1744,1748,1751],[11,941,942],{},"Najprostszy agent to pętla: wyślij rozmowę do modelu, wykonaj wywołania narzędzi, o które prosi, dopisz wyniki i powtarzaj, aż przestanie prosić. Na demo i przy jednym czy dwóch narzędziach to działa. Potem agent musi przetrwać restart procesu albo poczekać na człowieka, albo ktoś chce zdebugować przebieg kilka dni po fakcie.",[11,944,945],{},"Alternatywa to jawny przepływ sterowania. Agent staje się grafem stanów: węzły to kroki, krawędzie to nazwane przejścia, stan to typowana struktura, a runtime zapisuje go po każdym kroku. Model podejmuje decyzję w węźle, a graf wybiera następny krok. Przykłady są w LangGraph, ale to samo da się zrobić w każdym orkiestratorze z podobnymi prymitywami.",[15,947,949],{"id":948},"czego-brakuje-pętli","Czego brakuje pętli",[11,951,952],{},"Zacznijmy od ponowień. Nieudane wywołanie narzędzia w pętli ponawia model, twój kod albo nikt, a jedynym globalnym bezpiecznikiem jest licznik kroków. Nie da się powiedzieć „wyszukiwanie próbuj najwyżej trzy razy, płatności nie ponawiaj nigdy”.",[11,954,955],{},"Stan pośredni nigdzie się nie zapisuje, bo pętla trzyma go w zmiennych lokalnych. Jeśli proces padnie w kroku 7, wszystko przepada, a start od zera powtarza kroki 1–6 razem z ich efektami ubocznymi. Z tego samego powodu nie da się czekać na człowieka: akceptacja oznacza zatrzymanie w połowie przebiegu, czasem na kilka dni, i wznowienie w innym procesie, który nie ma się do czego odwołać.",[11,957,958],{},"Zostaje odtwarzanie. Pętla zostawia co najwyżej log, a do zdebugowania złego przebiegu potrzebny jest dokładny stan sprzed błędnej decyzji i możliwość uruchomienia od tego miejsca jeszcze raz.",[11,960,961],{},"Sam warunek końca pętli też potrafi zawieść. Testowałem gotowy harness agentowy z lokalnym modelem: w trybie nieinteraktywnym wystarczyła jedna odpowiedź bez wywołania narzędzia i pętla się kończyła, więc model stawał w połowie zadania. Harness przekazał wznowionemu przebiegowi 26,6 tys. tokenów historii, a model i tak twierdził, że jej nie widzi. To był błąd modelu. Z poleceniem „pracuj do końca, nie pytaj” poprawka w końcu się udała, ale zmiana jednego znaku trwała 19 minut. Same wywołania narzędzi były bezbłędne w każdym przebiegu.",[15,963,965],{"id":964},"graf","Graf",[23,967,969],{"className":105,"code":968,"language":107,"meta":28,"style":28},"import operator\nfrom typing import Annotated, TypedDict\n\nfrom langgraph.graph import END, START, StateGraph\nfrom langgraph.types import RetryPolicy\n\n\nclass AgentState(TypedDict):\n    task: str\n    plan: list[str]\n    context: Annotated[list[str], operator.add]  # dopisywane do listy, bez nadpisywania\n    result: str | None\n    needs_context: bool\n    done: bool\n    reflections: int\n\n\ndef route_after_plan(state: AgentState) -> str:\n    return \"retrieve\" if state[\"needs_context\"] else \"act\"\n\n\ndef route_after_act(state: AgentState) -> str:\n    if state[\"done\"] or state[\"reflections\"] >= 2:\n        return END\n    return \"reflect\"\n\n\nbuilder = StateGraph(AgentState)\nbuilder.add_node(\"plan\", planner)\nbuilder.add_node(\"retrieve\", retriever, retry_policy=RetryPolicy(max_attempts=3))\nbuilder.add_node(\"act\", tool_runner)\nbuilder.add_node(\"reflect\", critic)\n\nbuilder.add_edge(START, \"plan\")\nbuilder.add_conditional_edges(\"plan\", route_after_plan, [\"retrieve\", \"act\"])\nbuilder.add_edge(\"retrieve\", \"act\")\nbuilder.add_conditional_edges(\"act\", route_after_act, [\"reflect\", END])\nbuilder.add_edge(\"reflect\", \"plan\")\n",[30,970,971,976,981,985,990,995,999,1003,1008,1013,1018,1023,1028,1033,1038,1043,1047,1051,1056,1062,1067,1072,1078,1084,1090,1096,1101,1106,1112,1118,1124,1130,1136,1141,1147,1153,1159,1165],{"__ignoreMap":28},[33,972,973],{"class":35,"line":36},[33,974,975],{},"import operator\n",[33,977,978],{"class":35,"line":42},[33,979,980],{},"from typing import Annotated, TypedDict\n",[33,982,983],{"class":35,"line":48},[33,984,52],{"emptyLinePlaceholder":51},[33,986,987],{"class":35,"line":55},[33,988,989],{},"from langgraph.graph import END, START, StateGraph\n",[33,991,992],{"class":35,"line":61},[33,993,994],{},"from langgraph.types import RetryPolicy\n",[33,996,997],{"class":35,"line":67},[33,998,52],{"emptyLinePlaceholder":51},[33,1000,1001],{"class":35,"line":73},[33,1002,52],{"emptyLinePlaceholder":51},[33,1004,1005],{"class":35,"line":79},[33,1006,1007],{},"class AgentState(TypedDict):\n",[33,1009,1010],{"class":35,"line":85},[33,1011,1012],{},"    task: str\n",[33,1014,1015],{"class":35,"line":90},[33,1016,1017],{},"    plan: list[str]\n",[33,1019,1020],{"class":35,"line":96},[33,1021,1022],{},"    context: Annotated[list[str], operator.add]  # dopisywane do listy, bez nadpisywania\n",[33,1024,1025],{"class":35,"line":384},[33,1026,1027],{},"    result: str | None\n",[33,1029,1030],{"class":35,"line":390},[33,1031,1032],{},"    needs_context: bool\n",[33,1034,1035],{"class":35,"line":395},[33,1036,1037],{},"    done: bool\n",[33,1039,1040],{"class":35,"line":401},[33,1041,1042],{},"    reflections: int\n",[33,1044,1045],{"class":35,"line":407},[33,1046,52],{"emptyLinePlaceholder":51},[33,1048,1049],{"class":35,"line":413},[33,1050,52],{"emptyLinePlaceholder":51},[33,1052,1053],{"class":35,"line":419},[33,1054,1055],{},"def route_after_plan(state: AgentState) -> str:\n",[33,1057,1059],{"class":35,"line":1058},19,[33,1060,1061],{},"    return \"retrieve\" if state[\"needs_context\"] else \"act\"\n",[33,1063,1065],{"class":35,"line":1064},20,[33,1066,52],{"emptyLinePlaceholder":51},[33,1068,1070],{"class":35,"line":1069},21,[33,1071,52],{"emptyLinePlaceholder":51},[33,1073,1075],{"class":35,"line":1074},22,[33,1076,1077],{},"def route_after_act(state: AgentState) -> str:\n",[33,1079,1081],{"class":35,"line":1080},23,[33,1082,1083],{},"    if state[\"done\"] or state[\"reflections\"] >= 2:\n",[33,1085,1087],{"class":35,"line":1086},24,[33,1088,1089],{},"        return END\n",[33,1091,1093],{"class":35,"line":1092},25,[33,1094,1095],{},"    return \"reflect\"\n",[33,1097,1099],{"class":35,"line":1098},26,[33,1100,52],{"emptyLinePlaceholder":51},[33,1102,1104],{"class":35,"line":1103},27,[33,1105,52],{"emptyLinePlaceholder":51},[33,1107,1109],{"class":35,"line":1108},28,[33,1110,1111],{},"builder = StateGraph(AgentState)\n",[33,1113,1115],{"class":35,"line":1114},29,[33,1116,1117],{},"builder.add_node(\"plan\", planner)\n",[33,1119,1121],{"class":35,"line":1120},30,[33,1122,1123],{},"builder.add_node(\"retrieve\", retriever, retry_policy=RetryPolicy(max_attempts=3))\n",[33,1125,1127],{"class":35,"line":1126},31,[33,1128,1129],{},"builder.add_node(\"act\", tool_runner)\n",[33,1131,1133],{"class":35,"line":1132},32,[33,1134,1135],{},"builder.add_node(\"reflect\", critic)\n",[33,1137,1139],{"class":35,"line":1138},33,[33,1140,52],{"emptyLinePlaceholder":51},[33,1142,1144],{"class":35,"line":1143},34,[33,1145,1146],{},"builder.add_edge(START, \"plan\")\n",[33,1148,1150],{"class":35,"line":1149},35,[33,1151,1152],{},"builder.add_conditional_edges(\"plan\", route_after_plan, [\"retrieve\", \"act\"])\n",[33,1154,1156],{"class":35,"line":1155},36,[33,1157,1158],{},"builder.add_edge(\"retrieve\", \"act\")\n",[33,1160,1162],{"class":35,"line":1161},37,[33,1163,1164],{},"builder.add_conditional_edges(\"act\", route_after_act, [\"reflect\", END])\n",[33,1166,1168],{"class":35,"line":1167},38,[33,1169,1170],{},"builder.add_edge(\"reflect\", \"plan\")\n",[11,1172,1173,743,1176,743,1179,1182,1183,1186],{},[30,1174,1175],{},"planner",[30,1177,1178],{},"retriever",[30,1180,1181],{},"tool_runner"," i ",[30,1184,1185],{},"critic"," to zwykłe funkcje, które dostają stan i zwracają częściową aktualizację. Funkcje routujące to czysty Python i da się je testować jednostkowo bez modelu.",[11,1188,1189,1192,1193,1196,1197,1200,1201,1204,1205,743,1208,1211,1212,743,1215,743,1218,1221,1222,1225,1226,1229,1230,1182,1233,1236,1237,1239,1240,221],{},[30,1190,1191],{},"reflections"," to reguła biznesowa trzymana w stanie: najwyżej dwie rundy krytyki. Niezależnie od niej ",[30,1194,1195],{},"recursion_limit"," w konfiguracji przebiegu ogranicza technicznie łączną liczbę kroków. Po jego przekroczeniu leci ",[30,1198,1199],{},"GraphRecursionError"," i przebieg się kończy. Domyślny limit w langgraph 1.2 to 10007 kroków, więc własny warto podać jawnie, jak w przykładzie niżej. Polityka ponowień dotyczy tylko węzła ",[30,1202,1203],{},"retrieve",", więc narzędzia z efektami ubocznymi nie ponowi. Samo wyszukiwanie też nie przy każdym błędzie. Dopóki nie ustawisz własnego ",[30,1206,1207],{},"retry_on",[30,1209,1210],{},"RetryPolicy"," nie ponawia ",[30,1213,1214],{},"ValueError",[30,1216,1217],{},"RuntimeError",[30,1219,1220],{},"OSError"," i kilku innych wbudowanych błędów ani odpowiedzi 4xx z httpx. Ponawia wbudowany ",[30,1223,1224],{},"ConnectionError",", odpowiedzi 5xx i każdy inny wyjątek, także twoje własne klasy. Z requests jest odwrotnie, niż można by się spodziewać: ",[30,1227,1228],{},"HTTPError"," ponawia przy każdym statusie, także 404, a ",[30,1231,1232],{},"requests.ConnectionError",[30,1234,1235],{},"Timeout"," (podklasy ",[30,1238,1220],{},") nie ponawia wcale. Jeśli to ma znaczenie, podaj własne ",[30,1241,1207],{},[15,1243,1245],{"id":1244},"checkpointy-w-postgresie","Checkpointy w Postgresie",[11,1247,1248,1249,1252],{},"Checkpointer zapisuje stan po każdym kroku (w terminologii LangGraph: po każdym super-stepie) pod identyfikatorem wątku. Implementacja dla Postgresa jest w pakiecie ",[30,1250,1251],{},"langgraph-checkpoint-postgres"," i korzysta z psycopg 3.",[23,1254,1256],{"className":105,"code":1255,"language":107,"meta":28,"style":28},"from langgraph.checkpoint.postgres import PostgresSaver\n\nDB_URI = \"postgresql:\u002F\u002Fagent:secret@localhost:5432\u002Fagents\"\n\nwith PostgresSaver.from_conn_string(DB_URI) as checkpointer:\n    checkpointer.setup()  # tworzy tabele checkpointów; uruchamiać raz, jak migrację\n    graph = builder.compile(checkpointer=checkpointer)\n\n    config = {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30}\n    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[30,1257,1258,1263,1267,1272,1276,1281,1286,1291,1295,1300],{"__ignoreMap":28},[33,1259,1260],{"class":35,"line":36},[33,1261,1262],{},"from langgraph.checkpoint.postgres import PostgresSaver\n",[33,1264,1265],{"class":35,"line":42},[33,1266,52],{"emptyLinePlaceholder":51},[33,1268,1269],{"class":35,"line":48},[33,1270,1271],{},"DB_URI = \"postgresql:\u002F\u002Fagent:secret@localhost:5432\u002Fagents\"\n",[33,1273,1274],{"class":35,"line":55},[33,1275,52],{"emptyLinePlaceholder":51},[33,1277,1278],{"class":35,"line":61},[33,1279,1280],{},"with PostgresSaver.from_conn_string(DB_URI) as checkpointer:\n",[33,1282,1283],{"class":35,"line":67},[33,1284,1285],{},"    checkpointer.setup()  # tworzy tabele checkpointów; uruchamiać raz, jak migrację\n",[33,1287,1288],{"class":35,"line":73},[33,1289,1290],{},"    graph = builder.compile(checkpointer=checkpointer)\n",[33,1292,1293],{"class":35,"line":79},[33,1294,52],{"emptyLinePlaceholder":51},[33,1296,1297],{"class":35,"line":85},[33,1298,1299],{},"    config = {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30}\n",[33,1301,1302],{"class":35,"line":90},[33,1303,1304],{},"    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[11,1306,1307,1308,1311,1312,1315,1316,1319,1320,1323],{},"Po awarii ",[30,1309,1310],{},"graph.invoke(None, config)"," z tym samym ",[30,1313,1314],{},"thread_id"," rusza od ostatniego zakończonego kroku. ",[30,1317,1318],{},"graph.get_state(config)"," zwraca bieżący stan i następny węzeł, a ",[30,1321,1322],{},"graph.get_state_history(config)"," wszystkie wcześniejsze snapshoty.",[11,1325,1326,1327,1182,1330,1333,1334,1337,1338,1341],{},"Z każdego checkpointu da się odtworzyć cały stan. Duże teksty w stanie (pełne pobrane dokumenty, pliki w base64) trafiają do każdego wiersza checkpointu, a lista dopisywana przez reducer zapisuje się w całości przy każdej zmianie. W stanie trzymaj referencje, a treść gdzie indziej. Checkpointy w każdym wątku przybywają też bez końca, dopóki nie dodasz zadania retencji. Pakiet daje do tego ",[30,1328,1329],{},"delete_thread()",[30,1331,1332],{},"prune()",", które ze strategią ",[30,1335,1336],{},"keep_latest"," zostawia tylko ostatni checkpoint. ",[30,1339,1340],{},"ShallowPostgresSaver"," jest przestarzały od wersji 2.0.20 pakietu. W środowisku regulowanym retencja i tak musi się zgadzać z polityką przechowywania danych.",[15,1343,1345],{"id":1344},"czekanie-na-człowieka","Czekanie na człowieka",[11,1347,1348,1351,1352,221],{},[30,1349,1350],{},"interrupt()"," zatrzymuje graf wewnątrz węzła, zapisuje stan i oddaje payload wywołującemu. Przebieg wznawia się później, z dowolnego procesu, przez ",[30,1353,1354],{},"Command(resume=...)",[23,1356,1358],{"className":105,"code":1357,"language":107,"meta":28,"style":28},"from langgraph.types import Command, interrupt\n\n\ndef approve_refund(state: AgentState) -> dict:\n    # Zakłada pola `amount` i `approved` zadeklarowane w `AgentState`.\n    answer = interrupt({\"question\": \"Zatwierdzić zwrot?\", \"amount\": state[\"amount\"]})\n    return {\"approved\": answer == \"yes\"}\n\n\n# Kilka godzin później, np. w handlerze HTTP ekranu akceptacji:\ngraph.invoke(Command(resume=\"yes\"), {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30})\n",[30,1359,1360,1365,1369,1373,1378,1383,1388,1393,1397,1401,1406],{"__ignoreMap":28},[33,1361,1362],{"class":35,"line":36},[33,1363,1364],{},"from langgraph.types import Command, interrupt\n",[33,1366,1367],{"class":35,"line":42},[33,1368,52],{"emptyLinePlaceholder":51},[33,1370,1371],{"class":35,"line":48},[33,1372,52],{"emptyLinePlaceholder":51},[33,1374,1375],{"class":35,"line":55},[33,1376,1377],{},"def approve_refund(state: AgentState) -> dict:\n",[33,1379,1380],{"class":35,"line":61},[33,1381,1382],{},"    # Zakłada pola `amount` i `approved` zadeklarowane w `AgentState`.\n",[33,1384,1385],{"class":35,"line":67},[33,1386,1387],{},"    answer = interrupt({\"question\": \"Zatwierdzić zwrot?\", \"amount\": state[\"amount\"]})\n",[33,1389,1390],{"class":35,"line":73},[33,1391,1392],{},"    return {\"approved\": answer == \"yes\"}\n",[33,1394,1395],{"class":35,"line":79},[33,1396,52],{"emptyLinePlaceholder":51},[33,1398,1399],{"class":35,"line":85},[33,1400,52],{"emptyLinePlaceholder":51},[33,1402,1403],{"class":35,"line":90},[33,1404,1405],{},"# Kilka godzin później, np. w handlerze HTTP ekranu akceptacji:\n",[33,1407,1408],{"class":35,"line":96},[33,1409,1410],{},"graph.invoke(Command(resume=\"yes\"), {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30})\n",[11,1412,1413,1414,1416,1417,1420,1421,1423],{},"Przy wznowieniu węzeł wykonuje się ponownie od pierwszej linii, a ",[30,1415,1350],{}," zamiast zatrzymywać zwraca wartość z ",[30,1418,1419],{},"resume",". Cały kod przed ",[30,1422,1350],{}," wykona się więc dwa razy. Ta część nie może mieć efektów ubocznych. Jeśli jakiś jest potrzebny, przenieś go do osobnego węzła za akceptacją.",[11,1425,1426],{},"Sprawdź też, jak długo runtime w ogóle chce czekać. Jeden orkiestrator agentów miał zaszyty limit 15 minut na pauzę dla zgody człowieka. Po nim cały przebieg kończył się błędem i dalsze kroki już nie ruszały. Zgoda często przychodzi po kilku godzinach, więc pauzę trzeba zapisać w stanie przebiegu.",[15,1428,1430],{"id":1429},"przed-czym-checkpoint-nie-chroni","Przed czym checkpoint nie chroni",[11,1432,1433],{},"Checkpoint zapisuje się po zakończeniu węzła. Jeśli węzeł wyśle maila, obciąży kartę albo strumieniuje odpowiedź do użytkownika, a proces padnie przed zapisem checkpointu, wznowienie uruchomi węzeł jeszcze raz. Gwarancja na poziomie węzła to at-least-once.",[11,1435,1436,1437,1439,1440,1443,1444,1447],{},"Z tego wynika kształt węzłów. W jednym węźle jeden zewnętrzny efekt uboczny, żeby to, co może się powtórzyć, było małe i wiadomo było co. Klucz idempotencji składasz z ",[30,1438,1314],{},", nazwy węzła i numeru kroku (",[30,1441,1442],{},"langgraph_step"," w metadanych configu), bo w grafie z pętlą ten sam węzeł wykonuje się w wątku więcej niż raz, a powtórka po awarii dostaje ten sam numer. Klucz przekazujesz do systemu zewnętrznego, jeśli go obsługuje (Stripe przyjmuje na przykład nagłówek ",[30,1445,1446],{},"Idempotency-Key","). W API v1 Stripe może usunąć klucz po 24 godzinach, więc powtórka po kilkudniowej pauzie może już przejść jako nowe żądanie. Przy wznowieniu węzeł strumieniuje jeszcze raz, więc klient powinien traktować strumień jako podgląd i zastąpić to, co już wyświetlił, wartością, którą węzeł zwrócił do stanu w checkpoincie, zamiast doklejać drugą kopię.",[23,1449,1451],{"className":105,"code":1450,"language":107,"meta":28,"style":28},"from langchain_core.runnables import RunnableConfig\n\n\ndef send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n    # Zakłada pola `customer_email` i `confirmation_sent` zadeklarowane w `AgentState`.\n    thread_id = config[\"configurable\"][\"thread_id\"]\n    step = config[\"metadata\"][\"langgraph_step\"]\n    # `mailer` to twój klient; dostawca usuwa duplikaty po tym kluczu.\n    mailer.send(\n        to=state[\"customer_email\"],\n        body=state[\"result\"],\n        idempotency_key=f\"{thread_id}:send_confirmation:{step}\",\n    )\n    return {\"confirmation_sent\": True}\n",[30,1452,1453,1458,1462,1466,1471,1476,1481,1486,1491,1496,1501,1506,1511,1516],{"__ignoreMap":28},[33,1454,1455],{"class":35,"line":36},[33,1456,1457],{},"from langchain_core.runnables import RunnableConfig\n",[33,1459,1460],{"class":35,"line":42},[33,1461,52],{"emptyLinePlaceholder":51},[33,1463,1464],{"class":35,"line":48},[33,1465,52],{"emptyLinePlaceholder":51},[33,1467,1468],{"class":35,"line":55},[33,1469,1470],{},"def send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n",[33,1472,1473],{"class":35,"line":61},[33,1474,1475],{},"    # Zakłada pola `customer_email` i `confirmation_sent` zadeklarowane w `AgentState`.\n",[33,1477,1478],{"class":35,"line":67},[33,1479,1480],{},"    thread_id = config[\"configurable\"][\"thread_id\"]\n",[33,1482,1483],{"class":35,"line":73},[33,1484,1485],{},"    step = config[\"metadata\"][\"langgraph_step\"]\n",[33,1487,1488],{"class":35,"line":79},[33,1489,1490],{},"    # `mailer` to twój klient; dostawca usuwa duplikaty po tym kluczu.\n",[33,1492,1493],{"class":35,"line":85},[33,1494,1495],{},"    mailer.send(\n",[33,1497,1498],{"class":35,"line":90},[33,1499,1500],{},"        to=state[\"customer_email\"],\n",[33,1502,1503],{"class":35,"line":96},[33,1504,1505],{},"        body=state[\"result\"],\n",[33,1507,1508],{"class":35,"line":384},[33,1509,1510],{},"        idempotency_key=f\"{thread_id}:send_confirmation:{step}\",\n",[33,1512,1513],{"class":35,"line":390},[33,1514,1515],{},"    )\n",[33,1517,1518],{"class":35,"line":395},[33,1519,1520],{},"    return {\"confirmation_sent\": True}\n",[11,1522,1523],{},"Podobnie tnę zadania dla agentów, którzy piszą mi kod: paczki po około 250–300 linii diffu. Całe fazy (550–930 linii) wracały z poprawkami mniej więcej co trzecia.",[15,1525,1527],{"id":1526},"odtwarzanie-do-debugowania-i-regresji","Odtwarzanie do debugowania i regresji",[11,1529,1530,1531,1534,1535,1537,1538,1541],{},"Każdy snapshot z ",[30,1532,1533],{},"get_state_history"," ma własny config z ID checkpointu. Wywołanie grafu z ",[30,1536,428],{}," i tym configiem uruchamia przebieg od tego punktu i tworzy nową gałąź historii. Oryginał zostaje nietknięty. Jeśli węzły dostają narzędzia przez fabrykę (",[30,1539,1540],{},"build_graph(tools)","), można wziąć stan z nieudanego przebiegu na produkcji, zbudować graf z nagranymi albo zamockowanymi narzędziami i sprawdzić, czy zmieniony prompt albo reguła routingu prowadzi do innej decyzji. Stan z produkcji może zawierać dane osobowe. Środowisko odtwarzania potrzebuje takiej samej kontroli dostępu jak produkcja.",[15,1543,1545],{"id":1544},"obserwowalność","Obserwowalność",[11,1547,1548],{},"Loguj jedno ustrukturyzowane zdarzenie na każde wykonanie węzła. Wystarczy owinąć węzły przy rejestracji, ich kod zostaje bez zmian:",[23,1550,1552],{"className":105,"code":1551,"language":107,"meta":28,"style":28},"import inspect\nimport logging\nimport time\n\nfrom langgraph.errors import GraphBubbleUp\n\nlog = logging.getLogger(\"agent\")\n\n\ndef observed(name, fn):\n    # Węzeł może przyjmować sam stan albo (state, config).\n    takes_config = len(inspect.signature(fn).parameters) > 1\n\n    # Bez functools.wraps: LangGraph czytałby wtedy sygnaturę fn i przy węźle\n    # z samym stanem nie przekazałby configu do wrappera.\n    def wrapper(state, config):\n        started = time.perf_counter()\n        error = None\n        try:\n            return fn(state, config) if takes_config else fn(state)\n        except GraphBubbleUp:  # interrupt() zatrzymuje graf, rzucając ten wyjątek\n            raise\n        except Exception as exc:\n            error = type(exc).__name__\n            raise\n        finally:\n            log.info(\"agent_step\", extra={\n                \"thread_id\": config[\"configurable\"][\"thread_id\"],\n                \"node\": name,\n                \"latency_ms\": round((time.perf_counter() - started) * 1000),\n                \"error\": error,\n            })\n    return wrapper\n\n\n# Zamiast builder.add_node(\"act\", tool_runner); nazwę węzła można zarejestrować tylko raz.\nbuilder.add_node(\"act\", observed(\"act\", tool_runner))\n",[30,1553,1554,1559,1564,1569,1573,1578,1582,1587,1591,1595,1600,1605,1610,1614,1619,1624,1629,1634,1639,1644,1649,1654,1659,1664,1669,1673,1678,1683,1688,1693,1698,1703,1708,1713,1717,1721,1726],{"__ignoreMap":28},[33,1555,1556],{"class":35,"line":36},[33,1557,1558],{},"import inspect\n",[33,1560,1561],{"class":35,"line":42},[33,1562,1563],{},"import logging\n",[33,1565,1566],{"class":35,"line":48},[33,1567,1568],{},"import time\n",[33,1570,1571],{"class":35,"line":55},[33,1572,52],{"emptyLinePlaceholder":51},[33,1574,1575],{"class":35,"line":61},[33,1576,1577],{},"from langgraph.errors import GraphBubbleUp\n",[33,1579,1580],{"class":35,"line":67},[33,1581,52],{"emptyLinePlaceholder":51},[33,1583,1584],{"class":35,"line":73},[33,1585,1586],{},"log = logging.getLogger(\"agent\")\n",[33,1588,1589],{"class":35,"line":79},[33,1590,52],{"emptyLinePlaceholder":51},[33,1592,1593],{"class":35,"line":85},[33,1594,52],{"emptyLinePlaceholder":51},[33,1596,1597],{"class":35,"line":90},[33,1598,1599],{},"def observed(name, fn):\n",[33,1601,1602],{"class":35,"line":96},[33,1603,1604],{},"    # Węzeł może przyjmować sam stan albo (state, config).\n",[33,1606,1607],{"class":35,"line":384},[33,1608,1609],{},"    takes_config = len(inspect.signature(fn).parameters) > 1\n",[33,1611,1612],{"class":35,"line":390},[33,1613,52],{"emptyLinePlaceholder":51},[33,1615,1616],{"class":35,"line":395},[33,1617,1618],{},"    # Bez functools.wraps: LangGraph czytałby wtedy sygnaturę fn i przy węźle\n",[33,1620,1621],{"class":35,"line":401},[33,1622,1623],{},"    # z samym stanem nie przekazałby configu do wrappera.\n",[33,1625,1626],{"class":35,"line":407},[33,1627,1628],{},"    def wrapper(state, config):\n",[33,1630,1631],{"class":35,"line":413},[33,1632,1633],{},"        started = time.perf_counter()\n",[33,1635,1636],{"class":35,"line":419},[33,1637,1638],{},"        error = None\n",[33,1640,1641],{"class":35,"line":1058},[33,1642,1643],{},"        try:\n",[33,1645,1646],{"class":35,"line":1064},[33,1647,1648],{},"            return fn(state, config) if takes_config else fn(state)\n",[33,1650,1651],{"class":35,"line":1069},[33,1652,1653],{},"        except GraphBubbleUp:  # interrupt() zatrzymuje graf, rzucając ten wyjątek\n",[33,1655,1656],{"class":35,"line":1074},[33,1657,1658],{},"            raise\n",[33,1660,1661],{"class":35,"line":1080},[33,1662,1663],{},"        except Exception as exc:\n",[33,1665,1666],{"class":35,"line":1086},[33,1667,1668],{},"            error = type(exc).__name__\n",[33,1670,1671],{"class":35,"line":1092},[33,1672,1658],{},[33,1674,1675],{"class":35,"line":1098},[33,1676,1677],{},"        finally:\n",[33,1679,1680],{"class":35,"line":1103},[33,1681,1682],{},"            log.info(\"agent_step\", extra={\n",[33,1684,1685],{"class":35,"line":1108},[33,1686,1687],{},"                \"thread_id\": config[\"configurable\"][\"thread_id\"],\n",[33,1689,1690],{"class":35,"line":1114},[33,1691,1692],{},"                \"node\": name,\n",[33,1694,1695],{"class":35,"line":1120},[33,1696,1697],{},"                \"latency_ms\": round((time.perf_counter() - started) * 1000),\n",[33,1699,1700],{"class":35,"line":1126},[33,1701,1702],{},"                \"error\": error,\n",[33,1704,1705],{"class":35,"line":1132},[33,1706,1707],{},"            })\n",[33,1709,1710],{"class":35,"line":1138},[33,1711,1712],{},"    return wrapper\n",[33,1714,1715],{"class":35,"line":1143},[33,1716,52],{"emptyLinePlaceholder":51},[33,1718,1719],{"class":35,"line":1149},[33,1720,52],{"emptyLinePlaceholder":51},[33,1722,1723],{"class":35,"line":1155},[33,1724,1725],{},"# Zamiast builder.add_node(\"act\", tool_runner); nazwę węzła można zarejestrować tylko raz.\n",[33,1727,1728],{"class":35,"line":1161},[33,1729,1730],{},"builder.add_node(\"act\", observed(\"act\", tool_runner))\n",[11,1732,1733,1734,1737,1738,1740],{},"Liczniki tokenów należą do tego samego zdarzenia. W modelach czatowych LangChain są dostępne na wiadomości odpowiedzi jako ",[30,1735,1736],{},"usage_metadata","; zwróć je z węzła albo zaloguj tam, gdzie wołasz model. Gdy ",[30,1739,1314],{},", węzeł, czas, tokeny i błąd są w jednym rekordzie, koszt przebiegu, wolne narzędzie albo węzeł, który zaczął padać po deployu, znajdziesz jednym zapytaniem.",[11,1742,1743],{},"Raz krok wykonywany przez Codexa dostał zły model, API odrzuciło żądanie w 2 sekundy, a orkiestrator, który go prowadził, oznaczył krok jako zrobiony. Testy i lint przeszły, tyle że na niezmienionym kodzie.",[15,1745,1747],{"id":1746},"kiedy-graf-to-przesada","Kiedy graf to przesada",[11,1749,1750],{},"Pojedyncze wywołanie modelu ze strukturalnym wyjściem albo stała sekwencja kroków bez rozgałęzień lepiej wychodzi jako zwykła funkcja. Krótkie synchroniczne zadanie, którego nikt nie zatwierdza i nikt nie musi odtwarzać, nie uzasadnia tabeli checkpointów i dodatkowego zapisu do bazy na każdym kroku. Graf zaczyna się zwracać, kiedy przebieg może trwać na tyle długo, że coś go przerwie, kiedy ktoś w połowie coś zatwierdza albo kiedy nieudany przebieg trzeba potem odtworzyć.",[896,1752,898],{},{"title":28,"searchDepth":42,"depth":42,"links":1754},[1755,1756,1757,1758,1759,1760,1761,1762],{"id":948,"depth":42,"text":949},{"id":964,"depth":42,"text":965},{"id":1244,"depth":42,"text":1245},{"id":1344,"depth":42,"text":1345},{"id":1429,"depth":42,"text":1430},{"id":1526,"depth":42,"text":1527},{"id":1544,"depth":42,"text":1545},{"id":1746,"depth":42,"text":1747},"agents","2026-05-09",{},"\u002Fpl\u002Farticles\u002Fagent-graphs",{"x":1768,"y":1769,"depth":1770,"size":1771},0.66,0.27,1.5,"xl",[1773,1774],"microservice-cost","postgres-edge",{"title":936,"description":942},"orchestrator-graphs","pl\u002Farticles\u002Fagent-graphs",[1779,1780,1781,1782],"ai-agents","langgraph","state-machines","observability","2026-10-08","v1.1.4","pYXWk5_MorB-Tyg-F54czrYqW-3t-LnqhQVQJyiNfOs",{"id":1787,"title":1788,"articleId":1789,"body":1790,"category":3339,"codeLang":1856,"date":3340,"deploys":36,"description":1794,"excerpt":914,"extension":915,"lang":916,"meta":3341,"navigation":51,"path":3342,"pos":3343,"readMin":67,"related":3347,"seo":3348,"service":3349,"stem":3350,"tags":3351,"updated":1783,"version":3358,"__hash__":3359},"articles_pl\u002Fpl\u002Farticles\u002Fansible-production.md","Ansible na produkcji: idempotencja, wykrywanie dryftu i bezpieczne zmiany falami","ansible-production",{"type":8,"value":1791,"toc":3331},[1792,1795,1799,1806,1852,1924,1930,1934,1941,1995,2001,2149,2158,2161,2183,2193,2197,2200,2844,2847,2953,2961,2965,2972,3012,3022,3037,3109,3154,3157,3161,3164,3212,3229,3256,3259,3281,3285,3328],[11,1793,1794],{},"Playbook pisze się i testuje zwykle na czystej maszynie. Na produkcji trafia na maszyny w takim stanie, w jakim akurat są: z plikiem konfiguracyjnym poprawionym ręcznie w trakcie awarii, z pakietem podniesionym przez unattended-upgrades, po poprzednim uruchomieniu przerwanym w połowie. Liczy się wtedy to, czy doprowadzi host z nieznanego stanu do zadeklarowanego i czy powie, co po drodze musiał zmienić.",[15,1796,1798],{"id":1797},"idempotentny-to-nie-to-samo-co-zbieżny","Idempotentny to nie to samo co zbieżny",[11,1800,1801,1802,1805],{},"Idempotencja mówi, że drugie uruchomienie niczego już nie zmieni. Zbieżność mówi, że z dowolnego stanu startowego dojdziesz do docelowego. Zadanie może mieć pierwszą cechę bez drugiej: ",[30,1803,1804],{},"lineinfile",", który pilnuje jednej linii, nie zauważy drugiej, dopisanej obok ręcznie.",[11,1807,1808,1809,743,1812,743,1815,1818,1819,743,1822,1818,1825,1828,1829,1832,1833,1182,1836,1839,1840,1843,1844,1847,1848,1851],{},"Większość modułów ma to w standardzie, bo porównuje stan bieżący z docelowym (",[30,1810,1811],{},"template",[30,1813,1814],{},"copy",[30,1816,1817],{},"service"," ze ",[30,1820,1821],{},"state: started",[30,1823,1824],{},"apt",[30,1826,1827],{},"state: present","), więc drugie uruchomienie z tymi samymi argumentami raportuje ",[30,1830,1831],{},"ok",". Dla ",[30,1834,1835],{},"command",[30,1837,1838],{},"shell"," już nie, bo Ansible nie wie, co one robią. Raportują ",[30,1841,1842],{},"changed"," przy każdym uruchomieniu, chyba że podasz ",[30,1845,1846],{},"creates"," albo ",[30,1849,1850],{},"removes"," (wtedy zadanie nie wykona się, gdy wskazany plik już jest albo go nie ma) lub sam określisz, kiedy coś się zmieniło:",[23,1853,1857],{"className":1854,"code":1855,"language":1856,"meta":28,"style":28},"language-yaml shiki shiki-themes github-light github-dark","- name: Run database migrations\n  ansible.builtin.command: php artisan migrate --force\n  args:\n    chdir: \u002Fvar\u002Fwww\u002Fapp\n  register: migrate\n  changed_when: \"'Nothing to migrate' not in migrate.stdout\"\n","yaml",[30,1858,1859,1876,1886,1894,1904,1914],{"__ignoreMap":28},[33,1860,1861,1865,1869,1872],{"class":35,"line":36},[33,1862,1864],{"class":1863},"sVt8B","- ",[33,1866,1868],{"class":1867},"s9eBZ","name",[33,1870,1871],{"class":1863},": ",[33,1873,1875],{"class":1874},"sZZnC","Run database migrations\n",[33,1877,1878,1881,1883],{"class":35,"line":42},[33,1879,1880],{"class":1867},"  ansible.builtin.command",[33,1882,1871],{"class":1863},[33,1884,1885],{"class":1874},"php artisan migrate --force\n",[33,1887,1888,1891],{"class":35,"line":48},[33,1889,1890],{"class":1867},"  args",[33,1892,1893],{"class":1863},":\n",[33,1895,1896,1899,1901],{"class":35,"line":55},[33,1897,1898],{"class":1867},"    chdir",[33,1900,1871],{"class":1863},[33,1902,1903],{"class":1874},"\u002Fvar\u002Fwww\u002Fapp\n",[33,1905,1906,1909,1911],{"class":35,"line":61},[33,1907,1908],{"class":1867},"  register",[33,1910,1871],{"class":1863},[33,1912,1913],{"class":1874},"migrate\n",[33,1915,1916,1919,1921],{"class":35,"line":67},[33,1917,1918],{"class":1867},"  changed_when",[33,1920,1871],{"class":1863},[33,1922,1923],{"class":1874},"\"'Nothing to migrate' not in migrate.stdout\"\n",[11,1925,288,1926,1929],{},[30,1927,1928],{},"changed_when"," każde uruchomienie pokazuje co najmniej jedną zmianę, a licznik zmian, najtańszy sygnał dryftu, przestaje cokolwiek znaczyć.",[15,1931,1933],{"id":1932},"jak-zobaczyć-dryft","Jak zobaczyć dryft",[11,1935,1936,1937,1940],{},"Kiedy Ansible po cichu naprawia host, ginie informacja, że ktoś go zmienił. Weźmy usługę, której ktoś przy debugowaniu ręcznie wyłączył autostart (",[30,1938,1939],{},"systemctl disable","):",[23,1942,1944],{"className":1854,"code":1943,"language":1856,"meta":28,"style":28},"- name: Ensure application service is running\n  ansible.builtin.service:\n    name: invoicer\n    state: started\n    enabled: true\n",[30,1945,1946,1957,1964,1974,1984],{"__ignoreMap":28},[33,1947,1948,1950,1952,1954],{"class":35,"line":36},[33,1949,1864],{"class":1863},[33,1951,1868],{"class":1867},[33,1953,1871],{"class":1863},[33,1955,1956],{"class":1874},"Ensure application service is running\n",[33,1958,1959,1962],{"class":35,"line":42},[33,1960,1961],{"class":1867},"  ansible.builtin.service",[33,1963,1893],{"class":1863},[33,1965,1966,1969,1971],{"class":35,"line":48},[33,1967,1968],{"class":1867},"    name",[33,1970,1871],{"class":1863},[33,1972,1973],{"class":1874},"invoicer\n",[33,1975,1976,1979,1981],{"class":35,"line":55},[33,1977,1978],{"class":1867},"    state",[33,1980,1871],{"class":1863},[33,1982,1983],{"class":1874},"started\n",[33,1985,1986,1989,1991],{"class":35,"line":61},[33,1987,1988],{"class":1867},"    enabled",[33,1990,1871],{"class":1863},[33,1992,1994],{"class":1993},"sj4cs","true\n",[11,1996,1997,1998,2000],{},"Zadanie przywraca stan i raportuje ",[30,1999,1842],{},". Z raportu nie wynika, czy to pierwsza instalacja, czy cofnięcie ręcznej zmiany. Jeśli chcesz to wiedzieć, odczytaj stan przed zmianą:",[23,2002,2004],{"className":1854,"code":2003,"language":1856,"meta":28,"style":28},"- name: Read current enablement state\n  ansible.builtin.command: systemctl is-enabled invoicer\n  register: svc_enabled\n  changed_when: false\n  failed_when: false\n  check_mode: false\n\n- name: Report manual override\n  ansible.builtin.debug:\n    msg: \"invoicer is '{{ svc_enabled.stdout }}' on {{ inventory_hostname }}, expected 'enabled'\"\n  when: svc_enabled.stdout != 'enabled'\n\n- name: Converge service state\n  ansible.builtin.service:\n    name: invoicer\n    state: started\n    enabled: true\n",[30,2005,2006,2017,2026,2035,2044,2053,2062,2066,2077,2084,2094,2104,2108,2119,2125,2133,2141],{"__ignoreMap":28},[33,2007,2008,2010,2012,2014],{"class":35,"line":36},[33,2009,1864],{"class":1863},[33,2011,1868],{"class":1867},[33,2013,1871],{"class":1863},[33,2015,2016],{"class":1874},"Read current enablement state\n",[33,2018,2019,2021,2023],{"class":35,"line":42},[33,2020,1880],{"class":1867},[33,2022,1871],{"class":1863},[33,2024,2025],{"class":1874},"systemctl is-enabled invoicer\n",[33,2027,2028,2030,2032],{"class":35,"line":48},[33,2029,1908],{"class":1867},[33,2031,1871],{"class":1863},[33,2033,2034],{"class":1874},"svc_enabled\n",[33,2036,2037,2039,2041],{"class":35,"line":55},[33,2038,1918],{"class":1867},[33,2040,1871],{"class":1863},[33,2042,2043],{"class":1993},"false\n",[33,2045,2046,2049,2051],{"class":35,"line":61},[33,2047,2048],{"class":1867},"  failed_when",[33,2050,1871],{"class":1863},[33,2052,2043],{"class":1993},[33,2054,2055,2058,2060],{"class":35,"line":67},[33,2056,2057],{"class":1867},"  check_mode",[33,2059,1871],{"class":1863},[33,2061,2043],{"class":1993},[33,2063,2064],{"class":35,"line":73},[33,2065,52],{"emptyLinePlaceholder":51},[33,2067,2068,2070,2072,2074],{"class":35,"line":79},[33,2069,1864],{"class":1863},[33,2071,1868],{"class":1867},[33,2073,1871],{"class":1863},[33,2075,2076],{"class":1874},"Report manual override\n",[33,2078,2079,2082],{"class":35,"line":85},[33,2080,2081],{"class":1867},"  ansible.builtin.debug",[33,2083,1893],{"class":1863},[33,2085,2086,2089,2091],{"class":35,"line":90},[33,2087,2088],{"class":1867},"    msg",[33,2090,1871],{"class":1863},[33,2092,2093],{"class":1874},"\"invoicer is '{{ svc_enabled.stdout }}' on {{ inventory_hostname }}, expected 'enabled'\"\n",[33,2095,2096,2099,2101],{"class":35,"line":96},[33,2097,2098],{"class":1867},"  when",[33,2100,1871],{"class":1863},[33,2102,2103],{"class":1874},"svc_enabled.stdout != 'enabled'\n",[33,2105,2106],{"class":35,"line":384},[33,2107,52],{"emptyLinePlaceholder":51},[33,2109,2110,2112,2114,2116],{"class":35,"line":390},[33,2111,1864],{"class":1863},[33,2113,1868],{"class":1867},[33,2115,1871],{"class":1863},[33,2117,2118],{"class":1874},"Converge service state\n",[33,2120,2121,2123],{"class":35,"line":395},[33,2122,1961],{"class":1867},[33,2124,1893],{"class":1863},[33,2126,2127,2129,2131],{"class":35,"line":401},[33,2128,1968],{"class":1867},[33,2130,1871],{"class":1863},[33,2132,1973],{"class":1874},[33,2134,2135,2137,2139],{"class":35,"line":407},[33,2136,1978],{"class":1867},[33,2138,1871],{"class":1863},[33,2140,1983],{"class":1874},[33,2142,2143,2145,2147],{"class":35,"line":413},[33,2144,1988],{"class":1867},[33,2146,1871],{"class":1863},[33,2148,1994],{"class":1993},[11,2150,2151,2154,2155,2157],{},[30,2152,2153],{},"check_mode: false"," jest tu potrzebne, bo zadania ",[30,2156,1835],{}," są w check mode domyślnie pomijane. Bez tego odczyt nie wykona się przy próbnym uruchomieniu.",[11,2159,2160],{},"Na całej flocie dryft najprościej wykryć próbnym uruchomieniem puszczanym z harmonogramu:",[23,2162,2166],{"className":2163,"code":2164,"language":2165,"meta":28,"style":28},"language-bash shiki shiki-themes github-light github-dark","ansible-playbook site.yml --check --diff\n","bash",[30,2167,2168],{"__ignoreMap":28},[33,2169,2170,2174,2177,2180],{"class":35,"line":36},[33,2171,2173],{"class":2172},"sScJk","ansible-playbook",[33,2175,2176],{"class":1874}," site.yml",[33,2178,2179],{"class":1993}," --check",[33,2181,2182],{"class":1993}," --diff\n",[11,2184,2185,2186,2188,2189,2192],{},"Jeśli wszystkie playbooki mają poprawne ",[30,2187,1928],{},", a w zadaniach nie ma bezwarunkowych restartów, każdy host z ",[30,2190,2191],{},"changed>0"," w podsumowaniu różni się od tego, co deklarują, a diff pokazuje, w których liniach. Dokładne to nie jest. Check mode nie przewidzi wyniku zadań zależnych od wcześniejszego zadania, które coś by zmieniło, a moduły bez obsługi check mode są pomijane. Dostajesz listę hostów, którym warto się przyjrzeć.",[15,2194,2196],{"id":2195},"zmiany-falami-z-rollbackiem","Zmiany falami z rollbackiem",[11,2198,2199],{},"Załóżmy, że wdrożenie przerwało się w trakcie aktualizacji konfiguracji upstreamów nginx. Część floty działa na starej konfiguracji, część na nowej. Trzeba doprowadzić wszystkie hosty do jednej wersji, po kilka naraz, i zatrzymać się przy pierwszym hoście, który się wyłoży:",[23,2201,2203],{"className":1854,"code":2202,"language":1856,"meta":28,"style":28},"- name: Converge nginx upstream config\n  hosts: api_servers\n  become: true\n  serial: 2\n  max_fail_percentage: 0\n\n  handlers:\n    - name: Reload nginx\n      ansible.builtin.service:\n        name: nginx\n        state: reloaded\n\n  tasks:\n    - name: Deploy, verify, roll back on failure\n      block:\n        - name: Check whether upstream config exists\n          ansible.builtin.stat:\n            path: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n          register: upstream_before\n\n        - name: Render upstream config\n          ansible.builtin.template:\n            src: templates\u002Fupstream.conf.j2\n            dest: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n            owner: root\n            group: root\n            mode: '0644'\n            backup: true\n          register: upstream_conf\n          notify: Reload nginx\n\n        - name: Test full nginx configuration\n          ansible.builtin.command: nginx -t\n          changed_when: false\n\n        - name: Reload now instead of at the end of the play\n          ansible.builtin.meta: flush_handlers\n\n        - name: Wait for health endpoint\n          ansible.builtin.uri:\n            url: \"http:\u002F\u002F127.0.0.1:{{ nginx_port }}\u002Fhealth\"\n            status_code: 200\n            timeout: 5\n          register: health\n          until: health.status == 200\n          retries: 5\n          delay: 2\n\n      rescue:\n        - name: Restore previous config\n          ansible.builtin.copy:\n            src: \"{{ upstream_conf.backup_file }}\"\n            dest: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n            remote_src: true\n            mode: '0644'\n          when: upstream_conf.backup_file is defined\n\n        - name: Remove config that did not exist before\n          ansible.builtin.file:\n            path: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n            state: absent\n          when: not upstream_before.stat.exists\n\n        - name: Reload nginx with restored config\n          ansible.builtin.service:\n            name: nginx\n            state: reloaded\n\n        - name: Mark host as failed\n          ansible.builtin.fail:\n            msg: \"upstream.conf rolled back on {{ inventory_hostname }}\"\n",[30,2204,2205,2216,2226,2235,2245,2255,2259,2266,2278,2285,2295,2305,2309,2316,2327,2334,2346,2353,2363,2373,2377,2388,2395,2405,2414,2424,2433,2443,2452,2461,2470,2474,2485,2495,2504,2508,2519,2529,2533,2545,2553,2564,2575,2586,2596,2607,2617,2627,2632,2640,2652,2660,2670,2679,2689,2698,2709,2714,2726,2734,2743,2754,2764,2769,2781,2789,2799,2808,2813,2825,2833],{"__ignoreMap":28},[33,2206,2207,2209,2211,2213],{"class":35,"line":36},[33,2208,1864],{"class":1863},[33,2210,1868],{"class":1867},[33,2212,1871],{"class":1863},[33,2214,2215],{"class":1874},"Converge nginx upstream config\n",[33,2217,2218,2221,2223],{"class":35,"line":42},[33,2219,2220],{"class":1867},"  hosts",[33,2222,1871],{"class":1863},[33,2224,2225],{"class":1874},"api_servers\n",[33,2227,2228,2231,2233],{"class":35,"line":48},[33,2229,2230],{"class":1867},"  become",[33,2232,1871],{"class":1863},[33,2234,1994],{"class":1993},[33,2236,2237,2240,2242],{"class":35,"line":55},[33,2238,2239],{"class":1867},"  serial",[33,2241,1871],{"class":1863},[33,2243,2244],{"class":1993},"2\n",[33,2246,2247,2250,2252],{"class":35,"line":61},[33,2248,2249],{"class":1867},"  max_fail_percentage",[33,2251,1871],{"class":1863},[33,2253,2254],{"class":1993},"0\n",[33,2256,2257],{"class":35,"line":67},[33,2258,52],{"emptyLinePlaceholder":51},[33,2260,2261,2264],{"class":35,"line":73},[33,2262,2263],{"class":1867},"  handlers",[33,2265,1893],{"class":1863},[33,2267,2268,2271,2273,2275],{"class":35,"line":79},[33,2269,2270],{"class":1863},"    - ",[33,2272,1868],{"class":1867},[33,2274,1871],{"class":1863},[33,2276,2277],{"class":1874},"Reload nginx\n",[33,2279,2280,2283],{"class":35,"line":85},[33,2281,2282],{"class":1867},"      ansible.builtin.service",[33,2284,1893],{"class":1863},[33,2286,2287,2290,2292],{"class":35,"line":90},[33,2288,2289],{"class":1867},"        name",[33,2291,1871],{"class":1863},[33,2293,2294],{"class":1874},"nginx\n",[33,2296,2297,2300,2302],{"class":35,"line":96},[33,2298,2299],{"class":1867},"        state",[33,2301,1871],{"class":1863},[33,2303,2304],{"class":1874},"reloaded\n",[33,2306,2307],{"class":35,"line":384},[33,2308,52],{"emptyLinePlaceholder":51},[33,2310,2311,2314],{"class":35,"line":390},[33,2312,2313],{"class":1867},"  tasks",[33,2315,1893],{"class":1863},[33,2317,2318,2320,2322,2324],{"class":35,"line":395},[33,2319,2270],{"class":1863},[33,2321,1868],{"class":1867},[33,2323,1871],{"class":1863},[33,2325,2326],{"class":1874},"Deploy, verify, roll back on failure\n",[33,2328,2329,2332],{"class":35,"line":401},[33,2330,2331],{"class":1867},"      block",[33,2333,1893],{"class":1863},[33,2335,2336,2339,2341,2343],{"class":35,"line":407},[33,2337,2338],{"class":1863},"        - ",[33,2340,1868],{"class":1867},[33,2342,1871],{"class":1863},[33,2344,2345],{"class":1874},"Check whether upstream config exists\n",[33,2347,2348,2351],{"class":35,"line":413},[33,2349,2350],{"class":1867},"          ansible.builtin.stat",[33,2352,1893],{"class":1863},[33,2354,2355,2358,2360],{"class":35,"line":419},[33,2356,2357],{"class":1867},"            path",[33,2359,1871],{"class":1863},[33,2361,2362],{"class":1874},"\u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n",[33,2364,2365,2368,2370],{"class":35,"line":1058},[33,2366,2367],{"class":1867},"          register",[33,2369,1871],{"class":1863},[33,2371,2372],{"class":1874},"upstream_before\n",[33,2374,2375],{"class":35,"line":1064},[33,2376,52],{"emptyLinePlaceholder":51},[33,2378,2379,2381,2383,2385],{"class":35,"line":1069},[33,2380,2338],{"class":1863},[33,2382,1868],{"class":1867},[33,2384,1871],{"class":1863},[33,2386,2387],{"class":1874},"Render upstream config\n",[33,2389,2390,2393],{"class":35,"line":1074},[33,2391,2392],{"class":1867},"          ansible.builtin.template",[33,2394,1893],{"class":1863},[33,2396,2397,2400,2402],{"class":35,"line":1080},[33,2398,2399],{"class":1867},"            src",[33,2401,1871],{"class":1863},[33,2403,2404],{"class":1874},"templates\u002Fupstream.conf.j2\n",[33,2406,2407,2410,2412],{"class":35,"line":1086},[33,2408,2409],{"class":1867},"            dest",[33,2411,1871],{"class":1863},[33,2413,2362],{"class":1874},[33,2415,2416,2419,2421],{"class":35,"line":1092},[33,2417,2418],{"class":1867},"            owner",[33,2420,1871],{"class":1863},[33,2422,2423],{"class":1874},"root\n",[33,2425,2426,2429,2431],{"class":35,"line":1098},[33,2427,2428],{"class":1867},"            group",[33,2430,1871],{"class":1863},[33,2432,2423],{"class":1874},[33,2434,2435,2438,2440],{"class":35,"line":1103},[33,2436,2437],{"class":1867},"            mode",[33,2439,1871],{"class":1863},[33,2441,2442],{"class":1874},"'0644'\n",[33,2444,2445,2448,2450],{"class":35,"line":1108},[33,2446,2447],{"class":1867},"            backup",[33,2449,1871],{"class":1863},[33,2451,1994],{"class":1993},[33,2453,2454,2456,2458],{"class":35,"line":1114},[33,2455,2367],{"class":1867},[33,2457,1871],{"class":1863},[33,2459,2460],{"class":1874},"upstream_conf\n",[33,2462,2463,2466,2468],{"class":35,"line":1120},[33,2464,2465],{"class":1867},"          notify",[33,2467,1871],{"class":1863},[33,2469,2277],{"class":1874},[33,2471,2472],{"class":35,"line":1126},[33,2473,52],{"emptyLinePlaceholder":51},[33,2475,2476,2478,2480,2482],{"class":35,"line":1132},[33,2477,2338],{"class":1863},[33,2479,1868],{"class":1867},[33,2481,1871],{"class":1863},[33,2483,2484],{"class":1874},"Test full nginx configuration\n",[33,2486,2487,2490,2492],{"class":35,"line":1138},[33,2488,2489],{"class":1867},"          ansible.builtin.command",[33,2491,1871],{"class":1863},[33,2493,2494],{"class":1874},"nginx -t\n",[33,2496,2497,2500,2502],{"class":35,"line":1143},[33,2498,2499],{"class":1867},"          changed_when",[33,2501,1871],{"class":1863},[33,2503,2043],{"class":1993},[33,2505,2506],{"class":35,"line":1149},[33,2507,52],{"emptyLinePlaceholder":51},[33,2509,2510,2512,2514,2516],{"class":35,"line":1155},[33,2511,2338],{"class":1863},[33,2513,1868],{"class":1867},[33,2515,1871],{"class":1863},[33,2517,2518],{"class":1874},"Reload now instead of at the end of the play\n",[33,2520,2521,2524,2526],{"class":35,"line":1161},[33,2522,2523],{"class":1867},"          ansible.builtin.meta",[33,2525,1871],{"class":1863},[33,2527,2528],{"class":1874},"flush_handlers\n",[33,2530,2531],{"class":35,"line":1167},[33,2532,52],{"emptyLinePlaceholder":51},[33,2534,2536,2538,2540,2542],{"class":35,"line":2535},39,[33,2537,2338],{"class":1863},[33,2539,1868],{"class":1867},[33,2541,1871],{"class":1863},[33,2543,2544],{"class":1874},"Wait for health endpoint\n",[33,2546,2548,2551],{"class":35,"line":2547},40,[33,2549,2550],{"class":1867},"          ansible.builtin.uri",[33,2552,1893],{"class":1863},[33,2554,2556,2559,2561],{"class":35,"line":2555},41,[33,2557,2558],{"class":1867},"            url",[33,2560,1871],{"class":1863},[33,2562,2563],{"class":1874},"\"http:\u002F\u002F127.0.0.1:{{ nginx_port }}\u002Fhealth\"\n",[33,2565,2567,2570,2572],{"class":35,"line":2566},42,[33,2568,2569],{"class":1867},"            status_code",[33,2571,1871],{"class":1863},[33,2573,2574],{"class":1993},"200\n",[33,2576,2578,2581,2583],{"class":35,"line":2577},43,[33,2579,2580],{"class":1867},"            timeout",[33,2582,1871],{"class":1863},[33,2584,2585],{"class":1993},"5\n",[33,2587,2589,2591,2593],{"class":35,"line":2588},44,[33,2590,2367],{"class":1867},[33,2592,1871],{"class":1863},[33,2594,2595],{"class":1874},"health\n",[33,2597,2599,2602,2604],{"class":35,"line":2598},45,[33,2600,2601],{"class":1867},"          until",[33,2603,1871],{"class":1863},[33,2605,2606],{"class":1874},"health.status == 200\n",[33,2608,2610,2613,2615],{"class":35,"line":2609},46,[33,2611,2612],{"class":1867},"          retries",[33,2614,1871],{"class":1863},[33,2616,2585],{"class":1993},[33,2618,2620,2623,2625],{"class":35,"line":2619},47,[33,2621,2622],{"class":1867},"          delay",[33,2624,1871],{"class":1863},[33,2626,2244],{"class":1993},[33,2628,2630],{"class":35,"line":2629},48,[33,2631,52],{"emptyLinePlaceholder":51},[33,2633,2635,2638],{"class":35,"line":2634},49,[33,2636,2637],{"class":1867},"      rescue",[33,2639,1893],{"class":1863},[33,2641,2643,2645,2647,2649],{"class":35,"line":2642},50,[33,2644,2338],{"class":1863},[33,2646,1868],{"class":1867},[33,2648,1871],{"class":1863},[33,2650,2651],{"class":1874},"Restore previous config\n",[33,2653,2655,2658],{"class":35,"line":2654},51,[33,2656,2657],{"class":1867},"          ansible.builtin.copy",[33,2659,1893],{"class":1863},[33,2661,2663,2665,2667],{"class":35,"line":2662},52,[33,2664,2399],{"class":1867},[33,2666,1871],{"class":1863},[33,2668,2669],{"class":1874},"\"{{ upstream_conf.backup_file }}\"\n",[33,2671,2673,2675,2677],{"class":35,"line":2672},53,[33,2674,2409],{"class":1867},[33,2676,1871],{"class":1863},[33,2678,2362],{"class":1874},[33,2680,2682,2685,2687],{"class":35,"line":2681},54,[33,2683,2684],{"class":1867},"            remote_src",[33,2686,1871],{"class":1863},[33,2688,1994],{"class":1993},[33,2690,2692,2694,2696],{"class":35,"line":2691},55,[33,2693,2437],{"class":1867},[33,2695,1871],{"class":1863},[33,2697,2442],{"class":1874},[33,2699,2701,2704,2706],{"class":35,"line":2700},56,[33,2702,2703],{"class":1867},"          when",[33,2705,1871],{"class":1863},[33,2707,2708],{"class":1874},"upstream_conf.backup_file is defined\n",[33,2710,2712],{"class":35,"line":2711},57,[33,2713,52],{"emptyLinePlaceholder":51},[33,2715,2717,2719,2721,2723],{"class":35,"line":2716},58,[33,2718,2338],{"class":1863},[33,2720,1868],{"class":1867},[33,2722,1871],{"class":1863},[33,2724,2725],{"class":1874},"Remove config that did not exist before\n",[33,2727,2729,2732],{"class":35,"line":2728},59,[33,2730,2731],{"class":1867},"          ansible.builtin.file",[33,2733,1893],{"class":1863},[33,2735,2737,2739,2741],{"class":35,"line":2736},60,[33,2738,2357],{"class":1867},[33,2740,1871],{"class":1863},[33,2742,2362],{"class":1874},[33,2744,2746,2749,2751],{"class":35,"line":2745},61,[33,2747,2748],{"class":1867},"            state",[33,2750,1871],{"class":1863},[33,2752,2753],{"class":1874},"absent\n",[33,2755,2757,2759,2761],{"class":35,"line":2756},62,[33,2758,2703],{"class":1867},[33,2760,1871],{"class":1863},[33,2762,2763],{"class":1874},"not upstream_before.stat.exists\n",[33,2765,2767],{"class":35,"line":2766},63,[33,2768,52],{"emptyLinePlaceholder":51},[33,2770,2772,2774,2776,2778],{"class":35,"line":2771},64,[33,2773,2338],{"class":1863},[33,2775,1868],{"class":1867},[33,2777,1871],{"class":1863},[33,2779,2780],{"class":1874},"Reload nginx with restored config\n",[33,2782,2784,2787],{"class":35,"line":2783},65,[33,2785,2786],{"class":1867},"          ansible.builtin.service",[33,2788,1893],{"class":1863},[33,2790,2792,2795,2797],{"class":35,"line":2791},66,[33,2793,2794],{"class":1867},"            name",[33,2796,1871],{"class":1863},[33,2798,2294],{"class":1874},[33,2800,2802,2804,2806],{"class":35,"line":2801},67,[33,2803,2748],{"class":1867},[33,2805,1871],{"class":1863},[33,2807,2304],{"class":1874},[33,2809,2811],{"class":35,"line":2810},68,[33,2812,52],{"emptyLinePlaceholder":51},[33,2814,2816,2818,2820,2822],{"class":35,"line":2815},69,[33,2817,2338],{"class":1863},[33,2819,1868],{"class":1867},[33,2821,1871],{"class":1863},[33,2823,2824],{"class":1874},"Mark host as failed\n",[33,2826,2828,2831],{"class":35,"line":2827},70,[33,2829,2830],{"class":1867},"          ansible.builtin.fail",[33,2832,1893],{"class":1863},[33,2834,2836,2839,2841],{"class":35,"line":2835},71,[33,2837,2838],{"class":1867},"            msg",[33,2840,1871],{"class":1863},[33,2842,2843],{"class":1874},"\"upstream.conf rolled back on {{ inventory_hostname }}\"\n",[11,2845,2846],{},"Co robią poszczególne elementy:",[2848,2849,2850,2857,2875,2899,2923,2943],"ul",{},[2851,2852,2853,2856],"li",{},[30,2854,2855],{},"serial: 2"," dzieli inwentarz na paczki po dwa hosty. Przy ośmiu serwerach zmieniane są naraz najwyżej dwa.",[2851,2858,2859,2862,2863,2866,2867,2870,2871,2874],{},[30,2860,2861],{},"max_fail_percentage: 0"," zatrzymuje wdrożenie po pierwszym zadaniu, na którym host zostaje uznany za błędny. Błąd wewnątrz ",[30,2864,2865],{},"block"," łapie ",[30,2868,2869],{},"rescue",", więc tutaj dzieje się to dopiero przy jawnym ",[30,2872,2873],{},"fail",", a do tego czasu drugi host z paczki zwykle przechodzi cały blok z nową konfiguracją. Kolejna paczka już nie rusza. Bez tego Ansible przerywa dopiero wtedy, gdy padną wszystkie hosty w paczce, więc przy błędzie na jednym z dwóch kolejna paczka by ruszyła.",[2851,2876,2877,2880,2881,2884,2885,2888,2889,2892,2893,2895,2896,221],{},[30,2878,2879],{},"nginx -t"," testuje całą konfigurację razem z wyrenderowanym plikiem. Fragment z ",[30,2882,2883],{},"conf.d"," z blokiem ",[30,2886,2887],{},"upstream"," nie jest samodzielną poprawną konfiguracją, więc opcja ",[30,2890,2891],{},"validate"," modułu ",[30,2894,1811],{}," tu nie zadziała. Nadaje się do plików, które nginx potrafi przetestować samodzielnie, jak ",[30,2897,2898],{},"nginx.conf",[2851,2900,2901,2902,743,2905,1182,2908,743,2911,2914,2915,2918,2919,2922],{},"Handlery wykonują się standardowo na końcu każdej sekcji playa (",[30,2903,2904],{},"pre_tasks",[30,2906,2907],{},"roles",[30,2909,2910],{},"tasks",[30,2912,2913],{},"post_tasks","), dla każdej paczki osobno. ",[30,2916,2917],{},"meta: flush_handlers"," przeładowuje nginx przed health checkiem, który idzie przez nginx na ",[30,2920,2921],{},"nginx_port",". Inaczej sprawdzałbyś jeszcze starą konfigurację.",[2851,2924,2925,2928,2929,2932,2933,2935,2936,2939,2940,2942],{},[30,2926,2927],{},"backup: true"," zostawia kopię z datą w nazwie i zwraca jej ścieżkę w ",[30,2930,2931],{},"backup_file",". Sekcja ",[30,2934,2869],{}," przywraca kopię, przeładowuje nginx i jawnie oznacza host jako błędny, żeby ",[30,2937,2938],{},"max_fail_percentage"," zatrzymał wdrożenie. Jeśli pliku wcześniej nie było, kopii też nie ma, więc ",[30,2941,2869],{}," usuwa nowy plik i nginx przeładowuje się bez niego. Host i tak kończy jako błędny, więc wdrożenie staje.",[2851,2944,2945,2948,2949,2952],{},[30,2946,2947],{},"reloaded"," zamiast ",[30,2950,2951],{},"restarted",": przy reloadzie nowe połączenia trafiają do nowych workerów, a stare dokańczają żądania w toku, więc reload ich nie przerywa. Przy błędnej konfiguracji nginx zostaje przy starej.",[11,2954,2955,2956,2958,2959,221],{},"Ten playbook nie zdejmuje hostów z load balancera. Przy samym przeładowaniu nginx zwykle nie trzeba. Jeśli restartujesz aplikację, zdejmij host z balancera w ",[30,2957,2904],{}," i przywróć go w ",[30,2960,2913],{},[15,2962,2964],{"id":2963},"sekrety","Sekrety",[11,2966,2967,2968,2971],{},"Sekrety należące do playbooka trzymaj w zaszyfrowanym pliku zmiennych, do którego odwołuje się zwykły plik. Prefiks ",[30,2969,2970],{},"vault_",", konwencja z dokumentacji Ansible, od razu pokazuje, skąd pochodzi wartość:",[23,2973,2975],{"className":1854,"code":2974,"language":1856,"meta":28,"style":28},"# group_vars\u002Fprod\u002Fvars.yml\ndb_password: \"{{ vault_db_password }}\"\n\n# group_vars\u002Fprod\u002Fvault.yml, zaszyfrowany poleceniem: ansible-vault encrypt group_vars\u002Fprod\u002Fvault.yml\nvault_db_password: \"...\"\n",[30,2976,2977,2983,2993,2997,3002],{"__ignoreMap":28},[33,2978,2979],{"class":35,"line":36},[33,2980,2982],{"class":2981},"sJ8bj","# group_vars\u002Fprod\u002Fvars.yml\n",[33,2984,2985,2988,2990],{"class":35,"line":42},[33,2986,2987],{"class":1867},"db_password",[33,2989,1871],{"class":1863},[33,2991,2992],{"class":1874},"\"{{ vault_db_password }}\"\n",[33,2994,2995],{"class":35,"line":48},[33,2996,52],{"emptyLinePlaceholder":51},[33,2998,2999],{"class":35,"line":55},[33,3000,3001],{"class":2981},"# group_vars\u002Fprod\u002Fvault.yml, zaszyfrowany poleceniem: ansible-vault encrypt group_vars\u002Fprod\u002Fvault.yml\n",[33,3003,3004,3007,3009],{"class":35,"line":61},[33,3005,3006],{"class":1867},"vault_db_password",[33,3008,1871],{"class":1863},[33,3010,3011],{"class":1874},"\"...\"\n",[11,3013,3014,3015,1182,3018,3021],{},"Sekrety współdzielone z innymi systemami lepiej czytać w trakcie uruchomienia z menedżera sekretów przez lookup plugin (dostarczają je kolekcje ",[30,3016,3017],{},"amazon.aws",[30,3019,3020],{},"community.hashi_vault","), żeby wartość żyła w jednym miejscu.",[11,3023,3024,3025,3028,3029,3032,3033,3036],{},"Każde zadanie, które renderuje albo przekazuje sekret, potrzebuje ",[30,3026,3027],{},"no_log: true",". Ukrywa ono wynik zadania w logach i w wyjściu ",[30,3030,3031],{},"--diff",". Jeśli chcesz wyłączyć tylko diff, użyj ",[30,3034,3035],{},"diff: false",":",[23,3038,3040],{"className":1854,"code":3039,"language":1856,"meta":28,"style":28},"- name: Render database config\n  ansible.builtin.template:\n    src: templates\u002Fdatabase.env.j2\n    dest: \u002Fvar\u002Fwww\u002Fapp\u002F.env.database\n    owner: www-data\n    mode: '0640'\n  no_log: true\n",[30,3041,3042,3053,3060,3070,3080,3090,3100],{"__ignoreMap":28},[33,3043,3044,3046,3048,3050],{"class":35,"line":36},[33,3045,1864],{"class":1863},[33,3047,1868],{"class":1867},[33,3049,1871],{"class":1863},[33,3051,3052],{"class":1874},"Render database config\n",[33,3054,3055,3058],{"class":35,"line":42},[33,3056,3057],{"class":1867},"  ansible.builtin.template",[33,3059,1893],{"class":1863},[33,3061,3062,3065,3067],{"class":35,"line":48},[33,3063,3064],{"class":1867},"    src",[33,3066,1871],{"class":1863},[33,3068,3069],{"class":1874},"templates\u002Fdatabase.env.j2\n",[33,3071,3072,3075,3077],{"class":35,"line":55},[33,3073,3074],{"class":1867},"    dest",[33,3076,1871],{"class":1863},[33,3078,3079],{"class":1874},"\u002Fvar\u002Fwww\u002Fapp\u002F.env.database\n",[33,3081,3082,3085,3087],{"class":35,"line":61},[33,3083,3084],{"class":1867},"    owner",[33,3086,1871],{"class":1863},[33,3088,3089],{"class":1874},"www-data\n",[33,3091,3092,3095,3097],{"class":35,"line":67},[33,3093,3094],{"class":1867},"    mode",[33,3096,1871],{"class":1863},[33,3098,3099],{"class":1874},"'0640'\n",[33,3101,3102,3105,3107],{"class":35,"line":73},[33,3103,3104],{"class":1867},"  no_log",[33,3106,1871],{"class":1863},[33,3108,1994],{"class":1993},[11,3110,3111,1182,3114,3117,3118,3121,3122,3125,3126,3129,3130,3133,3134,3137,3138,743,3140,1182,3143,2948,3146,3149,3150,3153],{},[30,3112,3113],{},"owner",[30,3115,3116],{},"mode"," ustawiaj jawnie, nawet jeśli wydają się oczywiste. W CookieDomain, moim systemie zgód cookies, skrypt wdrożenia tworzył plik konfiguracyjny przez ",[30,3119,3120],{},"mktemp",", czyli z prawami 600. ",[30,3123,3124],{},"mv"," te prawa zachował, a ",[30,3127,3128],{},"rsync -a"," przeniósł je na produkcję. Serwer WWW działa jako www-data i nie mógł pliku przeczytać, więc ",[30,3131,3132],{},"require"," w bootstrapie padał, a produkcja i dev zwracały 500 na każdej trasie. W logach aplikacji nie było nic, fatal był tylko w logu nginxa. Naprawiłem to przez ",[30,3135,3136],{},"chmod 644"," przed ",[30,3139,3124],{},[30,3141,3142],{},"rsync --chmod=F644,D755",[30,3144,3145],{},"is_readable()",[30,3147,3148],{},"is_file()",". Dla pliku z sekretami 644 to za dużo, bo przeczyta go każdy użytkownik systemu. Wtedy lepsze jest ",[30,3151,3152],{},"0640"," z grupą serwera WWW albo właściciel www-data, jak w przykładzie wyżej. Ponowne wdrożenie nic nie dawało, bo każde tworzyło plik tak samo.",[11,3155,3156],{},"Jeśli sekret trafił już do repo otwartym tekstem, przepisanie historii gita nie wystarczy, bo zostaje w klonach i w cache CI. Najpierw rotacja sekretu, potem sprzątanie.",[15,3158,3160],{"id":3159},"testy-przed-produkcją","Testy przed produkcją",[11,3162,3163],{},"Do ról służy Molecule: stawia kontener albo maszynę wirtualną, nakłada rolę, uruchamia ją drugi raz, żeby sprawdzić idempotencję, i odpala weryfikator. Od Molecule 3.0 domyślnym weryfikatorem jest Ansible, więc Testinfra trzeba ustawić jawnie. Z nią testy sprawdzają stan, w jakim host faktycznie się znalazł, niezależnie od tego, co zaraportował Ansible:",[23,3165,3167],{"className":105,"code":3166,"language":107,"meta":28,"style":28},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\ndef test_nginx_running_and_enabled(host):\n    nginx = host.service(\"nginx\")\n    assert nginx.is_running\n    assert nginx.is_enabled\n\n\ndef test_nginx_config_valid(host):\n    assert host.run(\"nginx -t\").rc == 0\n",[30,3168,3169,3174,3179,3184,3189,3194,3198,3202,3207],{"__ignoreMap":28},[33,3170,3171],{"class":35,"line":36},[33,3172,3173],{},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\n",[33,3175,3176],{"class":35,"line":42},[33,3177,3178],{},"def test_nginx_running_and_enabled(host):\n",[33,3180,3181],{"class":35,"line":48},[33,3182,3183],{},"    nginx = host.service(\"nginx\")\n",[33,3185,3186],{"class":35,"line":55},[33,3187,3188],{},"    assert nginx.is_running\n",[33,3190,3191],{"class":35,"line":61},[33,3192,3193],{},"    assert nginx.is_enabled\n",[33,3195,3196],{"class":35,"line":67},[33,3197,52],{"emptyLinePlaceholder":51},[33,3199,3200],{"class":35,"line":73},[33,3201,52],{"emptyLinePlaceholder":51},[33,3203,3204],{"class":35,"line":79},[33,3205,3206],{},"def test_nginx_config_valid(host):\n",[33,3208,3209],{"class":35,"line":85},[33,3210,3211],{},"    assert host.run(\"nginx -t\").rc == 0\n",[11,3213,3214,3215,1182,3218,3221,3222,3224,3225,3228],{},"Na hoście z systemd ",[30,3216,3217],{},"is_running",[30,3219,3220],{},"is_enabled"," pytają systemd. W zwykłym kontenerze bez niego Testinfra przechodzi na polecenie ",[30,3223,1817],{}," i linki w ",[30,3226,3227],{},"\u002Fetc\u002Frc?.d\u002F",", więc sprawdza coś innego niż systemd na produkcji. Żeby testować systemd, potrzebny jest obraz z systemd albo maszyna wirtualna.",[11,3230,3231,3232,3235,3236,3239,3240,3243,3244,3247,3248,3251,3252,3255],{},"Ręcznie pisane skrypty wdrożenia i sprawdzenia łatwo pomylić. Przy jednym z moich wdrożeń ",[30,3233,3234],{},"docker kill"," okazał się w ogóle nie testować restart policy, potrzebny był ",[30,3237,3238],{},"kill -9"," na PID procesu z poziomu hosta. Inny mój skrypt deploy uruchamiał ",[30,3241,3242],{},"composer install --no-dev"," i tym samym kasował PHPUnit, zanim doszło do testów. A w skrypcie puszczanym przez ",[30,3245,3246],{},"ssh ... bash -s"," zwykły ",[30,3249,3250],{},"read"," ze stdin zjadł resztę skryptu i do ",[30,3253,3254],{},".env"," trafiły śmieci.",[11,3257,3258],{},"Przed uruchomieniem na produkcji zrób próbę na jednym reprezentatywnym hoście. Trwa krótko i pokazuje dokładne diffy szablonów:",[23,3260,3262],{"className":2163,"code":3261,"language":2165,"meta":28,"style":28},"ansible-playbook site.yml --check --diff --limit 'api_servers[0]'\n",[30,3263,3264],{"__ignoreMap":28},[33,3265,3266,3268,3270,3272,3275,3278],{"class":35,"line":36},[33,3267,2173],{"class":2172},[33,3269,2176],{"class":1874},[33,3271,2179],{"class":1993},[33,3273,3274],{"class":1993}," --diff",[33,3276,3277],{"class":1993}," --limit",[33,3279,3280],{"class":1874}," 'api_servers[0]'\n",[15,3282,3284],{"id":3283},"zanim-zatwierdzisz-zmianę-w-playbooku","Zanim zatwierdzisz zmianę w playbooku",[2848,3286,3287,3301,3313,3319],{},[2851,3288,3289,3291,3292,1182,3294,3296,3297,3300],{},[30,3290,1928],{}," przy każdym zadaniu ",[30,3293,1835],{},[30,3295,1838],{},", a ",[30,3298,3299],{},"failed_when"," tam, gdzie kod wyjścia nic nie mówi.",[2851,3302,3303,3304,3307,3308,3310,3311,221],{},"Żadnego ",[30,3305,3306],{},"ignore_errors: true"," w zadaniach infrastrukturalnych. Błąd ma zatrzymać play, żeby host został w stanie, który widać w raporcie. Do jawnej obsługi błędu jest ",[30,3309,2865],{},"\u002F",[30,3312,2869],{},[2851,3314,3315,3318],{},[30,3316,3317],{},"become: true"," tylko tam, gdzie potrzebny jest root.",[2851,3320,3321,3324,3325,3327],{},[30,3322,3323],{},"serial"," i świadomie dobrany ",[30,3326,2938],{}," przy wszystkim, co dotyka więcej niż jednego hosta.",[896,3329,3330],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":28,"searchDepth":42,"depth":42,"links":3332},[3333,3334,3335,3336,3337,3338],{"id":1797,"depth":42,"text":1798},{"id":1932,"depth":42,"text":1933},{"id":2195,"depth":42,"text":2196},{"id":2963,"depth":42,"text":2964},{"id":3159,"depth":42,"text":3160},{"id":3283,"depth":42,"text":3284},"backend","2024-05-06",{},"\u002Fpl\u002Farticles\u002Fansible-production",{"x":3344,"y":3345,"depth":3346,"size":915},0.36,0.8,0.9,[1773,1774],{"title":1788,"description":1794},"infra-automation","pl\u002Farticles\u002Fansible-production",[3352,3353,3354,3355,3356,3357],"ansible","devops","infrastructure","automation","idempotency","configuration-management","v2.1.2","MKu8Y-3TvuR4UAX9AVXC7grinispMx_WjaN1Y2Olpz8",{"id":3361,"title":3362,"articleId":3363,"body":3364,"category":27,"codeLang":27,"date":4279,"deploys":36,"description":3368,"excerpt":914,"extension":915,"lang":916,"meta":4280,"navigation":51,"path":4281,"pos":4282,"readMin":67,"related":4285,"seo":4288,"service":4289,"stem":4290,"tags":4291,"updated":1783,"version":4297,"__hash__":4298},"articles_pl\u002Fpl\u002Farticles\u002Fbridge-pattern.md","Wzorzec Bridge: oddziel to, co wysyłasz, od tego, jak to wysyłasz","bridge-pattern",{"type":8,"value":3365,"toc":4272},[3366,3369,3373,3376,3384,3387,3390,3394,3397,3577,3608,3611,3805,3808,3811,3840,3843,3959,3972,3976,3979,4042,4049,4058,4061,4077,4081,4084,4240,4253,4257,4270],[11,3367,3368],{},"Bridge rozcina hierarchię klas wzdłuż dwóch osi, które zmieniają się niezależnie, i łączy je kompozycją. Podręczniki pokazują go zwykle na kształtach i rendererach, a GoF na oknach i systemach okienkowych. Bliżej codziennej roboty jest system powiadomień, który ma dokładnie dwie osie: treść wiadomości i sposób jej dostarczenia.",[15,3370,3372],{"id":3371},"problem-m-n-klas","Problem: M × N klas",[11,3374,3375],{},"Weźmy system, który wysyła cztery typy powiadomień (potwierdzenie płatności, niski stan magazynu, zawieszenie konta, raport tygodniowy) mailem, SMS-em i na Slacka. Jeśli każda kombinacja dostaje osobną klasę, wychodzi tak:",[23,3377,3382],{"className":3378,"code":3380,"language":3381},[3379],"language-text","PaymentConfirmationEmail     PaymentConfirmationSms     PaymentConfirmationSlack\nLowInventoryAlertEmail       LowInventoryAlertSms       LowInventoryAlertSlack\nAccountSuspendedEmail        AccountSuspendedSms        AccountSuspendedSlack\nWeeklyReportEmail            WeeklyReportSms            WeeklyReportSlack\n","text",[30,3383,3380],{"__ignoreMap":28},[11,3385,3386],{},"Dwanaście klas. Czwarty kanał to cztery kolejne, piąty typ powiadomienia to trzy. Liczba rośnie jak M × N, a każda klasa powiela logikę treści swojego typu i logikę dostarczania swojego kanału, więc zmianę w skracaniu SMS-ów trzeba wprowadzić w czterech miejscach.",[11,3388,3389],{},"Każda z tych klas łączy dwie decyzje: co napisać i jak to dostarczyć. Bridge rozdziela je na dwie hierarchie.",[15,3391,3393],{"id":3392},"kanały-z-jednej-strony-wiadomości-z-drugiej","Kanały z jednej strony, wiadomości z drugiej",[11,3395,3396],{},"Strona implementacji (implementor) odpowiada za dostarczenie wiadomości:",[23,3398,3400],{"className":25,"code":3399,"language":27,"meta":28,"style":28},"interface NotificationChannel\n{\n    public function send(string $recipient, string $subject, string $body): void;\n}\n\nfinal class EmailChannel implements NotificationChannel\n{\n    public function __construct(private readonly Mailer $mailer) {}\n\n    public function send(string $recipient, string $subject, string $body): void\n    {\n        $this->mailer->send($recipient, $subject, $body);\n    }\n}\n\nfinal class SlackChannel implements NotificationChannel\n{\n    public function __construct(private readonly SlackClient $slack) {}\n\n    public function send(string $recipient, string $subject, string $body): void\n    {\n        \u002F\u002F Slack nie ma tematu wiadomości, więc temat trafia do treści jako pogrubienie\n        $this->slack->postMessage($recipient, \"*{$subject}*\\n{$body}\");\n    }\n}\n\nfinal class SmsChannel implements NotificationChannel\n{\n    public function __construct(\n        private readonly SmsProvider $sms,\n        private readonly int $maxLength = 70,\n    ) {}\n\n    public function send(string $recipient, string $subject, string $body): void\n    {\n        $text = \"{$subject}: \" . strip_tags($body);\n        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));\n    }\n}\n",[30,3401,3402,3407,3411,3416,3420,3424,3429,3433,3438,3442,3447,3452,3457,3462,3466,3470,3475,3479,3484,3488,3492,3496,3501,3506,3510,3514,3518,3523,3527,3532,3537,3542,3547,3551,3555,3559,3564,3569,3573],{"__ignoreMap":28},[33,3403,3404],{"class":35,"line":36},[33,3405,3406],{},"interface NotificationChannel\n",[33,3408,3409],{"class":35,"line":42},[33,3410,64],{},[33,3412,3413],{"class":35,"line":48},[33,3414,3415],{},"    public function send(string $recipient, string $subject, string $body): void;\n",[33,3417,3418],{"class":35,"line":55},[33,3419,82],{},[33,3421,3422],{"class":35,"line":61},[33,3423,52],{"emptyLinePlaceholder":51},[33,3425,3426],{"class":35,"line":67},[33,3427,3428],{},"final class EmailChannel implements NotificationChannel\n",[33,3430,3431],{"class":35,"line":73},[33,3432,64],{},[33,3434,3435],{"class":35,"line":79},[33,3436,3437],{},"    public function __construct(private readonly Mailer $mailer) {}\n",[33,3439,3440],{"class":35,"line":85},[33,3441,52],{"emptyLinePlaceholder":51},[33,3443,3444],{"class":35,"line":90},[33,3445,3446],{},"    public function send(string $recipient, string $subject, string $body): void\n",[33,3448,3449],{"class":35,"line":96},[33,3450,3451],{},"    {\n",[33,3453,3454],{"class":35,"line":384},[33,3455,3456],{},"        $this->mailer->send($recipient, $subject, $body);\n",[33,3458,3459],{"class":35,"line":390},[33,3460,3461],{},"    }\n",[33,3463,3464],{"class":35,"line":395},[33,3465,82],{},[33,3467,3468],{"class":35,"line":401},[33,3469,52],{"emptyLinePlaceholder":51},[33,3471,3472],{"class":35,"line":407},[33,3473,3474],{},"final class SlackChannel implements NotificationChannel\n",[33,3476,3477],{"class":35,"line":413},[33,3478,64],{},[33,3480,3481],{"class":35,"line":419},[33,3482,3483],{},"    public function __construct(private readonly SlackClient $slack) {}\n",[33,3485,3486],{"class":35,"line":1058},[33,3487,52],{"emptyLinePlaceholder":51},[33,3489,3490],{"class":35,"line":1064},[33,3491,3446],{},[33,3493,3494],{"class":35,"line":1069},[33,3495,3451],{},[33,3497,3498],{"class":35,"line":1074},[33,3499,3500],{},"        \u002F\u002F Slack nie ma tematu wiadomości, więc temat trafia do treści jako pogrubienie\n",[33,3502,3503],{"class":35,"line":1080},[33,3504,3505],{},"        $this->slack->postMessage($recipient, \"*{$subject}*\\n{$body}\");\n",[33,3507,3508],{"class":35,"line":1086},[33,3509,3461],{},[33,3511,3512],{"class":35,"line":1092},[33,3513,82],{},[33,3515,3516],{"class":35,"line":1098},[33,3517,52],{"emptyLinePlaceholder":51},[33,3519,3520],{"class":35,"line":1103},[33,3521,3522],{},"final class SmsChannel implements NotificationChannel\n",[33,3524,3525],{"class":35,"line":1108},[33,3526,64],{},[33,3528,3529],{"class":35,"line":1114},[33,3530,3531],{},"    public function __construct(\n",[33,3533,3534],{"class":35,"line":1120},[33,3535,3536],{},"        private readonly SmsProvider $sms,\n",[33,3538,3539],{"class":35,"line":1126},[33,3540,3541],{},"        private readonly int $maxLength = 70,\n",[33,3543,3544],{"class":35,"line":1132},[33,3545,3546],{},"    ) {}\n",[33,3548,3549],{"class":35,"line":1138},[33,3550,52],{"emptyLinePlaceholder":51},[33,3552,3553],{"class":35,"line":1143},[33,3554,3446],{},[33,3556,3557],{"class":35,"line":1149},[33,3558,3451],{},[33,3560,3561],{"class":35,"line":1155},[33,3562,3563],{},"        $text = \"{$subject}: \" . strip_tags($body);\n",[33,3565,3566],{"class":35,"line":1161},[33,3567,3568],{},"        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));\n",[33,3570,3571],{"class":35,"line":1167},[33,3572,3461],{},[33,3574,3575],{"class":35,"line":2535},[33,3576,82],{},[11,3578,3579,743,3582,1182,3585,3588,3589,3592,3593,3596,3597,3599,3600,3603,3604,3607],{},[30,3580,3581],{},"Mailer",[30,3583,3584],{},"SlackClient",[30,3586,3587],{},"SmsProvider"," zastępują tu klientów, których aplikacja i tak już używa. Limit SMS-a nie jest parametrem przypadkiem. Jeden segment mieści 160 znaków w alfabecie GSM-7, ale wystarczy jeden znak spoza niego (choćby polskie ogonki), żeby wiadomość poszła w UCS-2, a wtedy segment mieści 70. Stąd też ",[30,3590,3591],{},"mb_substr",", bo zwykłe ",[30,3594,3595],{},"substr"," liczy bajty i potrafi przeciąć wielobajtowy znak w połowie. ",[30,3598,3591],{}," liczy znaki, a UCS-2 jednostki 16-bitowe, więc emoji zajmie dwie z 70. Znaki z rozszerzenia GSM-7, jak ",[30,3601,3602],{},"€"," czy ",[30,3605,3606],{},"[",", też liczą się podwójnie.",[11,3609,3610],{},"Strona abstrakcji decyduje, co wysłać, a dostarczenie zostawia kanałowi:",[23,3612,3614],{"className":25,"code":3613,"language":27,"meta":28,"style":28},"final readonly class Payment\n{\n    public function __construct(\n        public string $reference,\n        public int $amountMinor,\n        public string $currency,\n        public DateTimeImmutable $paidAt,\n    ) {}\n}\n\nabstract class Notification\n{\n    public function __construct(protected readonly NotificationChannel $channel) {}\n\n    abstract public function send(string $recipient): void;\n}\n\nfinal class PaymentConfirmationNotification extends Notification\n{\n    public function __construct(\n        NotificationChannel $channel,\n        private readonly Payment $payment,\n    ) {\n        parent::__construct($channel);\n    }\n\n    public function send(string $recipient): void\n    {\n        $this->channel->send(\n            $recipient,\n            \"Payment confirmed: {$this->payment->reference}\",\n            sprintf(\n                \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n                number_format($this->payment->amountMinor \u002F 100, 2),\n                $this->payment->currency,\n                $this->payment->reference,\n                $this->payment->paidAt->format('Y-m-d H:i'),\n            ),\n        );\n    }\n}\n",[30,3615,3616,3621,3625,3629,3634,3639,3644,3649,3653,3657,3661,3666,3670,3675,3679,3684,3688,3692,3697,3701,3705,3710,3715,3720,3725,3729,3733,3738,3742,3747,3752,3757,3762,3767,3772,3777,3782,3787,3792,3797,3801],{"__ignoreMap":28},[33,3617,3618],{"class":35,"line":36},[33,3619,3620],{},"final readonly class Payment\n",[33,3622,3623],{"class":35,"line":42},[33,3624,64],{},[33,3626,3627],{"class":35,"line":48},[33,3628,3531],{},[33,3630,3631],{"class":35,"line":55},[33,3632,3633],{},"        public string $reference,\n",[33,3635,3636],{"class":35,"line":61},[33,3637,3638],{},"        public int $amountMinor,\n",[33,3640,3641],{"class":35,"line":67},[33,3642,3643],{},"        public string $currency,\n",[33,3645,3646],{"class":35,"line":73},[33,3647,3648],{},"        public DateTimeImmutable $paidAt,\n",[33,3650,3651],{"class":35,"line":79},[33,3652,3546],{},[33,3654,3655],{"class":35,"line":85},[33,3656,82],{},[33,3658,3659],{"class":35,"line":90},[33,3660,52],{"emptyLinePlaceholder":51},[33,3662,3663],{"class":35,"line":96},[33,3664,3665],{},"abstract class Notification\n",[33,3667,3668],{"class":35,"line":384},[33,3669,64],{},[33,3671,3672],{"class":35,"line":390},[33,3673,3674],{},"    public function __construct(protected readonly NotificationChannel $channel) {}\n",[33,3676,3677],{"class":35,"line":395},[33,3678,52],{"emptyLinePlaceholder":51},[33,3680,3681],{"class":35,"line":401},[33,3682,3683],{},"    abstract public function send(string $recipient): void;\n",[33,3685,3686],{"class":35,"line":407},[33,3687,82],{},[33,3689,3690],{"class":35,"line":413},[33,3691,52],{"emptyLinePlaceholder":51},[33,3693,3694],{"class":35,"line":419},[33,3695,3696],{},"final class PaymentConfirmationNotification extends Notification\n",[33,3698,3699],{"class":35,"line":1058},[33,3700,64],{},[33,3702,3703],{"class":35,"line":1064},[33,3704,3531],{},[33,3706,3707],{"class":35,"line":1069},[33,3708,3709],{},"        NotificationChannel $channel,\n",[33,3711,3712],{"class":35,"line":1074},[33,3713,3714],{},"        private readonly Payment $payment,\n",[33,3716,3717],{"class":35,"line":1080},[33,3718,3719],{},"    ) {\n",[33,3721,3722],{"class":35,"line":1086},[33,3723,3724],{},"        parent::__construct($channel);\n",[33,3726,3727],{"class":35,"line":1092},[33,3728,3461],{},[33,3730,3731],{"class":35,"line":1098},[33,3732,52],{"emptyLinePlaceholder":51},[33,3734,3735],{"class":35,"line":1103},[33,3736,3737],{},"    public function send(string $recipient): void\n",[33,3739,3740],{"class":35,"line":1108},[33,3741,3451],{},[33,3743,3744],{"class":35,"line":1114},[33,3745,3746],{},"        $this->channel->send(\n",[33,3748,3749],{"class":35,"line":1120},[33,3750,3751],{},"            $recipient,\n",[33,3753,3754],{"class":35,"line":1126},[33,3755,3756],{},"            \"Payment confirmed: {$this->payment->reference}\",\n",[33,3758,3759],{"class":35,"line":1132},[33,3760,3761],{},"            sprintf(\n",[33,3763,3764],{"class":35,"line":1138},[33,3765,3766],{},"                \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n",[33,3768,3769],{"class":35,"line":1143},[33,3770,3771],{},"                number_format($this->payment->amountMinor \u002F 100, 2),\n",[33,3773,3774],{"class":35,"line":1149},[33,3775,3776],{},"                $this->payment->currency,\n",[33,3778,3779],{"class":35,"line":1155},[33,3780,3781],{},"                $this->payment->reference,\n",[33,3783,3784],{"class":35,"line":1161},[33,3785,3786],{},"                $this->payment->paidAt->format('Y-m-d H:i'),\n",[33,3788,3789],{"class":35,"line":1167},[33,3790,3791],{},"            ),\n",[33,3793,3794],{"class":35,"line":2535},[33,3795,3796],{},"        );\n",[33,3798,3799],{"class":35,"line":2547},[33,3800,3461],{},[33,3802,3803],{"class":35,"line":2555},[33,3804,82],{},[11,3806,3807],{},"Klas jest teraz M + N, cztery od powiadomień i trzy od kanałów. Nowy kanał to jedna klasa. Nowy typ powiadomienia też, i żadna z tych zmian nie rusza drugiej hierarchii.",[11,3809,3810],{},"O kombinacji kanału i powiadomienia decyduje się przy tworzeniu obiektu:",[23,3812,3814],{"className":25,"code":3813,"language":27,"meta":28,"style":28},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n    ->send($user->email);\n\n(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n    ->send($user->phone);\n",[30,3815,3816,3821,3826,3830,3835],{"__ignoreMap":28},[33,3817,3818],{"class":35,"line":36},[33,3819,3820],{},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n",[33,3822,3823],{"class":35,"line":42},[33,3824,3825],{},"    ->send($user->email);\n",[33,3827,3828],{"class":35,"line":48},[33,3829,52],{"emptyLinePlaceholder":51},[33,3831,3832],{"class":35,"line":55},[33,3833,3834],{},"(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n",[33,3836,3837],{"class":35,"line":61},[33,3838,3839],{},"    ->send($user->phone);\n",[11,3841,3842],{},"W aplikacji to wszystko powinno siedzieć w jednym miejscu, zwykle w dispatcherze, który zna dostępne kanały i wie, jak zbudować każdy typ powiadomienia:",[23,3844,3846],{"className":25,"code":3845,"language":27,"meta":28,"style":28},"final class NotificationDispatcher\n{\n    \u002F**\n     * @param array\u003Cstring, NotificationChannel> $channels\n     * @param array\u003Cstring, Closure(NotificationChannel, array): Notification> $factories\n     *\u002F\n    public function __construct(\n        private readonly array $channels,\n        private readonly array $factories,\n    ) {}\n\n    public function dispatch(string $type, array $payload, User $user): void\n    {\n        $factory = $this->factories[$type]\n            ?? throw new InvalidArgumentException(\"Unknown notification type: {$type}\");\n\n        foreach ($user->enabledChannels() as $name) {\n            $channel = $this->channels[$name]\n                ?? throw new InvalidArgumentException(\"Unknown channel: {$name}\");\n\n            $factory($channel, $payload)->send($user->contactFor($name));\n        }\n    }\n}\n",[30,3847,3848,3853,3857,3862,3867,3872,3877,3881,3886,3891,3895,3899,3904,3908,3913,3918,3922,3927,3932,3937,3941,3946,3951,3955],{"__ignoreMap":28},[33,3849,3850],{"class":35,"line":36},[33,3851,3852],{},"final class NotificationDispatcher\n",[33,3854,3855],{"class":35,"line":42},[33,3856,64],{},[33,3858,3859],{"class":35,"line":48},[33,3860,3861],{},"    \u002F**\n",[33,3863,3864],{"class":35,"line":55},[33,3865,3866],{},"     * @param array\u003Cstring, NotificationChannel> $channels\n",[33,3868,3869],{"class":35,"line":61},[33,3870,3871],{},"     * @param array\u003Cstring, Closure(NotificationChannel, array): Notification> $factories\n",[33,3873,3874],{"class":35,"line":67},[33,3875,3876],{},"     *\u002F\n",[33,3878,3879],{"class":35,"line":73},[33,3880,3531],{},[33,3882,3883],{"class":35,"line":79},[33,3884,3885],{},"        private readonly array $channels,\n",[33,3887,3888],{"class":35,"line":85},[33,3889,3890],{},"        private readonly array $factories,\n",[33,3892,3893],{"class":35,"line":90},[33,3894,3546],{},[33,3896,3897],{"class":35,"line":96},[33,3898,52],{"emptyLinePlaceholder":51},[33,3900,3901],{"class":35,"line":384},[33,3902,3903],{},"    public function dispatch(string $type, array $payload, User $user): void\n",[33,3905,3906],{"class":35,"line":390},[33,3907,3451],{},[33,3909,3910],{"class":35,"line":395},[33,3911,3912],{},"        $factory = $this->factories[$type]\n",[33,3914,3915],{"class":35,"line":401},[33,3916,3917],{},"            ?? throw new InvalidArgumentException(\"Unknown notification type: {$type}\");\n",[33,3919,3920],{"class":35,"line":407},[33,3921,52],{"emptyLinePlaceholder":51},[33,3923,3924],{"class":35,"line":413},[33,3925,3926],{},"        foreach ($user->enabledChannels() as $name) {\n",[33,3928,3929],{"class":35,"line":419},[33,3930,3931],{},"            $channel = $this->channels[$name]\n",[33,3933,3934],{"class":35,"line":1058},[33,3935,3936],{},"                ?? throw new InvalidArgumentException(\"Unknown channel: {$name}\");\n",[33,3938,3939],{"class":35,"line":1064},[33,3940,52],{"emptyLinePlaceholder":51},[33,3942,3943],{"class":35,"line":1069},[33,3944,3945],{},"            $factory($channel, $payload)->send($user->contactFor($name));\n",[33,3947,3948],{"class":35,"line":1074},[33,3949,3950],{},"        }\n",[33,3952,3953],{"class":35,"line":1080},[33,3954,3461],{},[33,3956,3957],{"class":35,"line":1086},[33,3958,82],{},[11,3960,3961,1182,3964,3967,3968,3971],{},[30,3962,3963],{},"enabledChannels()",[30,3965,3966],{},"contactFor()"," to metody twojego modelu ",[30,3969,3970],{},"User",". Dispatcher jest jedyną klasą, która zna obie hierarchie.",[15,3973,3975],{"id":3974},"kiedy-wymiary-nie-są-niezależne","Kiedy wymiary nie są niezależne",[11,3977,3978],{},"Bridge zakłada, że każde powiadomienie ma sens w każdym kanale. Założenie się sypie, gdy któryś typ ma treść pasującą tylko do jednego kanału, jak raport tygodniowy w postaci tabeli HTML. Poznasz to po sprawdzaniu typu w abstrakcji:",[23,3980,3982],{"className":25,"code":3981,"language":27,"meta":28,"style":28},"final class WeeklyReportNotification extends Notification\n{\n    public function send(string $recipient): void\n    {\n        if ($this->channel instanceof SmsChannel) {\n            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');\n            return;\n        }\n\n        \u002F\u002F renderHtmlReport() pominięta w przykładzie\n        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n    }\n}\n",[30,3983,3984,3989,3993,3997,4001,4006,4011,4016,4020,4024,4029,4034,4038],{"__ignoreMap":28},[33,3985,3986],{"class":35,"line":36},[33,3987,3988],{},"final class WeeklyReportNotification extends Notification\n",[33,3990,3991],{"class":35,"line":42},[33,3992,64],{},[33,3994,3995],{"class":35,"line":48},[33,3996,3737],{},[33,3998,3999],{"class":35,"line":55},[33,4000,3451],{},[33,4002,4003],{"class":35,"line":61},[33,4004,4005],{},"        if ($this->channel instanceof SmsChannel) {\n",[33,4007,4008],{"class":35,"line":67},[33,4009,4010],{},"            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');\n",[33,4012,4013],{"class":35,"line":73},[33,4014,4015],{},"            return;\n",[33,4017,4018],{"class":35,"line":79},[33,4019,3950],{},[33,4021,4022],{"class":35,"line":85},[33,4023,52],{"emptyLinePlaceholder":51},[33,4025,4026],{"class":35,"line":90},[33,4027,4028],{},"        \u002F\u002F renderHtmlReport() pominięta w przykładzie\n",[33,4030,4031],{"class":35,"line":96},[33,4032,4033],{},"        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n",[33,4035,4036],{"class":35,"line":384},[33,4037,3461],{},[33,4039,4040],{"class":35,"line":390},[33,4041,82],{},[11,4043,4044,4045,4048],{},"Przez to ",[30,4046,4047],{},"instanceof"," powiadomienie zależy od konkretnego kanału, czyli dokładnie od tego, przed czym wzorzec miał chronić. Od tej chwili przy każdym nowym kanale trzeba przejrzeć wszystkie powiadomienia i szukać takich gałęzi.",[11,4050,4051,4052,1182,4055,221],{},"Jeśli dotyczy to jednego typu, najprościej przyznać, że ten przypadek ma kształt M × N, i napisać osobne klasy ",[30,4053,4054],{},"WeeklyReportEmailNotification",[30,4056,4057],{},"WeeklyReportSmsSummaryNotification",[11,4059,4060],{},"Gdy osobnych wariantów dla kanałów potrzebuje wiele typów, trzeba zmienić kontrakt. Powiadomienie buduje obiekt wiadomości z kilkoma reprezentacjami (pełna treść, krótki tekst), a kanał bierze tę, którą umie dostarczyć.",[11,4062,4063,4064,4067,4068,743,4071,4074,4075,221],{},"Powiadomienia w Laravelu leżą gdzieś pośrodku. Klasa powiadomienia deklaruje kanały w ",[30,4065,4066],{},"via()"," i ma osobną metodę dla każdego z nich (",[30,4069,4070],{},"toMail()",[30,4072,4073],{},"toArray()"," itd.), co daje do M × N metod w M klasach. Za to treść dla każdego kanału piszesz osobno i nie potrzebujesz ",[30,4076,4047],{},[15,4078,4080],{"id":4079},"testy","Testy",[11,4082,4083],{},"Każdą hierarchię da się testować bez drugiej. Kanał sprawdzasz z zamockowanym klientem, powiadomienie z zamockowanym kanałem:",[23,4085,4087],{"className":25,"code":4086,"language":27,"meta":28,"style":28},"use PHPUnit\\Framework\\TestCase;\n\nfinal class EmailChannelTest extends TestCase\n{\n    public function testDelegatesToMailer(): void\n    {\n        $mailer = $this->createMock(Mailer::class);\n        $mailer->expects($this->once())\n            ->method('send')\n            ->with('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n\n        (new EmailChannel($mailer))->send('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n    }\n}\n\nfinal class PaymentConfirmationNotificationTest extends TestCase\n{\n    public function testBuildsSubjectAndBody(): void\n    {\n        $channel = $this->createMock(NotificationChannel::class);\n        $channel->expects($this->once())\n            ->method('send')\n            ->with(\n                'ewa.lis@example.test',\n                $this->stringContains('PAY-2024-001'),\n                $this->stringContains('100.00'),\n            );\n\n        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));\n\n        (new PaymentConfirmationNotification($channel, $payment))->send('ewa.lis@example.test');\n    }\n}\n",[30,4088,4089,4094,4098,4103,4107,4112,4116,4121,4126,4131,4136,4140,4145,4149,4153,4157,4162,4166,4171,4175,4180,4185,4189,4194,4199,4204,4209,4214,4218,4223,4227,4232,4236],{"__ignoreMap":28},[33,4090,4091],{"class":35,"line":36},[33,4092,4093],{},"use PHPUnit\\Framework\\TestCase;\n",[33,4095,4096],{"class":35,"line":42},[33,4097,52],{"emptyLinePlaceholder":51},[33,4099,4100],{"class":35,"line":48},[33,4101,4102],{},"final class EmailChannelTest extends TestCase\n",[33,4104,4105],{"class":35,"line":55},[33,4106,64],{},[33,4108,4109],{"class":35,"line":61},[33,4110,4111],{},"    public function testDelegatesToMailer(): void\n",[33,4113,4114],{"class":35,"line":67},[33,4115,3451],{},[33,4117,4118],{"class":35,"line":73},[33,4119,4120],{},"        $mailer = $this->createMock(Mailer::class);\n",[33,4122,4123],{"class":35,"line":79},[33,4124,4125],{},"        $mailer->expects($this->once())\n",[33,4127,4128],{"class":35,"line":85},[33,4129,4130],{},"            ->method('send')\n",[33,4132,4133],{"class":35,"line":90},[33,4134,4135],{},"            ->with('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[33,4137,4138],{"class":35,"line":96},[33,4139,52],{"emptyLinePlaceholder":51},[33,4141,4142],{"class":35,"line":384},[33,4143,4144],{},"        (new EmailChannel($mailer))->send('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[33,4146,4147],{"class":35,"line":390},[33,4148,3461],{},[33,4150,4151],{"class":35,"line":395},[33,4152,82],{},[33,4154,4155],{"class":35,"line":401},[33,4156,52],{"emptyLinePlaceholder":51},[33,4158,4159],{"class":35,"line":407},[33,4160,4161],{},"final class PaymentConfirmationNotificationTest extends TestCase\n",[33,4163,4164],{"class":35,"line":413},[33,4165,64],{},[33,4167,4168],{"class":35,"line":419},[33,4169,4170],{},"    public function testBuildsSubjectAndBody(): void\n",[33,4172,4173],{"class":35,"line":1058},[33,4174,3451],{},[33,4176,4177],{"class":35,"line":1064},[33,4178,4179],{},"        $channel = $this->createMock(NotificationChannel::class);\n",[33,4181,4182],{"class":35,"line":1069},[33,4183,4184],{},"        $channel->expects($this->once())\n",[33,4186,4187],{"class":35,"line":1074},[33,4188,4130],{},[33,4190,4191],{"class":35,"line":1080},[33,4192,4193],{},"            ->with(\n",[33,4195,4196],{"class":35,"line":1086},[33,4197,4198],{},"                'ewa.lis@example.test',\n",[33,4200,4201],{"class":35,"line":1092},[33,4202,4203],{},"                $this->stringContains('PAY-2024-001'),\n",[33,4205,4206],{"class":35,"line":1098},[33,4207,4208],{},"                $this->stringContains('100.00'),\n",[33,4210,4211],{"class":35,"line":1103},[33,4212,4213],{},"            );\n",[33,4215,4216],{"class":35,"line":1108},[33,4217,52],{"emptyLinePlaceholder":51},[33,4219,4220],{"class":35,"line":1114},[33,4221,4222],{},"        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));\n",[33,4224,4225],{"class":35,"line":1120},[33,4226,52],{"emptyLinePlaceholder":51},[33,4228,4229],{"class":35,"line":1126},[33,4230,4231],{},"        (new PaymentConfirmationNotification($channel, $payment))->send('ewa.lis@example.test');\n",[33,4233,4234],{"class":35,"line":1132},[33,4235,3461],{},[33,4237,4238],{"class":35,"line":1138},[33,4239,82],{},[11,4241,4242,4243,4246,4247,4249,4250,221],{},"Testów jest tyle, ile klas, i żaden nie potrzebuje prawdziwego serwera pocztowego ani API Slacka. W projekcie to dwa pliki, bo PHPUnit szuka w pliku klasy o tej samej nazwie. ",[30,4244,4245],{},"createMock(Mailer::class)"," zadziała, jeśli ",[30,4248,3581],{}," jest interfejsem albo klasą bez ",[30,4251,4252],{},"final",[15,4254,4256],{"id":4255},"czy-to-się-tu-opłaci","Czy to się tu opłaci",[11,4258,4259,4260,743,4263,743,4266,4269],{},"Pierwszą wskazówką bywają nazwy klas. ",[30,4261,4262],{},"PaymentEmailNotification",[30,4264,4265],{},"PdfInvoiceExporter",[30,4267,4268],{},"CsvAuditLogFormatter"," sklejają po dwa pojęcia. Jeśli oba wymiary zmieniają się w czasie (nowe kanały i nowe typy wiadomości, nowe formaty eksportu i nowe raporty), a prawie każda kombinacja jest poprawna, Bridge się zwraca. Kiedy zmienia się tylko jeden wymiar, już nie. Przy jednym kanale wystarczy interfejs po stronie powiadomień. Gdy wiele kombinacji wymaga specjalnej obsługi, problem ma kształt M × N i jawne klasy albo osobne metody dla kanałów opisują go uczciwiej. A przy dwóch, trzech kombinacjach w sumie dodatkowa warstwa kosztuje więcej niż duplikacja, którą usuwa.",[896,4271,898],{},{"title":28,"searchDepth":42,"depth":42,"links":4273},[4274,4275,4276,4277,4278],{"id":3371,"depth":42,"text":3372},{"id":3392,"depth":42,"text":3393},{"id":3974,"depth":42,"text":3975},{"id":4079,"depth":42,"text":4080},{"id":4255,"depth":42,"text":4256},"2023-10-19",{},"\u002Fpl\u002Farticles\u002Fbridge-pattern",{"x":4283,"y":4284,"depth":3346,"size":915},0.86,0.18,[4286,4287],"factory-method","design-patterns-production",{"title":3362,"description":3368},"notification-hub","pl\u002Farticles\u002Fbridge-pattern",[27,4292,4293,4294,4295,4296],"design-patterns","bridge","architecture","notifications","abstraction","v2.1.0","FLKaidDcBv6RQCNd4GJZp7Y-wOVh0HFuD4-u2zqaU5g",{"id":4300,"title":4301,"articleId":4302,"body":4303,"category":3339,"codeLang":2165,"date":4815,"deploys":36,"description":4307,"excerpt":914,"extension":915,"lang":916,"meta":4816,"navigation":51,"path":4817,"pos":4818,"readMin":67,"related":4821,"seo":4823,"service":4824,"stem":4825,"tags":4826,"updated":914,"version":4830,"__hash__":4831},"articles_pl\u002Fpl\u002Farticles\u002Fcdn-cached-fallback.md","Cloudflare zapamiętał HTML zamiast CSS: notatki z przenosin statycznej strony","cdn-cached-fallback",{"type":8,"value":4304,"toc":4808},[4305,4308,4312,4319,4351,4354,4360,4375,4412,4416,4419,4422,4436,4439,4446,4450,4457,4554,4561,4568,4603,4617,4620,4624,4642,4649,4667,4680,4690,4710,4713,4785,4788,4792,4805],[11,4306,4307],{},"Ten blog to statyczna strona z Nuxta na hostingu współdzielonym, za Cloudflare. Dziś przeniosłem go do nowego katalogu na tym samym koncie: zbudowana strona idzie skryptem przez FTPS, a domenę przepina się w panelu na nowy katalog. Po przepięciu strona główna wyświetlała się jako biały tekst bez stylów, a sprawdzenie z terminala mówiło, że wszystko jest w porządku.",[15,4309,4311],{"id":4310},"objaw-curl-mówi-200-przeglądarka-nie-ładuje-css","Objaw: curl mówi 200, przeglądarka nie ładuje CSS",[11,4313,4314,4315,4318],{},"Pierwsze sprawdzenie wyglądało dobrze. HTML wskazywał na arkusz ",[30,4316,4317],{},"\u002F_nuxt\u002Fentry.Czy_6vul.css",", a ten plik odpowiadał poprawnie:",[23,4320,4322],{"className":2163,"code":4321,"language":2165,"meta":28,"style":28},"curl -s -o \u002Fdev\u002Fnull -w '%{http_code} %{content_type}\\n' https:\u002F\u002Ftkulesza.eu\u002F_nuxt\u002Fentry.Czy_6vul.css\n# 200 text\u002Fcss\n",[30,4323,4324,4346],{"__ignoreMap":28},[33,4325,4326,4328,4331,4334,4337,4340,4343],{"class":35,"line":36},[33,4327,272],{"class":2172},[33,4329,4330],{"class":1993}," -s",[33,4332,4333],{"class":1993}," -o",[33,4335,4336],{"class":1874}," \u002Fdev\u002Fnull",[33,4338,4339],{"class":1993}," -w",[33,4341,4342],{"class":1874}," '%{http_code} %{content_type}\\n'",[33,4344,4345],{"class":1874}," https:\u002F\u002Ftkulesza.eu\u002F_nuxt\u002Fentry.Czy_6vul.css\n",[33,4347,4348],{"class":35,"line":42},[33,4349,4350],{"class":2981},"# 200 text\u002Fcss\n",[11,4352,4353],{},"Dopiero przeglądarka bez interfejsu (puppeteer) pokazała, co się dzieje. W konsoli pojawiał się błąd:",[23,4355,4358],{"className":4356,"code":4357,"language":3381,"meta":28},[3379],"Failed to load module script: Expected a JavaScript-or-Wasm module script\nbut the server responded with a MIME type of \"text\u002Fhtml\".\n",[30,4359,4357],{"__ignoreMap":28},[11,4361,4362,4363,4366,4367,4370,4371,4374],{},"Po dopisaniu do skryptu logowania odpowiedzi z katalogu ",[30,4364,4365],{},"_nuxt",", których ",[30,4368,4369],{},"Content-Type"," zawiera ",[30,4372,4373],{},"html",", wyszło, że ten sam adres CSS i kilka modułów JS przychodzą do przeglądarki jako HTML, ze statusem 200, więc sam kod odpowiedzi niczego by nie wykazał:",[23,4376,4380],{"className":4377,"code":4378,"language":4379,"meta":28,"style":28},"language-javascript shiki shiki-themes github-light github-dark","page.on('response', r => {\n  const ct = r.headers()['content-type'] || ''\n  if (r.url().includes('\u002F_nuxt\u002F') && ct.includes('html')) {\n    console.log('BAD', r.status(), ct, r.url())\n  }\n})\n","javascript",[30,4381,4382,4387,4392,4397,4402,4407],{"__ignoreMap":28},[33,4383,4384],{"class":35,"line":36},[33,4385,4386],{},"page.on('response', r => {\n",[33,4388,4389],{"class":35,"line":42},[33,4390,4391],{},"  const ct = r.headers()['content-type'] || ''\n",[33,4393,4394],{"class":35,"line":48},[33,4395,4396],{},"  if (r.url().includes('\u002F_nuxt\u002F') && ct.includes('html')) {\n",[33,4398,4399],{"class":35,"line":55},[33,4400,4401],{},"    console.log('BAD', r.status(), ct, r.url())\n",[33,4403,4404],{"class":35,"line":61},[33,4405,4406],{},"  }\n",[33,4408,4409],{"class":35,"line":67},[33,4410,4411],{},"})\n",[15,4413,4415],{"id":4414},"przyczyna-odpowiedź-awaryjna-z-kodem-200-i-cache-po-rozszerzeniu","Przyczyna: odpowiedź awaryjna z kodem 200 i cache po rozszerzeniu",[11,4417,4418],{},"Złożyły się na to dwie rzeczy.",[11,4420,4421],{},"Pierwsza to zachowanie serwera. Na tym hostingu żądanie o nieistniejący plik kończyło się stroną główną z kodem 200 zamiast błędu 404. To ustawienie przychodzi z konfiguracji poza moim katalogiem, więc wcześniej go nie widziałem. W aplikacji jednostronicowej to bywa celowe. Tu szkodziło, bo każda strona ma własny plik i brak pliku powinien dawać 404.",[11,4423,4424,4425,743,4428,4431,4432,4435],{},"Druga to Cloudflare. Domyślnie zapisuje w cache odpowiedzi na podstawie rozszerzenia w adresie (",[30,4426,4427],{},".css",[30,4429,4430],{},".js",", obrazy), a nie typu treści, który zwrócił serwer. Jeśli serwer na adres ",[30,4433,4434],{},"entry.Czy_6vul.css"," odda HTML z kodem 200 i nagłówkami nie zabroni zapisu, Cloudflare zapisze ten HTML pod tym adresem i będzie go podawał dalej.",[11,4437,4438],{},"W czasie przenosin było okno, w którym nowy HTML z nowymi nazwami plików był już w obiegu, a żądania o te pliki trafiały jeszcze do starego katalogu. Stary katalog ich nie miał, więc odpowiadał stroną główną, a Cloudflare to zapamiętał. Nazwy plików z hashem są przy tym zdradliwe: zawartość się nie zmienia, więc nazwa też nie, i zła odpowiedź siedzi w cache tak długo, jak pozwala TTL.",[11,4440,4441,4442,4445],{},"Dlaczego curl dostawał poprawny plik? Przeglądarka i curl wysyłały różne nagłówki i najpewniej trafiały w różne wpisy cache. Nagłówek ",[30,4443,4444],{},"cf-cache-status: HIT"," był w obu przypadkach. Nie badałem dokładnie, którym nagłówkiem różnią się te wpisy, bo wyczyszczenie cache rozwiązało problem. Praktyczny wniosek jest prostszy: test curlem nie zastępuje testu w przeglądarce.",[15,4447,4449],{"id":4448},"naprawa","Naprawa",[11,4451,4452,4453,4456],{},"Doraźnie wystarczyło wyczyścić cache w panelu Cloudflare (Caching, Configuration, Purge Everything). Żeby problem nie wrócił przy następnym wdrożeniu, strona dostała własny ",[30,4454,4455],{},".htaccess",", w którym brakujący plik kończy się kodem 404:",[23,4458,4462],{"className":4459,"code":4460,"language":4461,"meta":28,"style":28},"language-apache shiki shiki-themes github-light github-dark","Options -Indexes\nDirectorySlash Off\nDirectoryIndex index.html\nErrorDocument 404 \u002F404.html\n\nRewriteEngine On\n\n# \u002Fx\u002F → \u002Fx, gdy istnieje prerenderowana strona\nRewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\nRewriteRule ^(.+)\u002F$ https:\u002F\u002Ftkulesza.eu\u002F$1 [R=301,L,NE]\n\n# \u002Fx → \u002Fx\u002Findex.html bez przekierowania\nRewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\nRewriteRule ^(.+)$ $1\u002Findex.html [L]\n\n# Wszystko inne, czego nie ma na dysku → 404\nRewriteCond %{REQUEST_FILENAME} !-f\nRewriteCond %{REQUEST_FILENAME} !-d\nRewriteRule ^ - [R=404,L]\n","apache",[30,4463,4464,4469,4474,4479,4484,4488,4493,4497,4502,4507,4512,4516,4521,4525,4530,4534,4539,4544,4549],{"__ignoreMap":28},[33,4465,4466],{"class":35,"line":36},[33,4467,4468],{},"Options -Indexes\n",[33,4470,4471],{"class":35,"line":42},[33,4472,4473],{},"DirectorySlash Off\n",[33,4475,4476],{"class":35,"line":48},[33,4477,4478],{},"DirectoryIndex index.html\n",[33,4480,4481],{"class":35,"line":55},[33,4482,4483],{},"ErrorDocument 404 \u002F404.html\n",[33,4485,4486],{"class":35,"line":61},[33,4487,52],{"emptyLinePlaceholder":51},[33,4489,4490],{"class":35,"line":67},[33,4491,4492],{},"RewriteEngine On\n",[33,4494,4495],{"class":35,"line":73},[33,4496,52],{"emptyLinePlaceholder":51},[33,4498,4499],{"class":35,"line":79},[33,4500,4501],{},"# \u002Fx\u002F → \u002Fx, gdy istnieje prerenderowana strona\n",[33,4503,4504],{"class":35,"line":85},[33,4505,4506],{},"RewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\n",[33,4508,4509],{"class":35,"line":90},[33,4510,4511],{},"RewriteRule ^(.+)\u002F$ https:\u002F\u002Ftkulesza.eu\u002F$1 [R=301,L,NE]\n",[33,4513,4514],{"class":35,"line":96},[33,4515,52],{"emptyLinePlaceholder":51},[33,4517,4518],{"class":35,"line":384},[33,4519,4520],{},"# \u002Fx → \u002Fx\u002Findex.html bez przekierowania\n",[33,4522,4523],{"class":35,"line":390},[33,4524,4506],{},[33,4526,4527],{"class":35,"line":395},[33,4528,4529],{},"RewriteRule ^(.+)$ $1\u002Findex.html [L]\n",[33,4531,4532],{"class":35,"line":401},[33,4533,52],{"emptyLinePlaceholder":51},[33,4535,4536],{"class":35,"line":407},[33,4537,4538],{},"# Wszystko inne, czego nie ma na dysku → 404\n",[33,4540,4541],{"class":35,"line":413},[33,4542,4543],{},"RewriteCond %{REQUEST_FILENAME} !-f\n",[33,4545,4546],{"class":35,"line":419},[33,4547,4548],{},"RewriteCond %{REQUEST_FILENAME} !-d\n",[33,4550,4551],{"class":35,"line":1058},[33,4552,4553],{},"RewriteRule ^ - [R=404,L]\n",[11,4555,4556,4557,4560],{},"Najpierw próbowałem ",[30,4558,4559],{},"FallbackResource disabled",", zakładając, że odpowiedź awaryjna pochodzi z tej dyrektywy w katalogu nadrzędnym. Nie zmieniło to niczego, więc mechanizm jest inny. Jawna reguła 404 na końcu zadziałała, choć nadal nie wiem, co dokładnie ustawił hosting.",[11,4562,4563,4564,4567],{},"Dla plików z hashem doszedł nagłówek, który pozwala przeglądarkom i CDN trzymać je długo. Wyjątkiem jest ",[30,4565,4566],{},"_nuxt\u002Fbuilds\u002F",", gdzie Nuxt trzyma plik z identyfikatorem bieżącego buildu:",[23,4569,4571],{"className":4459,"code":4570,"language":4461,"meta":28,"style":28},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F# && -f %{REQUEST_FILENAME}\">\n  Header always set Cache-Control \"public, max-age=31536000, immutable\"\n\u003C\u002FIf>\n\u003CElse>\n  Header always set Cache-Control \"no-cache\"\n\u003C\u002FElse>\n",[30,4572,4573,4578,4583,4588,4593,4598],{"__ignoreMap":28},[33,4574,4575],{"class":35,"line":36},[33,4576,4577],{},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F# && -f %{REQUEST_FILENAME}\">\n",[33,4579,4580],{"class":35,"line":42},[33,4581,4582],{},"  Header always set Cache-Control \"public, max-age=31536000, immutable\"\n",[33,4584,4585],{"class":35,"line":48},[33,4586,4587],{},"\u003C\u002FIf>\n",[33,4589,4590],{"class":35,"line":55},[33,4591,4592],{},"\u003CElse>\n",[33,4594,4595],{"class":35,"line":61},[33,4596,4597],{},"  Header always set Cache-Control \"no-cache\"\n",[33,4599,4600],{"class":35,"line":67},[33,4601,4602],{},"\u003C\u002FElse>\n",[11,4604,4605,4606,4609,4610,4613,4614,4616],{},"Warunek ",[30,4607,4608],{},"-f"," jest tu potrzebny. ",[30,4611,4612],{},"Header always"," dokleja nagłówek także do odpowiedzi z błędem, więc bez niego brakujący plik w ",[30,4615,4365],{}," mógłby dostać 404 z rocznym cache. Długi cache dostają tylko pliki, które istnieją na dysku.",[11,4618,4619],{},"Kolejność wgrywania też ma znaczenie. Skrypt wdrożeniowy wgrywa teraz najpierw pliki z hashem, a dopiero potem HTML, który na nie wskazuje. Dzięki temu w trakcie wgrywania HTML nie wskazuje na pliki, których jeszcze nie ma.",[15,4621,4623],{"id":4622},"druga-sprawa-wpuszczanie-ruchu-tylko-przez-cloudflare","Druga sprawa: wpuszczanie ruchu tylko przez Cloudflare",[11,4625,4626,4627,4630,4631,4638,4639,221],{},"Przy tej samej okazji zamknąłem bezpośredni dostęp do serwera. Kto zna IP hostingu, może ominąć Cloudflare, wysyłając żądanie z nagłówkiem ",[30,4628,4629],{},"Host"," domeny prosto na ten adres. Standardowe rozwiązanie to wpuszczać tylko adresy z ",[4632,4633,4637],"a",{"href":4634,"rel":4635},"https:\u002F\u002Fwww.cloudflare.com\u002Fips\u002F",[4636],"nofollow","listy Cloudflare",", na przykład przez ",[30,4640,4641],{},"Require ip",[11,4643,4644,4645,4648],{},"Przed wgraniem takiej reguły sprawdziłem, jaki adres widzi serwer. Tymczasowy skrypt PHP wypisał ",[30,4646,4647],{},"REMOTE_ADDR"," dla żądania przez Cloudflare:",[23,4650,4652],{"className":25,"code":4651,"language":27,"meta":28,"style":28},"\u003C?php\nheader('Content-Type: text\u002Fplain');\necho $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[30,4653,4654,4658,4662],{"__ignoreMap":28},[33,4655,4656],{"class":35,"line":36},[33,4657,39],{},[33,4659,4660],{"class":35,"line":42},[33,4661,93],{},[33,4663,4664],{"class":35,"line":48},[33,4665,4666],{},"echo $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[11,4668,4669,4670,4673,4674,4676,4677,4679],{},"Wynik pokazał dwa razy ten sam adres: mój, a nie adres Cloudflare. Hosting ma więc włączone przepisywanie adresu klienta (w Apache robi to ",[30,4671,4672],{},"mod_remoteip",") i ",[30,4675,4647],{}," zawiera już IP odwiedzającego. Reguła ",[30,4678,4641],{}," z listą Cloudflare zablokowałaby wszystkich czytelników, a przepuściła tylko tych, którzy akurat mają adres z puli Cloudflare.",[11,4681,4682,4683,4686,4687,4689],{},"Apache udostępnia w wyrażeniach zmienną ",[30,4684,4685],{},"CONN_REMOTE_ADDR",", czyli adres faktycznego połączenia TCP, którego ",[30,4688,4672],{}," nie zmienia. Reguła sprawdza właśnie ją:",[23,4691,4693],{"className":4459,"code":4692,"language":4461,"meta":28,"style":28},"# „...” to skrót na potrzeby wpisu. Apache go nie przyjmie (cała strona da 500). W pliku każdy z 22 zakresów stoi jako osobny warunek -ipmatch.\nRewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\nRewriteRule ^ - [F,L]\n",[30,4694,4695,4700,4705],{"__ignoreMap":28},[33,4696,4697],{"class":35,"line":36},[33,4698,4699],{},"# „...” to skrót na potrzeby wpisu. Apache go nie przyjmie (cała strona da 500). W pliku każdy z 22 zakresów stoi jako osobny warunek -ipmatch.\n",[33,4701,4702],{"class":35,"line":42},[33,4703,4704],{},"RewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\n",[33,4706,4707],{"class":35,"line":48},[33,4708,4709],{},"RewriteRule ^ - [F,L]\n",[11,4711,4712],{},"Lista ma 15 zakresów IPv4 i 7 IPv6. Po wgraniu sprawdziłem obie drogi:",[23,4714,4716],{"className":2163,"code":4715,"language":2165,"meta":28,"style":28},"curl -s -o \u002Fdev\u002Fnull -w '%{http_code}\\n' https:\u002F\u002Ftkulesza.eu\u002F\n# 200\ncurl -sk --resolve tkulesza.eu:443:\u003CIP hostingu> -o \u002Fdev\u002Fnull -w '%{http_code}\\n' https:\u002F\u002Ftkulesza.eu\u002F\n# 403\n",[30,4717,4718,4736,4741,4780],{"__ignoreMap":28},[33,4719,4720,4722,4724,4726,4728,4730,4733],{"class":35,"line":36},[33,4721,272],{"class":2172},[33,4723,4330],{"class":1993},[33,4725,4333],{"class":1993},[33,4727,4336],{"class":1874},[33,4729,4339],{"class":1993},[33,4731,4732],{"class":1874}," '%{http_code}\\n'",[33,4734,4735],{"class":1874}," https:\u002F\u002Ftkulesza.eu\u002F\n",[33,4737,4738],{"class":35,"line":42},[33,4739,4740],{"class":2981},"# 200\n",[33,4742,4743,4745,4748,4751,4754,4758,4761,4764,4767,4770,4772,4774,4776,4778],{"class":35,"line":48},[33,4744,272],{"class":2172},[33,4746,4747],{"class":1993}," -sk",[33,4749,4750],{"class":1993}," --resolve",[33,4752,4753],{"class":1874}," tkulesza.eu:443:",[33,4755,4757],{"class":4756},"szBVR","\u003C",[33,4759,4760],{"class":1874},"IP",[33,4762,4763],{"class":1874}," hosting",[33,4765,4766],{"class":1863},"u",[33,4768,4769],{"class":4756},">",[33,4771,4333],{"class":1993},[33,4773,4336],{"class":1874},[33,4775,4339],{"class":1993},[33,4777,4732],{"class":1874},[33,4779,4735],{"class":1874},[33,4781,4782],{"class":35,"line":55},[33,4783,4784],{"class":2981},"# 403\n",[11,4786,4787],{},"Plik testowy PHP usunąłem od razu po sprawdzeniu. Lista adresów Cloudflare zmienia się rzadko, ale się zmienia. Jeśli strona przez Cloudflare zacznie kiedyś zwracać 403, pierwszym podejrzanym jest nieaktualna lista.",[15,4789,4791],{"id":4790},"uwaga-o-skanowaniu-własnej-strony","Uwaga o skanowaniu własnej strony",[11,4793,4794,4795,1847,4798,4800,4801,4804],{},"Przy sprawdzaniu, czy z zewnątrz da się pobrać pliki typu ",[30,4796,4797],{},".git\u002Fconfig",[30,4799,3254],{},", odpytałem też kilka typowych ścieżek, między innymi ",[30,4802,4803],{},"wp-login.php",". Ochrona przed botami na hostingu uznała to za atak i przez kilka minut zamiast strony podawała mojemu adresowi planszę „One moment, please...”. Dalszą część przeglądu zrobiłem na zbudowanych plikach lokalnie.",[896,4806,4807],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":28,"searchDepth":42,"depth":42,"links":4809},[4810,4811,4812,4813,4814],{"id":4310,"depth":42,"text":4311},{"id":4414,"depth":42,"text":4415},{"id":4448,"depth":42,"text":4449},{"id":4622,"depth":42,"text":4623},{"id":4790,"depth":42,"text":4791},"2026-10-06",{},"\u002Fpl\u002Farticles\u002Fcdn-cached-fallback",{"x":4819,"y":4820,"depth":36,"size":915},0.28,0.14,[1789,4822],"dday-go-live",{"title":4301,"description":4307},"edge-cache","pl\u002Farticles\u002Fcdn-cached-fallback",[4827,4461,4828,930,4829],"cloudflare","caching","htaccess","v1.1.1","IhBlQox6_ImVBRNwQde-kMc_0Vlsa0R1tbrdvBE_WNw",{"id":4833,"title":4834,"articleId":4822,"body":4835,"category":5087,"codeLang":914,"date":5088,"deploys":36,"description":4839,"excerpt":914,"extension":915,"lang":916,"meta":5089,"navigation":51,"path":5090,"pos":5091,"readMin":61,"related":5093,"seo":5094,"service":5095,"stem":5096,"tags":5097,"updated":5101,"version":3358,"__hash__":5102},"articles_pl\u002Fpl\u002Farticles\u002Fdday-go-live.md","Go\u002Fno-go 6 czerwca 1944: z czego składa się decyzja o wdrożeniu",{"type":8,"value":4836,"toc":5080},[4837,4840,4844,4847,4850,4854,4857,4860,5021,5033,5040,5044,5047,5050,5057,5061,5064,5067,5071,5074,5077],[11,4838,4839],{},"Desant w Normandii zaplanowano na 5 czerwca 1944. Rano 4 czerwca przesunięto go o dobę z powodu sztormu nad kanałem La Manche. Wcześnie rano 5 czerwca Eisenhower potwierdził termin 6 czerwca na podstawie prognozy głównego meteorologa, Jamesa Stagga, który przewidział krótkie okno lepszej pogody. Tego dnia w Normandii wylądowało z morza i z powietrza ponad 150 tysięcy żołnierzy.",[15,4841,4843],{"id":4842},"okno-wyznaczyły-ograniczenia","Okno wyznaczyły ograniczenia",[11,4845,4846],{},"Datę wyznaczyły dwa warunki fizyczne. Desant powietrzny potrzebował księżyca blisko pełni, żeby w nocy widzieć strefy zrzutu. Marynarka i saperzy potrzebowali niskiej wody około świtu, żeby lądowanie zaczęło się przy rosnącej wodzie, póki zapory na plaży są odsłonięte. Oba warunki spełniało tylko kilka dni w miesiącu, w czerwcu 1944 były to 5, 6 i 7 czerwca. Następne dni z odpowiednim pływem wypadały między 18 a 20 czerwca, już bez księżyca.",[11,4848,4849],{},"Wdrożenia mają podobne okno i zwykle wyznacza je coś spoza zespołu: termin z umowy, wymóg regulatora, okres szczytowego ruchu, w którym nikt nie robi deployu, dostępność ludzi, którzy umieją zrobić rollback. Dlatego okno trzeba zapisać wcześnie, jako ograniczenie, razem z następnym oknem. Zdanie „jeśli nie zdążymy w czwartek, następny termin jest po freezie, 25-go” przyda się osobie decydującej właśnie w dniu decyzji.",[15,4851,4853],{"id":4852},"kryteria-istniały-przed-prognozą","Kryteria istniały przed prognozą",[11,4855,4856],{},"4 czerwca nikt z planistów nie zastanawiał się, czy pogoda wydaje się wystarczająco dobra. Limity wiatru, podstawy chmur, widoczności i stanu morza armia, marynarka i lotnictwo ustaliły z wyprzedzeniem, każde z własnych powodów. Meteorolodzy mieli powiedzieć, czy prognoza mieści się w tych limitach.",[11,4858,4859],{},"Kryteria ustalane rano w dniu wdrożenia łatwo nagiąć do terminu. Zdefiniuj je przy planowaniu zmiany i trzymaj obok niej, na przykład tak (format umowny, nie należy do żadnego konkretnego narzędzia):",[23,4861,4863],{"className":1854,"code":4862,"language":1856,"meta":28,"style":28},"release: payouts-2026-06\nwindow:\n  primary: 2026-06-18T07:00\u002F10:00\n  next: 2026-06-25T07:00\u002F10:00\ngo_criteria:\n  - ci_green_on_release_commit: true\n  - migration_tested_on_prod_size_copy: true\n  - migration_duration_max_minutes: 5\n  - rollback_rehearsed: true\n  - error_rate_baseline_known: true\nabort_criteria:            # sprawdzane w trakcie canary\n  - error_rate_above_baseline_pct: 50\n  - p95_latency_above_ms: 800\n  - failed_payment_jobs: 1\nowner: jedna wskazana osoba\n",[30,4864,4865,4875,4882,4892,4902,4909,4921,4932,4943,4954,4965,4976,4988,5000,5012],{"__ignoreMap":28},[33,4866,4867,4870,4872],{"class":35,"line":36},[33,4868,4869],{"class":1867},"release",[33,4871,1871],{"class":1863},[33,4873,4874],{"class":1874},"payouts-2026-06\n",[33,4876,4877,4880],{"class":35,"line":42},[33,4878,4879],{"class":1867},"window",[33,4881,1893],{"class":1863},[33,4883,4884,4887,4889],{"class":35,"line":48},[33,4885,4886],{"class":1867},"  primary",[33,4888,1871],{"class":1863},[33,4890,4891],{"class":1874},"2026-06-18T07:00\u002F10:00\n",[33,4893,4894,4897,4899],{"class":35,"line":55},[33,4895,4896],{"class":1867},"  next",[33,4898,1871],{"class":1863},[33,4900,4901],{"class":1874},"2026-06-25T07:00\u002F10:00\n",[33,4903,4904,4907],{"class":35,"line":61},[33,4905,4906],{"class":1867},"go_criteria",[33,4908,1893],{"class":1863},[33,4910,4911,4914,4917,4919],{"class":35,"line":67},[33,4912,4913],{"class":1863},"  - ",[33,4915,4916],{"class":1867},"ci_green_on_release_commit",[33,4918,1871],{"class":1863},[33,4920,1994],{"class":1993},[33,4922,4923,4925,4928,4930],{"class":35,"line":73},[33,4924,4913],{"class":1863},[33,4926,4927],{"class":1867},"migration_tested_on_prod_size_copy",[33,4929,1871],{"class":1863},[33,4931,1994],{"class":1993},[33,4933,4934,4936,4939,4941],{"class":35,"line":79},[33,4935,4913],{"class":1863},[33,4937,4938],{"class":1867},"migration_duration_max_minutes",[33,4940,1871],{"class":1863},[33,4942,2585],{"class":1993},[33,4944,4945,4947,4950,4952],{"class":35,"line":85},[33,4946,4913],{"class":1863},[33,4948,4949],{"class":1867},"rollback_rehearsed",[33,4951,1871],{"class":1863},[33,4953,1994],{"class":1993},[33,4955,4956,4958,4961,4963],{"class":35,"line":90},[33,4957,4913],{"class":1863},[33,4959,4960],{"class":1867},"error_rate_baseline_known",[33,4962,1871],{"class":1863},[33,4964,1994],{"class":1993},[33,4966,4967,4970,4973],{"class":35,"line":96},[33,4968,4969],{"class":1867},"abort_criteria",[33,4971,4972],{"class":1863},":            ",[33,4974,4975],{"class":2981},"# sprawdzane w trakcie canary\n",[33,4977,4978,4980,4983,4985],{"class":35,"line":384},[33,4979,4913],{"class":1863},[33,4981,4982],{"class":1867},"error_rate_above_baseline_pct",[33,4984,1871],{"class":1863},[33,4986,4987],{"class":1993},"50\n",[33,4989,4990,4992,4995,4997],{"class":35,"line":390},[33,4991,4913],{"class":1863},[33,4993,4994],{"class":1867},"p95_latency_above_ms",[33,4996,1871],{"class":1863},[33,4998,4999],{"class":1993},"800\n",[33,5001,5002,5004,5007,5009],{"class":35,"line":395},[33,5003,4913],{"class":1863},[33,5005,5006],{"class":1867},"failed_payment_jobs",[33,5008,1871],{"class":1863},[33,5010,5011],{"class":1993},"1\n",[33,5013,5014,5016,5018],{"class":35,"line":401},[33,5015,3113],{"class":1867},[33,5017,1871],{"class":1863},[33,5019,5020],{"class":1874},"jedna wskazana osoba\n",[11,5022,5023,5024,5026,5027,5029,5030,5032],{},"Blok ",[30,5025,4969],{}," jest tak samo ważny jak ",[30,5028,4906],{},". Przesunięcie z 4 czerwca to ",[30,5031,4906],{}," w działaniu: warunki były poza limitami, więc odpowiedź brzmiała „nie”.",[11,5034,5035,5036,5039],{},"Kiedy pierwszy raz stawiałem produkcję własnego SaaS-a, warunki „go” spisałem przed pierwszym deployem: kopie poza serwerem z przećwiczonym odtworzeniem (zajęło 2 sekundy), restart policy, rotacja logów, TLS z monitorem i runbook kluczy. W CI siedziała zmienna ",[30,5037,5038],{},"DEPLOY_ENABLED",", więc merge budował obraz, ale go nie wdrażał, dopóki wszystkie warunki nie były odhaczone. Sama podmiana dawała około sekundy przerwy. Przyjąłem to świadomie, bo nie było SLA, które uzasadniałoby pracę nad zero-downtime.",[15,5041,5043],{"id":5042},"blacksod-point-i-niemiecka-prognoza","Blacksod Point i niemiecka prognoza",[11,5045,5046],{},"Niemieccy meteorolodzy widzieli ten sam sztorm i uznali, że sztormowa pogoda potrwa około dwóch tygodni i desant będzie niemożliwy. Z Atlantyku mieli mniej obserwacji niż alianci. Alianci mieli raporty ze statków meteorologicznych, samolotów i stacji na zachodnim krańcu Europy, w tym z latarni Blacksod Point w Irlandii, i z tych raportów wynikała przerwa między dwoma frontami. Niemieccy dowódcy działali zgodnie ze swoją prognozą. Rommel pojechał do domu pod Ulm na urodziny żony, które wypadały 6 czerwca, a część dowódców była w drodze na ćwiczenia sztabowe w Rennes.",[11,5048,5049],{},"Canary nie da się ocenić bez punktu odniesienia, więc przed deployem trzeba znać normalny poziom błędów i opóźnień dla endpointów, których dotyczy zmiana. Ryzykowne elementy trzeba zmierzyć bezpośrednio, na przykład czas migracji na kopii bazy wielkości produkcyjnej. To, że na stagingu poszło szybko, niewiele mówi o produkcji. Sygnały biznesowe powinny wisieć obok technicznych. Proces płatności może zwracać 200 i nie tworzyć żadnych zamówień.",[11,5051,5052,5053,5056],{},"Raz pomyliłem kolejność przy przenosinach n8n na nowy serwer. Kolejność była twarda: najpierw wyłączyć starą instancję (wyczyścić aktywne wersje workflowów i webhooki, zatrzymać), dopiero potem aktywować workflowy na nowej. Do tego kroku wycofanie nic nie kosztowało, bo stara instancja dalej pracowała. I właśnie tu się potknąłem. Zatrzymałem starą instancję, zanim rekord A naprawdę wskazywał nowy serwer, i przez kilka minut serwis zwracał 404. Od tamtej pory przed wyłączeniem źródła sprawdzam oba originy przez ",[30,5054,5055],{},"curl --resolve",". Ten test omija DNS, więc to, dokąd wskazuje rekord A, trzeba sprawdzić osobno. Po przepięciu przetestowałem cały łańcuch: czat z odpowiedzią AI w 6,4 s, 8 aktywnych workflowów, 6 webhooków.",[15,5058,5060],{"id":5059},"sprzeczne-prognozy-wymagają-jednego-właściciela","Sprzeczne prognozy wymagają jednego właściciela",[11,5062,5063],{},"Stagg nie dostawał jednej prognozy. Trzy zespoły (Met Office, Admiralicja i grupa z lotnictwa armii USA) pracowały różnymi metodami i w dniach przed desantem się nie zgadzały. Jego zadaniem było sprowadzić je do jednego stanowiska dla Eisenhowera, a Eisenhower miał zdecydować.",[11,5065,5066],{},"Sygnały przed wdrożeniem też potrafią się nie zgadzać. W procesie powinno być jasno zapisane, kto je zbiera i przedstawia oraz kto, z imienia i nazwiska, podejmuje decyzję. To nazwisko trafia do notatki z wdrożenia.",[15,5068,5070],{"id":5069},"czekanie-też-ma-koszt","Czekanie też ma koszt",[11,5072,5073],{},"Alternatywą dla 6 czerwca było następne okno, między 18 a 20 czerwca. Od 19 do 22 czerwca nad kanałem przeszedł silny sztorm, który zniszczył sztuczny port Mulberry przy plaży Omaha. Gdyby desant przesunięto na to okno, trafiłby na najgorszą pogodę w miesiącu.",[11,5075,5076],{},"To wiedza po fakcie, 5 czerwca nikt nie mógł przewidzieć tego sztormu. Mimo to odłożenie wdrożenia ma własne ryzyko i trzeba je zapisać obok ryzyka wdrożenia. W oprogramowaniu opóźnienie zwykle oznacza większą paczkę zmian w kolejnym release, dłużej żyjące gałęzie, które odjeżdżają od main, i termin, który się zbliża, kiedy okno się zwęża.",[896,5078,5079],{},"html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":28,"searchDepth":42,"depth":42,"links":5081},[5082,5083,5084,5085,5086],{"id":4842,"depth":42,"text":4843},{"id":4852,"depth":42,"text":4853},{"id":5042,"depth":42,"text":5043},{"id":5059,"depth":42,"text":5060},{"id":5069,"depth":42,"text":5070},"arch","2026-06-06",{},"\u002Fpl\u002Farticles\u002Fdday-go-live",{"x":3346,"y":5092,"depth":36,"size":915},0.85,[1773,4287],{"title":4834,"description":4839},"go-no-go","pl\u002Farticles\u002Fdday-go-live",[5098,930,5099,5100,1782],"go-live","decision-making","release-management","2026-10-18","ZkDDtDJAdvSCQkXbLD8KTAW8wOTHanTrhyXhjuIdFvY",{"id":5104,"title":5105,"articleId":4287,"body":5106,"category":27,"codeLang":27,"date":5860,"deploys":36,"description":5861,"excerpt":914,"extension":915,"lang":916,"meta":5862,"navigation":51,"path":5863,"pos":5864,"readMin":79,"related":5867,"seo":5868,"service":5869,"stem":5870,"tags":5871,"updated":1783,"version":3358,"__hash__":5874},"articles_pl\u002Fpl\u002Farticles\u002Fdesign-patterns-production.md","Wzorce projektowe w produkcyjnym PHP: co dają, ile kosztują, kiedy ich nie używać",{"type":8,"value":5107,"toc":5844},[5108,5115,5118,5121,5125,5132,5194,5197,5201,5222,5231,5234,5237,5261,5265,5268,5271,5390,5421,5424,5427,5518,5521,5554,5557,5568,5572,5582,5585,5589,5593,5604,5671,5674,5696,5699,5702,5779,5786,5790,5805,5808,5812,5815,5818,5821,5825,5842],[11,5109,5110,5111,5114],{},"Wzorzec projektowy to nazwa dla powtarzalnego kształtu kodu. Przede wszystkim ułatwia rozmowę. Recenzent widzi ",[30,5112,5113],{},"CachingUserRepository implements UserRepositoryInterface",", rozpoznaje dekorator i wie, jak klasa jest podpięta, co potrafi i jak ją testować, zanim zajrzy do metody.",[11,5116,5117],{},"Płaci się za to warstwą pośrednią. Prawie każdy wzorzec wstawia co najmniej jeden interfejs albo jedną klasę między wywołującego a kod, który robi robotę. Ten koszt ponosisz od razu, przy każdym czytaniu. Zysk przychodzi dopiero wtedy, gdy zmiana, pod którą wzorzec powstał, naprawdę się wydarzy.",[11,5119,5120],{},"Poniżej przechodzę przez wzorce GoF z perspektywy aplikacyjnego PHP: Laravel albo Symfony, stan żyjący przez jedno żądanie, kontener DI pod ręką. Przy każdym patrzę, jaki problem rozwiązuje, jak się go zwykle nadużywa i czy kontener albo sam język nie robi już tego za niego.",[15,5122,5124],{"id":5123},"test-przed-użyciem-wzorca","Test przed użyciem wzorca",[11,5126,5127,5128],{},"Odpowiedz na jedno pytanie: ",[5129,5130,5131],"strong",{},"jaką przyszłą zmianę ten wzorzec robi tańszą?",[691,5133,5134,5144],{},[694,5135,5136],{},[697,5137,5138,5141],{},[700,5139,5140],{},"Wzorzec",[700,5142,5143],{},"Zmiana, którą ułatwia",[712,5145,5146,5154,5162,5170,5178,5186],{},[697,5147,5148,5151],{},[717,5149,5150],{},"Adapter",[717,5152,5153],{},"Wymiana albo aktualizacja zewnętrznej zależności",[697,5155,5156,5159],{},[717,5157,5158],{},"Decorator",[717,5160,5161],{},"Dodanie lub zdjęcie zachowania przekrojowego (cache, logowanie, rate limiting)",[697,5163,5164,5167],{},[717,5165,5166],{},"Strategy",[717,5168,5169],{},"Nowy wariant algorytmu bez ruszania wywołującego",[697,5171,5172,5175],{},[717,5173,5174],{},"Observer \u002F Event",[717,5176,5177],{},"Nowa reakcja na zmianę stanu bez ruszania jej źródła",[697,5179,5180,5183],{},[717,5181,5182],{},"Command",[717,5184,5185],{},"Przeniesienie pracy na kolejkę, ponawianie, audyt",[697,5187,5188,5191],{},[717,5189,5190],{},"Factory Method",[717,5192,5193],{},"Nowa implementacja wybierana w czasie wykonania",[11,5195,5196],{},"Jeśli tej zmiany nie da się nazwać albo jest hipotetyczna („może kiedyś zmienimy bazę”), wzorzec powstaje na zapas. Napisz wersję bezpośrednią, a wzorzec wprowadź w commicie, który go potrzebuje. Wzorzec łatwo dołożyć, kiedy pojawi się drugi wariant, a do tego czasu nie trzeba utrzymywać nieużywanej abstrakcji.",[15,5198,5200],{"id":5199},"wzorce-kreacyjne","Wzorce kreacyjne",[11,5202,5203,5206,5207,5210,5211,5214,5215,5217,5218],{},[5129,5204,5205],{},"Singleton."," Ma sens dla niezmiennego stanu, który drogo się buduje i który współdzieli jeden proces. We frameworku z kontenerem zarejestruj klasę jako współdzielone wiązanie (",[30,5208,5209],{},"$this->app->singleton(...)"," w Laravelu), zamiast pisać statyczne ",[30,5212,5213],{},"getInstance()",". Wersję z kontenera da się wstrzyknąć i podmienić w testach, statycznego ",[30,5216,5213],{}," już nie. ",[4632,5219,5221],{"href":5220},"\u002Fpl\u002Farticles\u002Fsingleton-pattern","Osobny artykuł.",[11,5223,5224,5227,5228],{},[5129,5225,5226],{},"Factory Method."," Przydaje się, gdy konkretny typ wybierasz dopiero w czasie wykonania: bramkę płatności według kraju, parser według sygnatury pliku, kanał powiadomień według ustawień użytkownika. Dobra fabryka wybiera typ, budowę obiektu zostawia kontenerowi i zwraca interfejs. ",[4632,5229,5221],{"href":5230},"\u002Fpl\u002Farticles\u002Ffactory-method",[11,5232,5233],{},"Builder pasuje do obiektów składanych krokami i na końcu zamrażanych. Query builder zbiera warunki i oddaje jedno zapytanie. W testach buildery do fixture’ów są w porządku, testy łatwiej się wtedy czyta. Inaczej jest, gdy kod produkcyjny potrzebuje buildera tylko po to, żeby złożyć encję. Zwykle znaczy to, że konstruktor encji przyjmuje za dużo argumentów, i lepiej podzielić encję albo część argumentów zamknąć w value objectach.",[11,5235,5236],{},"Abstract Factory tworzy rodziny powiązanych obiektów, które muszą do siebie pasować, na przykład zestaw kontrolek UI dla jednego motywu. W backendzie większość takich przypadków załatwia kontekstowe wiązanie w kontenerze, więc ręcznie pisana fabryka abstrakcyjna zdarza się rzadko.",[11,5238,5239,5240,1182,5243,5246,5247,5250,5251,5254,5255,5258,5259,221],{},"Prototype PHP ma wbudowany jako ",[30,5241,5242],{},"clone",[30,5244,5245],{},"__clone()",". Interfejs ",[30,5248,5249],{},"Prototype"," z metodą ",[30,5252,5253],{},"copy()",", która woła ",[30,5256,5257],{},"clone $this",", dokłada warstwę i nic poza nią. Głęboką kopię zagnieżdżonych obiektów robi się w ",[30,5260,5245],{},[15,5262,5264],{"id":5263},"wzorce-strukturalne","Wzorce strukturalne",[195,5266,5150],{"id":5267},"adapter",[11,5269,5270],{},"Każda integracja z zewnętrznym API jest adapterem: tłumaczy typy i błędy dostawcy na typy i błędy twojej domeny. Adapter powinien tylko tłumaczyć. Polityka ponowień, liczenie prowizji i decyzje biznesowe trafiają do serwisu, który zależy od interfejsu adaptera.",[23,5272,5274],{"className":25,"code":5273,"language":27,"meta":28,"style":28},"final class StripePaymentGateway implements PaymentGatewayInterface\n{\n    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n\n    public function charge(Money $amount, string $paymentMethodId): ChargeResult\n    {\n        try {\n            $intent = $this->stripe->paymentIntents->create([\n                'amount'                       => $amount->minorUnits,  \u002F\u002F Stripe oczekuje kwoty w najmniejszej jednostce waluty\n                'currency'                     => strtolower($amount->currency),\n                'payment_method'               => $paymentMethodId,\n                'allowed_payment_method_types' => ['card'],\n                'confirm'                      => true,                 \u002F\u002F bez tego nic nie zostaje pobrane i nie pojawia się błąd karty\n            ]);\n\n            return $intent->status === \\Stripe\\PaymentIntent::STATUS_SUCCEEDED\n                ? ChargeResult::succeeded($intent->id)\n                : ChargeResult::pending($intent->id);\n        } catch (\\Stripe\\Exception\\CardException $e) {\n            return ChargeResult::declined($e->getMessage());\n        }\n    }\n}\n",[30,5275,5276,5281,5285,5290,5294,5299,5303,5308,5313,5321,5326,5331,5336,5344,5349,5353,5358,5363,5368,5373,5378,5382,5386],{"__ignoreMap":28},[33,5277,5278],{"class":35,"line":36},[33,5279,5280],{},"final class StripePaymentGateway implements PaymentGatewayInterface\n",[33,5282,5283],{"class":35,"line":42},[33,5284,64],{},[33,5286,5287],{"class":35,"line":48},[33,5288,5289],{},"    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n",[33,5291,5292],{"class":35,"line":55},[33,5293,52],{"emptyLinePlaceholder":51},[33,5295,5296],{"class":35,"line":61},[33,5297,5298],{},"    public function charge(Money $amount, string $paymentMethodId): ChargeResult\n",[33,5300,5301],{"class":35,"line":67},[33,5302,3451],{},[33,5304,5305],{"class":35,"line":73},[33,5306,5307],{},"        try {\n",[33,5309,5310],{"class":35,"line":79},[33,5311,5312],{},"            $intent = $this->stripe->paymentIntents->create([\n",[33,5314,5315,5318],{"class":35,"line":85},[33,5316,5317],{},"                'amount'                       => $amount->minorUnits,",[33,5319,5320],{},"  \u002F\u002F Stripe oczekuje kwoty w najmniejszej jednostce waluty\n",[33,5322,5323],{"class":35,"line":90},[33,5324,5325],{},"                'currency'                     => strtolower($amount->currency),\n",[33,5327,5328],{"class":35,"line":96},[33,5329,5330],{},"                'payment_method'               => $paymentMethodId,\n",[33,5332,5333],{"class":35,"line":384},[33,5334,5335],{},"                'allowed_payment_method_types' => ['card'],\n",[33,5337,5338,5341],{"class":35,"line":390},[33,5339,5340],{},"                'confirm'                      => true,",[33,5342,5343],{},"                 \u002F\u002F bez tego nic nie zostaje pobrane i nie pojawia się błąd karty\n",[33,5345,5346],{"class":35,"line":395},[33,5347,5348],{},"            ]);\n",[33,5350,5351],{"class":35,"line":401},[33,5352,52],{"emptyLinePlaceholder":51},[33,5354,5355],{"class":35,"line":407},[33,5356,5357],{},"            return $intent->status === \\Stripe\\PaymentIntent::STATUS_SUCCEEDED\n",[33,5359,5360],{"class":35,"line":413},[33,5361,5362],{},"                ? ChargeResult::succeeded($intent->id)\n",[33,5364,5365],{"class":35,"line":419},[33,5366,5367],{},"                : ChargeResult::pending($intent->id);\n",[33,5369,5370],{"class":35,"line":1058},[33,5371,5372],{},"        } catch (\\Stripe\\Exception\\CardException $e) {\n",[33,5374,5375],{"class":35,"line":1064},[33,5376,5377],{},"            return ChargeResult::declined($e->getMessage());\n",[33,5379,5380],{"class":35,"line":1069},[33,5381,3950],{},[33,5383,5384],{"class":35,"line":1074},[33,5385,3461],{},[33,5387,5388],{"class":35,"line":1080},[33,5389,82],{},[11,5391,5392,5393,5396,5397,5400,5401,5404,5405,5408,5409,5412,5413,5416,5417,5420],{},"Ten adapter mapuje ",[30,5394,5395],{},"Money"," na tablicę żądania, odpowiedź na ",[30,5398,5399],{},"ChargeResult",", a odrzucenie karty zamienia na wynik domenowy. To szkic pod wersję API Stripe ",[30,5402,5403],{},"2026-09-30.endive"," (przypiętą w stripe-php 22), która odrzuca ",[30,5406,5407],{},"payment_method_types",", więc listę metod zawęża ",[30,5410,5411],{},"allowed_payment_method_types",". Prawdziwy adapter odróżniałby jeszcze ",[30,5414,5415],{},"requires_action"," (3DS) od ",[30,5418,5419],{},"processing"," i mapował pozostałe wyjątki Stripe, na przykład błąd połączenia. Kiedy taka klasa zaczyna o czymś decydować, na przykład czy ponowić odrzuconą kartę, urosła do serwisu i trzeba ją rozdzielić.",[195,5422,5158],{"id":5423},"decorator",[11,5425,5426],{},"Dekorator opakowuje obiekt implementujący ten sam interfejs i dokłada zachowanie przed delegacją albo po niej. Zwykle chodzi o cache, logowanie, metryki i ograniczanie ruchu.",[23,5428,5430],{"className":25,"code":5429,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n\nfinal class CachingUserRepository implements UserRepositoryInterface\n{\n    public function __construct(\n        private readonly UserRepositoryInterface $inner,\n        private readonly Cache $cache,\n        private readonly int $ttlSeconds = 300,\n    ) {}\n\n    public function findById(int $id): ?User\n    {\n        return $this->cache->remember(\n            \"user.{$id}\",\n            $this->ttlSeconds,\n            fn () => $this->inner->findById($id),\n        );\n    }\n}\n",[30,5431,5432,5437,5441,5446,5450,5454,5459,5464,5469,5473,5477,5482,5486,5491,5496,5501,5506,5510,5514],{"__ignoreMap":28},[33,5433,5434],{"class":35,"line":36},[33,5435,5436],{},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n",[33,5438,5439],{"class":35,"line":42},[33,5440,52],{"emptyLinePlaceholder":51},[33,5442,5443],{"class":35,"line":48},[33,5444,5445],{},"final class CachingUserRepository implements UserRepositoryInterface\n",[33,5447,5448],{"class":35,"line":55},[33,5449,64],{},[33,5451,5452],{"class":35,"line":61},[33,5453,3531],{},[33,5455,5456],{"class":35,"line":67},[33,5457,5458],{},"        private readonly UserRepositoryInterface $inner,\n",[33,5460,5461],{"class":35,"line":73},[33,5462,5463],{},"        private readonly Cache $cache,\n",[33,5465,5466],{"class":35,"line":79},[33,5467,5468],{},"        private readonly int $ttlSeconds = 300,\n",[33,5470,5471],{"class":35,"line":85},[33,5472,3546],{},[33,5474,5475],{"class":35,"line":90},[33,5476,52],{"emptyLinePlaceholder":51},[33,5478,5479],{"class":35,"line":96},[33,5480,5481],{},"    public function findById(int $id): ?User\n",[33,5483,5484],{"class":35,"line":384},[33,5485,3451],{},[33,5487,5488],{"class":35,"line":390},[33,5489,5490],{},"        return $this->cache->remember(\n",[33,5492,5493],{"class":35,"line":395},[33,5494,5495],{},"            \"user.{$id}\",\n",[33,5497,5498],{"class":35,"line":401},[33,5499,5500],{},"            $this->ttlSeconds,\n",[33,5502,5503],{"class":35,"line":407},[33,5504,5505],{},"            fn () => $this->inner->findById($id),\n",[33,5507,5508],{"class":35,"line":413},[33,5509,3796],{},[33,5511,5512],{"class":35,"line":419},[33,5513,3461],{},[33,5515,5516],{"class":35,"line":1058},[33,5517,82],{},[11,5519,5520],{},"Podpięcie w service providerze:",[23,5522,5524],{"className":25,"code":5523,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n\n$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n    $app->make(EloquentUserRepository::class),\n    $app->make(Cache::class),\n));\n",[30,5525,5526,5530,5534,5539,5544,5549],{"__ignoreMap":28},[33,5527,5528],{"class":35,"line":36},[33,5529,5436],{},[33,5531,5532],{"class":35,"line":42},[33,5533,52],{"emptyLinePlaceholder":51},[33,5535,5536],{"class":35,"line":48},[33,5537,5538],{},"$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n",[33,5540,5541],{"class":35,"line":55},[33,5542,5543],{},"    $app->make(EloquentUserRepository::class),\n",[33,5545,5546],{"class":35,"line":61},[33,5547,5548],{},"    $app->make(Cache::class),\n",[33,5550,5551],{"class":35,"line":67},[33,5552,5553],{},"));\n",[11,5555,5556],{},"Każda warstwa ma osobny test. Test dekoratora sprawdza tylko pudło i trafienie, dostęp do danych zostaje w teście repozytorium.",[11,5558,5559,5560,5563,5564,5567],{},"Kłopoty zaczynają się przy długich łańcuchach dekoratorów, składanych w różnej kolejności w różnych miejscach. Jeśli żeby ustalić, czy wywołanie jest cache’owane, logowane i ponawiane, trzeba prześledzić konfigurację kontenera, stos jest za głęboki. Dwie, trzy warstwy podpięte w jednym miejscu to rozsądna granica. I szczegół tego konkretnego dekoratora: ",[30,5561,5562],{},"remember()"," w Laravelu traktuje zapisany ",[30,5565,5566],{},"null"," jak brak wpisu, więc zapytania o nieistniejące ID zawsze trafiają do bazy. Jeśli ten ruch ma znaczenie, zapisuj w cache’u jawny znacznik „nie znaleziono”.",[195,5569,5571],{"id":5570},"facade-i-proxy","Facade i Proxy",[11,5573,5574,5575,1847,5578,5581],{},"Fasada daje jeden punkt wejścia do podsystemu złożonego z wielu klas, gdy wywołującym wystarcza kilka operacji. Statyczne fasady Laravela to inny mechanizm pod tą samą nazwą, statyczne proxy do wiązania w kontenerze. W kodzie aplikacji są wygodne i testuje się je przez ",[30,5576,5577],{},"::fake()",[30,5579,5580],{},"::shouldReceive()",". Chowają za to zależności, bo w sygnaturze konstruktora nic się nie pojawia. W serwisach domenowych wolę wstrzykiwanie przez konstruktor, wtedy lista zależności jest na wierzchu.",[11,5583,5584],{},"Proxy w PHP spotyka się głównie jako klasy generowane do leniwego ładowania przez ORM-y i kontenery, najczęściej encje Doctrine i lazy services w Symfony. Od PHP 8.4 oba mogą zamiast generowanych klas korzystać z natywnych lazy objects. Własne rzadko ma uzasadnienie. Do przechwytywania wywołań wystarczy dekorator, robi to samo jawnie i łatwiej go przetestować.",[15,5586,5588],{"id":5587},"wzorce-behawioralne","Wzorce behawioralne",[195,5590,5592],{"id":5591},"observer-i-zdarzenia","Observer i zdarzenia",[11,5594,5595,5596,5599,5600,5603],{},"Kiedy ",[30,5597,5598],{},"Order"," zostaje opłacone, kod oznaczający płatność wysyła zdarzenie ",[30,5601,5602],{},"OrderPaid",". Listenery od maili, stanów magazynowych i analityki subskrybują je niezależnie, a dołożenie czwartego nie zmienia kodu zamówienia.",[23,5605,5607],{"className":25,"code":5606,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Queue\\ShouldQueue;\n\nfinal class OrderPaid\n{\n    public function __construct(public readonly int $orderId) {}\n}\n\nfinal class ReserveInventory implements ShouldQueue\n{\n    public function handle(OrderPaid $event): void\n    {\n        \u002F\u002F wczytaj zamówienie po id i zarezerwuj towar\n    }\n}\n",[30,5608,5609,5614,5618,5623,5627,5632,5636,5640,5645,5649,5654,5658,5663,5667],{"__ignoreMap":28},[33,5610,5611],{"class":35,"line":36},[33,5612,5613],{},"use Illuminate\\Contracts\\Queue\\ShouldQueue;\n",[33,5615,5616],{"class":35,"line":42},[33,5617,52],{"emptyLinePlaceholder":51},[33,5619,5620],{"class":35,"line":48},[33,5621,5622],{},"final class OrderPaid\n",[33,5624,5625],{"class":35,"line":55},[33,5626,64],{},[33,5628,5629],{"class":35,"line":61},[33,5630,5631],{},"    public function __construct(public readonly int $orderId) {}\n",[33,5633,5634],{"class":35,"line":67},[33,5635,82],{},[33,5637,5638],{"class":35,"line":73},[33,5639,52],{"emptyLinePlaceholder":51},[33,5641,5642],{"class":35,"line":79},[33,5643,5644],{},"final class ReserveInventory implements ShouldQueue\n",[33,5646,5647],{"class":35,"line":85},[33,5648,64],{},[33,5650,5651],{"class":35,"line":90},[33,5652,5653],{},"    public function handle(OrderPaid $event): void\n",[33,5655,5656],{"class":35,"line":96},[33,5657,3451],{},[33,5659,5660],{"class":35,"line":384},[33,5661,5662],{},"        \u002F\u002F wczytaj zamówienie po id i zarezerwuj towar\n",[33,5664,5665],{"class":35,"line":390},[33,5666,3461],{},[33,5668,5669],{"class":35,"line":395},[33,5670,82],{},[11,5672,5673],{},"Ryzykiem są kaskady zdarzeń. Listener wysyła kolejne zdarzenie, jego listener następne i powstaje graf wywołań, którego nikt nigdy nie rozrysował. Synchroniczne listenery w takim łańcuchu wykonują się w ramach pierwotnego żądania, więc czas odpowiedzi i liczba zapytań prostego „oznacz jako opłacone” rosną z każdym subskrybentem. W ryzach trzyma to kilka zasad:",[2848,5675,5676,5682,5685],{},[2851,5677,5678,5679,221],{},"Listenery z efektami ubocznymi poza bazą (mail, wywołania HTTP) implementują ",[30,5680,5681],{},"ShouldQueue",[2851,5683,5684],{},"Zdarzenia niosą identyfikatory, nie modele. Listener sam ładuje aktualny stan.",[2851,5686,5687,5688,5691,5692,5695],{},"Listenery, które nie mogą ruszyć przed commitem transakcji, są tak oznaczone: ",[30,5689,5690],{},"ShouldDispatchAfterCommit"," na zdarzeniu albo ",[30,5693,5694],{},"$afterCommit = true"," na listenerze kolejkowym.",[195,5697,5166],{"id":5698},"strategy",[11,5700,5701],{},"Strategy oddziela wybór algorytmu od jego użycia. Podręcznikowy przypadek to kalkulator wysyłki ze stawką ryczałtową, wagową i strefową.",[23,5703,5705],{"className":25,"code":5704,"language":27,"meta":28,"style":28},"interface ShippingPricing\n{\n    public function price(Parcel $parcel): Money;\n}\n\nfinal class ShippingQuote\n{\n    \u002F** @param array\u003Cstring, ShippingPricing> $pricings *\u002F\n    public function __construct(private readonly array $pricings) {}\n\n    public function for(Carrier $carrier, Parcel $parcel): Money\n    {\n        return ($this->pricings[$carrier->value] ?? throw new UnsupportedCarrier($carrier))\n            ->price($parcel);\n    }\n}\n",[30,5706,5707,5712,5716,5721,5725,5729,5734,5738,5743,5748,5752,5757,5761,5766,5771,5775],{"__ignoreMap":28},[33,5708,5709],{"class":35,"line":36},[33,5710,5711],{},"interface ShippingPricing\n",[33,5713,5714],{"class":35,"line":42},[33,5715,64],{},[33,5717,5718],{"class":35,"line":48},[33,5719,5720],{},"    public function price(Parcel $parcel): Money;\n",[33,5722,5723],{"class":35,"line":55},[33,5724,82],{},[33,5726,5727],{"class":35,"line":61},[33,5728,52],{"emptyLinePlaceholder":51},[33,5730,5731],{"class":35,"line":67},[33,5732,5733],{},"final class ShippingQuote\n",[33,5735,5736],{"class":35,"line":73},[33,5737,64],{},[33,5739,5740],{"class":35,"line":79},[33,5741,5742],{},"    \u002F** @param array\u003Cstring, ShippingPricing> $pricings *\u002F\n",[33,5744,5745],{"class":35,"line":85},[33,5746,5747],{},"    public function __construct(private readonly array $pricings) {}\n",[33,5749,5750],{"class":35,"line":90},[33,5751,52],{"emptyLinePlaceholder":51},[33,5753,5754],{"class":35,"line":96},[33,5755,5756],{},"    public function for(Carrier $carrier, Parcel $parcel): Money\n",[33,5758,5759],{"class":35,"line":384},[33,5760,3451],{},[33,5762,5763],{"class":35,"line":390},[33,5764,5765],{},"        return ($this->pricings[$carrier->value] ?? throw new UnsupportedCarrier($carrier))\n",[33,5767,5768],{"class":35,"line":395},[33,5769,5770],{},"            ->price($parcel);\n",[33,5772,5773],{"class":35,"line":401},[33,5774,3461],{},[33,5776,5777],{"class":35,"line":407},[33,5778,82],{},[11,5780,5781,5782,5785],{},"Strategię wybieraj raz, na brzegu operacji. Gdy wybór siedzi w pętli po pozycjach, trudniej go śledzić, bo to samo pytanie pada przy każdej pozycji. Przy dwóch wariantach, kiedy trzeciego nie widać, wyrażenie ",[30,5783,5784],{},"match"," jest krótsze i równie czytelne.",[195,5787,5789],{"id":5788},"command-i-template-method","Command i Template Method",[11,5791,5792,5793,5796,5797,5800,5801,5804],{},"Komenda to serializowalny opis intencji, ",[30,5794,5795],{},"ChargeCustomer(customerId: 42, amountMinor: 1999)",". Że jest danymi, można ją wrzucić na kolejkę, opóźnić, ponowić, zalogować i odtworzyć. Kolejkowany job w Laravelu to komenda, która zwykle niesie własną metodę ",[30,5798,5799],{},"handle()",", a osobny handler można podpiąć przez ",[30,5802,5803],{},"Bus::map",". Najwięcej myślenia wymaga payload. Trzymaj w nim identyfikatory i skalary, a handler zrób idempotentny, bo kolejka z ponowieniami może dostarczyć tę samą komendę więcej niż raz.",[11,5806,5807],{},"Template Method przydaje się, gdy dwie klasy mają wspólną, stałą sekwencję kroków i różnią się jednym, na przykład raport budowany tak samo i eksportowany do CSV albo do PDF. Klasa bazowa definiuje sekwencję, a zmienny krok deklaruje jako abstrakcyjny. Takie dziedziczenie wystarcza, dopóki różni się jeden krok. Gdy różnych kroków przybywa albo warianty trzeba łączyć, przejdź na kompozycję i wstrzyknij eksporter jako strategię.",[15,5809,5811],{"id":5810},"wzorce-które-rzadko-pasują-do-kodu-aplikacji","Wzorce, które rzadko pasują do kodu aplikacji",[11,5813,5814],{},"Interpreter to reprezentacja gramatyki własnego języka i ewaluacja jej drzewa, a w praktyce dochodzi do tego jeszcze parser. Zanim go napiszesz, sprawdź gotowy silnik wyrażeń. Symfony ExpressionLanguage obsłuży większość reguł biznesowych, warunki cenowe czy predykaty feature flag, przy dużo mniejszej ilości kodu do utrzymania.",[11,5816,5817],{},"Mediator to centralny obiekt koordynujący komponenty, które inaczej odwoływałyby się do siebie nawzajem. Jeśli framework ma dispatcher zdarzeń i kontener, dispatcher zwykle już pełni tę rolę. Własny mediator ma sens, gdy sama koordynacja jest złożona i ma stan, jak w formularzu, w którym pola wpływają na siebie.",[11,5819,5820],{},"Flyweight współdzieli niezmienny stan między wieloma drobnymi obiektami, żeby oszczędzić pamięć. W PHP obiekty utworzone dla żądania znikają po jego zakończeniu, a jedno żądanie rzadko tworzy ich tyle, żeby to miało znaczenie. Wyjątkiem są długo działające zadania CLI, które tworzą ogromne ilości identycznych value objectów, na przykład tokenów w parserze.",[15,5822,5824],{"id":5823},"zanim-zatwierdzisz-pr","Zanim zatwierdzisz PR",[2848,5826,5827,5830,5833,5836],{},[2851,5828,5829],{},"Czy autor umie nazwać zmianę, którą wzorzec ułatwia, i czy ta zmiana jest naprawdę zaplanowana?",[2851,5831,5832],{},"Czy nowy interfejs ma drugą implementację albo przynajmniej dublera w testach, który go potrzebuje?",[2851,5834,5835],{},"Czy adapter tylko tłumaczy?",[2851,5837,5838,5839,5841],{},"Czy ",[30,5840,5784],{}," albo zwykła funkcja nie załatwiłyby tego samego krótszym kodem?",[896,5843,898],{},{"title":28,"searchDepth":42,"depth":42,"links":5845},[5846,5847,5848,5853,5858,5859],{"id":5123,"depth":42,"text":5124},{"id":5199,"depth":42,"text":5200},{"id":5263,"depth":42,"text":5264,"children":5849},[5850,5851,5852],{"id":5267,"depth":48,"text":5150},{"id":5423,"depth":48,"text":5158},{"id":5570,"depth":48,"text":5571},{"id":5587,"depth":42,"text":5588,"children":5854},[5855,5856,5857],{"id":5591,"depth":48,"text":5592},{"id":5698,"depth":48,"text":5166},{"id":5788,"depth":48,"text":5789},{"id":5810,"depth":42,"text":5811},{"id":5823,"depth":42,"text":5824},"2023-02-10","Wzorzec projektowy to nazwa dla powtarzalnego kształtu kodu. Przede wszystkim ułatwia rozmowę. Recenzent widzi CachingUserRepository implements UserRepositoryInterface, rozpoznaje dekorator i wie, jak klasa jest podpięta, co potrafi i jak ją testować, zanim zajrzy do metody.",{},"\u002Fpl\u002Farticles\u002Fdesign-patterns-production",{"x":5865,"y":5092,"depth":36,"size":5866},0.62,"lg",[923,1773,4286],{"title":5105,"description":5861},"pattern-reference","pl\u002Farticles\u002Fdesign-patterns-production",[4292,4294,27,5872,5873],"refactoring","code-review","aTU5pJLpt7yTz1llA5h81Ho32y0D0IzYrVRbWl1XI28",{"id":5876,"title":5877,"articleId":4286,"body":5878,"category":27,"codeLang":27,"date":6515,"deploys":36,"description":5882,"excerpt":914,"extension":915,"lang":916,"meta":6516,"navigation":51,"path":5230,"pos":6517,"readMin":61,"related":6520,"seo":6521,"service":6522,"stem":6523,"tags":6524,"updated":1783,"version":4297,"__hash__":6527},"articles_pl\u002Fpl\u002Farticles\u002Ffactory-method.md","Factory Method w PHP: jedno miejsce na wybór implementacji w runtime",{"type":8,"value":5879,"toc":6505},[5880,5883,5887,5890,5989,5996,6000,6003,6043,6046,6050,6064,6142,6145,6231,6234,6282,6285,6319,6326,6344,6348,6355,6365,6369,6375,6377,6383,6424,6431,6485,6491,6497,6503],[11,5881,5882],{},"Fabryka odpowiada na jedno pytanie: jaką implementację utworzyć dla danego wejścia. Przydaje się, gdy odpowiedź zależy od danych z runtime, na przykład od pola w requeście albo kolumny w bazie, i gdy tej samej odpowiedzi potrzeba w więcej niż jednym miejscu. Bez obu warunków fabryka dokłada tylko kolejną warstwę.",[15,5884,5886],{"id":5885},"terminologia","Terminologia",[11,5888,5889],{},"W książce GoF Factory Method to konkretna struktura: klasa bazowa wywołuje abstrakcyjną metodę tworzącą, a podklasy ją nadpisują i w ten sposób wybierają konkretny produkt.",[23,5891,5893],{"className":25,"code":5892,"language":27,"meta":28,"style":28},"abstract class ReportExporter\n{\n    abstract protected function createWriter(): ReportWriter;\n\n    public function export(Report $report): string\n    {\n        $writer = $this->createWriter();\n        foreach ($report->rows() as $row) {\n            $writer->addRow($row);\n        }\n        return $writer->finish();\n    }\n}\n\nfinal class CsvReportExporter extends ReportExporter\n{\n    protected function createWriter(): ReportWriter\n    {\n        return new CsvWriter();\n    }\n}\n",[30,5894,5895,5900,5904,5909,5913,5918,5922,5927,5932,5937,5941,5946,5950,5954,5958,5963,5967,5972,5976,5981,5985],{"__ignoreMap":28},[33,5896,5897],{"class":35,"line":36},[33,5898,5899],{},"abstract class ReportExporter\n",[33,5901,5902],{"class":35,"line":42},[33,5903,64],{},[33,5905,5906],{"class":35,"line":48},[33,5907,5908],{},"    abstract protected function createWriter(): ReportWriter;\n",[33,5910,5911],{"class":35,"line":55},[33,5912,52],{"emptyLinePlaceholder":51},[33,5914,5915],{"class":35,"line":61},[33,5916,5917],{},"    public function export(Report $report): string\n",[33,5919,5920],{"class":35,"line":67},[33,5921,3451],{},[33,5923,5924],{"class":35,"line":73},[33,5925,5926],{},"        $writer = $this->createWriter();\n",[33,5928,5929],{"class":35,"line":79},[33,5930,5931],{},"        foreach ($report->rows() as $row) {\n",[33,5933,5934],{"class":35,"line":85},[33,5935,5936],{},"            $writer->addRow($row);\n",[33,5938,5939],{"class":35,"line":90},[33,5940,3950],{},[33,5942,5943],{"class":35,"line":96},[33,5944,5945],{},"        return $writer->finish();\n",[33,5947,5948],{"class":35,"line":384},[33,5949,3461],{},[33,5951,5952],{"class":35,"line":390},[33,5953,82],{},[33,5955,5956],{"class":35,"line":395},[33,5957,52],{"emptyLinePlaceholder":51},[33,5959,5960],{"class":35,"line":401},[33,5961,5962],{},"final class CsvReportExporter extends ReportExporter\n",[33,5964,5965],{"class":35,"line":407},[33,5966,64],{},[33,5968,5969],{"class":35,"line":413},[33,5970,5971],{},"    protected function createWriter(): ReportWriter\n",[33,5973,5974],{"class":35,"line":419},[33,5975,3451],{},[33,5977,5978],{"class":35,"line":1058},[33,5979,5980],{},"        return new CsvWriter();\n",[33,5982,5983],{"class":35,"line":1064},[33,5984,3461],{},[33,5986,5987],{"class":35,"line":1069},[33,5988,82],{},[11,5990,5991,5992,5995],{},"W aplikacjach PHP częściej trafia się fabryka parametryzowana: jeden obiekt z metodą ",[30,5993,5994],{},"make()",", który dostaje klucz i oddaje implementację interfejsu. Dalej piszę o tej formie, bo to ona zastępuje powielone warunki.",[15,5997,5999],{"id":5998},"problem-ta-sama-gałąź-w-kilku-miejscach","Problem: ta sama gałąź w kilku miejscach",[11,6001,6002],{},"Aplikacja przyjmuje płatności kartą, BLIK-iem, przelewem i na raty. Każdy dostawca ma swoje API, swój model błędów i swój format webhooków. Jeśli decyzja nie zapada w jednym miejscu, w kontrolerze ląduje coś takiego:",[23,6004,6006],{"className":25,"code":6005,"language":27,"meta":28,"style":28},"$gateway = match ($request->input('payment_method')) {\n    'card' => new StripeGateway(config('services.stripe.secret')),\n    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),\n    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),\n    'financing' => new FinancingGateway(config('services.financing.api_key')),\n    default => throw new InvalidArgumentException('Unknown payment method'),\n};\n",[30,6007,6008,6013,6018,6023,6028,6033,6038],{"__ignoreMap":28},[33,6009,6010],{"class":35,"line":36},[33,6011,6012],{},"$gateway = match ($request->input('payment_method')) {\n",[33,6014,6015],{"class":35,"line":42},[33,6016,6017],{},"    'card' => new StripeGateway(config('services.stripe.secret')),\n",[33,6019,6020],{"class":35,"line":48},[33,6021,6022],{},"    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),\n",[33,6024,6025],{"class":35,"line":55},[33,6026,6027],{},"    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),\n",[33,6029,6030],{"class":35,"line":61},[33,6031,6032],{},"    'financing' => new FinancingGateway(config('services.financing.api_key')),\n",[33,6034,6035],{"class":35,"line":67},[33,6036,6037],{},"    default => throw new InvalidArgumentException('Unknown payment method'),\n",[33,6039,6040],{"class":35,"line":73},[33,6041,6042],{},"};\n",[11,6044,6045],{},"Potem ten sam wybór wraca w obsłudze zwrotów, w endpoincie webhooków i w jobie uzgadniającym płatności, za każdym razem na podstawie metody zapisanej przy płatności i za każdym razem z przepisanymi argumentami konstruktorów. Nowy dostawca albo nowa zależność któregoś z istniejących to szukanie wszystkich kopii po kodzie.",[15,6047,6049],{"id":6048},"interfejs-enum-fabryka","Interfejs, enum, fabryka",[11,6051,6052,6053,743,6055,743,6057,1182,6060,6063],{},"Zaczynam od interfejsu wyrażonego w pojęciach aplikacji. ",[30,6054,5395],{},[30,6056,5399],{},[30,6058,6059],{},"RefundResult",[30,6061,6062],{},"WebhookEvent"," to value objecty aplikacji.",[23,6065,6067],{"className":25,"code":6066,"language":27,"meta":28,"style":28},"enum PaymentMethod: string\n{\n    case Card = 'card';\n    case Blik = 'blik';\n    case Transfer = 'transfer';\n    case Financing = 'financing';\n}\n\ninterface PaymentGateway\n{\n    public function charge(Money $amount, array $metadata): ChargeResult;\n\n    public function refund(string $chargeId, Money $amount): RefundResult;\n\n    public function parseWebhook(string $payload, array $headers): WebhookEvent;\n}\n",[30,6068,6069,6074,6078,6083,6088,6093,6098,6102,6106,6111,6115,6120,6124,6129,6133,6138],{"__ignoreMap":28},[33,6070,6071],{"class":35,"line":36},[33,6072,6073],{},"enum PaymentMethod: string\n",[33,6075,6076],{"class":35,"line":42},[33,6077,64],{},[33,6079,6080],{"class":35,"line":48},[33,6081,6082],{},"    case Card = 'card';\n",[33,6084,6085],{"class":35,"line":55},[33,6086,6087],{},"    case Blik = 'blik';\n",[33,6089,6090],{"class":35,"line":61},[33,6091,6092],{},"    case Transfer = 'transfer';\n",[33,6094,6095],{"class":35,"line":67},[33,6096,6097],{},"    case Financing = 'financing';\n",[33,6099,6100],{"class":35,"line":73},[33,6101,82],{},[33,6103,6104],{"class":35,"line":79},[33,6105,52],{"emptyLinePlaceholder":51},[33,6107,6108],{"class":35,"line":85},[33,6109,6110],{},"interface PaymentGateway\n",[33,6112,6113],{"class":35,"line":90},[33,6114,64],{},[33,6116,6117],{"class":35,"line":96},[33,6118,6119],{},"    public function charge(Money $amount, array $metadata): ChargeResult;\n",[33,6121,6122],{"class":35,"line":384},[33,6123,52],{"emptyLinePlaceholder":51},[33,6125,6126],{"class":35,"line":390},[33,6127,6128],{},"    public function refund(string $chargeId, Money $amount): RefundResult;\n",[33,6130,6131],{"class":35,"line":395},[33,6132,52],{"emptyLinePlaceholder":51},[33,6134,6135],{"class":35,"line":401},[33,6136,6137],{},"    public function parseWebhook(string $payload, array $headers): WebhookEvent;\n",[33,6139,6140],{"class":35,"line":407},[33,6141,82],{},[11,6143,6144],{},"Fabryka mapuje metodę na klasę, a budowę obiektu zostawia kontenerowi:",[23,6146,6148],{"className":25,"code":6147,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Container\\Container;\n\nfinal class PaymentGatewayFactory\n{\n    \u002F** @param array\u003Cstring, class-string\u003CPaymentGateway>> $gateways *\u002F\n    public function __construct(\n        private readonly Container $container,\n        private readonly array $gateways,\n    ) {}\n\n    public function make(PaymentMethod $method): PaymentGateway\n    {\n        $class = $this->gateways[$method->value]\n            ?? throw new UnsupportedPaymentMethod($method->value);\n\n        return $this->container->make($class);\n    }\n}\n",[30,6149,6150,6155,6159,6164,6168,6173,6177,6182,6187,6191,6195,6200,6204,6209,6214,6218,6223,6227],{"__ignoreMap":28},[33,6151,6152],{"class":35,"line":36},[33,6153,6154],{},"use Illuminate\\Contracts\\Container\\Container;\n",[33,6156,6157],{"class":35,"line":42},[33,6158,52],{"emptyLinePlaceholder":51},[33,6160,6161],{"class":35,"line":48},[33,6162,6163],{},"final class PaymentGatewayFactory\n",[33,6165,6166],{"class":35,"line":55},[33,6167,64],{},[33,6169,6170],{"class":35,"line":61},[33,6171,6172],{},"    \u002F** @param array\u003Cstring, class-string\u003CPaymentGateway>> $gateways *\u002F\n",[33,6174,6175],{"class":35,"line":67},[33,6176,3531],{},[33,6178,6179],{"class":35,"line":73},[33,6180,6181],{},"        private readonly Container $container,\n",[33,6183,6184],{"class":35,"line":79},[33,6185,6186],{},"        private readonly array $gateways,\n",[33,6188,6189],{"class":35,"line":85},[33,6190,3546],{},[33,6192,6193],{"class":35,"line":90},[33,6194,52],{"emptyLinePlaceholder":51},[33,6196,6197],{"class":35,"line":96},[33,6198,6199],{},"    public function make(PaymentMethod $method): PaymentGateway\n",[33,6201,6202],{"class":35,"line":384},[33,6203,3451],{},[33,6205,6206],{"class":35,"line":390},[33,6207,6208],{},"        $class = $this->gateways[$method->value]\n",[33,6210,6211],{"class":35,"line":395},[33,6212,6213],{},"            ?? throw new UnsupportedPaymentMethod($method->value);\n",[33,6215,6216],{"class":35,"line":401},[33,6217,52],{"emptyLinePlaceholder":51},[33,6219,6220],{"class":35,"line":407},[33,6221,6222],{},"        return $this->container->make($class);\n",[33,6224,6225],{"class":35,"line":413},[33,6226,3461],{},[33,6228,6229],{"class":35,"line":419},[33,6230,82],{},[11,6232,6233],{},"Rejestruje się ją raz, w service providerze. To, czego poszczególne bramki potrzebują w konstruktorach (dane dostępowe, klient HTTP, logger), jest bindowane osobno, a resztą zajmuje się kontener.",[23,6235,6237],{"className":25,"code":6236,"language":27,"meta":28,"style":28},"public function register(): void\n{\n    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [\n        PaymentMethod::Card->value => StripeGateway::class,\n        PaymentMethod::Blik->value => BlikGateway::class,\n        PaymentMethod::Transfer->value => BankTransferGateway::class,\n        PaymentMethod::Financing->value => FinancingGateway::class,\n    ]));\n}\n",[30,6238,6239,6244,6248,6253,6258,6263,6268,6273,6278],{"__ignoreMap":28},[33,6240,6241],{"class":35,"line":36},[33,6242,6243],{},"public function register(): void\n",[33,6245,6246],{"class":35,"line":42},[33,6247,64],{},[33,6249,6250],{"class":35,"line":48},[33,6251,6252],{},"    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [\n",[33,6254,6255],{"class":35,"line":55},[33,6256,6257],{},"        PaymentMethod::Card->value => StripeGateway::class,\n",[33,6259,6260],{"class":35,"line":61},[33,6261,6262],{},"        PaymentMethod::Blik->value => BlikGateway::class,\n",[33,6264,6265],{"class":35,"line":67},[33,6266,6267],{},"        PaymentMethod::Transfer->value => BankTransferGateway::class,\n",[33,6269,6270],{"class":35,"line":73},[33,6271,6272],{},"        PaymentMethod::Financing->value => FinancingGateway::class,\n",[33,6274,6275],{"class":35,"line":79},[33,6276,6277],{},"    ]));\n",[33,6279,6280],{"class":35,"line":85},[33,6281,82],{},[11,6283,6284],{},"Miejsca wywołania przekazują już tylko własne wejście:",[23,6286,6288],{"className":25,"code":6287,"language":27,"meta":28,"style":28},"\u002F\u002F kontroler: FormRequest waliduje pole regułami ['required', Rule::enum(PaymentMethod::class)]\n$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));\n$result = $gateway->charge($amount, ['order_id' => $order->id]);\n\n\u002F\u002F job zwrotu: wejściem jest kolumna rzutowana na PaymentMethod\n$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);\n",[30,6289,6290,6295,6300,6305,6309,6314],{"__ignoreMap":28},[33,6291,6292],{"class":35,"line":36},[33,6293,6294],{},"\u002F\u002F kontroler: FormRequest waliduje pole regułami ['required', Rule::enum(PaymentMethod::class)]\n",[33,6296,6297],{"class":35,"line":42},[33,6298,6299],{},"$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));\n",[33,6301,6302],{"class":35,"line":48},[33,6303,6304],{},"$result = $gateway->charge($amount, ['order_id' => $order->id]);\n",[33,6306,6307],{"class":35,"line":55},[33,6308,52],{"emptyLinePlaceholder":51},[33,6310,6311],{"class":35,"line":61},[33,6312,6313],{},"\u002F\u002F job zwrotu: wejściem jest kolumna rzutowana na PaymentMethod\n",[33,6315,6316],{"class":35,"line":67},[33,6317,6318],{},"$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);\n",[11,6320,6321,6322,6325],{},"Nowy dostawca to klasa implementująca ",[30,6323,6324],{},"PaymentGateway",", nowy przypadek w enumie, jedna linia w mapie i bindingi jego zależności, jeśli kontener nie zbuduje ich sam.",[11,6327,6328,6329,6332,6333,743,6336,6339,6340,6343],{},"Laravel stosuje ten sam pomysł wewnętrznie w ",[30,6330,6331],{},"Illuminate\\Support\\Manager"," (",[30,6334,6335],{},"driver()",[30,6337,6338],{},"extend()","), na którym opierają się m.in. sterowniki sesji, hashowania i kanałów powiadomień. Rozszerzenie ",[30,6341,6342],{},"Manager"," ma sens, gdy implementacje wybiera się po nazwie z plików konfiguracyjnych. Przy wyborze domenowym, jak tutaj, osobna fabryka czyta mi się lepiej.",[15,6345,6347],{"id":6346},"gdzie-fabryki-się-psują","Gdzie fabryki się psują",[11,6349,6350,6351,6354],{},"Najczęściej fabryka sama buduje obiekty. Jeśli woła ",[30,6352,6353],{},"new StripeGateway(config(...))",", trzeba ją zmieniać przy każdej zmianie konstruktora bramki, a kontener w ogóle nie widzi zależności bramki. Budowę obiektów oddaj kontenerowi. Gdy kontenera nie ma, przekaż do konstruktora fabryki gotowe instancje albo closure, które je tworzą.",[11,6356,6357,6358,6360,6361,6364],{},"Drugi błąd jest mniej widoczny. ",[30,6359,6324],{}," dostaje metody w rodzaju ",[30,6362,6363],{},"createPaymentIntent()",", bo pierwszą integracją akurat był Stripe, i odtąd każda kolejna bramka musi tłumaczyć swój model na model Stripe'a. W tym tłumaczeniu pojawiają się błędy. Operacje nazwij od tego, co robi aplikacja (obciążenie, zwrot, odczytanie webhooka), a przekład na model dostawcy trzymaj w jego klasie.",[15,6366,6368],{"id":6367},"fabryka-czy-strategy","Fabryka czy Strategy",[11,6370,6371,6372,6374],{},"Te dwa wzorce często chodzą w parze. Strategy to obiekt, którego zachowanie można podmienić za interfejsem, i każda ",[30,6373,6324],{}," jest strategią przetwarzania płatności. W fabryce zapada decyzja, której strategii użyć dla danego wejścia. Jeśli strategię znasz już przy starcie aplikacji, z konfiguracji, fabryka jest niepotrzebna. Zbinduj interfejs na implementację w kontenerze i wstrzyknij ją.",[15,6376,4080],{"id":4079},[11,6378,6379,6380,6382],{},"Samej fabryce wystarczy jeden test: każdy przypadek enuma daje ",[30,6381,6324],{},". Wywali się, gdy ktoś doda przypadek i zapomni o wpisie w mapie, a także gdy kontener nie umie zbudować którejś bramki.",[23,6384,6386],{"className":25,"code":6385,"language":27,"meta":28,"style":28},"public function test_every_payment_method_has_a_gateway(): void\n{\n    $factory = $this->app->make(PaymentGatewayFactory::class);\n\n    foreach (PaymentMethod::cases() as $method) {\n        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));\n    }\n}\n",[30,6387,6388,6393,6397,6402,6406,6411,6416,6420],{"__ignoreMap":28},[33,6389,6390],{"class":35,"line":36},[33,6391,6392],{},"public function test_every_payment_method_has_a_gateway(): void\n",[33,6394,6395],{"class":35,"line":42},[33,6396,64],{},[33,6398,6399],{"class":35,"line":48},[33,6400,6401],{},"    $factory = $this->app->make(PaymentGatewayFactory::class);\n",[33,6403,6404],{"class":35,"line":55},[33,6405,52],{"emptyLinePlaceholder":51},[33,6407,6408],{"class":35,"line":61},[33,6409,6410],{},"    foreach (PaymentMethod::cases() as $method) {\n",[33,6412,6413],{"class":35,"line":67},[33,6414,6415],{},"        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));\n",[33,6417,6418],{"class":35,"line":73},[33,6419,3461],{},[33,6421,6422],{"class":35,"line":79},[33,6423,82],{},[11,6425,6426,6427,6430],{},"Więcej daje wspólny test kontraktu, po którym dziedziczy test każdej bramki. Każda podklasa podaje swoją bramkę z podstawionym transportem, na przykład przez ",[30,6428,6429],{},"Http::fake()"," z przykładową odpowiedzią dostawcy, jeśli bramka korzysta z klienta HTTP Laravela.",[23,6432,6434],{"className":25,"code":6433,"language":27,"meta":28,"style":28},"abstract class PaymentGatewayContractTest extends TestCase\n{\n    abstract protected function gateway(): PaymentGateway;\n\n    public function test_charge_returns_a_charge_id(): void\n    {\n        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);\n\n        $this->assertNotSame('', $result->chargeId);\n    }\n}\n",[30,6435,6436,6441,6445,6450,6454,6459,6463,6468,6472,6477,6481],{"__ignoreMap":28},[33,6437,6438],{"class":35,"line":36},[33,6439,6440],{},"abstract class PaymentGatewayContractTest extends TestCase\n",[33,6442,6443],{"class":35,"line":42},[33,6444,64],{},[33,6446,6447],{"class":35,"line":48},[33,6448,6449],{},"    abstract protected function gateway(): PaymentGateway;\n",[33,6451,6452],{"class":35,"line":55},[33,6453,52],{"emptyLinePlaceholder":51},[33,6455,6456],{"class":35,"line":61},[33,6457,6458],{},"    public function test_charge_returns_a_charge_id(): void\n",[33,6460,6461],{"class":35,"line":67},[33,6462,3451],{},[33,6464,6465],{"class":35,"line":73},[33,6466,6467],{},"        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);\n",[33,6469,6470],{"class":35,"line":79},[33,6471,52],{"emptyLinePlaceholder":51},[33,6473,6474],{"class":35,"line":85},[33,6475,6476],{},"        $this->assertNotSame('', $result->chargeId);\n",[33,6478,6479],{"class":35,"line":90},[33,6480,3461],{},[33,6482,6483],{"class":35,"line":96},[33,6484,82],{},[15,6486,6488,6489],{"id":6487},"kiedy-wystarczy-binding-albo-match","Kiedy wystarczy binding albo ",[30,6490,5784],{},[11,6492,6493,6494,6496],{},"Przy jednej implementacji albo wyborze stałym dla danego wdrożenia wystarczy binding w kontenerze. Jeśli wybór zapada w dokładnie jednym miejscu, ",[30,6495,5784],{}," na enumie właśnie tam czyta się lepiej niż dodatkową klasę.",[11,6498,6499,6500,6502],{},"Bywa też, że implementacje nie mają wspólnego interfejsu, którego wywołujący mogą używać bez sprawdzania ",[30,6501,4047],{},". Fabryka tego nie naprawi, najpierw trzeba poprawić interfejs.",[896,6504,898],{},{"title":28,"searchDepth":42,"depth":42,"links":6506},[6507,6508,6509,6510,6511,6512,6513],{"id":5885,"depth":42,"text":5886},{"id":5998,"depth":42,"text":5999},{"id":6048,"depth":42,"text":6049},{"id":6346,"depth":42,"text":6347},{"id":6367,"depth":42,"text":6368},{"id":4079,"depth":42,"text":4080},{"id":6487,"depth":42,"text":6514},"Kiedy wystarczy binding albo match","2024-07-15",{},{"x":6518,"y":6519,"depth":36,"size":915},0.58,0.48,[923,1773],{"title":5877,"description":5882},"object-creation","pl\u002Farticles\u002Ffactory-method",[27,4292,6525,6526,4294],"factory","dependency-injection","vX9bcFQ2SmdTMH_subVcK5TaTovMcaLB6BJQxnnJ4kw",{"id":6529,"title":6530,"articleId":6531,"body":6532,"category":1763,"codeLang":27,"date":7835,"deploys":36,"description":6536,"excerpt":914,"extension":915,"lang":916,"meta":7836,"navigation":51,"path":7837,"pos":7838,"readMin":90,"related":7841,"seo":7842,"service":7843,"stem":7844,"tags":7845,"updated":1783,"version":4297,"__hash__":7851},"articles_pl\u002Fpl\u002Farticles\u002Fllm-in-php.md","LLM w aplikacji PHP: kolejki, idempotencja i walidacja zamiast przepisywania na Pythona","llm-in-php",{"type":8,"value":6533,"toc":7824},[6534,6537,6540,6544,6547,6570,6573,6577,6724,6738,6745,6748,6752,6755,6775,6778,6988,7002,7006,7030,7293,7304,7317,7321,7332,7542,7545,7552,7555,7559,7562,7622,7726,7752,7758,7761,7765,7775,7789,7792,7796,7799,7803,7822],[11,6535,6536],{},"Model językowy udostępniany przez dostawcę (Anthropic, OpenAI, Mistral) to endpoint HTTPS, który przyjmuje i zwraca JSON. Nie obchodzi go, w jakim języku napisano aplikację, która go woła. Python ma realną przewagę przy trenowaniu, fine-tuningu i uruchamianiu modeli lokalnie. Przy dokładaniu funkcji opartych na LLM do istniejącego systemu PHP trudności leżą gdzie indziej: długie i nieprzewidywalne opóźnienia, odpowiedzi różne przy każdym wywołaniu, walidacja tych odpowiedzi i koszt. To problemy kolejek, bazy danych i kontraktów. Aplikacja PHP ma już na nie narzędzia.",[11,6538,6539],{},"Ten tekst omawia je na przykładzie aplikacji w Laravelu. Przykłady wołają Anthropic Messages API przez klienta HTTP Laravela, więc każde pole w kodzie da się sprawdzić w dokumentacji dostawcy.",[15,6541,6543],{"id":6542},"biblioteka-czy-zwykłe-http","Biblioteka czy zwykłe HTTP",[11,6545,6546],{},"Typowe możliwości:",[2848,6548,6549,6556,6563],{},[2851,6550,6551,6552,6555],{},"Zwykłe HTTP przez Guzzle albo fasadę ",[30,6553,6554],{},"Http",". Jeden endpoint, kilka nagłówków, zero nowych zależności. Wystarcza, gdy wołasz jednego dostawcę w kilku miejscach.",[2851,6557,6558,6559,6562],{},"Biblioteka kliencka: ",[30,6560,6561],{},"openai-php\u002Fclient"," (z nakładką dla Laravela), Prism dla Laravela albo LLPhant. Zależnie od biblioteki dają abstrakcję nad dostawcami, obsługę streamingu albo gotowe elementy do wyszukiwania.",[2851,6564,6565,6566,6569],{},"Komponenty AI Symfony (projekt ",[30,6567,6568],{},"symfony\u002Fai","), jeśli aplikacja stoi na Symfony i chcesz integracji z kontenerem i konfiguracją frameworka.",[11,6571,6572],{},"Niezależnie od wyboru sprawdź przed produkcją, czy ustawisz osobno timeout połączenia i odczytu, czy decydujesz, które błędy są ponawiane, czy z każdej odpowiedzi dostajesz zużycie tokenów i czy da się dodać własne logowanie wokół wywołania. Niektóre biblioteki chowają coś z tego za własnymi ustawieniami domyślnymi i potem trzeba je obchodzić.",[15,6574,6576],{"id":6575},"cienki-klient","Cienki klient",[23,6578,6580],{"className":25,"code":6579,"language":27,"meta":28,"style":28},"use Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\Client\\RequestException;\nuse Illuminate\\Support\\Facades\\Http;\n\nfinal class AnthropicClient\n{\n    public function __construct(\n        private readonly string $apiKey,\n        private readonly string $model,\n    ) {}\n\n    \u002F** @return array\u003Cstring, mixed> *\u002F\n    public function messages(array $payload): array\n    {\n        return Http::baseUrl('https:\u002F\u002Fapi.anthropic.com\u002Fv1')\n            ->withHeaders([\n                'x-api-key' => $this->apiKey,\n                'anthropic-version' => '2023-06-01',\n            ])\n            ->connectTimeout(5)\n            ->timeout(30)\n            ->retry(2, 2000, fn (\\Throwable $e): bool =>\n                $e instanceof ConnectionException\n                || ($e instanceof RequestException\n                    && in_array($e->response->status(), [429, 500, 529], true))\n            )\n            ->post('\u002Fmessages', ['model' => $this->model, ...$payload])\n            ->json();\n    }\n}\n",[30,6581,6582,6587,6592,6597,6601,6606,6610,6614,6619,6624,6628,6632,6637,6642,6646,6651,6656,6661,6666,6671,6676,6681,6686,6691,6696,6701,6706,6711,6716,6720],{"__ignoreMap":28},[33,6583,6584],{"class":35,"line":36},[33,6585,6586],{},"use Illuminate\\Http\\Client\\ConnectionException;\n",[33,6588,6589],{"class":35,"line":42},[33,6590,6591],{},"use Illuminate\\Http\\Client\\RequestException;\n",[33,6593,6594],{"class":35,"line":48},[33,6595,6596],{},"use Illuminate\\Support\\Facades\\Http;\n",[33,6598,6599],{"class":35,"line":55},[33,6600,52],{"emptyLinePlaceholder":51},[33,6602,6603],{"class":35,"line":61},[33,6604,6605],{},"final class AnthropicClient\n",[33,6607,6608],{"class":35,"line":67},[33,6609,64],{},[33,6611,6612],{"class":35,"line":73},[33,6613,3531],{},[33,6615,6616],{"class":35,"line":79},[33,6617,6618],{},"        private readonly string $apiKey,\n",[33,6620,6621],{"class":35,"line":85},[33,6622,6623],{},"        private readonly string $model,\n",[33,6625,6626],{"class":35,"line":90},[33,6627,3546],{},[33,6629,6630],{"class":35,"line":96},[33,6631,52],{"emptyLinePlaceholder":51},[33,6633,6634],{"class":35,"line":384},[33,6635,6636],{},"    \u002F** @return array\u003Cstring, mixed> *\u002F\n",[33,6638,6639],{"class":35,"line":390},[33,6640,6641],{},"    public function messages(array $payload): array\n",[33,6643,6644],{"class":35,"line":395},[33,6645,3451],{},[33,6647,6648],{"class":35,"line":401},[33,6649,6650],{},"        return Http::baseUrl('https:\u002F\u002Fapi.anthropic.com\u002Fv1')\n",[33,6652,6653],{"class":35,"line":407},[33,6654,6655],{},"            ->withHeaders([\n",[33,6657,6658],{"class":35,"line":413},[33,6659,6660],{},"                'x-api-key' => $this->apiKey,\n",[33,6662,6663],{"class":35,"line":419},[33,6664,6665],{},"                'anthropic-version' => '2023-06-01',\n",[33,6667,6668],{"class":35,"line":1058},[33,6669,6670],{},"            ])\n",[33,6672,6673],{"class":35,"line":1064},[33,6674,6675],{},"            ->connectTimeout(5)\n",[33,6677,6678],{"class":35,"line":1069},[33,6679,6680],{},"            ->timeout(30)\n",[33,6682,6683],{"class":35,"line":1074},[33,6684,6685],{},"            ->retry(2, 2000, fn (\\Throwable $e): bool =>\n",[33,6687,6688],{"class":35,"line":1080},[33,6689,6690],{},"                $e instanceof ConnectionException\n",[33,6692,6693],{"class":35,"line":1086},[33,6694,6695],{},"                || ($e instanceof RequestException\n",[33,6697,6698],{"class":35,"line":1092},[33,6699,6700],{},"                    && in_array($e->response->status(), [429, 500, 529], true))\n",[33,6702,6703],{"class":35,"line":1098},[33,6704,6705],{},"            )\n",[33,6707,6708],{"class":35,"line":1103},[33,6709,6710],{},"            ->post('\u002Fmessages', ['model' => $this->model, ...$payload])\n",[33,6712,6713],{"class":35,"line":1108},[33,6714,6715],{},"            ->json();\n",[33,6717,6718],{"class":35,"line":1114},[33,6719,3461],{},[33,6721,6722],{"class":35,"line":1120},[33,6723,82],{},[11,6725,6726,6727,6730,6731,6734,6735,221],{},"Nazwa modelu siedzi w konfiguracji. Dostawcy wycofują wersje modeli według własnego harmonogramu. Ponawiane są tylko błędy połączenia, limit zapytań (429), wewnętrzne błędy serwera (500) i przeciążenie (529). Odpowiedź 400 oznacza, że błędne jest samo żądanie, więc klient jej nie ponawia. Wyjątek i tak wyjdzie z ",[30,6728,6729],{},"messages()",", a job z ",[30,6732,6733],{},"$tries = 3"," spróbuje jeszcze raz, chyba że przy błędzie 4xx sam zawoła ",[30,6736,6737],{},"fail()",[11,6739,6740,6741,6744],{},"Timeout odczytu musi pasować do najdłuższej odpowiedzi, o jaką prosisz. Czas rośnie z długością odpowiedzi, a ",[30,6742,6743],{},"max_tokens"," wyznacza jej górną granicę. Klasyfikacja ograniczona do 256 tokenów kończy się w kilka sekund (na modelach, w których myślenia nie da się wyłączyć, tokeny myślenia też liczą się do tego limitu, więc trzeba go podnieść), długie streszczenie może nie zmieścić się w 30 sekundach i wtedy potrzebny jest wyższy timeout albo streaming.",[11,6746,6747],{},"Przy modelach lokalnych widać to jeszcze wyraźniej. Na moim domowym serwerze z Ollamą mały model (4B) na procesorze generował 12 tokenów na sekundę, a cała odpowiedź na log o długości około 3 tysięcy tokenów zajmowała 146 sekund, z czego samo czytanie logu (prefill) szło z prędkością 28 tok\u002Fs. Z kartą graficzną było to 66 tok\u002Fs, a prefill od 1900 do 3400 tok\u002Fs. Pierwsze zapytanie po wczytaniu modelu i tak szło z prędkością 3 tok\u002Fs, więc timeouty ustawiłem pod zimny start, a nie pod średnią z rozgrzanego modelu.",[15,6749,6751],{"id":6750},"wywołania-modelu-w-jobach-kolejki","Wywołania modelu w jobach kolejki",[11,6753,6754],{},"Większość pracy z modelem powinna iść przez kolejkę. Użytkownik nie czeka wtedy kilku sekund na odpowiedź HTTP, a worker może ponowić próbę. Kłopoty zaczynają się od tego, jak timeouty mają się do siebie.",[11,6756,6757,6758,6761,6762,6765,6766,3296,6768,6771,6772,6774],{},"W Laravelu job, który działa dłużej niż ",[30,6759,6760],{},"retry_after"," połączenia (w ",[30,6763,6764],{},"config\u002Fqueue.php","), zostaje oddany innemu workerowi, choć pierwszy wciąż go wykonuje. Przy wolnym API kończy się to dwoma równoległymi wykonaniami tego samego joba, dwoma opłaconymi wywołaniami i czasem dwoma różnymi wynikami. Dokumentacja Laravela wymaga, żeby timeout joba był krótszy niż ",[30,6767,6760],{},[30,6769,6770],{},"--timeout"," workera co najmniej o kilka sekund. Do tego timeout joba musi objąć całe wywołanie klienta razem z ponowieniami. Dla klienta powyżej to najwyżej 2 × 30 s plus 2 s przerwy, więc timeout joba 90 s i ",[30,6773,6760],{}," 120 s są spójne.",[11,6776,6777],{},"Do tego dochodzi niedeterminizm. To samo wejście przy kolejnym wywołaniu może dać inną klasyfikację, więc ponowienie w praktyce nie powtarza tej samej operacji. Jeśli pierwsza próba zapisała wynik, a coś dalej już na niego zareagowało, retry może go nadpisać inną odpowiedzią. Rozwiązanie to warunkowy zapis, po którym wygrywa dokładnie jedna próba:",[23,6779,6781],{"className":25,"code":6780,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Queue\\ShouldBeUnique;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Queue\\InteractsWithQueue;\n\nfinal class TriageTicket implements ShouldQueue, ShouldBeUnique\n{\n    use Dispatchable, InteractsWithQueue, Queueable;\n\n    public int $timeout = 90;\n    public int $tries = 3;\n    public array $backoff = [10, 60];\n    public int $uniqueFor = 600;\n\n    public function __construct(public readonly int $ticketId) {}\n\n    public function uniqueId(): string\n    {\n        return (string) $this->ticketId;\n    }\n\n    public function handle(TicketClassifier $classifier): void\n    {\n        $ticket = Ticket::findOrFail($this->ticketId);\n\n        if ($ticket->triaged_at !== null) {\n            return;\n        }\n\n        $result = $classifier->classify($ticket->body);\n\n        $updated = Ticket::whereKey($ticket->id)\n            ->whereNull('triaged_at')\n            ->update([\n                'department' => $result->department->value,\n                'priority' => $result->priority->value,\n                'triaged_at' => now(),\n            ]);\n\n        if ($updated === 1) {\n            TicketTriaged::dispatch($ticket->id);\n        }\n    }\n}\n",[30,6782,6783,6788,6792,6797,6802,6807,6811,6816,6820,6825,6829,6834,6839,6844,6849,6853,6858,6862,6867,6871,6876,6880,6884,6889,6893,6898,6902,6907,6911,6915,6919,6924,6928,6933,6938,6943,6948,6953,6958,6962,6966,6971,6976,6980,6984],{"__ignoreMap":28},[33,6784,6785],{"class":35,"line":36},[33,6786,6787],{},"use Illuminate\\Contracts\\Queue\\ShouldBeUnique;\n",[33,6789,6790],{"class":35,"line":42},[33,6791,5613],{},[33,6793,6794],{"class":35,"line":48},[33,6795,6796],{},"use Illuminate\\Foundation\\Bus\\Dispatchable;\n",[33,6798,6799],{"class":35,"line":55},[33,6800,6801],{},"use Illuminate\\Foundation\\Queue\\Queueable;\n",[33,6803,6804],{"class":35,"line":61},[33,6805,6806],{},"use Illuminate\\Queue\\InteractsWithQueue;\n",[33,6808,6809],{"class":35,"line":67},[33,6810,52],{"emptyLinePlaceholder":51},[33,6812,6813],{"class":35,"line":73},[33,6814,6815],{},"final class TriageTicket implements ShouldQueue, ShouldBeUnique\n",[33,6817,6818],{"class":35,"line":79},[33,6819,64],{},[33,6821,6822],{"class":35,"line":85},[33,6823,6824],{},"    use Dispatchable, InteractsWithQueue, Queueable;\n",[33,6826,6827],{"class":35,"line":90},[33,6828,52],{"emptyLinePlaceholder":51},[33,6830,6831],{"class":35,"line":96},[33,6832,6833],{},"    public int $timeout = 90;\n",[33,6835,6836],{"class":35,"line":384},[33,6837,6838],{},"    public int $tries = 3;\n",[33,6840,6841],{"class":35,"line":390},[33,6842,6843],{},"    public array $backoff = [10, 60];\n",[33,6845,6846],{"class":35,"line":395},[33,6847,6848],{},"    public int $uniqueFor = 600;\n",[33,6850,6851],{"class":35,"line":401},[33,6852,52],{"emptyLinePlaceholder":51},[33,6854,6855],{"class":35,"line":407},[33,6856,6857],{},"    public function __construct(public readonly int $ticketId) {}\n",[33,6859,6860],{"class":35,"line":413},[33,6861,52],{"emptyLinePlaceholder":51},[33,6863,6864],{"class":35,"line":419},[33,6865,6866],{},"    public function uniqueId(): string\n",[33,6868,6869],{"class":35,"line":1058},[33,6870,3451],{},[33,6872,6873],{"class":35,"line":1064},[33,6874,6875],{},"        return (string) $this->ticketId;\n",[33,6877,6878],{"class":35,"line":1069},[33,6879,3461],{},[33,6881,6882],{"class":35,"line":1074},[33,6883,52],{"emptyLinePlaceholder":51},[33,6885,6886],{"class":35,"line":1080},[33,6887,6888],{},"    public function handle(TicketClassifier $classifier): void\n",[33,6890,6891],{"class":35,"line":1086},[33,6892,3451],{},[33,6894,6895],{"class":35,"line":1092},[33,6896,6897],{},"        $ticket = Ticket::findOrFail($this->ticketId);\n",[33,6899,6900],{"class":35,"line":1098},[33,6901,52],{"emptyLinePlaceholder":51},[33,6903,6904],{"class":35,"line":1103},[33,6905,6906],{},"        if ($ticket->triaged_at !== null) {\n",[33,6908,6909],{"class":35,"line":1108},[33,6910,4015],{},[33,6912,6913],{"class":35,"line":1114},[33,6914,3950],{},[33,6916,6917],{"class":35,"line":1120},[33,6918,52],{"emptyLinePlaceholder":51},[33,6920,6921],{"class":35,"line":1126},[33,6922,6923],{},"        $result = $classifier->classify($ticket->body);\n",[33,6925,6926],{"class":35,"line":1132},[33,6927,52],{"emptyLinePlaceholder":51},[33,6929,6930],{"class":35,"line":1138},[33,6931,6932],{},"        $updated = Ticket::whereKey($ticket->id)\n",[33,6934,6935],{"class":35,"line":1143},[33,6936,6937],{},"            ->whereNull('triaged_at')\n",[33,6939,6940],{"class":35,"line":1149},[33,6941,6942],{},"            ->update([\n",[33,6944,6945],{"class":35,"line":1155},[33,6946,6947],{},"                'department' => $result->department->value,\n",[33,6949,6950],{"class":35,"line":1161},[33,6951,6952],{},"                'priority' => $result->priority->value,\n",[33,6954,6955],{"class":35,"line":1167},[33,6956,6957],{},"                'triaged_at' => now(),\n",[33,6959,6960],{"class":35,"line":2535},[33,6961,5348],{},[33,6963,6964],{"class":35,"line":2547},[33,6965,52],{"emptyLinePlaceholder":51},[33,6967,6968],{"class":35,"line":2555},[33,6969,6970],{},"        if ($updated === 1) {\n",[33,6972,6973],{"class":35,"line":2566},[33,6974,6975],{},"            TicketTriaged::dispatch($ticket->id);\n",[33,6977,6978],{"class":35,"line":2577},[33,6979,3950],{},[33,6981,6982],{"class":35,"line":2588},[33,6983,3461],{},[33,6985,6986],{"class":35,"line":2598},[33,6987,82],{},[11,6989,6990,6993,6994,6997,6998,7001],{},[30,6991,6992],{},"ShouldBeUnique"," nie pozwala wrzucić do kolejki drugiej kopii joba, dopóki pierwsza nie skończy się wykonywać albo nie wyczerpie wszystkich prób (lub nie minie ",[30,6995,6996],{},"uniqueFor","). Warunek ",[30,6999,7000],{},"whereNull('triaged_at')"," rozstrzyga, która próba wygrywa, jeśli mimo to wykonają się dwie. Zdarzenie dla dalszych kroków odpala tylko ta próba, która faktycznie zapisała wiersz. Retry może kosztować drugie wywołanie API, ale wiersz i zdarzenie powstają tylko raz.",[15,7003,7005],{"id":7004},"ustrukturyzowana-odpowiedź-i-walidacja","Ustrukturyzowana odpowiedź i walidacja",[11,7007,7008,7009,7012,7013,7015,7016,7019,7020,7022,7023,7026,7027,7029],{},"Wolny tekst to słaby interfejs między modelem a kodem. Przy klasyfikacji i ekstrakcji użyj structured outputs: przekaż schemat JSON Schema w ",[30,7010,7011],{},"output_config.format",", a model zwróci blok ",[30,7014,3381],{}," z JSON-em zgodnym ze schematem. Wymuszanie wywołania narzędzia przez ",[30,7017,7018],{},"tool_choice"," to starszy, obejściowy sposób na ustrukturyzowane dane, a najnowsze modele (np. Claude Opus 5.5) odrzucają wymuszony ",[30,7021,7018],{}," błędem 400. Dokumentacja wymienia przypadki, w których wynik nie pasuje do schematu: odmowa (",[30,7024,7025],{},"stop_reason: refusal","), odpowiedź ucięta przez ",[30,7028,6743],{}," i wartości enuma zwrócone z inną wielkością liter, dlatego wynik walidujesz w kodzie.",[23,7031,7033],{"className":25,"code":7032,"language":27,"meta":28,"style":28},"enum Department: string\n{\n    case Billing = 'billing';\n    case Technical = 'technical';\n    case Sales = 'sales';\n}\n\nenum Priority: string\n{\n    case Low = 'low';\n    case Normal = 'normal';\n    case Urgent = 'urgent';\n}\n\nfinal class TicketClassifier\n{\n    public function __construct(private readonly AnthropicClient $client) {}\n\n    public function classify(string $body): TicketClassification\n    {\n        $values = fn (array $cases): array => array_map(fn ($c) => $c->value, $cases);\n\n        $response = $this->client->messages([\n            'max_tokens' => 256,\n            'system' => 'Classify the customer support ticket by department and priority.',\n            'messages' => [['role' => 'user', 'content' => $body]],\n            'output_config' => [\n                'format' => [\n                    'type' => 'json_schema',\n                    'schema' => [\n                        'type' => 'object',\n                        'properties' => [\n                            'department' => ['type' => 'string', 'enum' => $values(Department::cases())],\n                            'priority' => ['type' => 'string', 'enum' => $values(Priority::cases())],\n                        ],\n                        'required' => ['department', 'priority'],\n                        'additionalProperties' => false,\n                    ],\n                ],\n            ],\n        ]);\n\n        $text = collect($response['content'])->firstWhere('type', 'text')['text'] ?? '';\n        $output = (array) json_decode($text, true);\n\n        $department = Department::tryFrom(strtolower((string) ($output['department'] ?? '')));\n        $priority = Priority::tryFrom(strtolower((string) ($output['priority'] ?? '')));\n\n        if ($department === null || $priority === null) {\n            throw new InvalidModelOutput('ticket_classification', $response);\n        }\n\n        return new TicketClassification($department, $priority, $response['usage']);\n    }\n}\n",[30,7034,7035,7040,7044,7049,7054,7059,7063,7067,7072,7076,7081,7086,7091,7095,7099,7104,7108,7113,7117,7122,7126,7131,7135,7140,7145,7150,7155,7160,7165,7170,7175,7180,7185,7190,7195,7200,7205,7210,7215,7220,7225,7230,7234,7239,7244,7248,7253,7258,7262,7267,7272,7276,7280,7285,7289],{"__ignoreMap":28},[33,7036,7037],{"class":35,"line":36},[33,7038,7039],{},"enum Department: string\n",[33,7041,7042],{"class":35,"line":42},[33,7043,64],{},[33,7045,7046],{"class":35,"line":48},[33,7047,7048],{},"    case Billing = 'billing';\n",[33,7050,7051],{"class":35,"line":55},[33,7052,7053],{},"    case Technical = 'technical';\n",[33,7055,7056],{"class":35,"line":61},[33,7057,7058],{},"    case Sales = 'sales';\n",[33,7060,7061],{"class":35,"line":67},[33,7062,82],{},[33,7064,7065],{"class":35,"line":73},[33,7066,52],{"emptyLinePlaceholder":51},[33,7068,7069],{"class":35,"line":79},[33,7070,7071],{},"enum Priority: string\n",[33,7073,7074],{"class":35,"line":85},[33,7075,64],{},[33,7077,7078],{"class":35,"line":90},[33,7079,7080],{},"    case Low = 'low';\n",[33,7082,7083],{"class":35,"line":96},[33,7084,7085],{},"    case Normal = 'normal';\n",[33,7087,7088],{"class":35,"line":384},[33,7089,7090],{},"    case Urgent = 'urgent';\n",[33,7092,7093],{"class":35,"line":390},[33,7094,82],{},[33,7096,7097],{"class":35,"line":395},[33,7098,52],{"emptyLinePlaceholder":51},[33,7100,7101],{"class":35,"line":401},[33,7102,7103],{},"final class TicketClassifier\n",[33,7105,7106],{"class":35,"line":407},[33,7107,64],{},[33,7109,7110],{"class":35,"line":413},[33,7111,7112],{},"    public function __construct(private readonly AnthropicClient $client) {}\n",[33,7114,7115],{"class":35,"line":419},[33,7116,52],{"emptyLinePlaceholder":51},[33,7118,7119],{"class":35,"line":1058},[33,7120,7121],{},"    public function classify(string $body): TicketClassification\n",[33,7123,7124],{"class":35,"line":1064},[33,7125,3451],{},[33,7127,7128],{"class":35,"line":1069},[33,7129,7130],{},"        $values = fn (array $cases): array => array_map(fn ($c) => $c->value, $cases);\n",[33,7132,7133],{"class":35,"line":1074},[33,7134,52],{"emptyLinePlaceholder":51},[33,7136,7137],{"class":35,"line":1080},[33,7138,7139],{},"        $response = $this->client->messages([\n",[33,7141,7142],{"class":35,"line":1086},[33,7143,7144],{},"            'max_tokens' => 256,\n",[33,7146,7147],{"class":35,"line":1092},[33,7148,7149],{},"            'system' => 'Classify the customer support ticket by department and priority.',\n",[33,7151,7152],{"class":35,"line":1098},[33,7153,7154],{},"            'messages' => [['role' => 'user', 'content' => $body]],\n",[33,7156,7157],{"class":35,"line":1103},[33,7158,7159],{},"            'output_config' => [\n",[33,7161,7162],{"class":35,"line":1108},[33,7163,7164],{},"                'format' => [\n",[33,7166,7167],{"class":35,"line":1114},[33,7168,7169],{},"                    'type' => 'json_schema',\n",[33,7171,7172],{"class":35,"line":1120},[33,7173,7174],{},"                    'schema' => [\n",[33,7176,7177],{"class":35,"line":1126},[33,7178,7179],{},"                        'type' => 'object',\n",[33,7181,7182],{"class":35,"line":1132},[33,7183,7184],{},"                        'properties' => [\n",[33,7186,7187],{"class":35,"line":1138},[33,7188,7189],{},"                            'department' => ['type' => 'string', 'enum' => $values(Department::cases())],\n",[33,7191,7192],{"class":35,"line":1143},[33,7193,7194],{},"                            'priority' => ['type' => 'string', 'enum' => $values(Priority::cases())],\n",[33,7196,7197],{"class":35,"line":1149},[33,7198,7199],{},"                        ],\n",[33,7201,7202],{"class":35,"line":1155},[33,7203,7204],{},"                        'required' => ['department', 'priority'],\n",[33,7206,7207],{"class":35,"line":1161},[33,7208,7209],{},"                        'additionalProperties' => false,\n",[33,7211,7212],{"class":35,"line":1167},[33,7213,7214],{},"                    ],\n",[33,7216,7217],{"class":35,"line":2535},[33,7218,7219],{},"                ],\n",[33,7221,7222],{"class":35,"line":2547},[33,7223,7224],{},"            ],\n",[33,7226,7227],{"class":35,"line":2555},[33,7228,7229],{},"        ]);\n",[33,7231,7232],{"class":35,"line":2566},[33,7233,52],{"emptyLinePlaceholder":51},[33,7235,7236],{"class":35,"line":2577},[33,7237,7238],{},"        $text = collect($response['content'])->firstWhere('type', 'text')['text'] ?? '';\n",[33,7240,7241],{"class":35,"line":2588},[33,7242,7243],{},"        $output = (array) json_decode($text, true);\n",[33,7245,7246],{"class":35,"line":2598},[33,7247,52],{"emptyLinePlaceholder":51},[33,7249,7250],{"class":35,"line":2609},[33,7251,7252],{},"        $department = Department::tryFrom(strtolower((string) ($output['department'] ?? '')));\n",[33,7254,7255],{"class":35,"line":2619},[33,7256,7257],{},"        $priority = Priority::tryFrom(strtolower((string) ($output['priority'] ?? '')));\n",[33,7259,7260],{"class":35,"line":2629},[33,7261,52],{"emptyLinePlaceholder":51},[33,7263,7264],{"class":35,"line":2634},[33,7265,7266],{},"        if ($department === null || $priority === null) {\n",[33,7268,7269],{"class":35,"line":2642},[33,7270,7271],{},"            throw new InvalidModelOutput('ticket_classification', $response);\n",[33,7273,7274],{"class":35,"line":2654},[33,7275,3950],{},[33,7277,7278],{"class":35,"line":2662},[33,7279,52],{"emptyLinePlaceholder":51},[33,7281,7282],{"class":35,"line":2672},[33,7283,7284],{},"        return new TicketClassification($department, $priority, $response['usage']);\n",[33,7286,7287],{"class":35,"line":2681},[33,7288,3461],{},[33,7290,7291],{"class":35,"line":2691},[33,7292,82],{},[11,7294,7295,7296,7299,7300,7303],{},"Enumy są jedynym źródłem dozwolonych wartości: z nich powstaje schemat i nimi sprawdzana jest odpowiedź. Przed ",[30,7297,7298],{},"tryFrom()"," wartości idą na małe litery, bo nikt nie gwarantuje wielkości liter w wartościach enuma. Niepoprawna odpowiedź rzuca wyjątek i job idzie do ponowienia. Po ostatniej próbie zgłoszenie zostaje niesklasyfikowane, a job ląduje w ",[30,7301,7302],{},"failed_jobs",". Takie zgłoszenie ktoś tam zobaczy, a zgadnięta wartość przeszłaby niezauważona.",[11,7305,7306,7307,7310,7311,1847,7313,7316],{},"Jak pewnie potrafi wyglądać błędna odpowiedź, zobaczyłem przy testach małych modeli lokalnych na zadaniach w PHP. Oba modele 4B napisały błędną walidację NIP-u i podały ją bez cienia wątpliwości. Większy model (26B) napisał ją poprawnie, 6 na 6 przypadków testowych, przy 33 tok\u002Fs. Jeden z modeli ignorował też wyłączenie „myślenia” i zjadał cały limit 400 tokenów na rozumowanie, więc część odpowiedzi wracała pusta, ze zwykłym statusem HTTP. API nie zgłasza wtedy błędu, więc kod musi taki przypadek wyłapać sam. Dotyczy to też pętli narzędzi poniżej, która przy ",[30,7308,7309],{},"stop_reason"," równym ",[30,7312,6743],{},[30,7314,7315],{},"refusal"," może zwrócić pusty tekst.",[15,7318,7320],{"id":7319},"narzędzia-działają-z-uprawnieniami-użytkownika","Narzędzia działają z uprawnieniami użytkownika",[11,7322,7323,7324,7327,7328,7331],{},"Kiedy model może wołać funkcje aplikacji (status zamówienia, wyliczenie zwrotu), PHP ma przewagę: narzędzia to istniejące serwisy z istniejącą kontrolą dostępu. Pętla poniżej trzyma się protokołu Messages API. Model odpowiada z ",[30,7325,7326],{},"stop_reason: tool_use",", aplikacja wykonuje narzędzia i odsyła bloki ",[30,7329,7330],{},"tool_result",", i tak do chwili, gdy model zwróci tekst.",[23,7333,7335],{"className":25,"code":7334,"language":27,"meta":28,"style":28},"public function answer(User $user, string $question): string\n{\n    $messages = [['role' => 'user', 'content' => $question]];\n\n    for ($step = 0; $step \u003C 5; $step++) {\n        $response = $this->client->messages([\n            'max_tokens' => 1024,\n            'tools' => $this->tools,\n            'messages' => $messages,\n        ]);\n\n        if ($response['stop_reason'] !== 'tool_use') {\n            return collect($response['content'])\n                ->where('type', 'text')\n                ->pluck('text')\n                ->implode(\"\\n\");\n        }\n\n        $messages[] = ['role' => 'assistant', 'content' => $response['content']];\n\n        $results = [];\n        foreach ($response['content'] as $block) {\n            if ($block['type'] !== 'tool_use') {\n                continue;\n            }\n            $results[] = [\n                'type' => 'tool_result',\n                'tool_use_id' => $block['id'],\n                'content' => json_encode($this->runTool($user, $block['name'], $block['input']), JSON_THROW_ON_ERROR),\n            ];\n        }\n        $messages[] = ['role' => 'user', 'content' => $results];\n    }\n\n    throw new ToolLoopLimitExceeded($step);\n}\n\nprivate function runTool(User $user, string $name, array $input): array\n{\n    return match ($name) {\n        'get_order_status' => $this->orders->statusFor($user, (string) ($input['order_id'] ?? '')),\n        default => ['error' => \"Unknown tool: {$name}\"],\n    };\n}\n",[30,7336,7337,7342,7346,7351,7355,7360,7364,7369,7374,7379,7383,7387,7392,7397,7402,7407,7412,7416,7420,7425,7429,7434,7439,7444,7449,7454,7459,7464,7469,7474,7479,7483,7488,7492,7496,7501,7505,7509,7514,7518,7523,7528,7533,7538],{"__ignoreMap":28},[33,7338,7339],{"class":35,"line":36},[33,7340,7341],{},"public function answer(User $user, string $question): string\n",[33,7343,7344],{"class":35,"line":42},[33,7345,64],{},[33,7347,7348],{"class":35,"line":48},[33,7349,7350],{},"    $messages = [['role' => 'user', 'content' => $question]];\n",[33,7352,7353],{"class":35,"line":55},[33,7354,52],{"emptyLinePlaceholder":51},[33,7356,7357],{"class":35,"line":61},[33,7358,7359],{},"    for ($step = 0; $step \u003C 5; $step++) {\n",[33,7361,7362],{"class":35,"line":67},[33,7363,7139],{},[33,7365,7366],{"class":35,"line":73},[33,7367,7368],{},"            'max_tokens' => 1024,\n",[33,7370,7371],{"class":35,"line":79},[33,7372,7373],{},"            'tools' => $this->tools,\n",[33,7375,7376],{"class":35,"line":85},[33,7377,7378],{},"            'messages' => $messages,\n",[33,7380,7381],{"class":35,"line":90},[33,7382,7229],{},[33,7384,7385],{"class":35,"line":96},[33,7386,52],{"emptyLinePlaceholder":51},[33,7388,7389],{"class":35,"line":384},[33,7390,7391],{},"        if ($response['stop_reason'] !== 'tool_use') {\n",[33,7393,7394],{"class":35,"line":390},[33,7395,7396],{},"            return collect($response['content'])\n",[33,7398,7399],{"class":35,"line":395},[33,7400,7401],{},"                ->where('type', 'text')\n",[33,7403,7404],{"class":35,"line":401},[33,7405,7406],{},"                ->pluck('text')\n",[33,7408,7409],{"class":35,"line":407},[33,7410,7411],{},"                ->implode(\"\\n\");\n",[33,7413,7414],{"class":35,"line":413},[33,7415,3950],{},[33,7417,7418],{"class":35,"line":419},[33,7419,52],{"emptyLinePlaceholder":51},[33,7421,7422],{"class":35,"line":1058},[33,7423,7424],{},"        $messages[] = ['role' => 'assistant', 'content' => $response['content']];\n",[33,7426,7427],{"class":35,"line":1064},[33,7428,52],{"emptyLinePlaceholder":51},[33,7430,7431],{"class":35,"line":1069},[33,7432,7433],{},"        $results = [];\n",[33,7435,7436],{"class":35,"line":1074},[33,7437,7438],{},"        foreach ($response['content'] as $block) {\n",[33,7440,7441],{"class":35,"line":1080},[33,7442,7443],{},"            if ($block['type'] !== 'tool_use') {\n",[33,7445,7446],{"class":35,"line":1086},[33,7447,7448],{},"                continue;\n",[33,7450,7451],{"class":35,"line":1092},[33,7452,7453],{},"            }\n",[33,7455,7456],{"class":35,"line":1098},[33,7457,7458],{},"            $results[] = [\n",[33,7460,7461],{"class":35,"line":1103},[33,7462,7463],{},"                'type' => 'tool_result',\n",[33,7465,7466],{"class":35,"line":1108},[33,7467,7468],{},"                'tool_use_id' => $block['id'],\n",[33,7470,7471],{"class":35,"line":1114},[33,7472,7473],{},"                'content' => json_encode($this->runTool($user, $block['name'], $block['input']), JSON_THROW_ON_ERROR),\n",[33,7475,7476],{"class":35,"line":1120},[33,7477,7478],{},"            ];\n",[33,7480,7481],{"class":35,"line":1126},[33,7482,3950],{},[33,7484,7485],{"class":35,"line":1132},[33,7486,7487],{},"        $messages[] = ['role' => 'user', 'content' => $results];\n",[33,7489,7490],{"class":35,"line":1138},[33,7491,3461],{},[33,7493,7494],{"class":35,"line":1143},[33,7495,52],{"emptyLinePlaceholder":51},[33,7497,7498],{"class":35,"line":1149},[33,7499,7500],{},"    throw new ToolLoopLimitExceeded($step);\n",[33,7502,7503],{"class":35,"line":1155},[33,7504,82],{},[33,7506,7507],{"class":35,"line":1161},[33,7508,52],{"emptyLinePlaceholder":51},[33,7510,7511],{"class":35,"line":1167},[33,7512,7513],{},"private function runTool(User $user, string $name, array $input): array\n",[33,7515,7516],{"class":35,"line":2535},[33,7517,64],{},[33,7519,7520],{"class":35,"line":2547},[33,7521,7522],{},"    return match ($name) {\n",[33,7524,7525],{"class":35,"line":2555},[33,7526,7527],{},"        'get_order_status' => $this->orders->statusFor($user, (string) ($input['order_id'] ?? '')),\n",[33,7529,7530],{"class":35,"line":2566},[33,7531,7532],{},"        default => ['error' => \"Unknown tool: {$name}\"],\n",[33,7534,7535],{"class":35,"line":2577},[33,7536,7537],{},"    };\n",[33,7539,7540],{"class":35,"line":2588},[33,7541,82],{},[11,7543,7544],{},"Pętla potrzebuje limitu kroków. Bez niego model, który wciąż prosi o narzędzia, kręci się aż do timeoutu, a za każdą iterację się płaci.",[11,7546,7547,7548,7551],{},"Argumenty od modelu to niezaufane wejście, tak samo jak pole formularza. ",[30,7549,7550],{},"statusFor($user, ...)"," ogranicza zapytanie do zamówień tego użytkownika, więc to narzędzie nie zwróci cudzego zamówienia, nawet jeśli ktoś w rozmowie każe modelowi o nie zapytać.",[11,7553,7554],{},"Narzędzia zmieniające stan (zwroty, anulowania) nie powinny wykonywać się wprost na prośbę modelu. Niech narzędzie przygotuje propozycję, a zatwierdzi ją ktoś lub coś poza modelem: przycisk, osobne żądanie, człowiek.",[15,7556,7558],{"id":7557},"wyszukiwanie-w-postgresie-z-pgvector","Wyszukiwanie w Postgresie z pgvector",[11,7560,7561],{},"Jeśli masz już Postgresa, do retrieval-augmented generation nie potrzebujesz osobnej bazy wektorowej. Najdroższa część, liczenie embeddingów, dzieje się przy indeksowaniu dokumentów. Przy samym zapytaniu to jedno wywołanie po embedding i jedno zapytanie SQL.",[23,7563,7567],{"className":7564,"code":7565,"language":7566,"meta":28,"style":28},"language-sql shiki shiki-themes github-light github-dark","CREATE EXTENSION IF NOT EXISTS vector;\n\nCREATE TABLE document_chunks (\n    id          bigserial PRIMARY KEY,\n    document_id bigint NOT NULL REFERENCES documents (id) ON DELETE CASCADE,\n    content     text   NOT NULL,\n    embedding   vector(1536) NOT NULL\n);\n\nCREATE INDEX document_chunks_embedding_idx\n    ON document_chunks USING hnsw (embedding vector_cosine_ops);\n","sql",[30,7568,7569,7574,7578,7583,7588,7593,7598,7603,7608,7612,7617],{"__ignoreMap":28},[33,7570,7571],{"class":35,"line":36},[33,7572,7573],{},"CREATE EXTENSION IF NOT EXISTS vector;\n",[33,7575,7576],{"class":35,"line":42},[33,7577,52],{"emptyLinePlaceholder":51},[33,7579,7580],{"class":35,"line":48},[33,7581,7582],{},"CREATE TABLE document_chunks (\n",[33,7584,7585],{"class":35,"line":55},[33,7586,7587],{},"    id          bigserial PRIMARY KEY,\n",[33,7589,7590],{"class":35,"line":61},[33,7591,7592],{},"    document_id bigint NOT NULL REFERENCES documents (id) ON DELETE CASCADE,\n",[33,7594,7595],{"class":35,"line":67},[33,7596,7597],{},"    content     text   NOT NULL,\n",[33,7599,7600],{"class":35,"line":73},[33,7601,7602],{},"    embedding   vector(1536) NOT NULL\n",[33,7604,7605],{"class":35,"line":79},[33,7606,7607],{},");\n",[33,7609,7610],{"class":35,"line":85},[33,7611,52],{"emptyLinePlaceholder":51},[33,7613,7614],{"class":35,"line":90},[33,7615,7616],{},"CREATE INDEX document_chunks_embedding_idx\n",[33,7618,7619],{"class":35,"line":96},[33,7620,7621],{},"    ON document_chunks USING hnsw (embedding vector_cosine_ops);\n",[23,7623,7625],{"className":25,"code":7624,"language":27,"meta":28,"style":28},"$embedding = Http::withToken(config('services.openai.key'))\n    ->timeout(15)\n    ->post('https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fembeddings', [\n        'model' => 'text-embedding-3-small', \u002F\u002F 1536 wymiarów, tyle co w kolumnie\n        'input' => $question,\n    ])\n    ->throw()\n    ->json('data.0.embedding');\n\n$vector = '[' . implode(',', $embedding) . ']';\n\n$chunks = DB::select(\n    'SELECT id, content, embedding \u003C=> ?::vector AS distance\n       FROM document_chunks\n      ORDER BY embedding \u003C=> ?::vector\n      LIMIT 5',\n    [$vector, $vector],\n);\n\n$relevant = array_filter($chunks, fn (object $c): bool => $c->distance \u003C= $maxDistance);\n",[30,7626,7627,7632,7637,7642,7650,7655,7660,7665,7670,7674,7679,7683,7688,7693,7698,7703,7708,7713,7717,7721],{"__ignoreMap":28},[33,7628,7629],{"class":35,"line":36},[33,7630,7631],{},"$embedding = Http::withToken(config('services.openai.key'))\n",[33,7633,7634],{"class":35,"line":42},[33,7635,7636],{},"    ->timeout(15)\n",[33,7638,7639],{"class":35,"line":48},[33,7640,7641],{},"    ->post('https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fembeddings', [\n",[33,7643,7644,7647],{"class":35,"line":55},[33,7645,7646],{},"        'model' => 'text-embedding-3-small',",[33,7648,7649],{}," \u002F\u002F 1536 wymiarów, tyle co w kolumnie\n",[33,7651,7652],{"class":35,"line":61},[33,7653,7654],{},"        'input' => $question,\n",[33,7656,7657],{"class":35,"line":67},[33,7658,7659],{},"    ])\n",[33,7661,7662],{"class":35,"line":73},[33,7663,7664],{},"    ->throw()\n",[33,7666,7667],{"class":35,"line":79},[33,7668,7669],{},"    ->json('data.0.embedding');\n",[33,7671,7672],{"class":35,"line":85},[33,7673,52],{"emptyLinePlaceholder":51},[33,7675,7676],{"class":35,"line":90},[33,7677,7678],{},"$vector = '[' . implode(',', $embedding) . ']';\n",[33,7680,7681],{"class":35,"line":96},[33,7682,52],{"emptyLinePlaceholder":51},[33,7684,7685],{"class":35,"line":384},[33,7686,7687],{},"$chunks = DB::select(\n",[33,7689,7690],{"class":35,"line":390},[33,7691,7692],{},"    'SELECT id, content, embedding \u003C=> ?::vector AS distance\n",[33,7694,7695],{"class":35,"line":395},[33,7696,7697],{},"       FROM document_chunks\n",[33,7699,7700],{"class":35,"line":401},[33,7701,7702],{},"      ORDER BY embedding \u003C=> ?::vector\n",[33,7704,7705],{"class":35,"line":407},[33,7706,7707],{},"      LIMIT 5',\n",[33,7709,7710],{"class":35,"line":413},[33,7711,7712],{},"    [$vector, $vector],\n",[33,7714,7715],{"class":35,"line":419},[33,7716,7607],{},[33,7718,7719],{"class":35,"line":1058},[33,7720,52],{"emptyLinePlaceholder":51},[33,7722,7723],{"class":35,"line":1064},[33,7724,7725],{},"$relevant = array_filter($chunks, fn (object $c): bool => $c->distance \u003C= $maxDistance);\n",[11,7727,7728,7731,7732,7735,7736,7739,7740,7743,7744,7747,7748,7751],{},[30,7729,7730],{},"\u003C=>"," to odległość kosinusowa, a indeks ",[30,7733,7734],{},"hnsw"," z ",[30,7737,7738],{},"vector_cosine_ops"," obsługuje dokładnie takie sortowanie. Próg filtruję w PHP po ",[30,7741,7742],{},"LIMIT",", żeby widzieć odległości odrzuconych fragmentów. Warunek ",[30,7745,7746],{},"WHERE"," na odległości w SQL, przy zachowanym ",[30,7749,7750],{},"ORDER BY … LIMIT",", też korzystałby z indeksu.",[11,7753,7754,7757],{},[30,7755,7756],{},"$maxDistance"," nie ma uniwersalnej wartości domyślnej. Zależy od modelu embeddingów, języka i długości fragmentów. Trzeba go zmierzyć. Przygotuj kilkadziesiąt pytań ze znanymi pasującymi fragmentami, puść je przy kilku progach i zapisz, ile pasujących fragmentów wraca i ile niepasujących przychodzi razem z nimi. Powtórz pomiar po zmianie modelu embeddingów, bo odległości z różnych modeli nie są porównywalne. Zmiana modelu oznacza też ponowne zindeksowanie wszystkich dokumentów.",[11,7759,7760],{},"Gdy nic nie przechodzi przez próg, powiedz to użytkownikowi. Model zawołany z pustym kontekstem chętnie odpowie z ogólnej wiedzy, a temu właśnie miało zapobiec wyszukiwanie.",[15,7762,7764],{"id":7763},"co-mierzyć","Co mierzyć",[11,7766,7767,7768,743,7771,7774],{},"Każde wywołanie zapisuj w tabeli albo w systemie metryk z polami: nazwa funkcji, model, ",[30,7769,7770],{},"usage.input_tokens",[30,7772,7773],{},"usage.output_tokens",", czas odpowiedzi i wynik (sukces, niepoprawna odpowiedź, błąd API). Z tego da się odpowiedzieć na pytania, które w praktyce padają:",[2848,7776,7777,7780,7783,7786],{},[2851,7778,7779],{},"Koszt w rozbiciu na funkcje. Koszt rośnie z liczbą tokenów. Długi prompt systemowy wysyłany przy każdym wywołaniu często dominuje w rachunku, a grupowanie po funkcji pokazuje, gdzie.",[2851,7781,7782],{},"Percentyle czasu odpowiedzi (p50, p95). Średnia chowa wolne wywołania, a to one wpadają w timeout.",[2851,7784,7785],{},"Odsetek niepoprawnych odpowiedzi. Alarm ustaw na odchylenie od jego zwykłego poziomu. Skok zwykle oznacza zmianę promptu, inny rozkład danych wejściowych albo nową wersję modelu.",[2851,7787,7788],{},"Czas oczekiwania jobów LLM w kolejce. Widać w nim, że workerów jest za mało, zanim zauważą to użytkownicy.",[11,7790,7791],{},"Jeśli prompty zawierają dane osobowe albo finansowe, loguj identyfikatory i liczby. Pełna treść promptu nie powinna trafiać do logów.",[15,7793,7795],{"id":7794},"kiedy-osobny-serwis-w-pythonie-ma-sens","Kiedy osobny serwis w Pythonie ma sens",[11,7797,7798],{},"Osobny serwis jest uzasadniony, gdy uruchamiasz własne modele (fine-tuning, inferencja lokalna), gdy potrzebujesz bibliotek dostępnych tylko w Pythonie albo gdy zespół utrzymuje pipeline'y ML niezależnie od produktu. Nawet wtedy niech będzie wąski: określone wejście, określone wyjście, bez logiki biznesowej. Reguły domenowe (kto może zobaczyć które zamówienie, czym jest zwrot) powinny zostać tam, gdzie już są. Przeniesienie ich do nowego serwisu tylko po to, żeby model mógł je wołać, dokłada granicę sieciową, drugi deploy i drugą kopię reguł.",[15,7800,7802],{"id":7801},"przed-pierwszym-wdrożeniem","Przed pierwszym wdrożeniem",[2848,7804,7805,7813,7816,7819],{},[2851,7806,7807,7810,7811,221],{},[30,7808,7809],{},"$timeout"," joba obejmuje całe wywołanie klienta z ponowieniami i jest krótszy niż ",[30,7812,6760],{},[2851,7814,7815],{},"Wynik zapisuje się warunkowo, a efekty uboczne uruchamia tylko próba, która zapisała.",[2851,7817,7818],{},"Pętla narzędzi ma limit kroków, a narzędzia zmieniające stan czekają na zatwierdzenie.",[2851,7820,7821],{},"Próg wyszukiwania pochodzi z pomiaru na własnych danych.",[896,7823,898],{},{"title":28,"searchDepth":42,"depth":42,"links":7825},[7826,7827,7828,7829,7830,7831,7832,7833,7834],{"id":6542,"depth":42,"text":6543},{"id":6575,"depth":42,"text":6576},{"id":6750,"depth":42,"text":6751},{"id":7004,"depth":42,"text":7005},{"id":7319,"depth":42,"text":7320},{"id":7557,"depth":42,"text":7558},{"id":7763,"depth":42,"text":7764},{"id":7794,"depth":42,"text":7795},{"id":7801,"depth":42,"text":7802},"2024-10-28",{},"\u002Fpl\u002Farticles\u002Fllm-in-php",{"x":7839,"y":4284,"depth":7840,"size":5866},0.47,1.3,[937,923],{"title":6530,"description":6536},"llm-integration","pl\u002Farticles\u002Fllm-in-php",[27,7846,7847,7848,7849,7850],"laravel","llm","anthropic-api","pgvector","queues","xe92e-h6Uu6u2l86erth-DJgx2YJ93-vXbGT-2xCWTA",{"id":7853,"title":7854,"articleId":1773,"body":7855,"category":5087,"codeLang":3381,"date":8517,"deploys":36,"description":7859,"excerpt":914,"extension":915,"lang":916,"meta":8518,"navigation":51,"path":8519,"pos":8520,"readMin":73,"related":8524,"seo":8525,"service":8526,"stem":8527,"tags":8528,"updated":5101,"version":8532,"__hash__":8533},"articles_pl\u002Fpl\u002Farticles\u002Fmicroservice-cost.md","Ukryty koszt granic między mikroserwisami: jak wycenić granicę, zanim ją narysujesz",{"type":8,"value":7856,"toc":8509},[7857,7860,7867,7871,7874,7877,7880,7883,7886,7889,7893,7899,7902,7931,7940,7944,7949,8258,8264,8267,8271,8274,8277,8283,8286,8289,8292,8296,8299,8481,8484,8487,8490,8494,8497,8503,8506],[11,7858,7859],{},"Podział systemu na serwisy przenosi złożoność w inne miejsce, ale jej nie zmniejsza. Kod, który wołał funkcję, wysyła teraz żądanie przez sieć, a każda zmiana dotykająca obu stron tego wywołania staje się zmianą w dwóch repozytoriach, dwóch pipeline’ach wdrożeniowych i jednym kontrakcie między nimi. Później granicę przesuwa się bardzo trudno, bo to oznacza migrację danych i przepisanie kontraktu.",[11,7861,7862,7863,7866],{},"Zanim ją narysujesz, zapytaj: ",[5129,7864,7865],{},"jaka jest najmniejsza zmiana, która będzie tę granicę przekraczać, i jak często będzie się zdarzać?"," Jeśli odpowiedź brzmi „większość funkcji, po obu stronach naraz”, linia jest w złym miejscu.",[15,7868,7870],{"id":7869},"co-kosztuje-granica","Co kosztuje granica",[11,7872,7873],{},"W monolicie zmiana sygnatury metody razem z wywołaniami to jeden commit, sprawdzony przez kompilator albo analizę statyczną i wdrożony atomowo. Przez granicę serwisów ta sama zmiana robi się dużo droższa.",[11,7875,7876],{},"Producent musi obsługiwać stary i nowy kontrakt jednocześnie, bo oba serwisy nie wdrażają się w tej samej chwili. Wdrożenia idą w ustalonej kolejności: rozszerz producenta i wdróż, przenieś konsumentów i wdróż, usuń stary kontrakt i wdróż jeszcze raz. Jedna zmiana logiczna, trzy wydania.",[11,7878,7879],{},"Analiza statyczna nie widzi już obu stron, więc to, że pole zmieniło nazwę, musi wykryć coś innego: testy kontraktowe albo środowisko integracyjne.",[11,7881,7882],{},"Awarie też się komplikują. Wywołanie funkcji zwraca wynik albo rzuca wyjątek. Wywołanie sieciowe może dodatkowo skończyć się timeoutem, kiedy druga strona zdążyła już zrobić commit, więc przy każdym zapisie przez granicę trzeba zdecydować o ponowieniach, kluczach idempotencji i o tym, co się dzieje ze stanem częściowym.",[11,7884,7885],{},"Raport, który łączył dwie tabele, potrzebuje teraz wywołań API (w najgorszym razie po jednym na wiersz), replikowanego modelu do odczytu albo strumienia zdarzeń zasilającego osobny magazyn. Żądanie przechodzące przez trzy serwisy debuguje się bez distributed tracingu dużo wolniej, z logów sklejanych po correlation ID.",[11,7887,7888],{},"Każdy z tych kosztów osobno jest niewielki. Płacisz jednak wszystkie przy każdej zmianie przekraczającej linię, więc suma zależy od tego, jak często to się dzieje.",[15,7890,7892],{"id":7891},"model-kosztu","Model kosztu",[23,7894,7897],{"className":7895,"code":7896,"language":3381,"meta":28},[3379],"cost(boundary) =\n      f_change  * cost_per_change      # skoordynowane zmiany po obu stronach linii\n    + f_failure * blast_radius         # jak często i jak mocno jedna strona psuje drugą\n    - autonomy_gained                  # niezależne wdrożenia, skalowanie, własność\n\n# dominuje pierwszy składnik  -> granica jest w złym miejscu\n# dominuje drugi składnik     -> granica może być dobra; zainwestuj w izolację (timeouty, kolejki, fallbacki)\n# dominuje trzeci składnik    -> granica zarabia na siebie\n",[30,7898,7896],{"__ignoreMap":28},[11,7900,7901],{},"Nikt tego nie liczy do jednej liczby. Chodzi o to, żeby przed decyzją zgrubnie oszacować składniki i zapisać szacunek:",[2848,7903,7904,7910,7916,7925],{},[2851,7905,7906,7909],{},[30,7907,7908],{},"f_change",": ile zmian miesięcznie wymaga ruszenia obu stron.",[2851,7911,7912,7915],{},[30,7913,7914],{},"cost_per_change",": opisany wyżej narzut, w godzinach pracy na jedną skoordynowaną zmianę.",[2851,7917,7918,1182,7921,7924],{},[30,7919,7920],{},"f_failure",[30,7922,7923],{},"blast_radius",": jak często awaria albo spowolnienie jednej strony dotyka drugiej i jak daleko się to rozlewa.",[2851,7926,7927,7930],{},[30,7928,7929],{},"autonomy_gained",": co każda strona może teraz zrobić samodzielnie, czego wcześniej nie mogła. Gdy oba serwisy należą do tego samego zespołu, część wynikająca z własności jest bliska zera, bo zespół i tak musi się dogadać sam ze sobą. Zostają powody operacyjne, opisane niżej.",[11,7932,7933,7934,7936,7937,7939],{},"Weźmy dwa serwisy jednego zespołu, bez różnic operacyjnych, gdzie większość funkcji dotyka obu. ",[30,7935,7908],{}," jest wysokie, ",[30,7938,7929],{}," bliskie zera. W modelu dominuje pierwszy składnik: granica dokłada wersjonowanie i wdrożenia w kolejności do większości funkcji.",[15,7941,7943],{"id":7942},"pomiar-sprzężenia-zmian-przed-narysowaniem-linii","Pomiar sprzężenia zmian przed narysowaniem linii",[11,7945,7946,7948],{},[30,7947,7908],{}," nie trzeba zgadywać, da się go zmierzyć z historii repozytorium. Jeśli system jest jeszcze monolitem z katalogiem na moduł, policz, jak często commity dotykają obu kandydatów.",[23,7950,7952],{"className":2163,"code":7951,"language":2165,"meta":28,"style":28},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# Sprzężenie zmian między dwoma katalogami w zadanym okresie.\n# Użycie: .\u002Fcoupling.sh src\u002FBilling src\u002FFraud '6 months ago'\na=\"$1\"; b=\"$2\"; since=\"${3:-6 months ago}\"\n\ntotal_a=$(git log --no-merges --since=\"$since\" --format='%H' -- \"$a\" | wc -l)\nboth=0\nfor h in $(git log --no-merges --since=\"$since\" --format='%H' -- \"$a\"); do\n  if [ -n \"$(git show --name-only --format= \"$h\" -- \"$b\")\" ]; then\n    both=$((both + 1))\n  fi\ndone\n\necho \"commits touching $a: $total_a\"\necho \"of which also touch $b: $both\"\n",[30,7953,7954,7959,7964,7969,8024,8028,8086,8095,8141,8190,8211,8216,8221,8225,8242],{"__ignoreMap":28},[33,7955,7956],{"class":35,"line":36},[33,7957,7958],{"class":2981},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[33,7960,7961],{"class":35,"line":42},[33,7962,7963],{"class":2981},"# Sprzężenie zmian między dwoma katalogami w zadanym okresie.\n",[33,7965,7966],{"class":35,"line":48},[33,7967,7968],{"class":2981},"# Użycie: .\u002Fcoupling.sh src\u002FBilling src\u002FFraud '6 months ago'\n",[33,7970,7971,7973,7976,7979,7982,7984,7987,7989,7991,7994,7996,7999,8001,8003,8006,8009,8012,8015,8018,8021],{"class":35,"line":55},[33,7972,4632],{"class":1863},[33,7974,7975],{"class":4756},"=",[33,7977,7978],{"class":1874},"\"",[33,7980,7981],{"class":1993},"$1",[33,7983,7978],{"class":1874},[33,7985,7986],{"class":1863},"; b",[33,7988,7975],{"class":4756},[33,7990,7978],{"class":1874},[33,7992,7993],{"class":1993},"$2",[33,7995,7978],{"class":1874},[33,7997,7998],{"class":1863},"; since",[33,8000,7975],{"class":4756},[33,8002,7978],{"class":1874},[33,8004,8005],{"class":1993},"${3",[33,8007,8008],{"class":4756},":-",[33,8010,8011],{"class":1993},"6",[33,8013,8014],{"class":1863}," months",[33,8016,8017],{"class":1863}," ago",[33,8019,8020],{"class":1993},"}",[33,8022,8023],{"class":1874},"\"\n",[33,8025,8026],{"class":35,"line":61},[33,8027,52],{"emptyLinePlaceholder":51},[33,8029,8030,8033,8035,8038,8041,8044,8047,8050,8052,8055,8057,8060,8063,8066,8069,8072,8074,8077,8080,8083],{"class":35,"line":67},[33,8031,8032],{"class":1863},"total_a",[33,8034,7975],{"class":4756},[33,8036,8037],{"class":1863},"$(",[33,8039,8040],{"class":2172},"git",[33,8042,8043],{"class":1874}," log",[33,8045,8046],{"class":1993}," --no-merges",[33,8048,8049],{"class":1993}," --since=",[33,8051,7978],{"class":1874},[33,8053,8054],{"class":1863},"$since",[33,8056,7978],{"class":1874},[33,8058,8059],{"class":1993}," --format=",[33,8061,8062],{"class":1874},"'%H'",[33,8064,8065],{"class":1993}," --",[33,8067,8068],{"class":1874}," \"",[33,8070,8071],{"class":1863},"$a",[33,8073,7978],{"class":1874},[33,8075,8076],{"class":4756}," |",[33,8078,8079],{"class":2172}," wc",[33,8081,8082],{"class":1993}," -l",[33,8084,8085],{"class":1863},")\n",[33,8087,8088,8091,8093],{"class":35,"line":73},[33,8089,8090],{"class":1863},"both",[33,8092,7975],{"class":4756},[33,8094,2254],{"class":1874},[33,8096,8097,8100,8103,8106,8109,8111,8113,8115,8117,8119,8121,8123,8125,8127,8129,8131,8133,8135,8138],{"class":35,"line":79},[33,8098,8099],{"class":4756},"for",[33,8101,8102],{"class":1863}," h ",[33,8104,8105],{"class":4756},"in",[33,8107,8108],{"class":1863}," $(",[33,8110,8040],{"class":2172},[33,8112,8043],{"class":1874},[33,8114,8046],{"class":1993},[33,8116,8049],{"class":1993},[33,8118,7978],{"class":1874},[33,8120,8054],{"class":1863},[33,8122,7978],{"class":1874},[33,8124,8059],{"class":1993},[33,8126,8062],{"class":1874},[33,8128,8065],{"class":1993},[33,8130,8068],{"class":1874},[33,8132,8071],{"class":1863},[33,8134,7978],{"class":1874},[33,8136,8137],{"class":1863},"); ",[33,8139,8140],{"class":4756},"do\n",[33,8142,8143,8146,8149,8152,8155,8157,8160,8163,8165,8167,8170,8173,8176,8178,8181,8184,8187],{"class":35,"line":85},[33,8144,8145],{"class":4756},"  if",[33,8147,8148],{"class":1863}," [ ",[33,8150,8151],{"class":4756},"-n",[33,8153,8154],{"class":1874}," \"$(",[33,8156,8040],{"class":2172},[33,8158,8159],{"class":1874}," show ",[33,8161,8162],{"class":1993},"--name-only",[33,8164,8059],{"class":1993},[33,8166,8068],{"class":1874},[33,8168,8169],{"class":1863},"$h",[33,8171,8172],{"class":1874},"\" ",[33,8174,8175],{"class":1993},"--",[33,8177,8068],{"class":1874},[33,8179,8180],{"class":1863},"$b",[33,8182,8183],{"class":1874},"\")\"",[33,8185,8186],{"class":1863}," ]; ",[33,8188,8189],{"class":4756},"then\n",[33,8191,8192,8195,8197,8200,8202,8205,8208],{"class":35,"line":90},[33,8193,8194],{"class":1863},"    both",[33,8196,7975],{"class":4756},[33,8198,8199],{"class":1863},"$((",[33,8201,8090],{"class":2172},[33,8203,8204],{"class":1874}," +",[33,8206,8207],{"class":1993}," 1",[33,8209,8210],{"class":1863},"))\n",[33,8212,8213],{"class":35,"line":96},[33,8214,8215],{"class":4756},"  fi\n",[33,8217,8218],{"class":35,"line":384},[33,8219,8220],{"class":4756},"done\n",[33,8222,8223],{"class":35,"line":390},[33,8224,52],{"emptyLinePlaceholder":51},[33,8226,8227,8230,8233,8235,8237,8240],{"class":35,"line":395},[33,8228,8229],{"class":1993},"echo",[33,8231,8232],{"class":1874}," \"commits touching ",[33,8234,8071],{"class":1863},[33,8236,1871],{"class":1874},[33,8238,8239],{"class":1863},"$total_a",[33,8241,8023],{"class":1874},[33,8243,8244,8246,8249,8251,8253,8256],{"class":35,"line":401},[33,8245,8229],{"class":1993},[33,8247,8248],{"class":1874}," \"of which also touch ",[33,8250,8180],{"class":1863},[33,8252,1871],{"class":1874},[33,8254,8255],{"class":1863},"$both",[33,8257,8023],{"class":1874},[11,8259,8260,8261,221],{},"Sprzężenie liczone po commitach zaniża wynik, gdy jedna funkcja jest rozbita na kilka commitów. Jeśli commity mają ID zadania, grupuj po zadaniu. Jeśli PR-y wchodzą jako merge commity, grupuj po merge commicie. PR-y squash-merge’owane to już jeden commit na zmianę i nie wymagają grupowania. Przy większej historii code-maat i podobne narzędzia liczą sprzężenie dla wszystkich par plików z wyjścia ",[30,8262,8263],{},"git log",[11,8265,8266],{},"Orientacyjny próg na początek: jeśli ponad jedna trzecia zmian po jednej stronie dotyka też drugiej, obie strony są jedną jednostką zmiany i powinny zostać w jednym deployu.",[15,8268,8270],{"id":8269},"kiedy-granica-się-opłaca","Kiedy granica się opłaca",[11,8272,8273],{},"Granica zarabia na siebie, gdy obie strony różnią się czymś, co ma znaczenie operacyjne.",[11,8275,8276],{},"Najprościej widać to na rytmie wdrożeń. Serwis płatności wychodzi kilka razy w tygodniu, a model scoringu fraudów dopiero po kilkutygodniowej walidacji. Po rozdzieleniu płatności nie czekają na walidację modelu.",[11,8278,8279,8280,8282],{},"To własność sprawia, że ",[30,8281,7929],{}," jest realne: inne zespoły, inne dyżury, inne priorytety.",[11,8284,8285],{},"Do tego dochodzą powody czysto operacyjne. Parser dokumentów obciążający CPU i API wrażliwe na opóźnienia potrzebują innych zasobów, a w osobnych procesach każde z nich skaluje się i pada osobno. Komponent wołający niestabilną usługę zewnętrzną można schować za kolejką, żeby jej awarie nie blokowały ścieżki żądania.",[11,8287,8288],{},"Czasem wystarcza sama technologia. Model serwowany z Pythona obok aplikacji w PHP to naturalna granica procesu, bo inaczej trzeba by osadzać jeden runtime w drugim.",[11,8290,8291],{},"Jeśli nic z tego nie zachodzi, kandydat na granicę to granica modułu i jej miejsce jest w jednym deployu.",[15,8293,8295],{"id":8294},"zacznij-od-modułów","Zacznij od modułów",[11,8297,8298],{},"Modularny monolit trzyma granice w kodzie bez płacenia kosztu sieci. Każdy moduł ma własne tabele, wystawia interfejs i nie ma prawa sięgać do wnętrza innego modułu. W PHP można to wymusić w CI deptrakiem:",[23,8300,8302],{"className":1854,"code":8301,"language":1856,"meta":28,"style":28},"# deptrac.yaml\ndeptrac:\n  paths:\n    - .\u002Fsrc\n  layers:\n    - name: Billing\n      collectors:\n        - type: directory\n          value: src\u002FBilling\u002F.*\n    - name: Fraud\n      collectors:\n        - type: directory\n          value: src\u002FFraud\u002F.*\n    - name: Shared\n      collectors:\n        - type: directory\n          value: src\u002FShared\u002F.*\n  ruleset:\n    Billing: [Shared]\n    Fraud: [Shared]\n",[30,8303,8304,8309,8316,8323,8330,8337,8348,8355,8367,8377,8388,8394,8404,8413,8424,8430,8440,8449,8456,8470],{"__ignoreMap":28},[33,8305,8306],{"class":35,"line":36},[33,8307,8308],{"class":2981},"# deptrac.yaml\n",[33,8310,8311,8314],{"class":35,"line":42},[33,8312,8313],{"class":1867},"deptrac",[33,8315,1893],{"class":1863},[33,8317,8318,8321],{"class":35,"line":48},[33,8319,8320],{"class":1867},"  paths",[33,8322,1893],{"class":1863},[33,8324,8325,8327],{"class":35,"line":55},[33,8326,2270],{"class":1863},[33,8328,8329],{"class":1874},".\u002Fsrc\n",[33,8331,8332,8335],{"class":35,"line":61},[33,8333,8334],{"class":1867},"  layers",[33,8336,1893],{"class":1863},[33,8338,8339,8341,8343,8345],{"class":35,"line":67},[33,8340,2270],{"class":1863},[33,8342,1868],{"class":1867},[33,8344,1871],{"class":1863},[33,8346,8347],{"class":1874},"Billing\n",[33,8349,8350,8353],{"class":35,"line":73},[33,8351,8352],{"class":1867},"      collectors",[33,8354,1893],{"class":1863},[33,8356,8357,8359,8362,8364],{"class":35,"line":79},[33,8358,2338],{"class":1863},[33,8360,8361],{"class":1867},"type",[33,8363,1871],{"class":1863},[33,8365,8366],{"class":1874},"directory\n",[33,8368,8369,8372,8374],{"class":35,"line":85},[33,8370,8371],{"class":1867},"          value",[33,8373,1871],{"class":1863},[33,8375,8376],{"class":1874},"src\u002FBilling\u002F.*\n",[33,8378,8379,8381,8383,8385],{"class":35,"line":90},[33,8380,2270],{"class":1863},[33,8382,1868],{"class":1867},[33,8384,1871],{"class":1863},[33,8386,8387],{"class":1874},"Fraud\n",[33,8389,8390,8392],{"class":35,"line":96},[33,8391,8352],{"class":1867},[33,8393,1893],{"class":1863},[33,8395,8396,8398,8400,8402],{"class":35,"line":384},[33,8397,2338],{"class":1863},[33,8399,8361],{"class":1867},[33,8401,1871],{"class":1863},[33,8403,8366],{"class":1874},[33,8405,8406,8408,8410],{"class":35,"line":390},[33,8407,8371],{"class":1867},[33,8409,1871],{"class":1863},[33,8411,8412],{"class":1874},"src\u002FFraud\u002F.*\n",[33,8414,8415,8417,8419,8421],{"class":35,"line":395},[33,8416,2270],{"class":1863},[33,8418,1868],{"class":1867},[33,8420,1871],{"class":1863},[33,8422,8423],{"class":1874},"Shared\n",[33,8425,8426,8428],{"class":35,"line":401},[33,8427,8352],{"class":1867},[33,8429,1893],{"class":1863},[33,8431,8432,8434,8436,8438],{"class":35,"line":407},[33,8433,2338],{"class":1863},[33,8435,8361],{"class":1867},[33,8437,1871],{"class":1863},[33,8439,8366],{"class":1874},[33,8441,8442,8444,8446],{"class":35,"line":413},[33,8443,8371],{"class":1867},[33,8445,1871],{"class":1863},[33,8447,8448],{"class":1874},"src\u002FShared\u002F.*\n",[33,8450,8451,8454],{"class":35,"line":419},[33,8452,8453],{"class":1867},"  ruleset",[33,8455,1893],{"class":1863},[33,8457,8458,8461,8464,8467],{"class":35,"line":1058},[33,8459,8460],{"class":1867},"    Billing",[33,8462,8463],{"class":1863},": [",[33,8465,8466],{"class":1874},"Shared",[33,8468,8469],{"class":1863},"]\n",[33,8471,8472,8475,8477,8479],{"class":35,"line":1064},[33,8473,8474],{"class":1867},"    Fraud",[33,8476,8463],{"class":1863},[33,8478,8466],{"class":1874},[33,8480,8469],{"class":1863},[11,8482,8483],{},"Przy takiej konfiguracji Billing i Fraud mogą zależeć tylko od Shared, a pull request, który importuje klasę z Fraud do Billing, wywala build.",[11,8485,8486],{},"Późniejsze wydzielenie będzie tanie, jeśli moduły nie robią joinów po cudzych tabelach. Moduł czyta dane innego modułu wyłącznie przez jego publiczny interfejs, a po wydzieleniu ten interfejs staje się API. Efekty uboczne między modułami idą przez zdarzenia. Jeśli Billing potrzebuje reakcji Fraud na płatność, wysyła zdarzenie, a Fraud na nie reaguje. Przeniesienie tego zdarzenia później na brokera wiadomości to wtedy zmiana transportu plus obsługa duplikatów i opóźnień, bez przepisywania obu modułów. Zdarzenie w procesie idzie zwykle synchronicznie, w tej samej transakcji, a broker dostarcza je asynchronicznie, co najmniej raz i poza nią, więc odbiorca musi być idempotentny, a nadawca potrzebuje outboxa.",[11,8488,8489],{},"Wydzielenie dobrze odseparowanego modułu to koszt jednorazowy. Sklejenie dwóch serwisów, których nie należało rozdzielać, też jest możliwe, ale do tego czasu każda strona zwykle zdąży zbudować własną wersję wspólnego modelu domeny. Pogodzenie ich wymaga migracji danych i przepisania każdego miejsca, w którym oba modele się rozjeżdżają.",[15,8491,8493],{"id":8492},"najpierw-platforma-potem-serwisy","Najpierw platforma, potem serwisy",[11,8495,8496],{},"Każdy nowy serwis potrzebuje pipeline’u budowania, konfiguracji wdrożenia, logów, metryk, tracingu, health checków, sekretów i alertów. Jeśli to wszystko nie jest wspólne i oparte na szablonie, każdy serwis wymyśla to trochę inaczej, a koszt operacyjny rośnie z liczbą serwisów, niezależnie od ilości logiki biznesowej. Przed podziałem miej gotowy jeden szablon CI\u002FCD, który nowy serwis przyjmuje bez zmian, strukturalne logi z correlation ID przekazywanym między wywołaniami, distributed tracing i standard timeoutów, ponowień oraz kluczy idempotencji dla każdego wywołania między serwisami.",[11,8498,8499,8500,221],{},"Przekonałem się o tym w małej skali, na jednym dodatkowym procesie. W grze z pojedynkami online w Laravelu WebSockety obsługuje Reverb, czyli osobny proces. Bez niego „pojedynek online” dawał 500, a gdy już działał, ale bez proxy w vhoście, gość dołączał po HTTP, a kanał presence przez WebSocket dostawał 404. Po deployu, który zmieniał eventy, Reverb trzymał stary kod i trzeba go było ręcznie zrestartować. Do tego pułapka przy diagnozie: curl przez Cloudflare szedł po HTTP\u002F2 i gubił nagłówek Upgrade, więc wracało fałszywe 404, a pomagało dopiero ",[30,8501,8502],{},"--http1.1",[11,8504,8505],{},"Z wszystkich danych do tej decyzji częstotliwość zmian najłatwiej zmierzyć przed podziałem: skryptem z góry, na historii z ostatnich sześciu miesięcy. Reszta (zespoły, rytm wdrożeń, skalowanie, gotowość platformy) wymaga w dużej części oceny, i jeśli zmierzone sprzężenie wychodzi „większość funkcji”, rzadko zmienia wynik.",[896,8507,8508],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":28,"searchDepth":42,"depth":42,"links":8510},[8511,8512,8513,8514,8515,8516],{"id":7869,"depth":42,"text":7870},{"id":7891,"depth":42,"text":7892},{"id":7942,"depth":42,"text":7943},{"id":8269,"depth":42,"text":8270},{"id":8294,"depth":42,"text":8295},{"id":8492,"depth":42,"text":8493},"2026-05-14",{},"\u002Fpl\u002Farticles\u002Fmicroservice-cost",{"x":8521,"y":8522,"depth":8523,"size":5866},0.72,0.68,1.1,[937,4287],{"title":7854,"description":7859},"service-boundaries","pl\u002Farticles\u002Fmicroservice-cost",[4294,8529,8530,8531],"organisation","platform","devex","v6.1.2","ujZpJtiuS0dnk2bSmTbWksJzd26Thf1ONaAlFI6a53c",{"id":8535,"title":8536,"articleId":8537,"body":8538,"category":1763,"codeLang":4379,"date":9411,"deploys":36,"description":8542,"excerpt":914,"extension":915,"lang":916,"meta":9412,"navigation":51,"path":9413,"pos":9414,"readMin":85,"related":9417,"seo":9418,"service":9419,"stem":9420,"tags":9421,"updated":1783,"version":9427,"__hash__":9428},"articles_pl\u002Fpl\u002Farticles\u002Fn8n-rag-data-quality.md","Zasilanie RAG w n8n: wersje dokumentów, kasowanie i metadane świeżości","n8n-rag-data-quality",{"type":8,"value":8539,"toc":9400},[8540,8543,8546,8550,8553,8560,8567,8570,8573,8618,8621,8625,8628,8645,8649,8678,8689,8699,8916,8923,8926,8930,8953,9053,9056,9060,9063,9130,9133,9137,9157,9190,9193,9197,9200,9206,9333,9343,9347,9350,9385,9388,9392,9395,9398],[11,8541,8542],{},"Chatbot z RAG odpowiada na podstawie tego, co zwróci baza wektorowa. Jeśli w bazie leżą dwie wersje tego samego cennika, retriever wybierze chunk z wyższym wynikiem podobieństwa, a ten wynik nic nie mówi o tym, która wersja obowiązuje. Model odpowiada zgodnie z kontekstem. Odpowiedź brzmi dobrze i powołuje się na prawdziwy dokument, tyle że jest błędna. W logach nie ma błędu, detektor halucynacji milczy, bo model niczego nie wymyślił.",[11,8544,8545],{},"Usterka siedzi w przepływie zasilania, nie w modelu ani w prompcie. Poniżej opisuję niezmienniki, których pipeline zasilania powinien pilnować, i sposób na ich wdrożenie w n8n z Pinecone. Z Qdrantem, Weaviate czy pgvectorem zasady są te same.",[15,8547,8549],{"id":8548},"skąd-biorą-się-stare-chunki","Skąd biorą się stare chunki",[11,8551,8552],{},"Baza wektorowa nie zna pojęcia wersji dokumentu. Trzyma rekordy pod identyfikatorami, a upsert podmienia rekord tylko przy zgodnym ID. Cała reszta się kumuluje.",[11,8554,8555,8556,8559],{},"Najczęściej duplikaty biorą się z ID wyliczanego z nazwy pliku albo z treści. Nowa wersja zapisana jako ",[30,8557,8558],{},"cennik-2026-v2.pdf"," albo wersja ze zmienionym pierwszym akapitem dostaje nowe ID, a stare rekordy zostają.",[11,8561,8562,8563,8566],{},"Krótsza nowa wersja robi to samo, tylko mniej widocznie. Wersja 1 dała 12 chunków, wersja 2 daje 9. Upsert po ",[30,8564,8565],{},"docId + indeks"," nadpisze chunki 0–8, a 9–11 ze starej wersji zostaną.",[11,8568,8569],{},"Do tego dochodzi usuwanie u źródła. Przeniesienie pliku do archiwum albo skasowanie go na Google Drive niczego nie zmienia w bazie wektorowej, więc bez jawnego delete chunki da się wyszukać w nieskończoność.",[11,8571,8572],{},"Typowa pierwsza wersja przepływu (trigger Drive, splitter tekstu, upsert do Pinecone) ma wszystkie te problemy naraz:",[23,8574,8576],{"className":4377,"code":8575,"language":4379,"meta":28,"style":28},"\u002F\u002F n8n Code node: naiwne przygotowanie chunków\nreturn $input.all().map(chunk => ({\n  json: {\n    id: chunk.json.metadata.loc.pageNumber + '_' + chunk.json.pageContent.slice(0, 32),\n    text: chunk.json.pageContent,\n    metadata: { source: chunk.json.metadata.source },\n  },\n}));\n",[30,8577,8578,8583,8588,8593,8598,8603,8608,8613],{"__ignoreMap":28},[33,8579,8580],{"class":35,"line":36},[33,8581,8582],{},"\u002F\u002F n8n Code node: naiwne przygotowanie chunków\n",[33,8584,8585],{"class":35,"line":42},[33,8586,8587],{},"return $input.all().map(chunk => ({\n",[33,8589,8590],{"class":35,"line":48},[33,8591,8592],{},"  json: {\n",[33,8594,8595],{"class":35,"line":55},[33,8596,8597],{},"    id: chunk.json.metadata.loc.pageNumber + '_' + chunk.json.pageContent.slice(0, 32),\n",[33,8599,8600],{"class":35,"line":61},[33,8601,8602],{},"    text: chunk.json.pageContent,\n",[33,8604,8605],{"class":35,"line":67},[33,8606,8607],{},"    metadata: { source: chunk.json.metadata.source },\n",[33,8609,8610],{"class":35,"line":73},[33,8611,8612],{},"  },\n",[33,8614,8615],{"class":35,"line":79},[33,8616,8617],{},"}));\n",[11,8619,8620],{},"ID zależy od treści strony, jedyną metadaną jest nazwa pliku, a w całym przepływie nie ma kroku kasowania.",[15,8622,8624],{"id":8623},"niezmiennik","Niezmiennik",[11,8626,8627],{},"Dla każdego dokumentu w folderze źródłowym indeks zawiera dokładnie chunki jego bieżącej wersji i nic więcej. Z tego wynikają dalsze decyzje:",[2848,8629,8630,8633,8636,8639,8642],{},[2851,8631,8632],{},"ID dokumentu pochodzi z systemu źródłowego (file ID z Drive), bo file ID nie zmienia się przy zmianie nazwy ani przy wgraniu nowej rewizji;",[2851,8634,8635],{},"ID chunka zaczyna się od ID dokumentu i hasha treści, więc wszystkie chunki dokumentu da się znaleźć po prefiksie, a starą wersję odróżnić od nowej;",[2851,8637,8638],{},"podmiana dokumentu to zapis nowych chunków, a potem usunięcie wszystkich chunków z tym samym prefiksem dokumentu i innym hashem;",[2851,8640,8641],{},"uzgadnianie uruchamiane z harmonogramu usuwa dokumenty, których nie ma już u źródła;",[2851,8643,8644],{},"świeżość jest zapisana jako liczby, bo na liczbach działają filtry zakresowe w metadanych.",[15,8646,8648],{"id":8647},"przygotowanie-chunków","Przygotowanie chunków",[11,8650,8651,8652,743,8655,743,8657,743,8660,8663,8664,1182,8666,8669,8670,8673,8674,8677],{},"Poniższy węzeł działa w trybie „Run Once for All Items”. Oczekuje jednego itemu na plik z Drive z polami ",[30,8653,8654],{},"id",[30,8656,1868],{},[30,8658,8659],{},"modifiedTime",[30,8661,8662],{},"mimeType",", wyciągniętym ",[30,8665,3381],{},[30,8667,8668],{},"docType"," (na przykład wyliczonym z podfolderu). Na self-hosted n8n ",[30,8671,8672],{},"require('crypto')"," działa tylko wtedy, gdy instancja ma ustawione ",[30,8675,8676],{},"NODE_FUNCTION_ALLOW_BUILTIN=crypto"," (przy zewnętrznych task runnerach zmienną ustawia się na runnerze, główna instancja jej nie czyta).",[11,8679,8680,8681,8684,8685,8688],{},"Self-hosted n8n ma więcej takich zachowań, które wychodzą dopiero w praktyce. Przy przenosinach mojego n8n na nowy serwer ",[30,8682,8683],{},"active = false"," wcale nie wyłączało workflowów. Prawdziwym wyłącznikiem było ",[30,8686,8687],{},"activeVersionId"," i dopiero po jego wyczyszczeniu w logu pojawiło się „Processed 8 draft workflows”. Telegram Trigger przejmuje webhooka, zamiast się do niego dołączyć: moja kopia testowa żyła 41 sekund i przez ten czas „wypisała” produkcję z Telegrama. Naprawił to dopiero restart produkcyjnej instancji. Test klucza szyfrowania poszedł czysto, odszyfrowało się 14 z 14 credentiali.",[11,8690,8691,8692,2948,8695,8698],{},"Po tych przenosinach goniłem też fałszywy alarm. Błąd „Error in sub-node Postgres Chat Memory” wyglądał na skutek migracji, a wziął się z mojego własnego testu, który wysyłał pole ",[30,8693,8694],{},"sessionId",[30,8696,8697],{},"session_id",". Teraz, zanim uznam coś za skutek migracji, sprawdzam, jak to działało przed nią.",[23,8700,8702],{"className":4377,"code":8701,"language":4379,"meta":28,"style":28},"\u002F\u002F n8n Code node, tryb: Run Once for All Items\nconst crypto = require('crypto');\n\nconst CHUNK_SIZE = 1500; \u002F\u002F znaki\nconst OVERLAP = 200;\n\u002F\u002F Liczba dni od ostatniej modyfikacji, po której dokument przestaje być zwracany.\n\u002F\u002F null oznacza, że dany typ dokumentu nie wygasa.\nconst TTL_DAYS = { pricing: 90, changelog: 180, adr: null, general: null };\nconst NEVER = 4102444800; \u002F\u002F 2100-01-01 jako Unix timestamp\n\nfunction split(text) {\n  const chunks = [];\n  for (let start = 0; start \u003C text.length; start += CHUNK_SIZE - OVERLAP) {\n    chunks.push(text.slice(start, start + CHUNK_SIZE));\n    if (start + CHUNK_SIZE >= text.length) break;\n  }\n  return chunks;\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const { id: docId, name, modifiedTime, mimeType, text } = item.json;\n  if (typeof text !== 'string' || text.trim().length \u003C 50) continue;\n\n  const docType = item.json.docType ?? 'general';\n  const body = text.trim();\n  const version = crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);\n  const modifiedTs = Math.floor(Date.parse(modifiedTime) \u002F 1000);\n  const ttl = TTL_DAYS[docType] ?? null;\n  const expiresTs = ttl === null ? NEVER : modifiedTs + ttl * 86400;\n\n  split(body).forEach((chunk, i) => {\n    out.push({\n      json: {\n        id: `${docId}#${version}#${String(i).padStart(4, '0')}`,\n        docId,\n        version,\n        text: chunk,\n        metadata: { docId, version, docType, source: name, mimeType, modifiedTs, expiresTs, text: chunk },\n      },\n    });\n  });\n}\nreturn out;\n",[30,8703,8704,8709,8714,8718,8723,8728,8733,8738,8743,8748,8752,8757,8762,8767,8772,8777,8781,8786,8790,8794,8799,8804,8809,8814,8818,8823,8828,8833,8838,8843,8848,8852,8857,8862,8867,8872,8877,8882,8887,8892,8897,8902,8907,8911],{"__ignoreMap":28},[33,8705,8706],{"class":35,"line":36},[33,8707,8708],{},"\u002F\u002F n8n Code node, tryb: Run Once for All Items\n",[33,8710,8711],{"class":35,"line":42},[33,8712,8713],{},"const crypto = require('crypto');\n",[33,8715,8716],{"class":35,"line":48},[33,8717,52],{"emptyLinePlaceholder":51},[33,8719,8720],{"class":35,"line":55},[33,8721,8722],{},"const CHUNK_SIZE = 1500; \u002F\u002F znaki\n",[33,8724,8725],{"class":35,"line":61},[33,8726,8727],{},"const OVERLAP = 200;\n",[33,8729,8730],{"class":35,"line":67},[33,8731,8732],{},"\u002F\u002F Liczba dni od ostatniej modyfikacji, po której dokument przestaje być zwracany.\n",[33,8734,8735],{"class":35,"line":73},[33,8736,8737],{},"\u002F\u002F null oznacza, że dany typ dokumentu nie wygasa.\n",[33,8739,8740],{"class":35,"line":79},[33,8741,8742],{},"const TTL_DAYS = { pricing: 90, changelog: 180, adr: null, general: null };\n",[33,8744,8745],{"class":35,"line":85},[33,8746,8747],{},"const NEVER = 4102444800; \u002F\u002F 2100-01-01 jako Unix timestamp\n",[33,8749,8750],{"class":35,"line":90},[33,8751,52],{"emptyLinePlaceholder":51},[33,8753,8754],{"class":35,"line":96},[33,8755,8756],{},"function split(text) {\n",[33,8758,8759],{"class":35,"line":384},[33,8760,8761],{},"  const chunks = [];\n",[33,8763,8764],{"class":35,"line":390},[33,8765,8766],{},"  for (let start = 0; start \u003C text.length; start += CHUNK_SIZE - OVERLAP) {\n",[33,8768,8769],{"class":35,"line":395},[33,8770,8771],{},"    chunks.push(text.slice(start, start + CHUNK_SIZE));\n",[33,8773,8774],{"class":35,"line":401},[33,8775,8776],{},"    if (start + CHUNK_SIZE >= text.length) break;\n",[33,8778,8779],{"class":35,"line":407},[33,8780,4406],{},[33,8782,8783],{"class":35,"line":413},[33,8784,8785],{},"  return chunks;\n",[33,8787,8788],{"class":35,"line":419},[33,8789,82],{},[33,8791,8792],{"class":35,"line":1058},[33,8793,52],{"emptyLinePlaceholder":51},[33,8795,8796],{"class":35,"line":1064},[33,8797,8798],{},"const out = [];\n",[33,8800,8801],{"class":35,"line":1069},[33,8802,8803],{},"for (const item of $input.all()) {\n",[33,8805,8806],{"class":35,"line":1074},[33,8807,8808],{},"  const { id: docId, name, modifiedTime, mimeType, text } = item.json;\n",[33,8810,8811],{"class":35,"line":1080},[33,8812,8813],{},"  if (typeof text !== 'string' || text.trim().length \u003C 50) continue;\n",[33,8815,8816],{"class":35,"line":1086},[33,8817,52],{"emptyLinePlaceholder":51},[33,8819,8820],{"class":35,"line":1092},[33,8821,8822],{},"  const docType = item.json.docType ?? 'general';\n",[33,8824,8825],{"class":35,"line":1098},[33,8826,8827],{},"  const body = text.trim();\n",[33,8829,8830],{"class":35,"line":1103},[33,8831,8832],{},"  const version = crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);\n",[33,8834,8835],{"class":35,"line":1108},[33,8836,8837],{},"  const modifiedTs = Math.floor(Date.parse(modifiedTime) \u002F 1000);\n",[33,8839,8840],{"class":35,"line":1114},[33,8841,8842],{},"  const ttl = TTL_DAYS[docType] ?? null;\n",[33,8844,8845],{"class":35,"line":1120},[33,8846,8847],{},"  const expiresTs = ttl === null ? NEVER : modifiedTs + ttl * 86400;\n",[33,8849,8850],{"class":35,"line":1126},[33,8851,52],{"emptyLinePlaceholder":51},[33,8853,8854],{"class":35,"line":1132},[33,8855,8856],{},"  split(body).forEach((chunk, i) => {\n",[33,8858,8859],{"class":35,"line":1138},[33,8860,8861],{},"    out.push({\n",[33,8863,8864],{"class":35,"line":1143},[33,8865,8866],{},"      json: {\n",[33,8868,8869],{"class":35,"line":1149},[33,8870,8871],{},"        id: `${docId}#${version}#${String(i).padStart(4, '0')}`,\n",[33,8873,8874],{"class":35,"line":1155},[33,8875,8876],{},"        docId,\n",[33,8878,8879],{"class":35,"line":1161},[33,8880,8881],{},"        version,\n",[33,8883,8884],{"class":35,"line":1167},[33,8885,8886],{},"        text: chunk,\n",[33,8888,8889],{"class":35,"line":2535},[33,8890,8891],{},"        metadata: { docId, version, docType, source: name, mimeType, modifiedTs, expiresTs, text: chunk },\n",[33,8893,8894],{"class":35,"line":2547},[33,8895,8896],{},"      },\n",[33,8898,8899],{"class":35,"line":2555},[33,8900,8901],{},"    });\n",[33,8903,8904],{"class":35,"line":2566},[33,8905,8906],{},"  });\n",[33,8908,8909],{"class":35,"line":2577},[33,8910,82],{},[33,8912,8913],{"class":35,"line":2588},[33,8914,8915],{},"return out;\n",[11,8917,8918,8919,8922],{},"Hash w ID przydaje się niezależnie od tego, czy dokument się zmienił. Jeśli nie, ID chunków są identyczne i przepływ może całkiem pominąć embedding (przed wywołaniem modelu embeddingów sprawdź, czy istnieje jakiekolwiek ID z prefiksem ",[30,8920,8921],{},"docId#version#","). Jeśli się zmienił, nowe chunki współistnieją ze starymi tylko na czas przebiegu, a krok sprzątania dokładnie wie, które są stare.",[11,8924,8925],{},"Tekst chunka trafia też do metadanych, żeby wyniki zapytań dało się logować i audytować bez drugiego odczytu. Pinecone ogranicza rozmiar metadanych rekordu (w chwili pisania 40 KB), stąd ograniczony rozmiar chunka.",[15,8927,8929],{"id":8928},"podmiana-wersji","Podmiana wersji",[11,8931,8932,8933,8936,8937,8940,8941,8944,8945,8948,8949,8952],{},"Po udanym upsercie usuń poprzednie wersje każdego dokumentu. Przy indeksie serverless w Pinecone to dwa wywołania z węzła HTTP Request: listowanie ID rekordów po prefiksie (",[30,8934,8935],{},"GET \u002Fvectors\u002Flist?prefix=\u003CdocId>%23",", ze stronicowaniem) i kasowanie po ID (",[30,8938,8939],{},"POST \u002Fvectors\u002Fdelete"," z tablicą ",[30,8942,8943],{},"ids","). Listowanie po prefiksie działa tylko w indeksach serverless. Pinecone przyjmuje też delete po filtrze metadanych (na przykład ",[30,8946,8947],{},"docId"," równe dokumentowi i ",[30,8950,8951],{},"version"," różne od bieżącej), co usuwa krok listowania, ale ma znacznie niższy limit zapytań niż delete po ID. Logika wyboru jest w obu przypadkach ta sama:",[23,8954,8956],{"className":4377,"code":8955,"language":4379,"meta":28,"style":28},"\u002F\u002F n8n Code node: wybór ID nieaktualnych wersji\n\u002F\u002F Wejście: itemy z węzła \"List IDs by prefix\", każdy z polem { id }\n\u002F\u002F Bieżąca wersja każdego dokumentu pochodzi z węzła \"Prepare chunks\"\nconst current = new Map(\n  $('Prepare chunks').all().map(i => [i.json.docId, i.json.version])\n);\n\nconst stale = $input.all()\n  .map(i => i.json.id)\n  .filter(id => {\n    const [docId, version] = id.split('#');\n    return current.has(docId) && current.get(docId) !== version;\n  });\n\n\u002F\u002F Pinecone przyjmuje do 1000 ID w jednym wywołaniu delete\nconst batches = [];\nfor (let i = 0; i \u003C stale.length; i += 1000) {\n  batches.push({ json: { ids: stale.slice(i, i + 1000) } });\n}\nreturn batches;\n",[30,8957,8958,8963,8968,8973,8978,8983,8987,8991,8996,9001,9006,9011,9016,9020,9024,9029,9034,9039,9044,9048],{"__ignoreMap":28},[33,8959,8960],{"class":35,"line":36},[33,8961,8962],{},"\u002F\u002F n8n Code node: wybór ID nieaktualnych wersji\n",[33,8964,8965],{"class":35,"line":42},[33,8966,8967],{},"\u002F\u002F Wejście: itemy z węzła \"List IDs by prefix\", każdy z polem { id }\n",[33,8969,8970],{"class":35,"line":48},[33,8971,8972],{},"\u002F\u002F Bieżąca wersja każdego dokumentu pochodzi z węzła \"Prepare chunks\"\n",[33,8974,8975],{"class":35,"line":55},[33,8976,8977],{},"const current = new Map(\n",[33,8979,8980],{"class":35,"line":61},[33,8981,8982],{},"  $('Prepare chunks').all().map(i => [i.json.docId, i.json.version])\n",[33,8984,8985],{"class":35,"line":67},[33,8986,7607],{},[33,8988,8989],{"class":35,"line":73},[33,8990,52],{"emptyLinePlaceholder":51},[33,8992,8993],{"class":35,"line":79},[33,8994,8995],{},"const stale = $input.all()\n",[33,8997,8998],{"class":35,"line":85},[33,8999,9000],{},"  .map(i => i.json.id)\n",[33,9002,9003],{"class":35,"line":90},[33,9004,9005],{},"  .filter(id => {\n",[33,9007,9008],{"class":35,"line":96},[33,9009,9010],{},"    const [docId, version] = id.split('#');\n",[33,9012,9013],{"class":35,"line":384},[33,9014,9015],{},"    return current.has(docId) && current.get(docId) !== version;\n",[33,9017,9018],{"class":35,"line":390},[33,9019,8906],{},[33,9021,9022],{"class":35,"line":395},[33,9023,52],{"emptyLinePlaceholder":51},[33,9025,9026],{"class":35,"line":401},[33,9027,9028],{},"\u002F\u002F Pinecone przyjmuje do 1000 ID w jednym wywołaniu delete\n",[33,9030,9031],{"class":35,"line":407},[33,9032,9033],{},"const batches = [];\n",[33,9035,9036],{"class":35,"line":413},[33,9037,9038],{},"for (let i = 0; i \u003C stale.length; i += 1000) {\n",[33,9040,9041],{"class":35,"line":419},[33,9042,9043],{},"  batches.push({ json: { ids: stale.slice(i, i + 1000) } });\n",[33,9045,9046],{"class":35,"line":1058},[33,9047,82],{},[33,9049,9050],{"class":35,"line":1064},[33,9051,9052],{},"return batches;\n",[11,9054,9055],{},"Najpierw upsert, potem delete, i to celowo. Odwrotna kolejność zostawia okno, w którym dokument nie ma żadnych chunków, a nieudany upsert po udanym delete wycina dokument z wiedzy bota do następnego przebiegu.",[15,9057,9059],{"id":9058},"uzgadnianie-usunięć","Uzgadnianie usunięć",[11,9061,9062],{},"Osobny przepływ, uruchamiany z harmonogramu, porównuje ID dokumentów w indeksie z plikami w folderze źródłowym i kasuje sieroty.",[23,9064,9066],{"className":4377,"code":9065,"language":4379,"meta":28,"style":28},"\u002F\u002F n8n Code node: dokumenty usunięte u źródła\nconst live = new Set($('List Drive files').all().map(i => i.json.id));\nconst stored = new Set($('List index IDs').all().map(i => i.json.id.split('#')[0]));\n\n\u002F\u002F Zabezpieczenie: pusta albo niepełna lista z Drive (wygasłe credentials,\n\u002F\u002F zły folder, pominięte stronicowanie) skasowałaby cały indeks.\nif (live.size === 0 || live.size \u003C stored.size * 0.8) {\n  throw new Error(`Drive zwrócił ${live.size} plików przy ${stored.size} dokumentach w indeksie, przerywam`);\n}\n\nreturn [...stored]\n  .filter(docId => !live.has(docId))\n  .map(docId => ({ json: { docId } }));\n",[30,9067,9068,9073,9078,9083,9087,9092,9097,9102,9107,9111,9115,9120,9125],{"__ignoreMap":28},[33,9069,9070],{"class":35,"line":36},[33,9071,9072],{},"\u002F\u002F n8n Code node: dokumenty usunięte u źródła\n",[33,9074,9075],{"class":35,"line":42},[33,9076,9077],{},"const live = new Set($('List Drive files').all().map(i => i.json.id));\n",[33,9079,9080],{"class":35,"line":48},[33,9081,9082],{},"const stored = new Set($('List index IDs').all().map(i => i.json.id.split('#')[0]));\n",[33,9084,9085],{"class":35,"line":55},[33,9086,52],{"emptyLinePlaceholder":51},[33,9088,9089],{"class":35,"line":61},[33,9090,9091],{},"\u002F\u002F Zabezpieczenie: pusta albo niepełna lista z Drive (wygasłe credentials,\n",[33,9093,9094],{"class":35,"line":67},[33,9095,9096],{},"\u002F\u002F zły folder, pominięte stronicowanie) skasowałaby cały indeks.\n",[33,9098,9099],{"class":35,"line":73},[33,9100,9101],{},"if (live.size === 0 || live.size \u003C stored.size * 0.8) {\n",[33,9103,9104],{"class":35,"line":79},[33,9105,9106],{},"  throw new Error(`Drive zwrócił ${live.size} plików przy ${stored.size} dokumentach w indeksie, przerywam`);\n",[33,9108,9109],{"class":35,"line":85},[33,9110,82],{},[33,9112,9113],{"class":35,"line":90},[33,9114,52],{"emptyLinePlaceholder":51},[33,9116,9117],{"class":35,"line":96},[33,9118,9119],{},"return [...stored]\n",[33,9121,9122],{"class":35,"line":384},[33,9123,9124],{},"  .filter(docId => !live.has(docId))\n",[33,9126,9127],{"class":35,"line":390},[33,9128,9129],{},"  .map(docId => ({ json: { docId } }));\n",[11,9131,9132],{},"Listowanie z Drive musi pomijać pliki w koszu i obsługiwać stronicowanie. Próg 80% jest umowny. Dobierz go do tego, ile dokumentów zespół realnie usuwa między przebiegami.",[15,9134,9136],{"id":9135},"filtr-świeżości-przy-zapytaniu","Filtr świeżości przy zapytaniu",[11,9138,9139,9140,743,9143,743,9146,743,9149,9152,9153,9156],{},"Operatory zakresowe w metadanych Pinecone (",[30,9141,9142],{},"$gt",[30,9144,9145],{},"$gte",[30,9147,9148],{},"$lt",[30,9150,9151],{},"$lte",") działają tylko na liczbach. Daty w formacie ISO odpadają, dlatego chunk ma ",[30,9154,9155],{},"expiresTs"," jako Unix timestamp. Treść zapytania w węźle HTTP Request z włączonymi expressions:",[23,9158,9160],{"className":4377,"code":9159,"language":4379,"meta":28,"style":28},"{\n  \"vector\": {{ JSON.stringify($json.embedding) }},\n  \"topK\": 6,\n  \"includeMetadata\": true,\n  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n}\n",[30,9161,9162,9166,9171,9176,9181,9186],{"__ignoreMap":28},[33,9163,9164],{"class":35,"line":36},[33,9165,64],{},[33,9167,9168],{"class":35,"line":42},[33,9169,9170],{},"  \"vector\": {{ JSON.stringify($json.embedding) }},\n",[33,9172,9173],{"class":35,"line":48},[33,9174,9175],{},"  \"topK\": 6,\n",[33,9177,9178],{"class":35,"line":55},[33,9179,9180],{},"  \"includeMetadata\": true,\n",[33,9182,9183],{"class":35,"line":61},[33,9184,9185],{},"  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n",[33,9187,9188],{"class":35,"line":67},[33,9189,82],{},[11,9191,9192],{},"Gdy datę wygaśnięcia liczy się przy zasilaniu, polityka dla każdego typu dokumentu siedzi w jednym miejscu, a zapytanie zostaje proste. Cena za to jest taka, że poprawny cennik, którego nikt nie ruszał od 90 dni, też wypada. Przy danych zależnych od czasu lepiej, żeby system psuł się właśnie tak. Bot nie znajduje kontekstu i mówi, że nie wie, a to łatwiej wykryć niż złą cenę. Do TTL dodaj alert na zapytania bez wyników, żeby właściciel dokumentu go odświeżył albo potwierdził.",[15,9194,9196],{"id":9195},"logowanie-wyszukiwań","Logowanie wyszukiwań",[11,9198,9199],{},"Loguj każde wyszukiwanie (wszystkie, próbka tu nie wystarczy): zapytanie, zwrócone ID, wyniki podobieństwa, wersje dokumentów i daty modyfikacji. Każdą z opisanych wyżej awarii znajdziesz wtedy jednym zapytaniem do tego logu.",[11,9201,9202,9203,221],{},"Jak cicho potrafią znikać dane, przekonałem się na własnym produkcie, CookieDomain, systemie zgód cookies. Przez 5 dni każda zmiana zgody kończyła się błędem 500 i około 88 wpisów przepadło bez śladu, bo widget połykał błąd w ",[30,9204,9205],{},".catch",[23,9207,9209],{"className":4377,"code":9208,"language":4379,"meta":28,"style":28},"\u002F\u002F n8n Code node: ustrukturyzowany log wyszukiwania\nconst query = $('User query').first().json.text;\nconst matches = $('Pinecone query').first().json.matches ?? [];\nconst now = Date.now() \u002F 1000;\n\nreturn [{\n  json: {\n    ts: new Date().toISOString(),\n    query,\n    matches: matches.map(m => ({\n      id: m.id,\n      score: m.score,\n      docId: m.metadata.docId,\n      version: m.metadata.version,\n      ageDays: Math.round((now - m.metadata.modifiedTs) \u002F 86400),\n    })),\n    topScore: matches.length ? Math.max(...matches.map(m => m.score)) : null,\n    versionsPerDoc: Object.values(\n      matches.reduce((acc, m) => {\n        (acc[m.metadata.docId] ??= new Set()).add(m.metadata.version);\n        return acc;\n      }, {})\n    ).reduce((max, s) => Math.max(max, s.size), 0),\n  },\n}];\n",[30,9210,9211,9216,9221,9226,9231,9235,9240,9244,9249,9254,9259,9264,9269,9274,9279,9284,9289,9294,9299,9304,9309,9314,9319,9324,9328],{"__ignoreMap":28},[33,9212,9213],{"class":35,"line":36},[33,9214,9215],{},"\u002F\u002F n8n Code node: ustrukturyzowany log wyszukiwania\n",[33,9217,9218],{"class":35,"line":42},[33,9219,9220],{},"const query = $('User query').first().json.text;\n",[33,9222,9223],{"class":35,"line":48},[33,9224,9225],{},"const matches = $('Pinecone query').first().json.matches ?? [];\n",[33,9227,9228],{"class":35,"line":55},[33,9229,9230],{},"const now = Date.now() \u002F 1000;\n",[33,9232,9233],{"class":35,"line":61},[33,9234,52],{"emptyLinePlaceholder":51},[33,9236,9237],{"class":35,"line":67},[33,9238,9239],{},"return [{\n",[33,9241,9242],{"class":35,"line":73},[33,9243,8592],{},[33,9245,9246],{"class":35,"line":79},[33,9247,9248],{},"    ts: new Date().toISOString(),\n",[33,9250,9251],{"class":35,"line":85},[33,9252,9253],{},"    query,\n",[33,9255,9256],{"class":35,"line":90},[33,9257,9258],{},"    matches: matches.map(m => ({\n",[33,9260,9261],{"class":35,"line":96},[33,9262,9263],{},"      id: m.id,\n",[33,9265,9266],{"class":35,"line":384},[33,9267,9268],{},"      score: m.score,\n",[33,9270,9271],{"class":35,"line":390},[33,9272,9273],{},"      docId: m.metadata.docId,\n",[33,9275,9276],{"class":35,"line":395},[33,9277,9278],{},"      version: m.metadata.version,\n",[33,9280,9281],{"class":35,"line":401},[33,9282,9283],{},"      ageDays: Math.round((now - m.metadata.modifiedTs) \u002F 86400),\n",[33,9285,9286],{"class":35,"line":407},[33,9287,9288],{},"    })),\n",[33,9290,9291],{"class":35,"line":413},[33,9292,9293],{},"    topScore: matches.length ? Math.max(...matches.map(m => m.score)) : null,\n",[33,9295,9296],{"class":35,"line":419},[33,9297,9298],{},"    versionsPerDoc: Object.values(\n",[33,9300,9301],{"class":35,"line":1058},[33,9302,9303],{},"      matches.reduce((acc, m) => {\n",[33,9305,9306],{"class":35,"line":1064},[33,9307,9308],{},"        (acc[m.metadata.docId] ??= new Set()).add(m.metadata.version);\n",[33,9310,9311],{"class":35,"line":1069},[33,9312,9313],{},"        return acc;\n",[33,9315,9316],{"class":35,"line":1074},[33,9317,9318],{},"      }, {})\n",[33,9320,9321],{"class":35,"line":1080},[33,9322,9323],{},"    ).reduce((max, s) => Math.max(max, s.size), 0),\n",[33,9325,9326],{"class":35,"line":1086},[33,9327,8612],{},[33,9329,9330],{"class":35,"line":1092},[33,9331,9332],{},"}];\n",[11,9334,9335,9338,9339,9342],{},[30,9336,9337],{},"versionsPerDoc"," większe od 1 znaczy, że podmiana wersji nie zadziałała dla co najmniej jednego dokumentu. Stale niski ",[30,9340,9341],{},"topScore"," dla jakiejś grupy pytań znaczy, że baza wiedzy ich nie pokrywa. Progi podobieństwa zależą od modelu embeddingów i korpusu. Wyznacz je z rozkładu we własnym logu, bo liczba z cudzej konfiguracji się nie przeniesie.",[15,9344,9346],{"id":9345},"pgvector-jako-alternatywa","pgvector jako alternatywa",[11,9348,9349],{},"Jeśli baza wektorowa siedzi w Postgresie, podmiana wersji staje się transakcją, a sztuczki z prefiksem i hashem są zbędne:",[23,9351,9353],{"className":7564,"code":9352,"language":7566,"meta":28,"style":28},"BEGIN;\nDELETE FROM rag_chunks WHERE doc_id = $1;\nINSERT INTO rag_chunks (doc_id, version, chunk_no, body, embedding, modified_at, expires_at)\nVALUES ($1, $2, 0, $3, $4, $5, $6),\n       ($1, $2, 1, $7, $8, $5, $6);  -- jeden wiersz na chunk\nCOMMIT;\n",[30,9354,9355,9360,9365,9370,9375,9380],{"__ignoreMap":28},[33,9356,9357],{"class":35,"line":36},[33,9358,9359],{},"BEGIN;\n",[33,9361,9362],{"class":35,"line":42},[33,9363,9364],{},"DELETE FROM rag_chunks WHERE doc_id = $1;\n",[33,9366,9367],{"class":35,"line":48},[33,9368,9369],{},"INSERT INTO rag_chunks (doc_id, version, chunk_no, body, embedding, modified_at, expires_at)\n",[33,9371,9372],{"class":35,"line":55},[33,9373,9374],{},"VALUES ($1, $2, 0, $3, $4, $5, $6),\n",[33,9376,9377],{"class":35,"line":61},[33,9378,9379],{},"       ($1, $2, 1, $7, $8, $5, $6);  -- jeden wiersz na chunk\n",[33,9381,9382],{"class":35,"line":67},[33,9383,9384],{},"COMMIT;\n",[11,9386,9387],{},"Odczyt widzi starą wersję albo nową. Nie ma chwili, w której są obie albo żadna. Uzgadnianie sprowadza się do anti-joina z tabelą aktywnych ID dokumentów. Za to bazę i indeksy wektorowe utrzymujesz i stroisz sam.",[15,9389,9391],{"id":9390},"kiedy-to-zbędne","Kiedy to zbędne",[11,9393,9394],{},"Korpus wgrany raz i nieedytowany, na przykład zamknięty zestaw instrukcji do wydanej wersji produktu, nie potrzebuje wersjonowania ani uzgadniania. Korpus, który mieści się w oknie kontekstu modelu, nie potrzebuje bazy wektorowej w ogóle. Opisane mechanizmy są potrzebne tam, gdzie dokumenty edytują, przemianowują i archiwizują ludzie, którzy o żadnym indeksie nie myślą. Na współdzielonym dysku dotyczy to większości plików.",[11,9396,9397],{},"Przy przeglądzie istniejącego pipeline'u większość problemów wychodzi po jednym pytaniu: jeśli ktoś w tej chwili zmieni dokument, co stanie się z chunkami poprzedniej wersji i który węzeł za to odpowiada?",[896,9399,898],{},{"title":28,"searchDepth":42,"depth":42,"links":9401},[9402,9403,9404,9405,9406,9407,9408,9409,9410],{"id":8548,"depth":42,"text":8549},{"id":8623,"depth":42,"text":8624},{"id":8647,"depth":42,"text":8648},{"id":8928,"depth":42,"text":8929},{"id":9058,"depth":42,"text":9059},{"id":9135,"depth":42,"text":9136},{"id":9195,"depth":42,"text":9196},{"id":9345,"depth":42,"text":9346},{"id":9390,"depth":42,"text":9391},"2026-05-22",{},"\u002Fpl\u002Farticles\u002Fn8n-rag-data-quality",{"x":9415,"y":9416,"depth":8523,"size":5866},0.4,0.6,[937,6531],{"title":8536,"description":8542},"n8n-rag-pipeline","pl\u002Farticles\u002Fn8n-rag-data-quality",[9422,9423,9424,9425,9426],"n8n","rag","vector-db","pinecone","data-quality","v2.0.0","PvXoXRQHoXgEulTV5htKVjvTYq7E74JqnWC_N1mPh6k",{"id":4,"title":5,"articleId":6,"body":9430,"category":27,"codeLang":27,"date":913,"deploys":61,"description":13,"excerpt":914,"extension":915,"lang":916,"meta":10048,"navigation":51,"path":918,"pos":10049,"readMin":79,"related":10050,"seo":10051,"service":926,"stem":927,"tags":10052,"updated":914,"version":931,"__hash__":932},{"type":8,"value":9431,"toc":10034},[9432,9434,9436,9438,9486,9488,9536,9538,9544,9546,9548,9550,9554,9556,9558,9560,9562,9566,9570,9578,9580,9584,9586,9588,9604,9620,9622,9624,9700,9708,9714,9770,9774,9778,9786,9792,9794,9800,9802,9814,9882,9884,9886,9892,10026,10028,10030,10032],[11,9433,13],{},[15,9435,18],{"id":17},[11,9437,21],{},[23,9439,9440],{"className":25,"code":26,"language":27,"meta":28,"style":28},[30,9441,9442,9446,9450,9454,9458,9462,9466,9470,9474,9478,9482],{"__ignoreMap":28},[33,9443,9444],{"class":35,"line":36},[33,9445,39],{},[33,9447,9448],{"class":35,"line":42},[33,9449,45],{},[33,9451,9452],{"class":35,"line":48},[33,9453,52],{"emptyLinePlaceholder":51},[33,9455,9456],{"class":35,"line":55},[33,9457,58],{},[33,9459,9460],{"class":35,"line":61},[33,9461,64],{},[33,9463,9464],{"class":35,"line":67},[33,9465,70],{},[33,9467,9468],{"class":35,"line":73},[33,9469,76],{},[33,9471,9472],{"class":35,"line":79},[33,9473,82],{},[33,9475,9476],{"class":35,"line":85},[33,9477,52],{"emptyLinePlaceholder":51},[33,9479,9480],{"class":35,"line":90},[33,9481,93],{},[33,9483,9484],{"class":35,"line":96},[33,9485,99],{},[11,9487,102],{},[23,9489,9490],{"className":105,"code":106,"language":107,"meta":28,"style":28},[30,9491,9492,9496,9500,9504,9508,9512,9516,9520,9524,9528,9532],{"__ignoreMap":28},[33,9493,9494],{"class":35,"line":36},[33,9495,114],{},[33,9497,9498],{"class":35,"line":42},[33,9499,119],{},[33,9501,9502],{"class":35,"line":48},[33,9503,52],{"emptyLinePlaceholder":51},[33,9505,9506],{"class":35,"line":55},[33,9507,128],{},[33,9509,9510],{"class":35,"line":61},[33,9511,133],{},[33,9513,9514],{"class":35,"line":67},[33,9515,52],{"emptyLinePlaceholder":51},[33,9517,9518],{"class":35,"line":73},[33,9519,142],{},[33,9521,9522],{"class":35,"line":79},[33,9523,147],{},[33,9525,9526],{"class":35,"line":85},[33,9527,152],{},[33,9529,9530],{"class":35,"line":90},[33,9531,157],{},[33,9533,9534],{"class":35,"line":96},[33,9535,162],{},[11,9537,165],{},[11,9539,168,9540,172,9542,176],{},[30,9541,171],{},[30,9543,175],{},[15,9545,180],{"id":179},[11,9547,183],{},[11,9549,186],{},[11,9551,189,9552,193],{},[30,9553,192],{},[195,9555,198],{"id":197},[11,9557,201],{},[11,9559,204],{},[195,9561,208],{"id":207},[11,9563,211,9564,215],{},[30,9565,214],{},[11,9567,218,9568,221],{},[30,9569,192],{},[11,9571,224,9572,228,9574,232,9576,236],{},[30,9573,227],{},[30,9575,231],{},[30,9577,235],{},[15,9579,240],{"id":239},[11,9581,243,9582,247],{},[30,9583,246],{},[11,9585,250],{},[195,9587,254],{"id":253},[11,9589,257,9590,261,9592,265,9594,269,9596,273,9598,277,9600,281,9602,285],{},[30,9591,260],{},[30,9593,264],{},[30,9595,268],{},[30,9597,272],{},[30,9599,276],{},[30,9601,280],{},[30,9603,284],{},[11,9605,288,9606,292,9608,296,9610,300,9612,303,9614,307,9616,311,9618,315],{},[30,9607,291],{},[30,9609,295],{},[30,9611,299],{},[30,9613,284],{},[30,9615,306],{},[30,9617,310],{},[30,9619,314],{},[15,9621,319],{"id":318},[11,9623,322],{},[23,9625,9626],{"className":105,"code":325,"language":107,"meta":28,"style":28},[30,9627,9628,9632,9636,9640,9644,9648,9652,9656,9660,9664,9668,9672,9676,9680,9684,9688,9692,9696],{"__ignoreMap":28},[33,9629,9630],{"class":35,"line":36},[33,9631,332],{},[33,9633,9634],{"class":35,"line":42},[33,9635,337],{},[33,9637,9638],{"class":35,"line":48},[33,9639,342],{},[33,9641,9642],{"class":35,"line":55},[33,9643,347],{},[33,9645,9646],{"class":35,"line":61},[33,9647,52],{"emptyLinePlaceholder":51},[33,9649,9650],{"class":35,"line":67},[33,9651,356],{},[33,9653,9654],{"class":35,"line":73},[33,9655,361],{},[33,9657,9658],{"class":35,"line":79},[33,9659,366],{},[33,9661,9662],{"class":35,"line":85},[33,9663,371],{},[33,9665,9666],{"class":35,"line":90},[33,9667,376],{},[33,9669,9670],{"class":35,"line":96},[33,9671,381],{},[33,9673,9674],{"class":35,"line":384},[33,9675,387],{},[33,9677,9678],{"class":35,"line":390},[33,9679,52],{"emptyLinePlaceholder":51},[33,9681,9682],{"class":35,"line":395},[33,9683,398],{},[33,9685,9686],{"class":35,"line":401},[33,9687,404],{},[33,9689,9690],{"class":35,"line":407},[33,9691,410],{},[33,9693,9694],{"class":35,"line":413},[33,9695,416],{},[33,9697,9698],{"class":35,"line":419},[33,9699,422],{},[11,9701,425,9702,429,9704,433,9706,436],{},[30,9703,428],{},[30,9705,432],{},[30,9707,428],{},[11,9709,439,9710,442,9712,446],{},[30,9711,171],{},[30,9713,445],{},[23,9715,9716],{"className":105,"code":449,"language":107,"meta":28,"style":28},[30,9717,9718,9722,9726,9730,9734,9738,9742,9746,9750,9754,9758,9762,9766],{"__ignoreMap":28},[33,9719,9720],{"class":35,"line":36},[33,9721,456],{},[33,9723,9724],{"class":35,"line":42},[33,9725,461],{},[33,9727,9728],{"class":35,"line":48},[33,9729,52],{"emptyLinePlaceholder":51},[33,9731,9732],{"class":35,"line":55},[33,9733,470],{},[33,9735,9736],{"class":35,"line":61},[33,9737,371],{},[33,9739,9740],{"class":35,"line":67},[33,9741,479],{},[33,9743,9744],{"class":35,"line":73},[33,9745,484],{},[33,9747,9748],{"class":35,"line":79},[33,9749,489],{},[33,9751,9752],{"class":35,"line":85},[33,9753,494],{},[33,9755,9756],{"class":35,"line":90},[33,9757,52],{"emptyLinePlaceholder":51},[33,9759,9760],{"class":35,"line":96},[33,9761,398],{},[33,9763,9764],{"class":35,"line":384},[33,9765,507],{},[33,9767,9768],{"class":35,"line":390},[33,9769,512],{},[11,9771,515,9772,519],{},[30,9773,518],{},[11,9775,522,9776,526],{},[30,9777,525],{},[11,9779,529,9780,533,9782,537,9784,541],{},[30,9781,532],{},[30,9783,536],{},[30,9785,540],{},[11,9787,544,9788,548,9790,552],{},[30,9789,547],{},[30,9791,551],{},[15,9793,556],{"id":555},[11,9795,559,9796,563,9798,221],{},[30,9797,562],{},[30,9799,566],{},[11,9801,569],{},[11,9803,572,9804,576,9806,580,9808,584,9810,588,9812,221],{},[30,9805,575],{},[30,9807,579],{},[30,9809,583],{},[30,9811,587],{},[30,9813,591],{},[23,9815,9816],{"className":25,"code":594,"language":27,"meta":28,"style":28},[30,9817,9818,9822,9826,9830,9834,9838,9842,9846,9850,9854,9858,9862,9866,9870,9874,9878],{"__ignoreMap":28},[33,9819,9820],{"class":35,"line":36},[33,9821,601],{},[33,9823,9824],{"class":35,"line":42},[33,9825,606],{},[33,9827,9828],{"class":35,"line":48},[33,9829,611],{},[33,9831,9832],{"class":35,"line":55},[33,9833,64],{},[33,9835,9836],{"class":35,"line":61},[33,9837,620],{},[33,9839,9840],{"class":35,"line":67},[33,9841,82],{},[33,9843,9844],{"class":35,"line":73},[33,9845,52],{"emptyLinePlaceholder":51},[33,9847,9848],{"class":35,"line":79},[33,9849,633],{},[33,9851,9852],{"class":35,"line":85},[33,9853,638],{},[33,9855,9856],{"class":35,"line":90},[33,9857,643],{},[33,9859,9860],{"class":35,"line":96},[33,9861,82],{},[33,9863,9864],{"class":35,"line":384},[33,9865,52],{"emptyLinePlaceholder":51},[33,9867,9868],{"class":35,"line":390},[33,9869,656],{},[33,9871,9872],{"class":35,"line":395},[33,9873,661],{},[33,9875,9876],{"class":35,"line":401},[33,9877,666],{},[33,9879,9880],{"class":35,"line":407},[33,9881,671],{},[11,9883,674],{},[15,9885,678],{"id":677},[11,9887,681,9888,685,9890,689],{},[30,9889,684],{},[30,9891,688],{},[691,9893,9894,9906],{},[694,9895,9896],{},[697,9897,9898,9900,9902,9904],{},[700,9899],{},[700,9901,704],{},[700,9903,707],{},[700,9905,710],{},[712,9907,9908,9932,9948,9972,9984,9994,10014],{},[697,9909,9910,9912,9916,9924],{},[717,9911,719],{},[717,9913,9914,725],{},[30,9915,724],{},[717,9917,9918,731,9920,734,9922],{},[30,9919,730],{},[30,9921,306],{},[30,9923,737],{},[717,9925,9926,743,9928,743,9930],{},[30,9927,742],{},[30,9929,746],{},[30,9931,749],{},[697,9933,9934,9936,9940,9944],{},[717,9935,754],{},[717,9937,9938],{},[30,9939,759],{},[717,9941,9942],{},[30,9943,764],{},[717,9945,9946,769],{},[30,9947,764],{},[697,9949,9950,9952,9958,9966],{},[717,9951,774],{},[717,9953,777,9954,780,9956,784],{},[30,9955,235],{},[30,9957,783],{},[717,9959,9960,790,9962,793,9964,796],{},[30,9961,789],{},[30,9963,551],{},[30,9965,291],{},[717,9967,9968,743,9970,805],{},[30,9969,801],{},[30,9971,804],{},[697,9973,9974,9976,9980,9982],{},[717,9975,810],{},[717,9977,813,9978,817],{},[30,9979,816],{},[717,9981,820],{},[717,9983,823],{},[697,9985,9986,9988,9990,9992],{},[717,9987,828],{},[717,9989,831],{},[717,9991,834],{},[717,9993,837],{},[697,9995,9996,9998,10002,10008],{},[717,9997,842],{},[717,9999,10000,847],{},[30,10001,192],{},[717,10003,850,10004,853,10006,857],{},[30,10005,525],{},[30,10007,856],{},[717,10009,860,10010,863,10012],{},[30,10011,525],{},[30,10013,866],{},[697,10015,10016,10018,10020,10022],{},[717,10017,871],{},[717,10019,874],{},[717,10021,877],{},[717,10023,880,10024,884],{},[30,10025,883],{},[15,10027,888],{"id":887},[11,10029,891],{},[11,10031,894],{},[896,10033,898],{},{"title":28,"searchDepth":42,"depth":42,"links":10035},[10036,10037,10041,10044,10045,10046,10047],{"id":17,"depth":42,"text":18},{"id":179,"depth":42,"text":180,"children":10038},[10039,10040],{"id":197,"depth":48,"text":198},{"id":207,"depth":48,"text":208},{"id":239,"depth":42,"text":240,"children":10042},[10043],{"id":253,"depth":48,"text":254},{"id":318,"depth":42,"text":319},{"id":555,"depth":42,"text":556},{"id":677,"depth":42,"text":678},{"id":887,"depth":42,"text":888},{},{"x":920,"y":921,"depth":36,"size":915},[923,924],{"title":5,"description":13},[27,107,724,929,930],{"id":10054,"title":10055,"articleId":924,"body":10056,"category":3339,"codeLang":27,"date":10591,"deploys":36,"description":10592,"excerpt":914,"extension":915,"lang":916,"meta":10593,"navigation":51,"path":10594,"pos":10595,"readMin":61,"related":10598,"seo":10599,"service":10600,"stem":10601,"tags":10602,"updated":5101,"version":10608,"__hash__":10609},"articles_pl\u002Fpl\u002Farticles\u002Fphp-references.md","Referencje w PHP: co naprawdę robi & i gdzie psuje kod",{"type":8,"value":10057,"toc":10583},[10058,10065,10069,10076,10100,10148,10159,10163,10219,10240,10243,10265,10268,10301,10305,10308,10376,10400,10403,10407,10410,10448,10457,10461,10464,10533,10542,10546,10578,10581],[11,10059,10060,10061,10064],{},"Dokumentacja PHP mówi, że referencje to nie wskaźniki, i odradza zwracanie wartości przez referencję dla wydajności. Oba zalecenia są prawdziwe i łatwo o nich zapomnieć. Poniżej opisuję, co ",[30,10062,10063],{},"&"," robi w silniku i gdzie psuje kod.",[15,10066,10068],{"id":10067},"wartości-copy-on-write-i-referencje","Wartości, copy-on-write i referencje",[11,10070,10071,10072,10075],{},"Domyślnie PHP przypisuje i przekazuje wartości. Tablice i stringi mają licznik referencji (refcount), więc ",[30,10073,10074],{},"$b = $a"," niczego nie kopiuje: obie zmienne wskazują tę samą wartość, a licznik rośnie. Kopia (separacja) powstaje dopiero wtedy, gdy coś zapisuje do jednej ze zmiennych, a licznik jest większy od jednego, i właśnie to nazywa się copy-on-write.",[11,10077,10078,10079,10082,10083,10085,10086,10088,10089,10092,10093,10096,10097,221],{},"Referencja działa inaczej. ",[30,10080,10081],{},"$b = &$a"," opakowuje wartość w kontener referencji, który obie nazwy współdzielą. Zapis przez dowolną z nich zmienia wartość, którą widzą obie, i między ",[30,10084,8071],{}," a ",[30,10087,8180],{}," separacji nie ma. Jeśli jednak sama wartość jest współdzielona z inną zmienną, pierwszy zapis i tak zrobi kopię: po ",[30,10090,10091],{},"$x = [1]; $a = $x; $b = &$a; $b[] = 2;"," w ",[30,10094,10095],{},"$x"," dalej jest ",[30,10098,10099],{},"[1]",[23,10101,10103],{"className":25,"code":10102,"language":27,"meta":28,"style":28},"$a = 'original';\n$b = $a;           \u002F\u002F bez kopii, obie nazwy współdzielą jedną wartość\n$b = 'modified';   \u002F\u002F $b dostaje własną wartość\nvar_dump($a);      \u002F\u002F string(8) \"original\"\n\n$a = 'original';\n$b = &$a;          \u002F\u002F $a i $b to teraz jedna zmienna o dwóch nazwach\n$b = 'modified';\nvar_dump($a);      \u002F\u002F string(8) \"modified\"\n",[30,10104,10105,10110,10115,10120,10125,10129,10133,10138,10143],{"__ignoreMap":28},[33,10106,10107],{"class":35,"line":36},[33,10108,10109],{},"$a = 'original';\n",[33,10111,10112],{"class":35,"line":42},[33,10113,10114],{},"$b = $a;           \u002F\u002F bez kopii, obie nazwy współdzielą jedną wartość\n",[33,10116,10117],{"class":35,"line":48},[33,10118,10119],{},"$b = 'modified';   \u002F\u002F $b dostaje własną wartość\n",[33,10121,10122],{"class":35,"line":55},[33,10123,10124],{},"var_dump($a);      \u002F\u002F string(8) \"original\"\n",[33,10126,10127],{"class":35,"line":61},[33,10128,52],{"emptyLinePlaceholder":51},[33,10130,10131],{"class":35,"line":67},[33,10132,10109],{},[33,10134,10135],{"class":35,"line":73},[33,10136,10137],{},"$b = &$a;          \u002F\u002F $a i $b to teraz jedna zmienna o dwóch nazwach\n",[33,10139,10140],{"class":35,"line":79},[33,10141,10142],{},"$b = 'modified';\n",[33,10144,10145],{"class":35,"line":85},[33,10146,10147],{},"var_dump($a);      \u002F\u002F string(8) \"modified\"\n",[11,10149,10150,10151,10153,10154,10156,10157,221],{},"Po przypisaniu nic w kodzie nie oznacza ",[30,10152,8180],{}," jako referencji. Żeby wiedzieć, jak zachowa się zapis do ",[30,10155,8180],{},", trzeba znaleźć linię, w której użyto ",[30,10158,10063],{},[15,10160,10162],{"id":10161},"błąd-1-referencja-zostawiona-przez-foreach","Błąd 1: referencja zostawiona przez foreach",[23,10164,10166],{"className":25,"code":10165,"language":27,"meta":28,"style":28},"$prices = [100, 200, 300, 400, 500];\n\nforeach ($prices as &$price) {\n    $price = $price * 0.9;\n}\n\u002F\u002F $prices to teraz [90, 180, 270, 360, 450]\n\nforeach ($prices as $price) {\n    echo $price, ' ';\n}\n\u002F\u002F Wynik: 90 180 270 360 360\n",[30,10167,10168,10173,10177,10182,10187,10191,10196,10200,10205,10210,10214],{"__ignoreMap":28},[33,10169,10170],{"class":35,"line":36},[33,10171,10172],{},"$prices = [100, 200, 300, 400, 500];\n",[33,10174,10175],{"class":35,"line":42},[33,10176,52],{"emptyLinePlaceholder":51},[33,10178,10179],{"class":35,"line":48},[33,10180,10181],{},"foreach ($prices as &$price) {\n",[33,10183,10184],{"class":35,"line":55},[33,10185,10186],{},"    $price = $price * 0.9;\n",[33,10188,10189],{"class":35,"line":61},[33,10190,82],{},[33,10192,10193],{"class":35,"line":67},[33,10194,10195],{},"\u002F\u002F $prices to teraz [90, 180, 270, 360, 450]\n",[33,10197,10198],{"class":35,"line":73},[33,10199,52],{"emptyLinePlaceholder":51},[33,10201,10202],{"class":35,"line":79},[33,10203,10204],{},"foreach ($prices as $price) {\n",[33,10206,10207],{"class":35,"line":85},[33,10208,10209],{},"    echo $price, ' ';\n",[33,10211,10212],{"class":35,"line":90},[33,10213,82],{},[33,10215,10216],{"class":35,"line":96},[33,10217,10218],{},"\u002F\u002F Wynik: 90 180 270 360 360\n",[11,10220,10221,10222,10225,10226,10229,10230,10232,10233,10235,10236,10239],{},"Po pierwszej pętli ",[30,10223,10224],{},"$price"," nadal jest referencją do ",[30,10227,10228],{},"$prices[4]",". Druga pętla przypisuje każdy element do ",[30,10231,10224],{},", czyli w praktyce zapisuje go pod ",[30,10234,10228],{},". W czwartej iteracji ląduje tam 360, więc piąta odczytuje 360 zamiast 450. W PHP 8 dzieje się to samo. Zmiana semantyki ",[30,10237,10238],{},"foreach"," w PHP 7 tego nie usunęła.",[11,10241,10242],{},"Najmniejsza poprawka to zerwanie referencji zaraz po pętli:",[23,10244,10246],{"className":25,"code":10245,"language":27,"meta":28,"style":28},"foreach ($prices as &$price) {\n    $price = $price * 0.9;\n}\nunset($price); \u002F\u002F usuwa nazwę $price; $prices[4] zachowuje wartość\n",[30,10247,10248,10252,10256,10260],{"__ignoreMap":28},[33,10249,10250],{"class":35,"line":36},[33,10251,10181],{},[33,10253,10254],{"class":35,"line":42},[33,10255,10186],{},[33,10257,10258],{"class":35,"line":48},[33,10259,82],{},[33,10261,10262],{"class":35,"line":55},[33,10263,10264],{},"unset($price); \u002F\u002F usuwa nazwę $price; $prices[4] zachowuje wartość\n",[11,10266,10267],{},"Lepiej w ogóle obejść się bez referencji:",[23,10269,10271],{"className":25,"code":10270,"language":27,"meta":28,"style":28},"foreach ($prices as $i => $price) {\n    $prices[$i] = $price * 0.9;\n}\n\n\u002F\u002F albo, jeśli nowa tablica nie przeszkadza\n$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);\n",[30,10272,10273,10278,10283,10287,10291,10296],{"__ignoreMap":28},[33,10274,10275],{"class":35,"line":36},[33,10276,10277],{},"foreach ($prices as $i => $price) {\n",[33,10279,10280],{"class":35,"line":42},[33,10281,10282],{},"    $prices[$i] = $price * 0.9;\n",[33,10284,10285],{"class":35,"line":48},[33,10286,82],{},[33,10288,10289],{"class":35,"line":55},[33,10290,52],{"emptyLinePlaceholder":51},[33,10292,10293],{"class":35,"line":61},[33,10294,10295],{},"\u002F\u002F albo, jeśli nowa tablica nie przeszkadza\n",[33,10297,10298],{"class":35,"line":67},[33,10299,10300],{},"$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);\n",[15,10302,10304],{"id":10303},"błąd-2-parametr-przez-referencję-zmienia-kontrakt-funkcji","Błąd 2: parametr przez referencję zmienia kontrakt funkcji",[11,10306,10307],{},"Przykład: funkcja normalizująca dostaje duże tablice, więc ktoś przestawia ją na parametr przez referencję, „żeby nie kopiować”.",[23,10309,10311],{"className":25,"code":10310,"language":27,"meta":28,"style":28},"\u002F\u002F Przed: czysta funkcja, tablica wołającego zostaje nietknięta\nfunction normaliseProduct(array $product): array\n{\n    $product['title'] = trim(strtolower($product['title']));\n    $product['price'] = round($product['price'], 2);\n    return $product;\n}\n\n\u002F\u002F Po: zmienia argument i nic nie zwraca\nfunction normaliseProduct(array &$product): void\n{\n    $product['title'] = trim(strtolower($product['title']));\n    $product['price'] = round($product['price'], 2);\n}\n",[30,10312,10313,10318,10323,10327,10332,10337,10342,10346,10350,10355,10360,10364,10368,10372],{"__ignoreMap":28},[33,10314,10315],{"class":35,"line":36},[33,10316,10317],{},"\u002F\u002F Przed: czysta funkcja, tablica wołającego zostaje nietknięta\n",[33,10319,10320],{"class":35,"line":42},[33,10321,10322],{},"function normaliseProduct(array $product): array\n",[33,10324,10325],{"class":35,"line":48},[33,10326,64],{},[33,10328,10329],{"class":35,"line":55},[33,10330,10331],{},"    $product['title'] = trim(strtolower($product['title']));\n",[33,10333,10334],{"class":35,"line":61},[33,10335,10336],{},"    $product['price'] = round($product['price'], 2);\n",[33,10338,10339],{"class":35,"line":67},[33,10340,10341],{},"    return $product;\n",[33,10343,10344],{"class":35,"line":73},[33,10345,82],{},[33,10347,10348],{"class":35,"line":79},[33,10349,52],{"emptyLinePlaceholder":51},[33,10351,10352],{"class":35,"line":85},[33,10353,10354],{},"\u002F\u002F Po: zmienia argument i nic nie zwraca\n",[33,10356,10357],{"class":35,"line":90},[33,10358,10359],{},"function normaliseProduct(array &$product): void\n",[33,10361,10362],{"class":35,"line":96},[33,10363,64],{},[33,10365,10366],{"class":35,"line":384},[33,10367,10331],{},[33,10369,10370],{"class":35,"line":390},[33,10371,10336],{},[33,10373,10374],{"class":35,"line":395},[33,10375,82],{},[11,10377,10378,10379,10382,10383,3296,10385,10388,10389,10392,10393,743,10396,10399],{},"Każde istniejące wywołanie w postaci ",[30,10380,10381],{},"$normalised = normaliseProduct($product)"," zapisuje teraz ",[30,10384,5566],{},[30,10386,10387],{},"$product"," zmienia się jako efekt uboczny. Samo PHP nie zgłasza użycia wyniku funkcji ",[30,10390,10391],{},"void",". Wyłapie to PHPStan (od poziomu 0) albo Psalm (na errorLevel od 1 do 7, czyli na każdym poziomie poza najluźniejszym 8), jeśli działają w CI. A jeśli funkcja ma zmieniać argument, powinna to mówić jej nazwa (",[30,10394,10395],{},"sortInPlace",[30,10397,10398],{},"applyDefaults",").",[11,10401,10402],{},"Do tego optymalizacja daje mniej, niż się wydaje. Wersja przez wartość kopiuje tablicę raz, przy zapisie pierwszego klucza, i jest to kopia płytka: zagnieżdżone tablice zostają współdzielone, dopóki ktoś do nich nie zapisze. Wersja przez referencję unika tej kopii tylko wtedy, gdy tablica wołającego nie jest współdzielona. Jeśli jest współdzielona z inną zmienną (refcount większy od jednego), pierwszy zapis wewnątrz funkcji i tak ją rozdzieli.",[15,10404,10406],{"id":10405},"błąd-3-referencje-przeżywają-w-tablicach","Błąd 3: referencje przeżywają w tablicach",[11,10408,10409],{},"Referencja do elementu tablicy zostaje przypięta do tego elementu. Przy kopiowaniu tablicy referencja idzie razem z nią, więc element dalej jest współdzielony.",[23,10411,10413],{"className":25,"code":10412,"language":27,"meta":28,"style":28},"$a = [1, 2];\n$ref = &$a[0];\n\n$b = $a;        \u002F\u002F wygląda na kopię\n$b[0] = 99;\n\nvar_dump($a[0]); \u002F\u002F int(99)\n",[30,10414,10415,10420,10425,10429,10434,10439,10443],{"__ignoreMap":28},[33,10416,10417],{"class":35,"line":36},[33,10418,10419],{},"$a = [1, 2];\n",[33,10421,10422],{"class":35,"line":42},[33,10423,10424],{},"$ref = &$a[0];\n",[33,10426,10427],{"class":35,"line":48},[33,10428,52],{"emptyLinePlaceholder":51},[33,10430,10431],{"class":35,"line":55},[33,10432,10433],{},"$b = $a;        \u002F\u002F wygląda na kopię\n",[33,10435,10436],{"class":35,"line":61},[33,10437,10438],{},"$b[0] = 99;\n",[33,10440,10441],{"class":35,"line":67},[33,10442,52],{"emptyLinePlaceholder":51},[33,10444,10445],{"class":35,"line":73},[33,10446,10447],{},"var_dump($a[0]); \u002F\u002F int(99)\n",[11,10449,10450,10451,10453,10454,10456],{},"Dokumentacja opisuje to zachowanie. Z trzech opisanych tu błędów ten najtrudniej zauważyć, bo ",[30,10452,10063],{}," może stać daleko od miejsca kopii. Może zostać na przykład po ",[30,10455,10238],{}," przez referencję, kiedy nikt nie zwolnił zmiennej pętli.",[15,10458,10460],{"id":10459},"obiekty-nie-są-przekazywane-przez-referencję","Obiekty nie są przekazywane przez referencję",[11,10462,10463],{},"Obiekty przekazuje się przez wartość, a tą wartością jest uchwyt obiektu (handle). Przez uchwyt funkcja może zmienić stan obiektu. Przypisanie nowego obiektu do parametru zmienia tylko lokalną kopię uchwytu.",[23,10465,10467],{"className":25,"code":10466,"language":27,"meta":28,"style":28},"final class Counter\n{\n    public int $count = 0;\n}\n\nfunction increment(Counter $counter): void\n{\n    $counter->count++;       \u002F\u002F widoczne dla wołającego\n    $counter = new Counter(); \u002F\u002F niewidoczne dla wołającego\n}\n\n$c = new Counter();\nincrement($c);\nvar_dump($c->count); \u002F\u002F int(1)\n",[30,10468,10469,10474,10478,10483,10487,10491,10496,10500,10505,10510,10514,10518,10523,10528],{"__ignoreMap":28},[33,10470,10471],{"class":35,"line":36},[33,10472,10473],{},"final class Counter\n",[33,10475,10476],{"class":35,"line":42},[33,10477,64],{},[33,10479,10480],{"class":35,"line":48},[33,10481,10482],{},"    public int $count = 0;\n",[33,10484,10485],{"class":35,"line":55},[33,10486,82],{},[33,10488,10489],{"class":35,"line":61},[33,10490,52],{"emptyLinePlaceholder":51},[33,10492,10493],{"class":35,"line":67},[33,10494,10495],{},"function increment(Counter $counter): void\n",[33,10497,10498],{"class":35,"line":73},[33,10499,64],{},[33,10501,10502],{"class":35,"line":79},[33,10503,10504],{},"    $counter->count++;       \u002F\u002F widoczne dla wołającego\n",[33,10506,10507],{"class":35,"line":85},[33,10508,10509],{},"    $counter = new Counter(); \u002F\u002F niewidoczne dla wołającego\n",[33,10511,10512],{"class":35,"line":90},[33,10513,82],{},[33,10515,10516],{"class":35,"line":96},[33,10517,52],{"emptyLinePlaceholder":51},[33,10519,10520],{"class":35,"line":384},[33,10521,10522],{},"$c = new Counter();\n",[33,10524,10525],{"class":35,"line":390},[33,10526,10527],{},"increment($c);\n",[33,10529,10530],{"class":35,"line":395},[33,10531,10532],{},"var_dump($c->count); \u002F\u002F int(1)\n",[11,10534,10535,10537,10538,10541],{},[30,10536,10063],{}," przy parametrze obiektowym (",[30,10539,10540],{},"Counter &$counter",") jest potrzebne tylko wtedy, gdy funkcja ma podmienić obiekt wołającego. Zwykle lepiej zwrócić nowy obiekt.",[15,10543,10545],{"id":10544},"kiedy-referencja-ma-uzasadnienie","Kiedy referencja ma uzasadnienie",[2848,10547,10548,10561,10568],{},[2851,10549,10550,10551,743,10554,743,10557,10560],{},"Parametry wyjściowe funkcji wbudowanych: ",[30,10552,10553],{},"preg_match($pattern, $subject, $matches)",[30,10555,10556],{},"sort($array)",[30,10558,10559],{},"array_push()"," i inne przyjmują argument przez referencję z założenia.",[2851,10562,10563,10564,10567],{},"Modyfikacja w miejscu dużej zagnieżdżonej struktury, na przykład rekurencyjne przejście po drzewie tablic, które aktualizuje liście. Przekazywanie przez wartość rozdziela każdy modyfikowany poziom po drodze i wymaga, żeby wołający przypisał wynik z powrotem. Przed decyzją zmierz ",[30,10565,10566],{},"memory_get_peak_usage()",", bo przy danych wielkości typowego żądania HTTP różnica zwykle wychodzi pomijalna.",[2851,10569,10570,10571,10574,10575,221],{},"Budowanie zagnieżdżonej struktury przez referencję do bieżącego węzła, częste przy zamianie płaskiej listy z ",[30,10572,10573],{},"parent_id"," na drzewo. Trzymaj to w jednej funkcji i zwolnij referencję przed ",[30,10576,10577],{},"return",[11,10579,10580],{},"W kodzie aplikacyjnym, który operuje na obiektach, ten sam efekt da się zwykle uzyskać bez referencji: przekazać obiekt i zmienić jego stan albo zwrócić nową wartość.",[896,10582,898],{},{"title":28,"searchDepth":42,"depth":42,"links":10584},[10585,10586,10587,10588,10589,10590],{"id":10067,"depth":42,"text":10068},{"id":10161,"depth":42,"text":10162},{"id":10303,"depth":42,"text":10304},{"id":10405,"depth":42,"text":10406},{"id":10459,"depth":42,"text":10460},{"id":10544,"depth":42,"text":10545},"2023-09-30","Dokumentacja PHP mówi, że referencje to nie wskaźniki, i odradza zwracanie wartości przez referencję dla wydajności. Oba zalecenia są prawdziwe i łatwo o nich zapomnieć. Poniżej opisuję, co & robi w silniku i gdzie psuje kod.",{},"\u002Fpl\u002Farticles\u002Fphp-references",{"x":10596,"y":10597,"depth":3345,"size":915},0.12,0.5,[923,4286],{"title":10055,"description":10592},"memory-management","pl\u002Farticles\u002Fphp-references",[27,10603,10604,10605,10606,10607],"memory","debugging","references","performance","footguns","v2.1.1","Mvc85j6PFpqD3obCwHR-H4n45xVdVfcMzL_iR70cCYc",{"id":10611,"title":10612,"articleId":1774,"body":10613,"category":3339,"codeLang":7566,"date":11396,"deploys":79,"description":11397,"excerpt":914,"extension":915,"lang":916,"meta":11398,"navigation":51,"path":11399,"pos":11400,"readMin":73,"related":11403,"seo":11405,"service":11406,"stem":11407,"tags":11408,"updated":5101,"version":11412,"__hash__":11413},"articles_pl\u002Fpl\u002Farticles\u002Fpostgres-edge.md","Postgres z więcej niż jednym writerem: klucze główne i ich migracja",{"type":8,"value":10614,"toc":11391},[10615,10625,10629,10632,10635,10646,10652,10656,10662,10687,10690,10696,10706,10761,10797,10800,10811,10815,10829,10835,10855,10862,10872,10957,10976,10985,11019,11034,11046,11141,11166,11175,11185,11295,11306,11330,11333,11348,11363,11389],[11,10616,10617,10618,1847,10621,10624],{},"Kolumna ",[30,10619,10620],{},"bigserial",[30,10622,10623],{},"identity"," bierze wartości z sekwencji. Sekwencja jest lokalna dla jednej instancji Postgresa, więc daje unikalne wartości tylko wtedy, gdy każdy insert przechodzi przez tę instancję. Przy jednym primary tak jest, więc sama unikalność nie jest powodem do zmiany typu klucza. To się zmienia, gdy zapisy przyjmuje więcej niż jeden węzeł albo gdy identyfikatory powstają poza bazą.",[15,10626,10628],{"id":10627},"kiedy-typ-klucza-ma-znaczenie","Kiedy typ klucza ma znaczenie",[11,10630,10631],{},"Zmiana typu klucza nie skraca czasu zapisu przy jednym primary. Klient z innego regionu i tak wysyła całą transakcję do primary. Wywołanie sekwencji to pomijalna część tej drogi.",[11,10633,10634],{},"Typ klucza ma znaczenie, gdy:",[2848,10636,10637,10640,10643],{},[2851,10638,10639],{},"kilka węzłów przyjmuje zapisy do tej samej tabeli (dwukierunkowa replikacja logiczna, rozszerzenia multi-master, sharding po regionach),",[2851,10641,10642],{},"identyfikator musi istnieć, zanim wiersz trafi do bazy (klienci offline, klucze idempotencji generowane po stronie klienta, zdarzenia publikowane przed insertem),",[2851,10644,10645],{},"dane z kilku niezależnych baz są scalane w jednym miejscu.",[11,10647,10648,10649,221],{},"W każdym z tych przypadków dwa węzły bez koordynacji mogą wygenerować ten sam ",[30,10650,10651],{},"bigint",[15,10653,10655],{"id":10654},"opcje","Opcje",[11,10657,10658,10661],{},[5129,10659,10660],{},"Sekwencja per węzeł."," Każdy węzeł ma własną sekwencję z innym startem i wspólnym krokiem:",[23,10663,10665],{"className":7564,"code":10664,"language":7566,"meta":28,"style":28},"-- węzeł 1\ncreate sequence orders_id_seq start with 1 increment by 16;\n-- węzeł 2\ncreate sequence orders_id_seq start with 2 increment by 16;\n",[30,10666,10667,10672,10677,10682],{"__ignoreMap":28},[33,10668,10669],{"class":35,"line":36},[33,10670,10671],{},"-- węzeł 1\n",[33,10673,10674],{"class":35,"line":42},[33,10675,10676],{},"create sequence orders_id_seq start with 1 increment by 16;\n",[33,10678,10679],{"class":35,"line":48},[33,10680,10681],{},"-- węzeł 2\n",[33,10683,10684],{"class":35,"line":55},[33,10685,10686],{},"create sequence orders_id_seq start with 2 increment by 16;\n",[11,10688,10689],{},"Klucze zostają 8-bajtowymi liczbami, istniejące klucze obce działają bez zmian. Koszt jest operacyjny: krok ogranicza liczbę węzłów, każdy nowy węzeł potrzebuje wolnego offsetu, a źle skonfigurowany węzeł generuje kolizje, które wychodzą dopiero przy replikacji.",[11,10691,10692,10695],{},[5129,10693,10694],{},"UUIDv4."," Unikalny bez koordynacji i losowy. Każdy insert trafia na losową stronę liścia w indeksie B-tree. Roboczym zbiorem staje się więc cały indeks, podziały stron rozkładają się po całym drzewie, a po checkpoincie więcej stron trafia do WAL w całości. Póki indeks mieści się w pamięci, zwykle da się z tym żyć. Gdy przestaje, każdy insert czyta i zapisuje wyraźnie więcej stron.",[11,10697,10698,10701,10702,10705],{},[5129,10699,10700],{},"UUIDv7"," (RFC 9562). Pierwsze 48 bitów to znacznik czasu Unix w milisekundach. Poza bitami wersji i wariantu reszta jest losowa (Postgres 18 zużywa 12 z tych bitów na ułamek milisekundy). Wartości wygenerowane w podobnym czasie leżą w indeksie blisko siebie, więc inserty trafiają głównie na skrajne prawe strony, jak przy sekwencji. Postgres 18 ma wbudowaną funkcję ",[30,10703,10704],{},"uuidv7()",". Na Postgresie 13–17 można ją napisać w SQL:",[23,10707,10709],{"className":7564,"code":10708,"language":7566,"meta":28,"style":28},"create function uuidv7_at(ts timestamptz) returns uuid\nlanguage sql volatile as $$\n  select encode(\n    set_bit(set_bit(\n      overlay(uuid_send(gen_random_uuid())\n              placing substring(int8send(floor(extract(epoch from ts) * 1000)::bigint) from 3)\n              from 1 for 6),\n      52, 1), 53, 1),\n    'hex')::uuid;\n$$;\n",[30,10710,10711,10716,10721,10726,10731,10736,10741,10746,10751,10756],{"__ignoreMap":28},[33,10712,10713],{"class":35,"line":36},[33,10714,10715],{},"create function uuidv7_at(ts timestamptz) returns uuid\n",[33,10717,10718],{"class":35,"line":42},[33,10719,10720],{},"language sql volatile as $$\n",[33,10722,10723],{"class":35,"line":48},[33,10724,10725],{},"  select encode(\n",[33,10727,10728],{"class":35,"line":55},[33,10729,10730],{},"    set_bit(set_bit(\n",[33,10732,10733],{"class":35,"line":61},[33,10734,10735],{},"      overlay(uuid_send(gen_random_uuid())\n",[33,10737,10738],{"class":35,"line":67},[33,10739,10740],{},"              placing substring(int8send(floor(extract(epoch from ts) * 1000)::bigint) from 3)\n",[33,10742,10743],{"class":35,"line":73},[33,10744,10745],{},"              from 1 for 6),\n",[33,10747,10748],{"class":35,"line":79},[33,10749,10750],{},"      52, 1), 53, 1),\n",[33,10752,10753],{"class":35,"line":85},[33,10754,10755],{},"    'hex')::uuid;\n",[33,10757,10758],{"class":35,"line":90},[33,10759,10760],{},"$$;\n",[11,10762,10763,10764,10767,10768,10771,10772,10774,10775,10778,10779,10781,10782,10785,10786,10789,10790,10792,10793,10796],{},"Funkcja bierze losowy UUID v4, nadpisuje pierwsze 6 bajtów znacznikiem czasu w milisekundach i zmienia bity wersji z 4 na 7. Dzięki parametrowi ",[30,10765,10766],{},"ts"," nadaje się też do uzupełniania starych wierszy na podstawie ",[30,10769,10770],{},"created_at",". Na Postgresie 18 UUID ze znacznikiem równym ",[30,10773,10770],{}," z dokładnością do mikrosekund daje ",[30,10776,10777],{},"uuidv7((extract(epoch from created_at) - extract(epoch from clock_timestamp())) * interval '1 second')",", bo ",[30,10780,10704],{}," przyjmuje interwał przesuwający znacznik czasu. Przez te mikrosekundy zapisana milisekunda czasem przeskakuje o jedną. Krótsze ",[30,10783,10784],{},"uuidv7(created_at - clock_timestamp())"," jest bezpieczne tylko w strefie bez zmiany czasu, np. w UTC: różnica dwóch ",[30,10787,10788],{},"timestamptz"," zawiera dni, które ",[30,10791,10704],{}," dodaje w strefie czasowej sesji, i przy zmianie czasu w ",[30,10794,10795],{},"Europe\u002FWarsaw"," znacznik wychodził przesunięty o godzinę.",[11,10798,10799],{},"Dla nowych tabel zapisywanych z kilku węzłów wybrałbym UUIDv7, godząc się na koszty:",[2848,10801,10802,10805,10808],{},[2851,10803,10804],{},"16 bajtów zamiast 8 w kluczu głównym oraz w każdym kluczu obcym i indeksie, który go zawiera,",[2851,10806,10807],{},"identyfikator zdradza czas utworzenia wiersza, co ma znaczenie, jeśli ID pojawia się w publicznych URL-ach,",[2851,10809,10810],{},"sortowanie po ID jest chronologiczne tylko w przybliżeniu między węzłami, bo zależy od ich zegarów.",[15,10812,10814],{"id":10813},"migracja-istniejącej-tabeli-expand-backfill-swap","Migracja istniejącej tabeli: expand, backfill, swap",[11,10816,10817,10818,10821,10822,10824,10825,10828],{},"Weźmy tabelę ",[30,10819,10820],{},"orders"," z kluczem ",[30,10823,10651],{}," i tabelę ",[30,10826,10827],{},"order_items",", która się do niej odwołuje. Chcemy dojść do klucza głównego UUIDv7, nie blokując żadnej z nich na dłużej niż kilka sekund.",[11,10830,10831,10834],{},[5129,10832,10833],{},"1. Kolumna i domyślna wartość dla nowych wierszy."," Dodanie kolumny dopuszczającej null, bez wartości domyślnej, zmienia tylko katalog. Ustawienie domyślnej wartości później dotyczy wyłącznie nowych wierszy.",[23,10836,10838],{"className":7564,"code":10837,"language":7566,"meta":28,"style":28},"alter table orders add column id_v2 uuid;\nalter table orders alter column id_v2 set default uuidv7();            -- Postgres 18\n-- alter table orders alter column id_v2 set default uuidv7_at(clock_timestamp());  -- Postgres 13–17\n",[30,10839,10840,10845,10850],{"__ignoreMap":28},[33,10841,10842],{"class":35,"line":36},[33,10843,10844],{},"alter table orders add column id_v2 uuid;\n",[33,10846,10847],{"class":35,"line":42},[33,10848,10849],{},"alter table orders alter column id_v2 set default uuidv7();            -- Postgres 18\n",[33,10851,10852],{"class":35,"line":48},[33,10853,10854],{},"-- alter table orders alter column id_v2 set default uuidv7_at(clock_timestamp());  -- Postgres 13–17\n",[11,10856,10857,10858,10861],{},"Przy kilku węzłach nowe kolumny (tu i w kroku 4) trzeba dodać na wszystkich, zanim którykolwiek zacznie je wypełniać. Zapis do tabeli na węźle, który ma już nową kolumnę, zatrzymuje replikację do węzłów, które jej jeszcze nie mają, nawet gdy kolumna jest pusta. Replikacja rusza sama, gdy kolumna pojawi się na pozostałych węzłach. Wartość domyślną ustaw dopiero wtedy, gdy pusta kolumna jest już na wszystkich węzłach. W odwrotnej kolejności wiersz wstawiony na węźle bez kolumny dostaje na węźle z wartością domyślną własny UUID, a na swoim węźle, po dodaniu kolumny, ma NULL. Backfill puść wtedy na węźle z NULL-ami: jego UUID dojdzie replikacją do drugiego węzła i zastąpi tamten. Na węźle, który ma już wartości, pętla z ",[30,10859,10860],{},"id_v2 is null"," niczego nie zmieni i NULL-e zostaną (sprawdzone na Postgresie 18.6).",[11,10863,10864,10867,10868,10871],{},[5129,10865,10866],{},"2. Backfill w paczkach."," Jeden ",[30,10869,10870],{},"update"," na całej tabeli trzyma blokady wierszy przez cały czas trwania i zostawia naraz ogromną liczbę martwych krotek. Paczki z commitem po każdej z nich skracają blokady i dają autovacuum czas na sprzątanie między paczkami:",[23,10873,10875],{"className":7564,"code":10874,"language":7566,"meta":28,"style":28},"do $$\ndeclare\n  batch_size constant bigint := 10000;\n  from_id bigint := 0;\n  max_id bigint;\nbegin\n  select max(id) into max_id from orders;\n  while from_id \u003C= max_id loop\n    update orders\n       set id_v2 = uuidv7_at(created_at)\n     where id >= from_id and id \u003C from_id + batch_size\n       and id_v2 is null;\n    commit;\n    from_id := from_id + batch_size;\n  end loop;\nend $$;\n",[30,10876,10877,10882,10887,10892,10897,10902,10907,10912,10917,10922,10927,10932,10937,10942,10947,10952],{"__ignoreMap":28},[33,10878,10879],{"class":35,"line":36},[33,10880,10881],{},"do $$\n",[33,10883,10884],{"class":35,"line":42},[33,10885,10886],{},"declare\n",[33,10888,10889],{"class":35,"line":48},[33,10890,10891],{},"  batch_size constant bigint := 10000;\n",[33,10893,10894],{"class":35,"line":55},[33,10895,10896],{},"  from_id bigint := 0;\n",[33,10898,10899],{"class":35,"line":61},[33,10900,10901],{},"  max_id bigint;\n",[33,10903,10904],{"class":35,"line":67},[33,10905,10906],{},"begin\n",[33,10908,10909],{"class":35,"line":73},[33,10910,10911],{},"  select max(id) into max_id from orders;\n",[33,10913,10914],{"class":35,"line":79},[33,10915,10916],{},"  while from_id \u003C= max_id loop\n",[33,10918,10919],{"class":35,"line":85},[33,10920,10921],{},"    update orders\n",[33,10923,10924],{"class":35,"line":90},[33,10925,10926],{},"       set id_v2 = uuidv7_at(created_at)\n",[33,10928,10929],{"class":35,"line":96},[33,10930,10931],{},"     where id >= from_id and id \u003C from_id + batch_size\n",[33,10933,10934],{"class":35,"line":384},[33,10935,10936],{},"       and id_v2 is null;\n",[33,10938,10939],{"class":35,"line":390},[33,10940,10941],{},"    commit;\n",[33,10943,10944],{"class":35,"line":395},[33,10945,10946],{},"    from_id := from_id + batch_size;\n",[33,10948,10949],{"class":35,"line":401},[33,10950,10951],{},"  end loop;\n",[33,10953,10954],{"class":35,"line":407},[33,10955,10956],{},"end $$;\n",[11,10958,10959,10960,10963,10964,10967,10968,10971,10972,10975],{},"Pętla woła ",[30,10961,10962],{},"uuidv7_at",", więc na Postgresie 18 też trzeba tę funkcję utworzyć albo wpisać zamiast niej podane wyżej wyrażenie ",[30,10965,10966],{},"uuidv7(...)"," liczone od epoki. ",[30,10969,10970],{},"commit"," w bloku ",[30,10973,10974],{},"do"," działa, jeśli blok nie jest uruchomiony wewnątrz jawnej transakcji. Na produkcji wygodniej puścić tę samą pętlę ze skryptu, który zrobi przerwę, gdy rośnie opóźnienie replikacji.",[11,10977,10978],{},[5129,10979,10980,10981,10984],{},"3. Indeks i ",[30,10982,10983],{},"not null"," bez długiej blokady.",[23,10986,10988],{"className":7564,"code":10987,"language":7566,"meta":28,"style":28},"create unique index concurrently orders_id_v2_uq on orders (id_v2);\n\nalter table orders add constraint orders_id_v2_nn check (id_v2 is not null) not valid;\nalter table orders validate constraint orders_id_v2_nn;\nalter table orders alter column id_v2 set not null;   -- korzysta ze zwalidowanego checka, bez pełnego skanu\nalter table orders drop constraint orders_id_v2_nn;\n",[30,10989,10990,10995,10999,11004,11009,11014],{"__ignoreMap":28},[33,10991,10992],{"class":35,"line":36},[33,10993,10994],{},"create unique index concurrently orders_id_v2_uq on orders (id_v2);\n",[33,10996,10997],{"class":35,"line":42},[33,10998,52],{"emptyLinePlaceholder":51},[33,11000,11001],{"class":35,"line":48},[33,11002,11003],{},"alter table orders add constraint orders_id_v2_nn check (id_v2 is not null) not valid;\n",[33,11005,11006],{"class":35,"line":55},[33,11007,11008],{},"alter table orders validate constraint orders_id_v2_nn;\n",[33,11010,11011],{"class":35,"line":61},[33,11012,11013],{},"alter table orders alter column id_v2 set not null;   -- korzysta ze zwalidowanego checka, bez pełnego skanu\n",[33,11015,11016],{"class":35,"line":67},[33,11017,11018],{},"alter table orders drop constraint orders_id_v2_nn;\n",[11,11020,11021,11022,11025,11026,11029,11030,11033],{},"Od Postgresa 12 ",[30,11023,11024],{},"set not null"," pomija skan tabeli, jeśli warunek gwarantuje już zwalidowane ograniczenie ",[30,11027,11028],{},"check",". ",[30,11031,11032],{},"validate constraint"," bierze blokadę, która nie wstrzymuje odczytów ani zapisów.",[11,11035,11036,11039,11040,11043,11044,3036],{},[5129,11037,11038],{},"4. To samo dla tabel podrzędnych."," Dodaj ",[30,11041,11042],{},"order_id_v2",", nowe wiersze uzupełniaj triggerem, stare uzupełnij w paczkach przez złączenie z ",[30,11045,10820],{},[23,11047,11049],{"className":7564,"code":11048,"language":7566,"meta":28,"style":28},"alter table order_items add column order_id_v2 uuid;\n\ncreate function order_items_sync_order_id_v2() returns trigger\nlanguage plpgsql as $$\nbegin\n  select o.id_v2 into new.order_id_v2 from orders o where o.id = new.order_id;\n  return new;\nend $$;\n\ncreate trigger order_items_sync_order_id_v2\n  before insert or update of order_id on order_items\n  for each row execute function order_items_sync_order_id_v2();\n\nupdate order_items i\n   set order_id_v2 = o.id_v2\n  from orders o\n where o.id = i.order_id\n   and i.order_id_v2 is null\n   and i.id >= 0 and i.id \u003C 10000;   -- powtarzane dla każdej paczki\n",[30,11050,11051,11056,11060,11065,11070,11074,11079,11084,11088,11092,11097,11102,11107,11111,11116,11121,11126,11131,11136],{"__ignoreMap":28},[33,11052,11053],{"class":35,"line":36},[33,11054,11055],{},"alter table order_items add column order_id_v2 uuid;\n",[33,11057,11058],{"class":35,"line":42},[33,11059,52],{"emptyLinePlaceholder":51},[33,11061,11062],{"class":35,"line":48},[33,11063,11064],{},"create function order_items_sync_order_id_v2() returns trigger\n",[33,11066,11067],{"class":35,"line":55},[33,11068,11069],{},"language plpgsql as $$\n",[33,11071,11072],{"class":35,"line":61},[33,11073,10906],{},[33,11075,11076],{"class":35,"line":67},[33,11077,11078],{},"  select o.id_v2 into new.order_id_v2 from orders o where o.id = new.order_id;\n",[33,11080,11081],{"class":35,"line":73},[33,11082,11083],{},"  return new;\n",[33,11085,11086],{"class":35,"line":79},[33,11087,10956],{},[33,11089,11090],{"class":35,"line":85},[33,11091,52],{"emptyLinePlaceholder":51},[33,11093,11094],{"class":35,"line":90},[33,11095,11096],{},"create trigger order_items_sync_order_id_v2\n",[33,11098,11099],{"class":35,"line":96},[33,11100,11101],{},"  before insert or update of order_id on order_items\n",[33,11103,11104],{"class":35,"line":384},[33,11105,11106],{},"  for each row execute function order_items_sync_order_id_v2();\n",[33,11108,11109],{"class":35,"line":390},[33,11110,52],{"emptyLinePlaceholder":51},[33,11112,11113],{"class":35,"line":395},[33,11114,11115],{},"update order_items i\n",[33,11117,11118],{"class":35,"line":401},[33,11119,11120],{},"   set order_id_v2 = o.id_v2\n",[33,11122,11123],{"class":35,"line":407},[33,11124,11125],{},"  from orders o\n",[33,11127,11128],{"class":35,"line":413},[33,11129,11130],{}," where o.id = i.order_id\n",[33,11132,11133],{"class":35,"line":419},[33,11134,11135],{},"   and i.order_id_v2 is null\n",[33,11137,11138],{"class":35,"line":1058},[33,11139,11140],{},"   and i.id >= 0 and i.id \u003C 10000;   -- powtarzane dla każdej paczki\n",[11,11142,11143,11144,11147,11148,11150,11151,11153,11154,11157,11158,10092,11161,11163,11164,3036],{},"Potem ta sama sekwencja ",[30,11145,11146],{},"not valid"," \u002F ",[30,11149,2891],{}," dla ",[30,11152,10983],{}," na ",[30,11155,11156],{},"order_items.order_id_v2"," i indeks pod przyszły klucz obcy. Klucz obcy sam indeksu nie tworzy, a bez niego ",[30,11159,11160],{},"delete",[30,11162,10820],{}," (i update samego klucza) oraz złączenia szukające pozycji danego zamówienia skanują całe ",[30,11165,10827],{},[23,11167,11169],{"className":7564,"code":11168,"language":7566,"meta":28,"style":28},"create index concurrently order_items_order_id_v2_idx on order_items (order_id_v2);\n",[30,11170,11171],{"__ignoreMap":28},[33,11172,11173],{"class":35,"line":36},[33,11174,11168],{},[11,11176,11177,11180,11181,11184],{},[5129,11178,11179],{},"5. Podmiana w jednej krótkiej transakcji."," Jeśli coś nadal wyszukuje wiersze po starym numerycznym ID, przed tym krokiem załóż współbieżnie unikalny indeks na ",[30,11182,11183],{},"orders (id)",", bo usunięcie klucza głównego usuwa stary indeks.",[23,11186,11188],{"className":7564,"code":11187,"language":7566,"meta":28,"style":28},"begin;\nset local lock_timeout = '5s';\n\ndrop trigger order_items_sync_order_id_v2 on order_items;\nalter table order_items drop constraint order_items_order_id_fkey;\n\nalter table orders drop constraint orders_pkey;\nalter table orders add constraint orders_pkey primary key using index orders_id_v2_uq;\n\nalter table orders rename column id to legacy_id;\nalter table orders rename column id_v2 to id;\nalter table order_items rename column order_id to legacy_order_id;\nalter table order_items rename column order_id_v2 to order_id;\nalter table order_items alter column legacy_order_id drop not null;  -- nowe wiersze już jej nie wypełniają\n-- jeśli stare id nie miało wartości domyślnej (bez bigserial\u002Fidentity), także:\n-- alter table orders alter column legacy_id drop not null;\n\nalter table order_items add constraint order_items_order_id_fkey\n  foreign key (order_id) references orders (id) not valid;\ncommit;\n\nalter table order_items validate constraint order_items_order_id_fkey;\n",[30,11189,11190,11195,11200,11204,11209,11214,11218,11223,11228,11232,11237,11242,11247,11252,11257,11262,11267,11271,11276,11281,11286,11290],{"__ignoreMap":28},[33,11191,11192],{"class":35,"line":36},[33,11193,11194],{},"begin;\n",[33,11196,11197],{"class":35,"line":42},[33,11198,11199],{},"set local lock_timeout = '5s';\n",[33,11201,11202],{"class":35,"line":48},[33,11203,52],{"emptyLinePlaceholder":51},[33,11205,11206],{"class":35,"line":55},[33,11207,11208],{},"drop trigger order_items_sync_order_id_v2 on order_items;\n",[33,11210,11211],{"class":35,"line":61},[33,11212,11213],{},"alter table order_items drop constraint order_items_order_id_fkey;\n",[33,11215,11216],{"class":35,"line":67},[33,11217,52],{"emptyLinePlaceholder":51},[33,11219,11220],{"class":35,"line":73},[33,11221,11222],{},"alter table orders drop constraint orders_pkey;\n",[33,11224,11225],{"class":35,"line":79},[33,11226,11227],{},"alter table orders add constraint orders_pkey primary key using index orders_id_v2_uq;\n",[33,11229,11230],{"class":35,"line":85},[33,11231,52],{"emptyLinePlaceholder":51},[33,11233,11234],{"class":35,"line":90},[33,11235,11236],{},"alter table orders rename column id to legacy_id;\n",[33,11238,11239],{"class":35,"line":96},[33,11240,11241],{},"alter table orders rename column id_v2 to id;\n",[33,11243,11244],{"class":35,"line":384},[33,11245,11246],{},"alter table order_items rename column order_id to legacy_order_id;\n",[33,11248,11249],{"class":35,"line":390},[33,11250,11251],{},"alter table order_items rename column order_id_v2 to order_id;\n",[33,11253,11254],{"class":35,"line":395},[33,11255,11256],{},"alter table order_items alter column legacy_order_id drop not null;  -- nowe wiersze już jej nie wypełniają\n",[33,11258,11259],{"class":35,"line":401},[33,11260,11261],{},"-- jeśli stare id nie miało wartości domyślnej (bez bigserial\u002Fidentity), także:\n",[33,11263,11264],{"class":35,"line":407},[33,11265,11266],{},"-- alter table orders alter column legacy_id drop not null;\n",[33,11268,11269],{"class":35,"line":413},[33,11270,52],{"emptyLinePlaceholder":51},[33,11272,11273],{"class":35,"line":419},[33,11274,11275],{},"alter table order_items add constraint order_items_order_id_fkey\n",[33,11277,11278],{"class":35,"line":1058},[33,11279,11280],{},"  foreign key (order_id) references orders (id) not valid;\n",[33,11282,11283],{"class":35,"line":1064},[33,11284,11285],{},"commit;\n",[33,11287,11288],{"class":35,"line":1069},[33,11289,52],{"emptyLinePlaceholder":51},[33,11291,11292],{"class":35,"line":1074},[33,11293,11294],{},"alter table order_items validate constraint order_items_order_id_fkey;\n",[11,11296,11297,11298,11301,11302,11305],{},"Każda instrukcja w tej transakcji zmienia tylko katalog, więc blokada ",[30,11299,11300],{},"access exclusive"," trwa krótko. ",[30,11303,11304],{},"lock_timeout"," ogranicza czekanie: jeśli w ciągu 5 s nie dostanie blokady, transakcja się wycofa, zamiast stać w kolejce za długim zapytaniem. Limit dotyczy każdej blokady osobno, a transakcja bierze je po kolei, więc czekanie może się złożyć z kilku odcinków po 5 s. Przez ten czas zapytania ustawione za nią i tak czekają. Trigger jest usuwany w tej samej transakcji, bo jego treść odwołuje się do kolumn, którym zmieniamy nazwy.",[11,11307,11308,11309,11312,11313,11316,11317,11320,11321,11323,11324,1182,11327,221],{},"Po zmianie nazw ",[30,11310,11311],{},"orders.id"," jest typu ",[30,11314,11315],{},"uuid",". Zmiana nazw psuje więc kod, który używa ",[30,11318,11319],{},"id_v2"," albo traktuje ",[30,11322,8654],{}," jak liczbę. Wydanie znające nowe nazwy musi wyjść razem z krokiem 5. Do chwili wdrożenia stary kod, który używa tych identyfikatorów, dostaje błędy, także przy odczytach, więc na te kilka sekund wstrzymaj ruch do tych tabel albo przyjmij błędy. Drugie wydanie, sprzątające, po podmianie usuwa ",[30,11325,11326],{},"legacy_id",[30,11328,11329],{},"legacy_order_id",[11,11331,11332],{},"Przed krokiem 1 spisz wszystko, co przechowuje stare ID: tabele, widoki, widoki zmaterializowane, zapytania raportowe, systemy zewnętrzne. Tabele podrzędne bywają większe od nadrzędnej, a ich nowa kolumna musi skopiować UUID rodzica, dlatego ich backfill idzie przez złączenie z kroku 4. W trakcie backfillu trzeba pilnować opóźnienia replikacji i autovacuum.",[11,11334,11335,11336,1182,11339,11342,11343,1847,11345,11347],{},"Przy kilku węzłach z replikacją logiczną zmiany schematu (DDL) się nie replikują, więc każdy ",[30,11337,11338],{},"alter",[30,11340,11341],{},"create"," trzeba wykonać na każdym węźle osobno. Najtrudniejsza jest podmiana z kroku 5. Replikacja logiczna dopasowuje kolumny po nazwie, więc między podmianą na pierwszym węźle a podmianą na ostatnim, gdy tylko przyjdzie zmiana w ",[30,11344,10820],{},[30,11346,10827],{},", staje z błędem „missing replicated column” cała subskrypcja, która je przenosi, razem z każdą inną tabelą w niej. Na slocie wydawcy rośnie w tym czasie WAL. Na czas podmiany trzeba zatrzymać zapisy do tych tabel na wszystkich węzłach, poczekać, aż replikacja dogoni (zero opóźnienia na każdej subskrypcji), i dopiero wtedy podmienić wszędzie. Zapisy wznawia się po podmianie na ostatnim węźle. Samo wyłączenie subskrypcji nie wystarczy: zmiany zapisane przed podmianą przyjdą ze starymi nazwami kolumn.",[11,11349,11350,11351,11353,11354,11356,11357,11359,11360,11362],{},"Backfill to zwykłe zapisy. Puszcza się go na jednym węźle, a do reszty dociera replikacją: ",[30,11352,10962],{}," ma losową część i dwa węzły nadałyby temu samemu wierszowi różne UUID. Replikacja wysyła cały wiersz, więc jeśli inny węzeł zmieni ten sam wiersz, zanim dotrze do niego backfill, węzły się rozjadą bez żadnego błędu: w teście na 18.6 na jednym węźle uzupełnione ",[30,11355,11319],{}," wróciło do NULL, a na drugim przepadła zmiana użytkownika. Na czas backfillu wstrzymaj update'y tych tabel na pozostałych węzłach, a potem porównaj tabele między węzłami i powtórz pętlę dla wierszy, w których ",[30,11358,11319],{}," jest wciąż NULL. To samo dotyczy ",[30,11361,10827],{}," w kroku 4. Stare kolumny zostają, dopóki wszyscy konsumenci się nie przełączą, a ich usunięcie to osobna zmiana.",[11,11364,11365,11366,11369,11370,11373,11374,11377,11378,11381,11382,743,11385,11388],{},"Przed krokiem 1 przećwiczyłbym też na kopii odtwarzanie backupu, bo to droga powrotna, gdyby podmiana poszła źle. Kiedy robiłem to na kopii bazy mojego SaaS-a, okazało się, że ",[30,11367,11368],{},"pg_restore --clean --if-exists"," usuwa tylko obiekty obecne w dumpie. Tabela dodana przez nowszą migrację została, odtworzona tabela ",[30,11371,11372],{},"migrations"," nic o niej nie wiedziała i ",[30,11375,11376],{},"migrate --force"," kończyło się na ",[30,11379,11380],{},"SQLSTATE[42P07]: Duplicate table ... already exists",". Zadziałał wariant ze świeżą bazą: ",[30,11383,11384],{},"dropdb",[30,11386,11387],{},"createdb",", restore, migrate, razem 1,5 s. Robiłem to tylko na kopii, nie na produkcji. Kopia do ćwiczeń nie powinna mieć starszej wersji głównej niż produkcja, bo dokumentacja nie gwarantuje, że dump z 17 wgra się do 16. U mnie to ma znaczenie, bo jedna z moich produkcji stoi na VPS-ie z Postgresem 17 (system nie miał 16 w repozytoriach), a maszyna deweloperska ma 16.",[896,11390,898],{},{"title":28,"searchDepth":42,"depth":42,"links":11392},[11393,11394,11395],{"id":10627,"depth":42,"text":10628},{"id":10654,"depth":42,"text":10655},{"id":10813,"depth":42,"text":10814},"2026-03-30","Kolumna bigserial albo identity bierze wartości z sekwencji. Sekwencja jest lokalna dla jednej instancji Postgresa, więc daje unikalne wartości tylko wtedy, gdy każdy insert przechodzi przez tę instancję. Przy jednym primary tak jest, więc sama unikalność nie jest powodem do zmiany typu klucza. To się zmienia, gdy zapisy przyjmuje więcej niż jeden węzeł albo gdy identyfikatory powstają poza bazą.",{},"\u002Fpl\u002Farticles\u002Fpostgres-edge",{"x":11401,"y":11402,"depth":3346,"size":915},0.21,0.71,[1773,11404],"state-machine",{"title":10612,"description":11397},"pg-primary-keys","pl\u002Farticles\u002Fpostgres-edge",[11409,11410,11411,11372],"postgres","distributed-systems","uuidv7","v2.1.9","LkvwrbET_uummrMa9aNHCUNJ2H3wshWJ6q0epZDqn5I",{"id":11415,"title":11416,"articleId":923,"body":11417,"category":27,"codeLang":27,"date":12039,"deploys":48,"description":12040,"excerpt":914,"extension":915,"lang":916,"meta":12041,"navigation":51,"path":5220,"pos":12042,"readMin":61,"related":12046,"seo":12047,"service":12048,"stem":12049,"tags":12050,"updated":1783,"version":12052,"__hash__":12053},"articles_pl\u002Fpl\u002Farticles\u002Fsingleton-pattern.md","Singleton w PHP: co znaczy „jedna instancja” pod PHP-FPM, Octane i PHPUnit",{"type":8,"value":11418,"toc":12032},[11419,11425,11428,11432,11484,11488,11491,11586,11596,11619,11639,11643,11646,11653,11656,11708,11716,11733,11737,11744,11755,11822,11886,11890,11893,11907,11910,11991,12020,12030],[11,11420,11421,11422,11424],{},"Klasyczny Singleton trzyma instancję we właściwości statycznej i wydaje ją przez ",[30,11423,5213],{},". Łączy leniwe tworzenie obiektu z globalnym dostępem do niego. Leniwe tworzenie bywa przydatne. Problemy opisane niżej biorą się z globalnego dostępu.",[11,11426,11427],{},"W PHP ważniejsze jest jednak, jak długo trwa ta „jedna”. Czas życia właściwości statycznej zależy od środowiska uruchomieniowego. Pod PHP-FPM silnik zeruje ją na końcu każdego requestu, mimo że proces działa dalej. W procesie, który obsługuje po kolei wiele requestów albo jobów, przetrwa do końca tego procesu.",[15,11429,11431],{"id":11430},"ile-faktycznie-żyje-właściwość-statyczna","Ile faktycznie żyje właściwość statyczna",[691,11433,11434,11447],{},[694,11435,11436],{},[697,11437,11438,11441],{},[700,11439,11440],{},"Środowisko",[700,11442,11443,11444],{},"Czas życia ",[30,11445,11446],{},"self::$instance",[712,11448,11449,11457,11468,11476],{},[697,11450,11451,11454],{},[717,11452,11453],{},"PHP-FPM, mod_php",[717,11455,11456],{},"Jeden request. Silnik czyści cały stan statyczny na końcu żądania.",[697,11458,11459,11465],{},[717,11460,11461,11462],{},"Laravel Octane (Swoole, RoadRunner, FrankenPHP), ",[30,11463,11464],{},"queue:work",[717,11466,11467],{},"Jeden proces workera, przez wiele requestów lub jobów.",[697,11469,11470,11473],{},[717,11471,11472],{},"Skrypt CLI, komenda z crona",[717,11474,11475],{},"Jedno uruchomienie.",[697,11477,11478,11481],{},[717,11479,11480],{},"PHPUnit",[717,11482,11483],{},"Cały przebieg testów w tym procesie, przez wszystkie przypadki testowe.",[15,11485,11487],{"id":11486},"php-fpm-jedna-instancja-na-request","PHP-FPM: jedna instancja na request",[11,11489,11490],{},"Weźmy rate limiter napisany jako Singleton:",[23,11492,11494],{"className":25,"code":11493,"language":27,"meta":28,"style":28},"final class RateLimiter\n{\n    private static ?self $instance = null;\n\n    \u002F** @var array\u003Cstring, int> *\u002F\n    private array $hits = [];\n\n    public static function getInstance(): self\n    {\n        return self::$instance ??= new self();\n    }\n\n    public function allow(string $ip, int $limitPerMinute): bool\n    {\n        $key = $ip . ':' . intdiv(time(), 60);\n        $this->hits[$key] = ($this->hits[$key] ?? 0) + 1;\n\n        return $this->hits[$key] \u003C= $limitPerMinute;\n    }\n}\n",[30,11495,11496,11501,11505,11510,11514,11519,11524,11528,11533,11537,11542,11546,11550,11555,11559,11564,11569,11573,11578,11582],{"__ignoreMap":28},[33,11497,11498],{"class":35,"line":36},[33,11499,11500],{},"final class RateLimiter\n",[33,11502,11503],{"class":35,"line":42},[33,11504,64],{},[33,11506,11507],{"class":35,"line":48},[33,11508,11509],{},"    private static ?self $instance = null;\n",[33,11511,11512],{"class":35,"line":55},[33,11513,52],{"emptyLinePlaceholder":51},[33,11515,11516],{"class":35,"line":61},[33,11517,11518],{},"    \u002F** @var array\u003Cstring, int> *\u002F\n",[33,11520,11521],{"class":35,"line":67},[33,11522,11523],{},"    private array $hits = [];\n",[33,11525,11526],{"class":35,"line":73},[33,11527,52],{"emptyLinePlaceholder":51},[33,11529,11530],{"class":35,"line":79},[33,11531,11532],{},"    public static function getInstance(): self\n",[33,11534,11535],{"class":35,"line":85},[33,11536,3451],{},[33,11538,11539],{"class":35,"line":90},[33,11540,11541],{},"        return self::$instance ??= new self();\n",[33,11543,11544],{"class":35,"line":96},[33,11545,3461],{},[33,11547,11548],{"class":35,"line":384},[33,11549,52],{"emptyLinePlaceholder":51},[33,11551,11552],{"class":35,"line":390},[33,11553,11554],{},"    public function allow(string $ip, int $limitPerMinute): bool\n",[33,11556,11557],{"class":35,"line":395},[33,11558,3451],{},[33,11560,11561],{"class":35,"line":401},[33,11562,11563],{},"        $key = $ip . ':' . intdiv(time(), 60);\n",[33,11565,11566],{"class":35,"line":407},[33,11567,11568],{},"        $this->hits[$key] = ($this->hits[$key] ?? 0) + 1;\n",[33,11570,11571],{"class":35,"line":413},[33,11572,52],{"emptyLinePlaceholder":51},[33,11574,11575],{"class":35,"line":419},[33,11576,11577],{},"        return $this->hits[$key] \u003C= $limitPerMinute;\n",[33,11579,11580],{"class":35,"line":1058},[33,11581,3461],{},[33,11583,11584],{"class":35,"line":1064},[33,11585,82],{},[11,11587,11588,11589,11592,11593,11595],{},"Pod PHP-FPM każdy request zaczyna z pustą tablicą ",[30,11590,11591],{},"$hits",". Licznik dochodzi do 1 i limit nigdy nie zadziała. Pod Octane liczyłby osobno w każdym workerze, więc faktyczny limit mógłby być nawet tyle razy wyższy, ile jest workerów (i serwerów), a requesty z jednego IP trafiałyby do workerów bez żadnego porządku. Do tego tablica ",[30,11594,11591],{}," rosłaby w workerze bez końca, bo klucze z minionych minut nigdy nie znikają.",[11,11597,11598,11599,11602,11603,11606,11607,11610,11611,11614,11615,11618],{},"Wymaganie „100 requestów na minutę na klienta w całej aplikacji” dotyczy stanu wspólnego dla procesów i serwerów i żaden obiekt w pamięci procesu go nie spełni. Ten stan musi leżeć we współdzielonym cache store, np. w Redisie, Memcached albo w bazie. Laravel ma to gotowe: middleware ",[30,11600,11601],{},"throttle"," i fasada ",[30,11604,11605],{},"RateLimiter"," trzymają liczniki w cache store. Limit jest wspólny dla całej aplikacji tylko wtedy, gdy wszystkie serwery widzą ten sam store. Store ",[30,11608,11609],{},"array"," ani osobny ",[30,11612,11613],{},"file"," na każdym serwerze tego nie dają. Poniższy ",[30,11616,11617],{},"throttle:100,1"," liczy zalogowanych po id użytkownika, a gości po IP. Middleware najpierw sprawdza licznik, a dopiero potem go zwiększa, więc przy równoległych requestach kilka może przejść ponad limit.",[23,11620,11622],{"className":25,"code":11621,"language":27,"meta":28,"style":28},"Route::middleware('throttle:100,1')->group(function () {\n    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n});\n",[30,11623,11624,11629,11634],{"__ignoreMap":28},[33,11625,11626],{"class":35,"line":36},[33,11627,11628],{},"Route::middleware('throttle:100,1')->group(function () {\n",[33,11630,11631],{"class":35,"line":42},[33,11632,11633],{},"    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n",[33,11635,11636],{"class":35,"line":48},[33,11637,11638],{},"});\n",[15,11640,11642],{"id":11641},"workery-długo-żyjące-stan-przecieka-między-requestami","Workery długo żyjące: stan przecieka między requestami",[11,11644,11645],{},"W Octane i w queue workerze instancja statyczna przeżywa request i problem się odwraca. Wszystko, co Singleton zapamiętał z jednego requestu, widzi następny request obsłużony przez ten sam worker.",[11,11647,11648,11649,11652],{},"Singleton ",[30,11650,11651],{},"TenantContext"," zapisuje bieżącego tenanta przy pierwszym requeście. Pod PHP-FPM działa, bo instancja ginie razem z requestem. Pod Octane drugi request na tym samym workerze czyta kontekst pierwszego tenanta, jeśli nic go nie wyzeruje. Dokumentacja Octane ostrzega przed tym samym w przypadku singletonów z kontenera, które dostają w konstruktorze request albo sam kontener.",[11,11654,11655],{},"W kontenerze Laravela czas życia deklaruje się jawnie:",[23,11657,11659],{"className":25,"code":11658,"language":27,"meta":28,"style":28},"\u002F\u002F AppServiceProvider::register()\n\n\u002F\u002F Jedna instancja na instancję aplikacji. Bezpieczne tylko dla obiektów niezmiennych i niezależnych od requestu.\n$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));\n\n\u002F\u002F Jedna instancja na request albo job; Octane i queue worker czyszczą instancje scoped.\n$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));\n\n\u002F\u002F Nowa instancja przy każdym resolve.\n$this->app->bind(PaymentGateway::class, StripeGateway::class);\n",[30,11660,11661,11666,11670,11675,11680,11684,11689,11694,11698,11703],{"__ignoreMap":28},[33,11662,11663],{"class":35,"line":36},[33,11664,11665],{},"\u002F\u002F AppServiceProvider::register()\n",[33,11667,11668],{"class":35,"line":42},[33,11669,52],{"emptyLinePlaceholder":51},[33,11671,11672],{"class":35,"line":48},[33,11673,11674],{},"\u002F\u002F Jedna instancja na instancję aplikacji. Bezpieczne tylko dla obiektów niezmiennych i niezależnych od requestu.\n",[33,11676,11677],{"class":35,"line":55},[33,11678,11679],{},"$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));\n",[33,11681,11682],{"class":35,"line":61},[33,11683,52],{"emptyLinePlaceholder":51},[33,11685,11686],{"class":35,"line":67},[33,11687,11688],{},"\u002F\u002F Jedna instancja na request albo job; Octane i queue worker czyszczą instancje scoped.\n",[33,11690,11691],{"class":35,"line":73},[33,11692,11693],{},"$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));\n",[33,11695,11696],{"class":35,"line":79},[33,11697,52],{"emptyLinePlaceholder":51},[33,11699,11700],{"class":35,"line":85},[33,11701,11702],{},"\u002F\u002F Nowa instancja przy każdym resolve.\n",[33,11704,11705],{"class":35,"line":90},[33,11706,11707],{},"$this->app->bind(PaymentGateway::class, StripeGateway::class);\n",[11,11709,11710,743,11713,11715],{},[30,11711,11712],{},"ExchangeRateTable",[30,11714,11651],{}," i klasy gatewayów to przykładowe nazwy. Ważne, że czas życia siedzi w jednym miejscu i można go zmienić bez ruszania klas, które z tej zależności korzystają.",[11,11717,11718,11719,11722,11723,11726,11727,11730,11731,221],{},"W moim SaaS-ie (multi-tenant, Laravel) kontekst firmy zrobiłem „fail-closed”: obiekt kontekstu firmy, trait na modelach i dwa jawne wejścia. Jedno ustawia kontekst przy rozwiązaniu zalogowanego użytkownika, drugie to osobny kontekst systemowy dla konsoli. Te konteksty się wykluczają, a ustawienie obu naraz rzuca wyjątek. Nie wyszło mi to za pierwszym razem. Dwa przeglądy kodu dały 12 ustaleń, w tym dwa krytyczne, których nie zauważyły ani testy, ani linty: tabela firm, czyli korzeń granicy, sama stała poza granicą, więc zalogowany użytkownik widział wszystkie firmy. Drugie nie dotyczy czasu życia obiektów, ale wyszło w tym samym przeglądzie: ",[30,11720,11721],{},"trustProxies(at: '*')",", przez które limit prób logowania dało się obejść podrobionym nagłówkiem. Poprawiłem to na ",[30,11724,11725],{},"at: ['REMOTE_ADDR']",". To chroni tylko wtedy, gdy aplikacja przyjmuje połączenia wyłącznie przez jedno reverse proxy, które samo ustawia nagłówek X-Forwarded-For. Jeśli da się do niej dojść z pominięciem proxy albo proxy jest kilka warstw, trzeba wpisać konkretne adresy proxy. Liczy się też wersja: od Laravela 13.20.0 (lipiec 2026) ",[30,11728,11729],{},"'*'"," oznacza zaufanie do każdego adresu, a w 11, 12 i w 13 do wydania 13.19 działało dokładnie tak samo jak ",[30,11732,4647],{},[15,11734,11736],{"id":11735},"phpunit-jedna-instancja-na-cały-zestaw-testów","PHPUnit: jedna instancja na cały zestaw testów",[11,11738,11739,11740,11743],{},"PHPUnit domyślnie uruchamia testy w jednym procesie. ",[30,11741,11742],{},"TestCase"," Laravela buduje dla każdego testu nową aplikację, więc singletony z kontenera się resetują, ale zwykłe właściwości statyczne już nie. Singleton zmieniony w jednym teście przenosi ten stan do następnego i wynik zaczyna zależeć od kolejności testów.",[11,11745,11746,11747,11750,11751,11754],{},"Zwykle obchodzi się to metodą ",[30,11748,11749],{},"resetInstance()"," wołaną w ",[30,11752,11753],{},"tearDown()",", co działa, dopóki ktoś nie zapomni o niej w jednej klasie testowej. Pewniej jest przestać sięgać po obiekt globalny i wstrzyknąć zależność:",[23,11756,11758],{"className":25,"code":11757,"language":27,"meta":28,"style":28},"interface Clock\n{\n    public function now(): DateTimeImmutable;\n}\n\nfinal class InvoiceDueDate\n{\n    public function __construct(private readonly Clock $clock) {}\n\n    public function forTermDays(int $days): DateTimeImmutable\n    {\n        return $this->clock->now()->modify(\"+{$days} days\");\n    }\n}\n",[30,11759,11760,11765,11769,11774,11778,11782,11787,11791,11796,11800,11805,11809,11814,11818],{"__ignoreMap":28},[33,11761,11762],{"class":35,"line":36},[33,11763,11764],{},"interface Clock\n",[33,11766,11767],{"class":35,"line":42},[33,11768,64],{},[33,11770,11771],{"class":35,"line":48},[33,11772,11773],{},"    public function now(): DateTimeImmutable;\n",[33,11775,11776],{"class":35,"line":55},[33,11777,82],{},[33,11779,11780],{"class":35,"line":61},[33,11781,52],{"emptyLinePlaceholder":51},[33,11783,11784],{"class":35,"line":67},[33,11785,11786],{},"final class InvoiceDueDate\n",[33,11788,11789],{"class":35,"line":73},[33,11790,64],{},[33,11792,11793],{"class":35,"line":79},[33,11794,11795],{},"    public function __construct(private readonly Clock $clock) {}\n",[33,11797,11798],{"class":35,"line":85},[33,11799,52],{"emptyLinePlaceholder":51},[33,11801,11802],{"class":35,"line":90},[33,11803,11804],{},"    public function forTermDays(int $days): DateTimeImmutable\n",[33,11806,11807],{"class":35,"line":96},[33,11808,3451],{},[33,11810,11811],{"class":35,"line":384},[33,11812,11813],{},"        return $this->clock->now()->modify(\"+{$days} days\");\n",[33,11815,11816],{"class":35,"line":390},[33,11817,3461],{},[33,11819,11820],{"class":35,"line":395},[33,11821,82],{},[23,11823,11825],{"className":25,"code":11824,"language":27,"meta":28,"style":28},"public function test_due_date_is_counted_from_today(): void\n{\n    $clock = new class implements Clock {\n        public function now(): DateTimeImmutable\n        {\n            return new DateTimeImmutable('2024-10-15');\n        }\n    };\n\n    $dueDate = (new InvoiceDueDate($clock))->forTermDays(14);\n\n    $this->assertSame('2024-10-29', $dueDate->format('Y-m-d'));\n}\n",[30,11826,11827,11832,11836,11841,11846,11851,11856,11860,11864,11868,11873,11877,11882],{"__ignoreMap":28},[33,11828,11829],{"class":35,"line":36},[33,11830,11831],{},"public function test_due_date_is_counted_from_today(): void\n",[33,11833,11834],{"class":35,"line":42},[33,11835,64],{},[33,11837,11838],{"class":35,"line":48},[33,11839,11840],{},"    $clock = new class implements Clock {\n",[33,11842,11843],{"class":35,"line":55},[33,11844,11845],{},"        public function now(): DateTimeImmutable\n",[33,11847,11848],{"class":35,"line":61},[33,11849,11850],{},"        {\n",[33,11852,11853],{"class":35,"line":67},[33,11854,11855],{},"            return new DateTimeImmutable('2024-10-15');\n",[33,11857,11858],{"class":35,"line":73},[33,11859,3950],{},[33,11861,11862],{"class":35,"line":79},[33,11863,7537],{},[33,11865,11866],{"class":35,"line":85},[33,11867,52],{"emptyLinePlaceholder":51},[33,11869,11870],{"class":35,"line":90},[33,11871,11872],{},"    $dueDate = (new InvoiceDueDate($clock))->forTermDays(14);\n",[33,11874,11875],{"class":35,"line":96},[33,11876,52],{"emptyLinePlaceholder":51},[33,11878,11879],{"class":35,"line":384},[33,11880,11881],{},"    $this->assertSame('2024-10-29', $dueDate->format('Y-m-d'));\n",[33,11883,11884],{"class":35,"line":390},[33,11885,82],{},[15,11887,11889],{"id":11888},"kiedy-statyczny-singleton-da-się-obronić","Kiedy statyczny Singleton da się obronić",[11,11891,11892],{},"Wzorzec się broni, gdy obiekt spełnia wszystkie poniższe warunki:",[2848,11894,11895,11898,11901,11904],{},[2851,11896,11897],{},"cały stan ustawia konstruktor i nic go potem nie zmienia,",[2851,11899,11900],{},"stan nie zależy od requestu, użytkownika ani tenanta,",[2851,11902,11903],{},"budowa obiektu jest na tyle droga, że ma sens robić ją raz na request albo raz na proces workera,",[2851,11905,11906],{},"testy nigdy nie potrzebują innej instancji.",[11,11908,11909],{},"Pasuje do tego niezmienna konfiguracja czytana ze zmiennych środowiskowych:",[23,11911,11913],{"className":25,"code":11912,"language":27,"meta":28,"style":28},"final class AppConfig\n{\n    private static ?self $instance = null;\n\n    private function __construct(\n        public readonly string $appEnv,\n        public readonly string $databaseUrl,\n    ) {}\n\n    public static function get(): self\n    {\n        return self::$instance ??= new self(\n            appEnv: getenv('APP_ENV') ?: 'production',\n            databaseUrl: getenv('DATABASE_URL') ?: throw new RuntimeException('DATABASE_URL is not set'),\n        );\n    }\n}\n",[30,11914,11915,11920,11924,11928,11932,11937,11942,11947,11951,11955,11960,11964,11969,11974,11979,11983,11987],{"__ignoreMap":28},[33,11916,11917],{"class":35,"line":36},[33,11918,11919],{},"final class AppConfig\n",[33,11921,11922],{"class":35,"line":42},[33,11923,64],{},[33,11925,11926],{"class":35,"line":48},[33,11927,11509],{},[33,11929,11930],{"class":35,"line":55},[33,11931,52],{"emptyLinePlaceholder":51},[33,11933,11934],{"class":35,"line":61},[33,11935,11936],{},"    private function __construct(\n",[33,11938,11939],{"class":35,"line":67},[33,11940,11941],{},"        public readonly string $appEnv,\n",[33,11943,11944],{"class":35,"line":73},[33,11945,11946],{},"        public readonly string $databaseUrl,\n",[33,11948,11949],{"class":35,"line":79},[33,11950,3546],{},[33,11952,11953],{"class":35,"line":85},[33,11954,52],{"emptyLinePlaceholder":51},[33,11956,11957],{"class":35,"line":90},[33,11958,11959],{},"    public static function get(): self\n",[33,11961,11962],{"class":35,"line":96},[33,11963,3451],{},[33,11965,11966],{"class":35,"line":384},[33,11967,11968],{},"        return self::$instance ??= new self(\n",[33,11970,11971],{"class":35,"line":390},[33,11972,11973],{},"            appEnv: getenv('APP_ENV') ?: 'production',\n",[33,11975,11976],{"class":35,"line":395},[33,11977,11978],{},"            databaseUrl: getenv('DATABASE_URL') ?: throw new RuntimeException('DATABASE_URL is not set'),\n",[33,11980,11981],{"class":35,"line":401},[33,11982,3796],{},[33,11984,11985],{"class":35,"line":407},[33,11986,3461],{},[33,11988,11989],{"class":35,"line":413},[33,11990,82],{},[11,11992,11993,11994,11997,11998,12001,12002,12005,12006,12009,12010,12013,12014,1182,12016,12019],{},"Właściwości ",[30,11995,11996],{},"readonly"," blokują zmiany, a brak zmiennej wychodzi przy pierwszym wywołaniu zamiast gdzieś później, w kodzie, który nie ma z tym nic wspólnego. W aplikacji na Laravelu i tak zarejestrowałbym ten obiekt przez ",[30,11999,12000],{},"$this->app->singleton()",", bo wtedy test może go podmienić przez ",[30,12003,12004],{},"$this->app->instance()",". Wartości brałbym wtedy z ",[30,12007,12008],{},"config()",", bo po ",[30,12011,12012],{},"php artisan config:cache"," Laravel nie wczytuje pliku ",[30,12015,3254],{},[30,12017,12018],{},"getenv()"," widzi tylko zmienne ustawione w systemie.",[11,12021,12022,12023,12026,12027,12029],{},"Do zapamiętania wyniku drogiego obliczenia Laravel od wersji 11 ma helper ",[30,12024,12025],{},"once()",". Trzyma wynik domknięcia osobno dla każdego obiektu i miejsca wywołania, a Octane czyści go między requestami. Worker kolejki między jobami już nie. W metodzie samego joba nic to nie psuje, bo każdy job to nowy obiekt, ale ",[30,12028,12025],{}," w metodzie statycznej, w zwykłej funkcji albo w singletonie z kontenera trzyma wynik do końca procesu i opisany wyżej problem wraca.",[896,12031,898],{},{"title":28,"searchDepth":42,"depth":42,"links":12033},[12034,12035,12036,12037,12038],{"id":11430,"depth":42,"text":11431},{"id":11486,"depth":42,"text":11487},{"id":11641,"depth":42,"text":11642},{"id":11735,"depth":42,"text":11736},{"id":11888,"depth":42,"text":11889},"2024-10-15","Klasyczny Singleton trzyma instancję we właściwości statycznej i wydaje ją przez getInstance(). Łączy leniwe tworzenie obiektu z globalnym dostępem do niego. Leniwe tworzenie bywa przydatne. Problemy opisane niżej biorą się z globalnego dostępu.",{},{"x":12043,"y":12044,"depth":12045,"size":5866},0.22,0.34,1.2,[1773,937],{"title":11416,"description":12040},"global-state-mgmt","pl\u002Farticles\u002Fsingleton-pattern",[27,4292,6526,7846,12051,724],"testing","v5.1.2","6iV6Fk1VUIrvoF6QKbw9jf23XFp08X3al2vsiFSVItc",{"id":12055,"title":12056,"articleId":11404,"body":12057,"category":27,"codeLang":27,"date":12987,"deploys":36,"description":12988,"excerpt":914,"extension":915,"lang":916,"meta":12989,"navigation":51,"path":12990,"pos":12991,"readMin":73,"related":12993,"seo":12994,"service":12995,"stem":12996,"tags":12997,"updated":1783,"version":13000,"__hash__":13001},"articles_pl\u002Fpl\u002Farticles\u002Fstate-machine.md","Maszyna stanów w PHP: jawny cykl życia zamówienia na enumach, blokadach i zdarzeniach",{"type":8,"value":12058,"toc":12978},[12059,12070,12074,12159,12169,12173,12176,12335,12352,12359,12363,12374,12508,12522,12536,12551,12589,12592,12596,12610,12629,12654,12674,12678,12681,12687,12812,12833,12837,12840,12945,12948,12952,12959,12966,12976],[11,12060,12061,12062,12065,12066,12069],{},"Zamówienie, subskrypcja i wniosek kredytowy mają cykl życia, więc każde z nich da się opisać jako maszynę stanów. W większości projektów ta maszyna jest niejawna. To po prostu kolumna ",[30,12063,12064],{},"status"," i warunki ",[30,12067,12068],{},"if"," w serwisach, które ją zmieniają. Poniżej pokazuję, jak zrobić ją jawną w PHP 8.3 i Laravelu, jak bezpiecznie wykonywać przejścia przy współbieżności i gdzie trzymać efekty uboczne.",[15,12071,12073],{"id":12072},"wersja-niejawna","Wersja niejawna",[23,12075,12077],{"className":25,"code":12076,"language":27,"meta":28,"style":28},"class OrderService\n{\n    public function markPaid(Order $order): void\n    {\n        if ($order->status !== 'payment_pending') {\n            throw new \\LogicException(\"Cannot mark order {$order->id} as paid\");\n        }\n        \u002F\u002F ...\n    }\n\n    public function cancel(Order $order): void\n    {\n        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {\n            throw new \\LogicException(\"Cannot cancel order {$order->id}\");\n        }\n        \u002F\u002F ...\n    }\n}\n",[30,12078,12079,12084,12088,12093,12097,12102,12107,12111,12116,12120,12124,12129,12133,12138,12143,12147,12151,12155],{"__ignoreMap":28},[33,12080,12081],{"class":35,"line":36},[33,12082,12083],{},"class OrderService\n",[33,12085,12086],{"class":35,"line":42},[33,12087,64],{},[33,12089,12090],{"class":35,"line":48},[33,12091,12092],{},"    public function markPaid(Order $order): void\n",[33,12094,12095],{"class":35,"line":55},[33,12096,3451],{},[33,12098,12099],{"class":35,"line":61},[33,12100,12101],{},"        if ($order->status !== 'payment_pending') {\n",[33,12103,12104],{"class":35,"line":67},[33,12105,12106],{},"            throw new \\LogicException(\"Cannot mark order {$order->id} as paid\");\n",[33,12108,12109],{"class":35,"line":73},[33,12110,3950],{},[33,12112,12113],{"class":35,"line":79},[33,12114,12115],{},"        \u002F\u002F ...\n",[33,12117,12118],{"class":35,"line":85},[33,12119,3461],{},[33,12121,12122],{"class":35,"line":90},[33,12123,52],{"emptyLinePlaceholder":51},[33,12125,12126],{"class":35,"line":96},[33,12127,12128],{},"    public function cancel(Order $order): void\n",[33,12130,12131],{"class":35,"line":384},[33,12132,3451],{},[33,12134,12135],{"class":35,"line":390},[33,12136,12137],{},"        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {\n",[33,12139,12140],{"class":35,"line":395},[33,12141,12142],{},"            throw new \\LogicException(\"Cannot cancel order {$order->id}\");\n",[33,12144,12145],{"class":35,"line":401},[33,12146,3950],{},[33,12148,12149],{"class":35,"line":407},[33,12150,12115],{},[33,12152,12153],{"class":35,"line":413},[33,12154,3461],{},[33,12156,12157],{"class":35,"line":419},[33,12158,82],{},[11,12160,12161,12162,12164,12165,12168],{},"Każda metoda sprawdza to, co sama uważa za zabronione. Pełnej listy dozwolonych przejść nigdzie nie ma. Jest tylko suma warunków ze wszystkich metod. Kiedy więc pojawia się nowe miejsce zmieniające ",[30,12163,12064],{},", czy to kontroler, komenda importu, czy panel admina, nie ma się czego trzymać, dopóki autor nie znajdzie i nie skopiuje właściwego warunku. Na dodatek ",[30,12166,12167],{},"cancel()"," opiera się na liście zakazów. Status dodany później da się domyślnie anulować, a biznes raczej by tego nie chciał.",[15,12170,12172],{"id":12171},"przejścia-w-jednym-miejscu","Przejścia w jednym miejscu",[11,12174,12175],{},"W enumie z wartościami zmieszczą się i stany, i graf przejść:",[23,12177,12179],{"className":25,"code":12178,"language":27,"meta":28,"style":28},"enum OrderStatus: string\n{\n    case Draft = 'draft';\n    case PaymentPending = 'payment_pending';\n    case Paid = 'paid';\n    case Shipped = 'shipped';\n    case Delivered = 'delivered';\n    case Cancelled = 'cancelled';\n    case Refunded = 'refunded';\n\n    \u002F** @return list\u003Cself> *\u002F\n    public function allowedTransitions(): array\n    {\n        return match ($this) {\n            self::Draft => [self::PaymentPending, self::Cancelled],\n            self::PaymentPending => [self::Paid, self::Cancelled],\n            self::Paid => [self::Shipped, self::Refunded],\n            self::Shipped => [self::Delivered],\n            self::Delivered => [self::Refunded],\n            self::Cancelled, self::Refunded => [],\n        };\n    }\n\n    public function canTransitionTo(self $to): bool\n    {\n        return in_array($to, $this->allowedTransitions(), true);\n    }\n\n    public function isFinal(): bool\n    {\n        return $this->allowedTransitions() === [];\n    }\n}\n",[30,12180,12181,12186,12190,12195,12200,12205,12210,12215,12220,12225,12229,12234,12239,12243,12248,12253,12258,12263,12268,12273,12278,12283,12287,12291,12296,12300,12305,12309,12313,12318,12322,12327,12331],{"__ignoreMap":28},[33,12182,12183],{"class":35,"line":36},[33,12184,12185],{},"enum OrderStatus: string\n",[33,12187,12188],{"class":35,"line":42},[33,12189,64],{},[33,12191,12192],{"class":35,"line":48},[33,12193,12194],{},"    case Draft = 'draft';\n",[33,12196,12197],{"class":35,"line":55},[33,12198,12199],{},"    case PaymentPending = 'payment_pending';\n",[33,12201,12202],{"class":35,"line":61},[33,12203,12204],{},"    case Paid = 'paid';\n",[33,12206,12207],{"class":35,"line":67},[33,12208,12209],{},"    case Shipped = 'shipped';\n",[33,12211,12212],{"class":35,"line":73},[33,12213,12214],{},"    case Delivered = 'delivered';\n",[33,12216,12217],{"class":35,"line":79},[33,12218,12219],{},"    case Cancelled = 'cancelled';\n",[33,12221,12222],{"class":35,"line":85},[33,12223,12224],{},"    case Refunded = 'refunded';\n",[33,12226,12227],{"class":35,"line":90},[33,12228,52],{"emptyLinePlaceholder":51},[33,12230,12231],{"class":35,"line":96},[33,12232,12233],{},"    \u002F** @return list\u003Cself> *\u002F\n",[33,12235,12236],{"class":35,"line":384},[33,12237,12238],{},"    public function allowedTransitions(): array\n",[33,12240,12241],{"class":35,"line":390},[33,12242,3451],{},[33,12244,12245],{"class":35,"line":395},[33,12246,12247],{},"        return match ($this) {\n",[33,12249,12250],{"class":35,"line":401},[33,12251,12252],{},"            self::Draft => [self::PaymentPending, self::Cancelled],\n",[33,12254,12255],{"class":35,"line":407},[33,12256,12257],{},"            self::PaymentPending => [self::Paid, self::Cancelled],\n",[33,12259,12260],{"class":35,"line":413},[33,12261,12262],{},"            self::Paid => [self::Shipped, self::Refunded],\n",[33,12264,12265],{"class":35,"line":419},[33,12266,12267],{},"            self::Shipped => [self::Delivered],\n",[33,12269,12270],{"class":35,"line":1058},[33,12271,12272],{},"            self::Delivered => [self::Refunded],\n",[33,12274,12275],{"class":35,"line":1064},[33,12276,12277],{},"            self::Cancelled, self::Refunded => [],\n",[33,12279,12280],{"class":35,"line":1069},[33,12281,12282],{},"        };\n",[33,12284,12285],{"class":35,"line":1074},[33,12286,3461],{},[33,12288,12289],{"class":35,"line":1080},[33,12290,52],{"emptyLinePlaceholder":51},[33,12292,12293],{"class":35,"line":1086},[33,12294,12295],{},"    public function canTransitionTo(self $to): bool\n",[33,12297,12298],{"class":35,"line":1092},[33,12299,3451],{},[33,12301,12302],{"class":35,"line":1098},[33,12303,12304],{},"        return in_array($to, $this->allowedTransitions(), true);\n",[33,12306,12307],{"class":35,"line":1103},[33,12308,3461],{},[33,12310,12311],{"class":35,"line":1108},[33,12312,52],{"emptyLinePlaceholder":51},[33,12314,12315],{"class":35,"line":1114},[33,12316,12317],{},"    public function isFinal(): bool\n",[33,12319,12320],{"class":35,"line":1120},[33,12321,3451],{},[33,12323,12324],{"class":35,"line":1126},[33,12325,12326],{},"        return $this->allowedTransitions() === [];\n",[33,12328,12329],{"class":35,"line":1132},[33,12330,3461],{},[33,12332,12333],{"class":35,"line":1138},[33,12334,82],{},[11,12336,12337,12338,12340,12341,12344,12345,12348,12349,12351],{},"Teraz reguły to lista dozwolonych przejść. Nowy case bez własnej gałęzi w ",[30,12339,5784],{}," rzuci ",[30,12342,12343],{},"UnhandledMatchError"," przy pierwszym wywołaniu ",[30,12346,12347],{},"allowedTransitions()"," dla tego stanu, a PHPStan (od poziomu 4) albo Psalm wyłapią niepełny ",[30,12350,5784],{}," jeszcze wcześniej.",[11,12353,12354,12355,12358],{},"W modelu ustaw rzutowanie kolumny na enum (",[30,12356,12357],{},"protected $casts = ['status' => OrderStatus::class];","). Wtedy reszta kodu nie porównuje już gołych stringów.",[15,12360,12362],{"id":12361},"wykonanie-przejścia","Wykonanie przejścia",[11,12364,12365,12366,12369,12370,12373],{},"Samo sprawdzenie to za mało. Dwa żądania mogą odczytać to samo zamówienie w ",[30,12367,12368],{},"payment_pending",", oba przejdą ",[30,12371,12372],{},"canTransitionTo(Paid)"," i oba zapiszą zmianę. Dlatego przejście czyta i zapisuje wiersz pod blokadą:",[23,12375,12377],{"className":25,"code":12376,"language":27,"meta":28,"style":28},"final class OrderTransitions\n{\n    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order\n    {\n        return DB::transaction(function () use ($orderId, $to, $reason): Order {\n            $order = Order::query()->lockForUpdate()->findOrFail($orderId);\n            $from = $order->status;\n\n            if (! $from->canTransitionTo($to)) {\n                throw InvalidTransition::between($order->id, $from, $to);\n            }\n\n            $order->status = $to;\n            $order->status_changed_at = now();\n            $order->save();\n\n            $order->statusHistory()->create([\n                'from' => $from->value,\n                'to' => $to->value,\n                'reason' => $reason,\n            ]);\n\n            OrderStatusChanged::dispatch($order->id, $from, $to);\n\n            return $order;\n        });\n    }\n}\n",[30,12378,12379,12384,12388,12393,12397,12402,12407,12412,12416,12421,12426,12430,12434,12439,12444,12449,12453,12458,12463,12468,12473,12477,12481,12486,12490,12495,12500,12504],{"__ignoreMap":28},[33,12380,12381],{"class":35,"line":36},[33,12382,12383],{},"final class OrderTransitions\n",[33,12385,12386],{"class":35,"line":42},[33,12387,64],{},[33,12389,12390],{"class":35,"line":48},[33,12391,12392],{},"    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order\n",[33,12394,12395],{"class":35,"line":55},[33,12396,3451],{},[33,12398,12399],{"class":35,"line":61},[33,12400,12401],{},"        return DB::transaction(function () use ($orderId, $to, $reason): Order {\n",[33,12403,12404],{"class":35,"line":67},[33,12405,12406],{},"            $order = Order::query()->lockForUpdate()->findOrFail($orderId);\n",[33,12408,12409],{"class":35,"line":73},[33,12410,12411],{},"            $from = $order->status;\n",[33,12413,12414],{"class":35,"line":79},[33,12415,52],{"emptyLinePlaceholder":51},[33,12417,12418],{"class":35,"line":85},[33,12419,12420],{},"            if (! $from->canTransitionTo($to)) {\n",[33,12422,12423],{"class":35,"line":90},[33,12424,12425],{},"                throw InvalidTransition::between($order->id, $from, $to);\n",[33,12427,12428],{"class":35,"line":96},[33,12429,7453],{},[33,12431,12432],{"class":35,"line":384},[33,12433,52],{"emptyLinePlaceholder":51},[33,12435,12436],{"class":35,"line":390},[33,12437,12438],{},"            $order->status = $to;\n",[33,12440,12441],{"class":35,"line":395},[33,12442,12443],{},"            $order->status_changed_at = now();\n",[33,12445,12446],{"class":35,"line":401},[33,12447,12448],{},"            $order->save();\n",[33,12450,12451],{"class":35,"line":407},[33,12452,52],{"emptyLinePlaceholder":51},[33,12454,12455],{"class":35,"line":413},[33,12456,12457],{},"            $order->statusHistory()->create([\n",[33,12459,12460],{"class":35,"line":419},[33,12461,12462],{},"                'from' => $from->value,\n",[33,12464,12465],{"class":35,"line":1058},[33,12466,12467],{},"                'to' => $to->value,\n",[33,12469,12470],{"class":35,"line":1064},[33,12471,12472],{},"                'reason' => $reason,\n",[33,12474,12475],{"class":35,"line":1069},[33,12476,5348],{},[33,12478,12479],{"class":35,"line":1074},[33,12480,52],{"emptyLinePlaceholder":51},[33,12482,12483],{"class":35,"line":1080},[33,12484,12485],{},"            OrderStatusChanged::dispatch($order->id, $from, $to);\n",[33,12487,12488],{"class":35,"line":1086},[33,12489,52],{"emptyLinePlaceholder":51},[33,12491,12492],{"class":35,"line":1092},[33,12493,12494],{},"            return $order;\n",[33,12496,12497],{"class":35,"line":1098},[33,12498,12499],{},"        });\n",[33,12501,12502],{"class":35,"line":1103},[33,12503,3461],{},[33,12505,12506],{"class":35,"line":1108},[33,12507,82],{},[11,12509,12510,12513,12514,12517,12518,12521],{},[30,12511,12512],{},"lockForUpdate()"," dokłada ",[30,12515,12516],{},"FOR UPDATE"," do zapytania ",[30,12519,12520],{},"SELECT",". Druga transakcja, która chce ten sam wiersz, czeka na commit pierwszej, potem czyta nowy status i sprawdzenie odrzuca przejście. Tak działa to w MySQL i PostgreSQL przy domyślnym poziomie izolacji. SQLite blokadę ignoruje, a PostgreSQL w REPEATABLE READ zamiast nowego statusu zwróci drugiej transakcji błąd serializacji. W tabeli historii zostaje, co się zmieniło, kiedy i dlaczego (jeśli chcesz wiedzieć kto, dodaj id użytkownika).",[11,12523,12524,12525,12528,12529,12532,12533,221],{},"Tabela historii powinna być tylko do dopisywania, a jej wiersze potrzebują własnych identyfikatorów. W CookieDomain, moim systemie zgód cookies, dziennik zgód był append-only, ale kontroler brał ",[30,12526,12527],{},"consent_id"," odesłany z cookie, czyli id poprzedniej zgody, jako klucz główny nowego wiersza. Każda zmiana zgody po pierwszej kończyła się więc błędem ",[30,12530,12531],{},"1062 Duplicate entry",". Poprawka: każdy wiersz dostaje nowy UUID, a id poprzedniej zgody trafia do ",[30,12534,12535],{},"prev_consent_id",[11,12537,12538,12539,12542,12543,12546,12547,12550],{},"Jeśli nie chcesz trzymać blokady, warunkowy update też sprawi, że przejście wykona się tylko raz. Omija jednak ",[30,12540,12541],{},"apply()",", więc nie zapisze historii i nie wyśle ",[30,12544,12545],{},"OrderStatusChanged",", a do tego ",[30,12548,12549],{},"update()"," na zapytaniu pomija zdarzenia modelu:",[23,12552,12554],{"className":25,"code":12553,"language":27,"meta":28,"style":28},"$affected = Order::whereKey($orderId)\n    ->where('status', OrderStatus::PaymentPending->value)\n    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);\n\nif ($affected === 0) {\n    \u002F\u002F nie ma takiego zamówienia albo inne żądanie zmieniło status wcześniej\n}\n",[30,12555,12556,12561,12566,12571,12575,12580,12585],{"__ignoreMap":28},[33,12557,12558],{"class":35,"line":36},[33,12559,12560],{},"$affected = Order::whereKey($orderId)\n",[33,12562,12563],{"class":35,"line":42},[33,12564,12565],{},"    ->where('status', OrderStatus::PaymentPending->value)\n",[33,12567,12568],{"class":35,"line":48},[33,12569,12570],{},"    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);\n",[33,12572,12573],{"class":35,"line":55},[33,12574,52],{"emptyLinePlaceholder":51},[33,12576,12577],{"class":35,"line":61},[33,12578,12579],{},"if ($affected === 0) {\n",[33,12581,12582],{"class":35,"line":67},[33,12583,12584],{},"    \u002F\u002F nie ma takiego zamówienia albo inne żądanie zmieniło status wcześniej\n",[33,12586,12587],{"class":35,"line":73},[33,12588,82],{},[11,12590,12591],{},"Dobrze działa na gorących ścieżkach, na przykład w webhookach płatności. Wersję z blokadą łatwiej rozbudować, kiedy przejście musi też odczytać inne dane albo zapisać kilka wierszy.",[15,12593,12595],{"id":12594},"powtórzone-wywołania-z-systemów-zewnętrznych","Powtórzone wywołania z systemów zewnętrznych",[11,12597,12598,12599,12602,12603,12606,12607,12609],{},"Operatorzy płatności ponawiają webhooki, a dokumentacja Stripe mówi, że endpoint może czasem dostać to samo zdarzenie więcej niż raz. Dlatego to samo zdarzenie „płatność udana” potrafi przyjść dwa razy. Za drugim razem zamówienie jest już w ",[30,12600,12601],{},"paid",". Z ",[30,12604,12605],{},"Paid"," do ",[30,12608,12605],{}," przejścia nie ma. Przy takich wywołaniach „już jest w stanie docelowym” powinno oznaczać sukces:",[23,12611,12613],{"className":25,"code":12612,"language":27,"meta":28,"style":28},"if ($from === $to) {\n    return $order; \u002F\u002F idempotentne powtórzenie, nic do zrobienia\n}\n",[30,12614,12615,12620,12625],{"__ignoreMap":28},[33,12616,12617],{"class":35,"line":36},[33,12618,12619],{},"if ($from === $to) {\n",[33,12621,12622],{"class":35,"line":42},[33,12623,12624],{},"    return $order; \u002F\u002F idempotentne powtórzenie, nic do zrobienia\n",[33,12626,12627],{"class":35,"line":48},[33,12628,82],{},[11,12630,12631,12632,12635,12636,12639,12640,12642,12643,12646,12647,12649,12650,12653],{},"Ten warunek wstaw zaraz po ",[30,12633,12634],{},"$from = $order->status;",", przed ",[30,12637,12638],{},"canTransitionTo()",", w osobnym wariancie ",[30,12641,12541],{}," dla wywołań idempotentnych (webhooki, ponawiane joby), na przykład ",[30,12644,12645],{},"applyIdempotent()",". Ogólna metoda ",[30,12648,12541],{}," zostaje ścisła: użytkownik, który klika „Anuluj” przy zamówieniu już anulowanym, powinien dostać komunikat o błędzie. Porównanie stanów nie wystarczy, gdy powtórzone zdarzenie przyjdzie, kiedy zamówienie jest już w ",[30,12651,12652],{},"shipped",". Dlatego Stripe radzi zapisywać ID obsłużonych zdarzeń i pomijać te, które już są w bazie.",[11,12655,12656,12657,12659,12660,12662,12663,12666,12667,12670,12671,12673],{},"Dopóki każdy zapis statusu idzie przez ",[30,12658,12541],{}," albo warunkowy update, zamówienie przejdzie do ",[30,12661,12601],{}," raz, ale operator nadal może obciążyć klienta dwa razy: gdy klient kliknie „Zapłać” dwukrotnie, każde z dwóch żądań może utworzyć płatność u operatora. To trzeba zatrzymać tam, gdzie płatność powstaje. Twórz ją wyłącznie w przejściu ",[30,12664,12665],{},"Draft → PaymentPending",", pod tą samą blokadą (wiersz zostaje zablokowany przez całe wywołanie HTTP, więc daj mu krótki timeout), a drugie żądanie niech zastanie ",[30,12668,12669],{},"PaymentPending"," i zwróci istniejącą płatność zamiast tworzyć nową. Blokada obejmuje tylko twoją bazę, więc wywołanie operatora potrzebuje klucza idempotencji opartego o ID zamówienia (Stripe przyjmuje go w nagłówku ",[30,12672,1446],{},"): jeśli proces padnie po utworzeniu płatności u operatora, a przed commitem, ponowienie z tym samym kluczem i tymi samymi parametrami dostanie z powrotem tę samą płatność. Stripe może usunąć klucz po 24 godzinach, a przy innych parametrach (na przykład zmienionej kwocie) zwraca błąd.",[15,12675,12677],{"id":12676},"efekty-uboczne-idą-do-zdarzeń","Efekty uboczne idą do zdarzeń",[11,12679,12680],{},"Wysyłka maila, rezerwacja towaru albo powiadomienie analityki wewnątrz przejścia wiąże maszynę stanów z tymi systemami. Do tego nieudany mail nie może cofać statusu płatności, bo zamówienie jest opłacone niezależnie od tego, czy potwierdzenie wyszło.",[11,12682,12683,12684,12686],{},"Dlatego przejście tylko wysyła zdarzenie. Zdarzenie implementuje ",[30,12685,5690],{},", więc listenery nie ruszą, jeśli transakcja zostanie wycofana, i nie zobaczą starego stanu wiersza, jeśli czytają przez inne połączenie:",[23,12688,12690],{"className":25,"code":12689,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nfinal class OrderStatusChanged implements ShouldDispatchAfterCommit\n{\n    use Dispatchable;\n\n    public function __construct(\n        public readonly int $orderId,\n        public readonly OrderStatus $from,\n        public readonly OrderStatus $to,\n    ) {}\n}\n\nfinal class SendPaymentConfirmation implements ShouldQueue\n{\n    public function handle(OrderStatusChanged $event): void\n    {\n        if ($event->to !== OrderStatus::Paid) {\n            return;\n        }\n\n        $order = Order::findOrFail($event->orderId);\n        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));\n    }\n}\n",[30,12691,12692,12697,12701,12706,12710,12715,12719,12724,12728,12732,12737,12742,12747,12751,12755,12759,12764,12768,12773,12777,12782,12786,12790,12794,12799,12804,12808],{"__ignoreMap":28},[33,12693,12694],{"class":35,"line":36},[33,12695,12696],{},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\n",[33,12698,12699],{"class":35,"line":42},[33,12700,5613],{},[33,12702,12703],{"class":35,"line":48},[33,12704,12705],{},"use Illuminate\\Foundation\\Events\\Dispatchable;\n",[33,12707,12708],{"class":35,"line":55},[33,12709,52],{"emptyLinePlaceholder":51},[33,12711,12712],{"class":35,"line":61},[33,12713,12714],{},"final class OrderStatusChanged implements ShouldDispatchAfterCommit\n",[33,12716,12717],{"class":35,"line":67},[33,12718,64],{},[33,12720,12721],{"class":35,"line":73},[33,12722,12723],{},"    use Dispatchable;\n",[33,12725,12726],{"class":35,"line":79},[33,12727,52],{"emptyLinePlaceholder":51},[33,12729,12730],{"class":35,"line":85},[33,12731,3531],{},[33,12733,12734],{"class":35,"line":90},[33,12735,12736],{},"        public readonly int $orderId,\n",[33,12738,12739],{"class":35,"line":96},[33,12740,12741],{},"        public readonly OrderStatus $from,\n",[33,12743,12744],{"class":35,"line":384},[33,12745,12746],{},"        public readonly OrderStatus $to,\n",[33,12748,12749],{"class":35,"line":390},[33,12750,3546],{},[33,12752,12753],{"class":35,"line":395},[33,12754,82],{},[33,12756,12757],{"class":35,"line":401},[33,12758,52],{"emptyLinePlaceholder":51},[33,12760,12761],{"class":35,"line":407},[33,12762,12763],{},"final class SendPaymentConfirmation implements ShouldQueue\n",[33,12765,12766],{"class":35,"line":413},[33,12767,64],{},[33,12769,12770],{"class":35,"line":419},[33,12771,12772],{},"    public function handle(OrderStatusChanged $event): void\n",[33,12774,12775],{"class":35,"line":1058},[33,12776,3451],{},[33,12778,12779],{"class":35,"line":1064},[33,12780,12781],{},"        if ($event->to !== OrderStatus::Paid) {\n",[33,12783,12784],{"class":35,"line":1069},[33,12785,4015],{},[33,12787,12788],{"class":35,"line":1074},[33,12789,3950],{},[33,12791,12792],{"class":35,"line":1080},[33,12793,52],{"emptyLinePlaceholder":51},[33,12795,12796],{"class":35,"line":1086},[33,12797,12798],{},"        $order = Order::findOrFail($event->orderId);\n",[33,12800,12801],{"class":35,"line":1092},[33,12802,12803],{},"        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));\n",[33,12805,12806],{"class":35,"line":1098},[33,12807,3461],{},[33,12809,12810],{"class":35,"line":1103},[33,12811,82],{},[11,12813,12814,12815,12818,12819,12821,12822,12825,12826,1182,12829,12832],{},"Każdy listener w kolejce działa jako osobny job z własnymi próbami (Laravel wykonuje job raz, chyba że podniesiesz limit na listenerze albo opcją ",[30,12816,12817],{},"queue:work --tries","). Przy jednej próbie job po awarii serwera pocztowego ląduje w ",[30,12820,7302],{}," i mail nie wyjdzie, dopóki ktoś nie zrobi ",[30,12823,12824],{},"queue:retry",". Jeśli listener ma kilka prób i odstęp między nimi (",[30,12827,12828],{},"$tries",[30,12830,12831],{},"$backoff","), mail wyjdzie przy kolejnej próbie, jeśli serwer do tego czasu wróci, a status zamówienia w żadnym z tych przypadków się nie zmienia.",[15,12834,12836],{"id":12835},"testy-które-sprawdzają-graf","Testy, które sprawdzają graf",[11,12838,12839],{},"Testowanie każdej pary stanów względem kopii tej samej tabeli tylko powiela definicję. Więcej daje sprawdzanie własności grafu i tych reguł biznesowych, które są ważne:",[23,12841,12843],{"className":25,"code":12842,"language":27,"meta":28,"style":28},"public function test_every_status_is_reachable_from_draft(): void\n{\n    $seen = [OrderStatus::Draft];\n    $queue = [OrderStatus::Draft];\n\n    while ($queue !== []) {\n        foreach (array_shift($queue)->allowedTransitions() as $next) {\n            if (! in_array($next, $seen, true)) {\n                $seen[] = $next;\n                $queue[] = $next;\n            }\n        }\n    }\n\n    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);\n    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));\n}\n\npublic function test_shipped_order_cannot_be_cancelled(): void\n{\n    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));\n}\n",[30,12844,12845,12850,12854,12859,12864,12868,12873,12878,12883,12888,12893,12897,12901,12905,12909,12914,12919,12923,12927,12932,12936,12941],{"__ignoreMap":28},[33,12846,12847],{"class":35,"line":36},[33,12848,12849],{},"public function test_every_status_is_reachable_from_draft(): void\n",[33,12851,12852],{"class":35,"line":42},[33,12853,64],{},[33,12855,12856],{"class":35,"line":48},[33,12857,12858],{},"    $seen = [OrderStatus::Draft];\n",[33,12860,12861],{"class":35,"line":55},[33,12862,12863],{},"    $queue = [OrderStatus::Draft];\n",[33,12865,12866],{"class":35,"line":61},[33,12867,52],{"emptyLinePlaceholder":51},[33,12869,12870],{"class":35,"line":67},[33,12871,12872],{},"    while ($queue !== []) {\n",[33,12874,12875],{"class":35,"line":73},[33,12876,12877],{},"        foreach (array_shift($queue)->allowedTransitions() as $next) {\n",[33,12879,12880],{"class":35,"line":79},[33,12881,12882],{},"            if (! in_array($next, $seen, true)) {\n",[33,12884,12885],{"class":35,"line":85},[33,12886,12887],{},"                $seen[] = $next;\n",[33,12889,12890],{"class":35,"line":90},[33,12891,12892],{},"                $queue[] = $next;\n",[33,12894,12895],{"class":35,"line":96},[33,12896,7453],{},[33,12898,12899],{"class":35,"line":384},[33,12900,3950],{},[33,12902,12903],{"class":35,"line":390},[33,12904,3461],{},[33,12906,12907],{"class":35,"line":395},[33,12908,52],{"emptyLinePlaceholder":51},[33,12910,12911],{"class":35,"line":401},[33,12912,12913],{},"    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);\n",[33,12915,12916],{"class":35,"line":407},[33,12917,12918],{},"    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));\n",[33,12920,12921],{"class":35,"line":413},[33,12922,82],{},[33,12924,12925],{"class":35,"line":419},[33,12926,52],{"emptyLinePlaceholder":51},[33,12928,12929],{"class":35,"line":1058},[33,12930,12931],{},"public function test_shipped_order_cannot_be_cancelled(): void\n",[33,12933,12934],{"class":35,"line":1064},[33,12935,64],{},[33,12937,12938],{"class":35,"line":1069},[33,12939,12940],{},"    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));\n",[33,12942,12943],{"class":35,"line":1074},[33,12944,82],{},[11,12946,12947],{},"Pierwszy test wyłapie status dodany do enuma, do którego nie prowadzi żadne przejście. Drugi pilnuje reguły biznesowej, więc zmiana tego fragmentu grafu wywali test.",[15,12949,12951],{"id":12950},"ograniczenia-i-alternatywy","Ograniczenia i alternatywy",[11,12953,12954,12955,12958],{},"Kilka niezależnych wymiarów, takich jak płatność, realizacja i fakturowanie, lepiej opisać kilkoma małymi maszynami niż jednym statusem ze wszystkimi kombinacjami. Cztery stany realizacji (nowe, wysłane, dostarczone, anulowane) razy cztery stany płatności (brak, oczekuje, opłacone, zwrócone) to już 16 kombinacji w jednym statusie. Flaga logiczna bez żadnych reguł, choćby ",[30,12956,12957],{},"is_archived",", maszyny stanów w ogóle nie potrzebuje.",[11,12960,12961,12962,12965],{},"Ograniczenie ",[30,12963,12964],{},"CHECK"," w bazie zawęzi zbiór wartości, ale przejść nie upilnuje. Da się to zrobić triggerem, tylko że reguły biznesowe lądują wtedy w miejscu, którego większość zespołów PHP nie przegląda.",[11,12967,12968,12969,1182,12972,12975],{},"Są też gotowe komponenty: Symfony Workflow z typem ",[30,12970,12971],{},"state_machine",[30,12973,12974],{},"spatie\u002Flaravel-model-states"," dla Laravela. Symfony Workflow dokłada guardy, zdarzenia przejść i komendę do zrzutu grafu. Pakiet Spatie daje klasy stanów, własne klasy przejść i zdarzenia przejść. Enum z tego tekstu wystarcza, dopóki przejścia nie potrzebują własnych guardów ani logiki. Od tego miejsca te biblioteki zaczynają się opłacać.",[896,12977,898],{},{"title":28,"searchDepth":42,"depth":42,"links":12979},[12980,12981,12982,12983,12984,12985,12986],{"id":12072,"depth":42,"text":12073},{"id":12171,"depth":42,"text":12172},{"id":12361,"depth":42,"text":12362},{"id":12594,"depth":42,"text":12595},{"id":12676,"depth":42,"text":12677},{"id":12835,"depth":42,"text":12836},{"id":12950,"depth":42,"text":12951},"2024-02-10","Zamówienie, subskrypcja i wniosek kredytowy mają cykl życia, więc każde z nich da się opisać jako maszynę stanów. W większości projektów ta maszyna jest niejawna. To po prostu kolumna status i warunki if w serwisach, które ją zmieniają. Poniżej pokazuję, jak zrobić ją jawną w PHP 8.3 i Laravelu, jak bezpiecznie wykonywać przejścia przy współbieżności i gdzie trzymać efekty uboczne.",{},"\u002Fpl\u002Farticles\u002Fstate-machine",{"x":12992,"y":9415,"depth":8523,"size":915},0.82,[923,4286],{"title":12056,"description":12988},"order-lifecycle","pl\u002Farticles\u002Fstate-machine",[27,7846,4292,11404,12998,12999],"fsm","order-management","v2.1.3","sKbGgcBpeAfV8Jgadp3uYfrMvmAgrvF2D9hzoF0PE68",{"id":13003,"title":13004,"articleId":13005,"body":13006,"category":13649,"codeLang":27,"date":13650,"deploys":36,"description":13651,"excerpt":914,"extension":915,"lang":916,"meta":13652,"navigation":51,"path":13653,"pos":13654,"readMin":61,"related":13657,"seo":13658,"service":13659,"stem":13660,"tags":13661,"updated":1783,"version":4297,"__hash__":13664},"articles_pl\u002Fpl\u002Farticles\u002Fzero-is-not-missing-data.md","Zero to nie brak danych: stany „zmierzono”, „nie dostarczono” i „nie odczytano”","zero-is-not-missing-data",{"type":8,"value":13007,"toc":13643},[13008,13015,13018,13022,13025,13045,13048,13084,13087,13093,13097,13100,13121,13127,13261,13264,13345,13354,13423,13433,13436,13440,13443,13525,13528,13532,13535,13635,13638,13641],[11,13009,13010,13011,13014],{},"Gdy parser w scoringu rzuca wyjątek, widać to od razu. Zadanie pada i, jeśli monitoring jest ustawiony, ktoś dostaje alert. Parser, który zwraca ",[30,13012,13013],{},"0",", bo nie umiał odczytać danych, psuje wynik po cichu. Wartość ma właściwy typ, przechodzi walidację i dalsze reguły biorą ją za fakt. W ocenie kredytowej ten fakt bywa korzystny dla wnioskodawcy: zero wpisów w rejestrze dłużników, zero opóźnień, zero zapytań.",[11,13016,13017],{},"Trafiłem na to w pracy przy scoringu. Zewnętrzny silnik zwrócił podsumowanie, że wnioskodawca nie ma wpisów w rejestrze dłużników, a surowa odpowiedź rejestru dołączona do tego samego wywołania zawierała aktywny wpis. Podsumowanie było błędne, bo parser nie trafił w strukturę odpowiedzi i użył wartości domyślnych. W logach nic nie wskazywało na problem.",[15,13019,13021],{"id":13020},"skąd-się-bierze-zero","Skąd się bierze zero",[11,13023,13024],{},"Najczęściej z kodu, w którym każda linijka z osobna wygląda rozsądnie. W PHP łatwo zamienić „brak wartości” na zero:",[23,13026,13028],{"className":25,"code":13027,"language":27,"meta":28,"style":28},"$debts  = $response['summary']['count'] ?? 0;\n$amount = (int) $row['amount'];   \u002F\u002F null → 0, \"\" → 0, \"abc\" → 0\n$total  = array_sum($items);      \u002F\u002F [] → 0\n",[30,13029,13030,13035,13040],{"__ignoreMap":28},[33,13031,13032],{"class":35,"line":36},[33,13033,13034],{},"$debts  = $response['summary']['count'] ?? 0;\n",[33,13036,13037],{"class":35,"line":42},[33,13038,13039],{},"$amount = (int) $row['amount'];   \u002F\u002F null → 0, \"\" → 0, \"abc\" → 0\n",[33,13041,13042],{"class":35,"line":48},[33,13043,13044],{},"$total  = array_sum($items);      \u002F\u002F [] → 0\n",[11,13046,13047],{},"Do tego konstruktory z wartościami domyślnymi:",[23,13049,13051],{"className":25,"code":13050,"language":27,"meta":28,"style":28},"final class RegistryResult\n{\n    public function __construct(\n        public int $negativeCount = 0,\n        public float $negativeAmount = 0.0,\n    ) {}\n}\n",[30,13052,13053,13058,13062,13066,13071,13076,13080],{"__ignoreMap":28},[33,13054,13055],{"class":35,"line":36},[33,13056,13057],{},"final class RegistryResult\n",[33,13059,13060],{"class":35,"line":42},[33,13061,64],{},[33,13063,13064],{"class":35,"line":48},[33,13065,3531],{},[33,13067,13068],{"class":35,"line":55},[33,13069,13070],{},"        public int $negativeCount = 0,\n",[33,13072,13073],{"class":35,"line":61},[33,13074,13075],{},"        public float $negativeAmount = 0.0,\n",[33,13077,13078],{"class":35,"line":67},[33,13079,3546],{},[33,13081,13082],{"class":35,"line":73},[33,13083,82],{},[11,13085,13086],{},"Gdy to się złoży, parser, który nie trafi w strukturę odpowiedzi (nowa wersja schematu, inna przestrzeń nazw XML, zmieniona nazwa klucza), nie zgłasza błędu. Nic nie znajduje. „Nic” dostaje wartość domyślną. Testy oparte na pustych albo minimalnych odpowiedziach przechodzą, bo dla takiego parsera każda odpowiedź wygląda na pustą.",[11,13088,13089,13090,13092],{},"Ten nawyk bierze się ze strachu, że ",[30,13091,5566],{}," wywróci aplikację. Tony Hoare nazwał referencję null swoim „błędem za miliard dolarów” i sporo kodu defensywnego od tamtej pory ma po prostu sprawić, żeby null zniknął, a w efekcie często zamiast wyjątku dostajemy błędną liczbę.",[15,13094,13096],{"id":13095},"co-może-znaczyć-pusty-wynik","Co może znaczyć pusty wynik",[11,13098,13099],{},"W danych o osobie albo firmie „brak” obejmuje różne sytuacje:",[13101,13102,13103,13109,13115],"ol",{},[2851,13104,13105,13108],{},[5129,13106,13107],{},"Zmierzono, wynik to zero."," Rejestr odpowiedział i nie ma w nim wpisów o tej osobie. Na tym można oprzeć ocenę.",[2851,13110,13111,13114],{},[5129,13112,13113],{},"Nie dostarczono."," O źródło nie zapytano albo odpowiedź nie dotarła, więc na razie nie ma czego oceniać.",[2851,13116,13117,13120],{},[5129,13118,13119],{},"Nie odczytano."," Źródło przyszło, a parser nie umiał go zinterpretować. Coś zmieniło się w kodzie albo w formacie dostawcy, albo dane przyszły uszkodzone.",[11,13122,13123,13124,13126],{},"Tylko pierwszy przypadek może stać się ",[30,13125,13013],{},". Pozostałe muszą dojść do warstwy decyzyjnej jako osobne stany, a reguła, która od nich zależy, powinna zwrócić „nie da się ocenić” zamiast wyniku.",[23,13128,13130],{"className":25,"code":13129,"language":27,"meta":28,"style":28},"enum Presence\n{\n    case Measured;\n    case NotProvided;\n    case Unreadable;\n}\n\nfinal readonly class Feature\n{\n    private function __construct(\n        public Presence $presence,\n        public ?float $value,\n    ) {}\n\n    public static function measured(float $value): self\n    {\n        return new self(Presence::Measured, $value);\n    }\n\n    public static function notProvided(): self\n    {\n        return new self(Presence::NotProvided, null);\n    }\n\n    public static function unreadable(): self\n    {\n        return new self(Presence::Unreadable, null);\n    }\n}\n",[30,13131,13132,13137,13141,13146,13151,13156,13160,13164,13169,13173,13177,13182,13187,13191,13195,13200,13204,13209,13213,13217,13222,13226,13231,13235,13239,13244,13248,13253,13257],{"__ignoreMap":28},[33,13133,13134],{"class":35,"line":36},[33,13135,13136],{},"enum Presence\n",[33,13138,13139],{"class":35,"line":42},[33,13140,64],{},[33,13142,13143],{"class":35,"line":48},[33,13144,13145],{},"    case Measured;\n",[33,13147,13148],{"class":35,"line":55},[33,13149,13150],{},"    case NotProvided;\n",[33,13152,13153],{"class":35,"line":61},[33,13154,13155],{},"    case Unreadable;\n",[33,13157,13158],{"class":35,"line":67},[33,13159,82],{},[33,13161,13162],{"class":35,"line":73},[33,13163,52],{"emptyLinePlaceholder":51},[33,13165,13166],{"class":35,"line":79},[33,13167,13168],{},"final readonly class Feature\n",[33,13170,13171],{"class":35,"line":85},[33,13172,64],{},[33,13174,13175],{"class":35,"line":90},[33,13176,11936],{},[33,13178,13179],{"class":35,"line":96},[33,13180,13181],{},"        public Presence $presence,\n",[33,13183,13184],{"class":35,"line":384},[33,13185,13186],{},"        public ?float $value,\n",[33,13188,13189],{"class":35,"line":390},[33,13190,3546],{},[33,13192,13193],{"class":35,"line":395},[33,13194,52],{"emptyLinePlaceholder":51},[33,13196,13197],{"class":35,"line":401},[33,13198,13199],{},"    public static function measured(float $value): self\n",[33,13201,13202],{"class":35,"line":407},[33,13203,3451],{},[33,13205,13206],{"class":35,"line":413},[33,13207,13208],{},"        return new self(Presence::Measured, $value);\n",[33,13210,13211],{"class":35,"line":419},[33,13212,3461],{},[33,13214,13215],{"class":35,"line":1058},[33,13216,52],{"emptyLinePlaceholder":51},[33,13218,13219],{"class":35,"line":1064},[33,13220,13221],{},"    public static function notProvided(): self\n",[33,13223,13224],{"class":35,"line":1069},[33,13225,3451],{},[33,13227,13228],{"class":35,"line":1074},[33,13229,13230],{},"        return new self(Presence::NotProvided, null);\n",[33,13232,13233],{"class":35,"line":1080},[33,13234,3461],{},[33,13236,13237],{"class":35,"line":1086},[33,13238,52],{"emptyLinePlaceholder":51},[33,13240,13241],{"class":35,"line":1092},[33,13242,13243],{},"    public static function unreadable(): self\n",[33,13245,13246],{"class":35,"line":1098},[33,13247,3451],{},[33,13249,13250],{"class":35,"line":1103},[33,13251,13252],{},"        return new self(Presence::Unreadable, null);\n",[33,13254,13255],{"class":35,"line":1108},[33,13256,3461],{},[33,13258,13259],{"class":35,"line":1114},[33,13260,82],{},[11,13262,13263],{},"Parser rozstrzyga, który stan zachodzi. Nie zgaduje:",[23,13265,13267],{"className":25,"code":13266,"language":27,"meta":28,"style":28},"final class RegistryParser\n{\n    public function negativeCount(?array $response): Feature\n    {\n        if ($response === null) {\n            return Feature::notProvided();\n        }\n\n        $count = $response['summary']['count'] ?? null;\n\n        if (!is_int($count) || $count \u003C 0) {\n            return Feature::unreadable();\n        }\n\n        return Feature::measured($count);\n    }\n}\n",[30,13268,13269,13274,13278,13283,13287,13292,13297,13301,13305,13310,13314,13319,13324,13328,13332,13337,13341],{"__ignoreMap":28},[33,13270,13271],{"class":35,"line":36},[33,13272,13273],{},"final class RegistryParser\n",[33,13275,13276],{"class":35,"line":42},[33,13277,64],{},[33,13279,13280],{"class":35,"line":48},[33,13281,13282],{},"    public function negativeCount(?array $response): Feature\n",[33,13284,13285],{"class":35,"line":55},[33,13286,3451],{},[33,13288,13289],{"class":35,"line":61},[33,13290,13291],{},"        if ($response === null) {\n",[33,13293,13294],{"class":35,"line":67},[33,13295,13296],{},"            return Feature::notProvided();\n",[33,13298,13299],{"class":35,"line":73},[33,13300,3950],{},[33,13302,13303],{"class":35,"line":79},[33,13304,52],{"emptyLinePlaceholder":51},[33,13306,13307],{"class":35,"line":85},[33,13308,13309],{},"        $count = $response['summary']['count'] ?? null;\n",[33,13311,13312],{"class":35,"line":90},[33,13313,52],{"emptyLinePlaceholder":51},[33,13315,13316],{"class":35,"line":96},[33,13317,13318],{},"        if (!is_int($count) || $count \u003C 0) {\n",[33,13320,13321],{"class":35,"line":384},[33,13322,13323],{},"            return Feature::unreadable();\n",[33,13325,13326],{"class":35,"line":390},[33,13327,3950],{},[33,13329,13330],{"class":35,"line":395},[33,13331,52],{"emptyLinePlaceholder":51},[33,13333,13334],{"class":35,"line":401},[33,13335,13336],{},"        return Feature::measured($count);\n",[33,13338,13339],{"class":35,"line":407},[33,13340,3461],{},[33,13342,13343],{"class":35,"line":413},[33,13344,82],{},[11,13346,13347,13348,13350,13351,13353],{},"Reguła obsługuje każdy stan jawnie. Jeśli w ",[30,13349,5784],{}," po enumie zabraknie któregoś przypadku, PHP rzuci w trakcie działania ",[30,13352,12343],{}," i nie przejdzie po cichu do wartości domyślnej:",[23,13355,13357],{"className":25,"code":13356,"language":27,"meta":28,"style":28},"enum Decision\n{\n    case Pass;\n    case Reject;\n    case CannotAssess;\n}\n\nfunction debtorRegistryRule(Feature $negatives): Decision\n{\n    return match ($negatives->presence) {\n        Presence::Measured => $negatives->value > 0 ? Decision::Reject : Decision::Pass,\n        Presence::NotProvided, Presence::Unreadable => Decision::CannotAssess,\n    };\n}\n",[30,13358,13359,13364,13368,13373,13378,13383,13387,13391,13396,13400,13405,13410,13415,13419],{"__ignoreMap":28},[33,13360,13361],{"class":35,"line":36},[33,13362,13363],{},"enum Decision\n",[33,13365,13366],{"class":35,"line":42},[33,13367,64],{},[33,13369,13370],{"class":35,"line":48},[33,13371,13372],{},"    case Pass;\n",[33,13374,13375],{"class":35,"line":55},[33,13376,13377],{},"    case Reject;\n",[33,13379,13380],{"class":35,"line":61},[33,13381,13382],{},"    case CannotAssess;\n",[33,13384,13385],{"class":35,"line":67},[33,13386,82],{},[33,13388,13389],{"class":35,"line":73},[33,13390,52],{"emptyLinePlaceholder":51},[33,13392,13393],{"class":35,"line":79},[33,13394,13395],{},"function debtorRegistryRule(Feature $negatives): Decision\n",[33,13397,13398],{"class":35,"line":85},[33,13399,64],{},[33,13401,13402],{"class":35,"line":90},[33,13403,13404],{},"    return match ($negatives->presence) {\n",[33,13406,13407],{"class":35,"line":96},[33,13408,13409],{},"        Presence::Measured => $negatives->value > 0 ? Decision::Reject : Decision::Pass,\n",[33,13411,13412],{"class":35,"line":384},[33,13413,13414],{},"        Presence::NotProvided, Presence::Unreadable => Decision::CannotAssess,\n",[33,13416,13417],{"class":35,"line":390},[33,13418,7537],{},[33,13420,13421],{"class":35,"line":395},[33,13422,82],{},[11,13424,13425,13426,1182,13429,13432],{},"To samo rozróżnienie powinno trafić do bazy. Sama kolumna dopuszczająca null nie odróżni „nie dostarczono” od „nie odczytano”. Zapisuj stan obok wartości (np. ",[30,13427,13428],{},"negative_count integer null",[30,13430,13431],{},"negative_count_status text not null","), żeby raporty i późniejsze analizy mogły po nim filtrować.",[11,13434,13435],{},"To kosztuje. Każdy, kto korzysta z tych danych, musi teraz obsłużyć trzy stany, a produkt potrzebuje ustalonej ścieżki dla „nie da się ocenić” (ręczna weryfikacja, prośba o dokumenty, ponowienie zapytania). W zamian decyzja o brakujących danych jest regułą biznesową, którą ktoś świadomie zapisał. Dla pól, w których brak naprawdę oznacza zero, np. opcjonalnego rabatu, ta konstrukcja jest zbędna.",[15,13437,13439],{"id":13438},"testy-które-wyłapią-parser-zwracający-zera","Testy, które wyłapią parser zwracający zera",[11,13441,13442],{},"Test na pustej odpowiedzi niczego nie dowodzi, bo zepsuty parser też go przejdzie. Potrzebna jest prawdziwa, zanonimizowana odpowiedź z wpisem w rejestrze i asercja, że parser ten wpis znalazł:",[23,13444,13446],{"className":25,"code":13445,"language":27,"meta":28,"style":28},"public function test_detects_entry_in_registry_response(): void\n{\n    $json = file_get_contents(__DIR__ . '\u002Ffixtures\u002Fregistry_with_active_entry.json');\n    $response = json_decode($json, true, flags: JSON_THROW_ON_ERROR);\n\n    $feature = (new RegistryParser())->negativeCount($response);\n\n    $this->assertSame(Presence::Measured, $feature->presence);\n    $this->assertGreaterThan(0, $feature->value);\n}\n\npublic function test_unknown_structure_is_unreadable_not_zero(): void\n{\n    $feature = (new RegistryParser())->negativeCount(['v2' => ['items' => []]]);\n\n    $this->assertSame(Presence::Unreadable, $feature->presence);\n}\n",[30,13447,13448,13453,13457,13462,13467,13471,13476,13480,13485,13490,13494,13498,13503,13507,13512,13516,13521],{"__ignoreMap":28},[33,13449,13450],{"class":35,"line":36},[33,13451,13452],{},"public function test_detects_entry_in_registry_response(): void\n",[33,13454,13455],{"class":35,"line":42},[33,13456,64],{},[33,13458,13459],{"class":35,"line":48},[33,13460,13461],{},"    $json = file_get_contents(__DIR__ . '\u002Ffixtures\u002Fregistry_with_active_entry.json');\n",[33,13463,13464],{"class":35,"line":55},[33,13465,13466],{},"    $response = json_decode($json, true, flags: JSON_THROW_ON_ERROR);\n",[33,13468,13469],{"class":35,"line":61},[33,13470,52],{"emptyLinePlaceholder":51},[33,13472,13473],{"class":35,"line":67},[33,13474,13475],{},"    $feature = (new RegistryParser())->negativeCount($response);\n",[33,13477,13478],{"class":35,"line":73},[33,13479,52],{"emptyLinePlaceholder":51},[33,13481,13482],{"class":35,"line":79},[33,13483,13484],{},"    $this->assertSame(Presence::Measured, $feature->presence);\n",[33,13486,13487],{"class":35,"line":85},[33,13488,13489],{},"    $this->assertGreaterThan(0, $feature->value);\n",[33,13491,13492],{"class":35,"line":90},[33,13493,82],{},[33,13495,13496],{"class":35,"line":96},[33,13497,52],{"emptyLinePlaceholder":51},[33,13499,13500],{"class":35,"line":384},[33,13501,13502],{},"public function test_unknown_structure_is_unreadable_not_zero(): void\n",[33,13504,13505],{"class":35,"line":390},[33,13506,64],{},[33,13508,13509],{"class":35,"line":395},[33,13510,13511],{},"    $feature = (new RegistryParser())->negativeCount(['v2' => ['items' => []]]);\n",[33,13513,13514],{"class":35,"line":401},[33,13515,52],{"emptyLinePlaceholder":51},[33,13517,13518],{"class":35,"line":407},[33,13519,13520],{},"    $this->assertSame(Presence::Unreadable, $feature->presence);\n",[33,13522,13523],{"class":35,"line":413},[33,13524,82],{},[11,13526,13527],{},"Przy każdej zmianie schematu u dostawcy dodaj nową próbkę. Jedna niepusta próbka na format to test, który padnie, gdy parser zacznie zwracać zera. Testy szczęśliwej ścieżki na syntetycznych danych zwykle tego nie złapią, nawet jeśli jest ich dużo.",[15,13529,13531],{"id":13530},"sprawdzanie-pól-na-wielu-przypadkach","Sprawdzanie pól na wielu przypadkach",[11,13533,13534],{},"Kolejne sprawdzenie w ogóle nie wymaga znajomości parsera. Porównaj to samo pole w kilku raportach o różnych osobach. Jeśli w każdym raporcie ma tę samą wartość, to najpewniej wypełniacz albo zepsute mapowanie. Typowi kandydaci: flaga ryzyka zawsze ustawiona, znacznik „prowadzi działalność gospodarczą” zawsze na false, kategoria obecna w każdym raporcie niezależnie od danych wejściowych.",[23,13536,13538],{"className":25,"code":13537,"language":27,"meta":28,"style":28},"\u002F**\n * @param list\u003Carray\u003Cstring, mixed>> $reports\n * @return list\u003Cstring> pola z jedną wartością we wszystkich raportach (brak pola liczy się jako null)\n *\u002F\nfunction constantFields(array $reports): array\n{\n    if (count($reports) \u003C 2) {\n        return [];\n    }\n\n    $fields = array_keys(array_replace(...$reports));\n\n    return array_values(array_filter(\n        $fields,\n        static fn (string $field): bool => count(array_unique(array_map(\n            static fn (array $report): string => json_encode($report[$field] ?? null, JSON_THROW_ON_ERROR),\n            $reports,\n        ))) === 1,\n    ));\n}\n",[30,13539,13540,13545,13550,13555,13560,13565,13569,13574,13579,13583,13587,13592,13596,13601,13606,13611,13616,13621,13626,13631],{"__ignoreMap":28},[33,13541,13542],{"class":35,"line":36},[33,13543,13544],{},"\u002F**\n",[33,13546,13547],{"class":35,"line":42},[33,13548,13549],{}," * @param list\u003Carray\u003Cstring, mixed>> $reports\n",[33,13551,13552],{"class":35,"line":48},[33,13553,13554],{}," * @return list\u003Cstring> pola z jedną wartością we wszystkich raportach (brak pola liczy się jako null)\n",[33,13556,13557],{"class":35,"line":55},[33,13558,13559],{}," *\u002F\n",[33,13561,13562],{"class":35,"line":61},[33,13563,13564],{},"function constantFields(array $reports): array\n",[33,13566,13567],{"class":35,"line":67},[33,13568,64],{},[33,13570,13571],{"class":35,"line":73},[33,13572,13573],{},"    if (count($reports) \u003C 2) {\n",[33,13575,13576],{"class":35,"line":79},[33,13577,13578],{},"        return [];\n",[33,13580,13581],{"class":35,"line":85},[33,13582,3461],{},[33,13584,13585],{"class":35,"line":90},[33,13586,52],{"emptyLinePlaceholder":51},[33,13588,13589],{"class":35,"line":96},[33,13590,13591],{},"    $fields = array_keys(array_replace(...$reports));\n",[33,13593,13594],{"class":35,"line":384},[33,13595,52],{"emptyLinePlaceholder":51},[33,13597,13598],{"class":35,"line":390},[33,13599,13600],{},"    return array_values(array_filter(\n",[33,13602,13603],{"class":35,"line":395},[33,13604,13605],{},"        $fields,\n",[33,13607,13608],{"class":35,"line":401},[33,13609,13610],{},"        static fn (string $field): bool => count(array_unique(array_map(\n",[33,13612,13613],{"class":35,"line":407},[33,13614,13615],{},"            static fn (array $report): string => json_encode($report[$field] ?? null, JSON_THROW_ON_ERROR),\n",[33,13617,13618],{"class":35,"line":413},[33,13619,13620],{},"            $reports,\n",[33,13622,13623],{"class":35,"line":419},[33,13624,13625],{},"        ))) === 1,\n",[33,13627,13628],{"class":35,"line":1058},[33,13629,13630],{},"    ));\n",[33,13632,13633],{"class":35,"line":1064},[33,13634,82],{},[11,13636,13637],{},"Uruchom to na pięciu do dziesięciu prawdziwych przypadkach, zanim zaufasz nowemu źródłu danych.",[11,13639,13640],{},"Podobnie z danymi, które ktoś inny już wyciągnął ze źródła. Uporządkowany JSON z czytelnymi nazwami pól to czyjaś interpretacja źródła, razem z jego wartościami domyślnymi i błędami mapowania. Zanim zbudujesz na nim wnioski (np. „wszystkie zobowiązania są pozabankowe”), porównaj kilka przypadków z surowym dokumentem źródłowym.",[896,13642,898],{},{"title":28,"searchDepth":42,"depth":42,"links":13644},[13645,13646,13647,13648],{"id":13020,"depth":42,"text":13021},{"id":13095,"depth":42,"text":13096},{"id":13438,"depth":42,"text":13439},{"id":13530,"depth":42,"text":13531},"fintech","2026-09-24","Gdy parser w scoringu rzuca wyjątek, widać to od razu. Zadanie pada i, jeśli monitoring jest ustawiony, ktoś dostaje alert. Parser, który zwraca 0, bo nie umiał odczytać danych, psuje wynik po cichu. Wartość ma właściwy typ, przechodzi walidację i dalsze reguły biorą ją za fakt. W ocenie kredytowej ten fakt bywa korzystny dla wnioskodawcy: zero wpisów w rejestrze dłużników, zero opóźnień, zero zapytań.",{},"\u002Fpl\u002Farticles\u002Fzero-is-not-missing-data",{"x":13655,"y":13656,"depth":36,"size":915},0.88,0.56,[8537,924],{"title":13004,"description":13651},"missing-vs-zero","pl\u002Farticles\u002Fzero-is-not-missing-data",[27,9426,13662,13663,12051],"null-handling","credit-scoring","jAE9AWH4X_502Lc5CY6BHwgb1IXhV3vhyJGHxymrZTM",1792306063245]