[{"data":1,"prerenderedAt":12439},["ShallowReactive",2],{"article-pl-zero-is-not-missing-data":3,"articles-pl-sidebar":763},{"id":4,"title":5,"articleId":6,"body":7,"category":739,"codeLang":35,"date":740,"deploys":43,"description":741,"excerpt":742,"extension":743,"lang":744,"meta":745,"navigation":189,"path":746,"pos":747,"readMin":90,"related":750,"seo":753,"service":754,"stem":755,"tags":756,"version":761,"__hash__":762},"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":9,"toc":732},"minimark",[10,19,22,27,30,59,62,106,109,116,120,123,146,152,313,316,398,409,479,490,493,497,500,582,585,589,592,692,695,698,702,728],[11,12,13,14,18],"p",{},"W pipeline scoringowym wyjątek z parsera to awaria widoczna: job pada, ktoś dostaje alert. Parser, który zwraca ",[15,16,17],"code",{},"0",", bo nie umiał odczytać danych, to awaria niewidoczna. Wartość ma właściwy typ, przechodzi walidację, a reguły dalej traktują ją jak 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,20,21],{},"Przykład: zewnętrzny silnik zwraca 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 aktywny wpis. Podsumowanie jest błędne, bo parser nie trafił w strukturę odpowiedzi i użył wartości domyślnych. W logach nic nie wskazuje na problem.",[23,24,26],"h2",{"id":25},"skąd-się-bierze-zero","Skąd się bierze zero",[11,28,29],{},"Najczęściej z kodu, który linijka po linijce jest rozsądny. PHP ułatwia zamianę „braku wartości” na zero:",[31,32,37],"pre",{"className":33,"code":34,"language":35,"meta":36,"style":36},"language-php shiki shiki-themes github-light github-dark","$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","php","",[15,38,39,47,53],{"__ignoreMap":36},[40,41,44],"span",{"class":42,"line":43},"line",1,[40,45,46],{},"$debts  = $response['summary']['count'] ?? 0;\n",[40,48,50],{"class":42,"line":49},2,[40,51,52],{},"$amount = (int) $row['amount'];   \u002F\u002F null → 0, \"\" → 0, \"abc\" → 0\n",[40,54,56],{"class":42,"line":55},3,[40,57,58],{},"$total  = array_sum($items);      \u002F\u002F [] → 0\n",[11,60,61],{},"Do tego konstruktory z wartościami domyślnymi:",[31,63,65],{"className":33,"code":64,"language":35,"meta":36,"style":36},"final class RegistryResult\n{\n    public function __construct(\n        public int $negativeCount = 0,\n        public float $negativeAmount = 0.0,\n    ) {}\n}\n",[15,66,67,72,77,82,88,94,100],{"__ignoreMap":36},[40,68,69],{"class":42,"line":43},[40,70,71],{},"final class RegistryResult\n",[40,73,74],{"class":42,"line":49},[40,75,76],{},"{\n",[40,78,79],{"class":42,"line":55},[40,80,81],{},"    public function __construct(\n",[40,83,85],{"class":42,"line":84},4,[40,86,87],{},"        public int $negativeCount = 0,\n",[40,89,91],{"class":42,"line":90},5,[40,92,93],{},"        public float $negativeAmount = 0.0,\n",[40,95,97],{"class":42,"line":96},6,[40,98,99],{},"    ) {}\n",[40,101,103],{"class":42,"line":102},7,[40,104,105],{},"}\n",[11,107,108],{},"Po złożeniu tych elementów parser, który nie trafi w strukturę odpowiedzi (nowa wersja schematu, inny namespace XML, zmieniona nazwa klucza), nie zgłasza błędu. Nic nie znajduje, a „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,110,111,112,115],{},"Ten nawyk bierze się z traktowania ",[15,113,114],{},"null"," jako ryzyka wywrotki. 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ął. Efekt bywa gorszy: głośną awarię zastępuje cicha, błędna odpowiedź.",[23,117,119],{"id":118},"trzy-znaczenia-słowa-brak","Trzy znaczenia słowa „brak”",[11,121,122],{},"W danych opisujących osobę albo firmę pusty wynik może znaczyć trzy różne rzeczy:",[124,125,126,134,140],"ol",{},[127,128,129,133],"li",{},[130,131,132],"strong",{},"Zmierzono, wynik to zero."," Rejestr odpowiedział i wpisów nie ma. To jest informacja.",[127,135,136,139],{},[130,137,138],{},"Nie dostarczono."," O źródło nie zapytano albo nie przyszło. To jest luka.",[127,141,142,145],{},[130,143,144],{},"Nie odczytano."," Źródło przyszło, ale parser go nie zinterpretował. To jest awaria.",[11,147,148,149,151],{},"Tylko pierwszy przypadek może stać się ",[15,150,17],{},". Dwa 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.",[31,153,155],{"className":33,"code":154,"language":35,"meta":36,"style":36},"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",[15,156,157,162,166,171,176,181,185,191,197,202,208,214,220,225,230,236,242,248,254,259,265,270,276,281,286,292,297,303,308],{"__ignoreMap":36},[40,158,159],{"class":42,"line":43},[40,160,161],{},"enum Presence\n",[40,163,164],{"class":42,"line":49},[40,165,76],{},[40,167,168],{"class":42,"line":55},[40,169,170],{},"    case Measured;\n",[40,172,173],{"class":42,"line":84},[40,174,175],{},"    case NotProvided;\n",[40,177,178],{"class":42,"line":90},[40,179,180],{},"    case Unreadable;\n",[40,182,183],{"class":42,"line":96},[40,184,105],{},[40,186,187],{"class":42,"line":102},[40,188,190],{"emptyLinePlaceholder":189},true,"\n",[40,192,194],{"class":42,"line":193},8,[40,195,196],{},"final readonly class Feature\n",[40,198,200],{"class":42,"line":199},9,[40,201,76],{},[40,203,205],{"class":42,"line":204},10,[40,206,207],{},"    private function __construct(\n",[40,209,211],{"class":42,"line":210},11,[40,212,213],{},"        public Presence $presence,\n",[40,215,217],{"class":42,"line":216},12,[40,218,219],{},"        public ?float $value,\n",[40,221,223],{"class":42,"line":222},13,[40,224,99],{},[40,226,228],{"class":42,"line":227},14,[40,229,190],{"emptyLinePlaceholder":189},[40,231,233],{"class":42,"line":232},15,[40,234,235],{},"    public static function measured(float $value): self\n",[40,237,239],{"class":42,"line":238},16,[40,240,241],{},"    {\n",[40,243,245],{"class":42,"line":244},17,[40,246,247],{},"        return new self(Presence::Measured, $value);\n",[40,249,251],{"class":42,"line":250},18,[40,252,253],{},"    }\n",[40,255,257],{"class":42,"line":256},19,[40,258,190],{"emptyLinePlaceholder":189},[40,260,262],{"class":42,"line":261},20,[40,263,264],{},"    public static function notProvided(): self\n",[40,266,268],{"class":42,"line":267},21,[40,269,241],{},[40,271,273],{"class":42,"line":272},22,[40,274,275],{},"        return new self(Presence::NotProvided, null);\n",[40,277,279],{"class":42,"line":278},23,[40,280,253],{},[40,282,284],{"class":42,"line":283},24,[40,285,190],{"emptyLinePlaceholder":189},[40,287,289],{"class":42,"line":288},25,[40,290,291],{},"    public static function unreadable(): self\n",[40,293,295],{"class":42,"line":294},26,[40,296,241],{},[40,298,300],{"class":42,"line":299},27,[40,301,302],{},"        return new self(Presence::Unreadable, null);\n",[40,304,306],{"class":42,"line":305},28,[40,307,253],{},[40,309,311],{"class":42,"line":310},29,[40,312,105],{},[11,314,315],{},"Parser rozstrzyga, który stan zachodzi, i nie zgaduje:",[31,317,319],{"className":33,"code":318,"language":35,"meta":36,"style":36},"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",[15,320,321,326,330,335,339,344,349,354,358,363,367,372,377,381,385,390,394],{"__ignoreMap":36},[40,322,323],{"class":42,"line":43},[40,324,325],{},"final class RegistryParser\n",[40,327,328],{"class":42,"line":49},[40,329,76],{},[40,331,332],{"class":42,"line":55},[40,333,334],{},"    public function negativeCount(?array $response): Feature\n",[40,336,337],{"class":42,"line":84},[40,338,241],{},[40,340,341],{"class":42,"line":90},[40,342,343],{},"        if ($response === null) {\n",[40,345,346],{"class":42,"line":96},[40,347,348],{},"            return Feature::notProvided();\n",[40,350,351],{"class":42,"line":102},[40,352,353],{},"        }\n",[40,355,356],{"class":42,"line":193},[40,357,190],{"emptyLinePlaceholder":189},[40,359,360],{"class":42,"line":199},[40,361,362],{},"        $count = $response['summary']['count'] ?? null;\n",[40,364,365],{"class":42,"line":204},[40,366,190],{"emptyLinePlaceholder":189},[40,368,369],{"class":42,"line":210},[40,370,371],{},"        if (!is_int($count) || $count \u003C 0) {\n",[40,373,374],{"class":42,"line":216},[40,375,376],{},"            return Feature::unreadable();\n",[40,378,379],{"class":42,"line":222},[40,380,353],{},[40,382,383],{"class":42,"line":227},[40,384,190],{"emptyLinePlaceholder":189},[40,386,387],{"class":42,"line":232},[40,388,389],{},"        return Feature::measured($count);\n",[40,391,392],{"class":42,"line":238},[40,393,253],{},[40,395,396],{"class":42,"line":244},[40,397,105],{},[11,399,400,401,404,405,408],{},"Reguła obsługuje każdy stan jawnie. ",[15,402,403],{},"match"," po enumie przy pominiętym przypadku rzuci w trakcie działania ",[15,406,407],{},"UnhandledMatchError",", zamiast przejść do wartości domyślnej:",[31,410,412],{"className":33,"code":411,"language":35,"meta":36,"style":36},"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",[15,413,414,419,423,428,433,438,442,446,451,455,460,465,470,475],{"__ignoreMap":36},[40,415,416],{"class":42,"line":43},[40,417,418],{},"enum Decision\n",[40,420,421],{"class":42,"line":49},[40,422,76],{},[40,424,425],{"class":42,"line":55},[40,426,427],{},"    case Pass;\n",[40,429,430],{"class":42,"line":84},[40,431,432],{},"    case Reject;\n",[40,434,435],{"class":42,"line":90},[40,436,437],{},"    case CannotAssess;\n",[40,439,440],{"class":42,"line":96},[40,441,105],{},[40,443,444],{"class":42,"line":102},[40,445,190],{"emptyLinePlaceholder":189},[40,447,448],{"class":42,"line":193},[40,449,450],{},"function debtorRegistryRule(Feature $negatives): Decision\n",[40,452,453],{"class":42,"line":199},[40,454,76],{},[40,456,457],{"class":42,"line":204},[40,458,459],{},"    return match ($negatives->presence) {\n",[40,461,462],{"class":42,"line":210},[40,463,464],{},"        Presence::Measured => $negatives->value > 0 ? Decision::Reject : Decision::Pass,\n",[40,466,467],{"class":42,"line":216},[40,468,469],{},"        Presence::NotProvided, Presence::Unreadable => Decision::CannotAssess,\n",[40,471,472],{"class":42,"line":222},[40,473,474],{},"    };\n",[40,476,477],{"class":42,"line":227},[40,478,105],{},[11,480,481,482,485,486,489],{},"To samo rozróżnienie powinno trafić do bazy. Sama kolumna nullable nie odróżni „nie dostarczono” od „nie odczytano”. Zapisuj stan obok wartości (np. ",[15,483,484],{},"negative_count integer null"," i ",[15,487,488],{},"negative_count_status text not null","), żeby raporty i późniejsze analizy mogły po nim filtrować.",[11,491,492],{},"Koszty: każdy konsument 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). To dodatkowa praca i o to chodzi: decyzja o brakujących danych przestaje być przypadkową wartością domyślną i staje się jawną regułą biznesową. Dla pól, w których brak naprawdę oznacza zero, np. opcjonalnego rabatu, ta konstrukcja jest zbędna.",[23,494,496],{"id":495},"testy-które-wyłapią-parser-zwracający-zera","Testy, które wyłapią parser zwracający zera",[11,498,499],{},"Test na pustej odpowiedzi niczego nie dowodzi, bo zepsuty parser go przejdzie. Przydatny fixture to prawdziwa, zanonimizowana odpowiedź z pozytywnym wynikiem i asercja, że ten wynik został znaleziony:",[31,501,503],{"className":33,"code":502,"language":35,"meta":36,"style":36},"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",[15,504,505,510,514,519,524,528,533,537,542,547,551,555,560,564,569,573,578],{"__ignoreMap":36},[40,506,507],{"class":42,"line":43},[40,508,509],{},"public function test_detects_entry_in_registry_response(): void\n",[40,511,512],{"class":42,"line":49},[40,513,76],{},[40,515,516],{"class":42,"line":55},[40,517,518],{},"    $json = file_get_contents(__DIR__ . '\u002Ffixtures\u002Fregistry_with_active_entry.json');\n",[40,520,521],{"class":42,"line":84},[40,522,523],{},"    $response = json_decode($json, true, flags: JSON_THROW_ON_ERROR);\n",[40,525,526],{"class":42,"line":90},[40,527,190],{"emptyLinePlaceholder":189},[40,529,530],{"class":42,"line":96},[40,531,532],{},"    $feature = (new RegistryParser())->negativeCount($response);\n",[40,534,535],{"class":42,"line":102},[40,536,190],{"emptyLinePlaceholder":189},[40,538,539],{"class":42,"line":193},[40,540,541],{},"    $this->assertSame(Presence::Measured, $feature->presence);\n",[40,543,544],{"class":42,"line":199},[40,545,546],{},"    $this->assertGreaterThan(0, $feature->value);\n",[40,548,549],{"class":42,"line":204},[40,550,105],{},[40,552,553],{"class":42,"line":210},[40,554,190],{"emptyLinePlaceholder":189},[40,556,557],{"class":42,"line":216},[40,558,559],{},"public function test_unknown_structure_is_unreadable_not_zero(): void\n",[40,561,562],{"class":42,"line":222},[40,563,76],{},[40,565,566],{"class":42,"line":227},[40,567,568],{},"    $feature = (new RegistryParser())->negativeCount(['v2' => ['items' => []]]);\n",[40,570,571],{"class":42,"line":232},[40,572,190],{"emptyLinePlaceholder":189},[40,574,575],{"class":42,"line":238},[40,576,577],{},"    $this->assertSame(Presence::Unreadable, $feature->presence);\n",[40,579,580],{"class":42,"line":244},[40,581,105],{},[11,583,584],{},"Przy każdej zmianie schematu u dostawcy dodaj nowy fixture. Jedna niepusta próbka na format wyłapie więcej niż wiele testów happy path na syntetycznych danych.",[23,586,588],{"id":587},"sprawdzanie-pól-na-wielu-przypadkach","Sprawdzanie pól na wielu przypadkach",[11,590,591],{},"Drugi sposób nie wymaga znajomości parsera. Porównaj to samo pole w kilku raportach o różnych osobach. Pole, które w każdym raporcie ma tę samą wartość, to najpewniej wypełniacz albo zepsute mapowanie, a nie pomiar. 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.",[31,593,595],{"className":33,"code":594,"language":35,"meta":36,"style":36},"\u002F**\n * @param list\u003Carray\u003Cstring, mixed>> $reports\n * @return list\u003Cstring> pola z jedną wartością we wszystkich raportach\n *\u002F\nfunction constantFields(array $reports): array\n{\n    if (count($reports) \u003C 2) {\n        return [];\n    }\n\n    $fields = array_keys($reports[0]);\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),\n            $reports,\n        ))) === 1,\n    ));\n}\n",[15,596,597,602,607,612,617,622,626,631,636,640,644,649,653,658,663,668,673,678,683,688],{"__ignoreMap":36},[40,598,599],{"class":42,"line":43},[40,600,601],{},"\u002F**\n",[40,603,604],{"class":42,"line":49},[40,605,606],{}," * @param list\u003Carray\u003Cstring, mixed>> $reports\n",[40,608,609],{"class":42,"line":55},[40,610,611],{}," * @return list\u003Cstring> pola z jedną wartością we wszystkich raportach\n",[40,613,614],{"class":42,"line":84},[40,615,616],{}," *\u002F\n",[40,618,619],{"class":42,"line":90},[40,620,621],{},"function constantFields(array $reports): array\n",[40,623,624],{"class":42,"line":96},[40,625,76],{},[40,627,628],{"class":42,"line":102},[40,629,630],{},"    if (count($reports) \u003C 2) {\n",[40,632,633],{"class":42,"line":193},[40,634,635],{},"        return [];\n",[40,637,638],{"class":42,"line":199},[40,639,253],{},[40,641,642],{"class":42,"line":204},[40,643,190],{"emptyLinePlaceholder":189},[40,645,646],{"class":42,"line":210},[40,647,648],{},"    $fields = array_keys($reports[0]);\n",[40,650,651],{"class":42,"line":216},[40,652,190],{"emptyLinePlaceholder":189},[40,654,655],{"class":42,"line":222},[40,656,657],{},"    return array_values(array_filter(\n",[40,659,660],{"class":42,"line":227},[40,661,662],{},"        $fields,\n",[40,664,665],{"class":42,"line":232},[40,666,667],{},"        static fn (string $field): bool => count(array_unique(array_map(\n",[40,669,670],{"class":42,"line":238},[40,671,672],{},"            static fn (array $report): string => json_encode($report[$field] ?? null),\n",[40,674,675],{"class":42,"line":244},[40,676,677],{},"            $reports,\n",[40,679,680],{"class":42,"line":250},[40,681,682],{},"        ))) === 1,\n",[40,684,685],{"class":42,"line":256},[40,686,687],{},"    ));\n",[40,689,690],{"class":42,"line":261},[40,691,105],{},[11,693,694],{},"Uruchom to na pięciu do dziesięciu prawdziwych przypadkach, zanim zaufasz nowemu źródłu danych. Rozkład wartości pola mówi o jego jakości więcej niż jego nazwa.",[11,696,697],{},"To samo dotyczy cudzych ekstrakcji w ogóle. Uporządkowany JSON z czytelnymi nazwami pól to czyjaś interpretacja źródła, razem z cudzymi 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.",[23,699,701],{"id":700},"lista-kontrolna","Lista kontrolna",[703,704,705,716,719,722,725],"ul",{},[127,706,707,708,711,712,715],{},"Wyszukaj ",[15,709,710],{},"?? 0",", rzutowania ",[15,713,714],{},"(int)"," i liczbowe wartości domyślne w konstruktorach parserów i DTO.",[127,717,718],{},"Modeluj „zmierzono”, „nie dostarczono” i „nie odczytano” jako osobne stany, w kodzie i w bazie.",[127,720,721],{},"Reguły dla dwóch ostatnich stanów mają zwracać „nie da się ocenić”, a proces musi określać, co dalej.",[127,723,724],{},"Trzymaj co najmniej jeden niepusty, zanonimizowany fixture na każdy format źródła.",[127,726,727],{},"Sprawdź rozkład wartości na prawdziwych przypadkach, zanim oprzesz się na polu.",[729,730,731],"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":36,"searchDepth":49,"depth":49,"links":733},[734,735,736,737,738],{"id":25,"depth":49,"text":26},{"id":118,"depth":49,"text":119},{"id":495,"depth":49,"text":496},{"id":587,"depth":49,"text":588},{"id":700,"depth":49,"text":701},"fintech","2026-09-24","W pipeline scoringowym wyjątek z parsera to awaria widoczna: job pada, ktoś dostaje alert. Parser, który zwraca 0, bo nie umiał odczytać danych, to awaria niewidoczna. Wartość ma właściwy typ, przechodzi walidację, a reguły dalej traktują ją jak fakt. W ocenie kredytowej ten fakt bywa korzystny dla wnioskodawcy: zero wpisów w rejestrze dłużników, zero opóźnień, zero zapytań.",null,"md","pl",{},"\u002Fpl\u002Farticles\u002Fzero-is-not-missing-data",{"x":748,"y":749,"depth":43,"size":743},0.88,0.56,[751,752],"n8n-rag-data-quality","php-references",{"title":5,"description":741},"missing-vs-zero","pl\u002Farticles\u002Fzero-is-not-missing-data",[35,757,758,759,760],"data-quality","null-handling","credit-scoring","testing","v2.0.0","h4Skkk8XfuQJUFghJzwc8wWFIU3PPu9YFoxc4S9Uz-U",[764,1565,2908,3847,4399,4690,5444,6121,7417,8178,9077,9653,10346,10974,11878],{"id":765,"title":766,"articleId":767,"body":768,"category":1543,"codeLang":814,"date":1544,"deploys":967,"description":772,"excerpt":742,"extension":743,"lang":744,"meta":1545,"navigation":189,"path":1546,"pos":1547,"readMin":96,"related":1552,"seo":1555,"service":1556,"stem":1557,"tags":1558,"version":1563,"__hash__":1564},"articles_pl\u002Fpl\u002Farticles\u002Fagent-graphs.md","Agent AI jako graf stanów w LangGraph: checkpointy, przerwania i odtwarzanie","agent-graphs",{"type":8,"value":769,"toc":1532},[770,773,776,780,806,810,1006,1022,1041,1045,1052,1104,1123,1126,1130,1140,1196,1209,1213,1216,1219,1237,1300,1304,1319,1323,1326,1476,1486,1490,1493,1495,1530],[11,771,772],{},"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. Kiedy agent ma przetrwać restart procesu, poczekać na człowieka albo dać się zdebugować po fakcie, w pętli nie ma struktury, do której dałoby się te wymagania podpiąć.",[11,774,775],{},"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 decyduje wewnątrz węzła, graf decyduje, co dalej. Przykłady są w LangGraph, ale argument dotyczy każdego orkiestratora z tymi samymi prymitywami.",[23,777,779],{"id":778},"czego-brakuje-pętli","Czego brakuje pętli",[703,781,782,788,794,800],{},[127,783,784,787],{},[130,785,786],{},"Granic dla ponowień i limitów."," 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 ponów trzy razy, płatności nie ponawiaj nigdy”.",[127,789,790,793],{},[130,791,792],{},"Zapisanego stanu pośredniego."," Jeśli proces padnie w kroku 7, wszystko przepada. Start od zera powtarza kroki 1–6 razem z ich efektami ubocznymi.",[127,795,796,799],{},[130,797,798],{},"Miejsca na czekanie."," Akceptacja przez człowieka wymaga zatrzymania w połowie przebiegu, czasem na kilka dni, i wznowienia w innym procesie. Pętla trzyma stan w zmiennych lokalnych, więc tego nie zrobi.",[127,801,802,805],{},[130,803,804],{},"Odtworzenia."," Żeby zdebugować zły przebieg, potrzebujesz dokładnego stanu sprzed błędnej decyzji i możliwości uruchomienia od tego miejsca jeszcze raz. Pętla daje co najwyżej log.",[23,807,809],{"id":808},"graf","Graf",[31,811,815],{"className":812,"code":813,"language":814,"meta":36,"style":36},"language-python shiki shiki-themes github-light github-dark","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, nie nadpisywane\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","python",[15,816,817,822,827,831,836,841,845,849,854,859,864,869,874,879,884,889,893,897,902,907,911,915,920,925,930,935,939,943,948,953,959,965,971,976,982,988,994,1000],{"__ignoreMap":36},[40,818,819],{"class":42,"line":43},[40,820,821],{},"import operator\n",[40,823,824],{"class":42,"line":49},[40,825,826],{},"from typing import Annotated, TypedDict\n",[40,828,829],{"class":42,"line":55},[40,830,190],{"emptyLinePlaceholder":189},[40,832,833],{"class":42,"line":84},[40,834,835],{},"from langgraph.graph import END, START, StateGraph\n",[40,837,838],{"class":42,"line":90},[40,839,840],{},"from langgraph.types import RetryPolicy\n",[40,842,843],{"class":42,"line":96},[40,844,190],{"emptyLinePlaceholder":189},[40,846,847],{"class":42,"line":102},[40,848,190],{"emptyLinePlaceholder":189},[40,850,851],{"class":42,"line":193},[40,852,853],{},"class AgentState(TypedDict):\n",[40,855,856],{"class":42,"line":199},[40,857,858],{},"    task: str\n",[40,860,861],{"class":42,"line":204},[40,862,863],{},"    plan: list[str]\n",[40,865,866],{"class":42,"line":210},[40,867,868],{},"    context: Annotated[list[str], operator.add]  # dopisywane, nie nadpisywane\n",[40,870,871],{"class":42,"line":216},[40,872,873],{},"    result: str | None\n",[40,875,876],{"class":42,"line":222},[40,877,878],{},"    needs_context: bool\n",[40,880,881],{"class":42,"line":227},[40,882,883],{},"    done: bool\n",[40,885,886],{"class":42,"line":232},[40,887,888],{},"    reflections: int\n",[40,890,891],{"class":42,"line":238},[40,892,190],{"emptyLinePlaceholder":189},[40,894,895],{"class":42,"line":244},[40,896,190],{"emptyLinePlaceholder":189},[40,898,899],{"class":42,"line":250},[40,900,901],{},"def route_after_plan(state: AgentState) -> str:\n",[40,903,904],{"class":42,"line":256},[40,905,906],{},"    return \"retrieve\" if state[\"needs_context\"] else \"act\"\n",[40,908,909],{"class":42,"line":261},[40,910,190],{"emptyLinePlaceholder":189},[40,912,913],{"class":42,"line":267},[40,914,190],{"emptyLinePlaceholder":189},[40,916,917],{"class":42,"line":272},[40,918,919],{},"def route_after_act(state: AgentState) -> str:\n",[40,921,922],{"class":42,"line":278},[40,923,924],{},"    if state[\"done\"] or state[\"reflections\"] >= 2:\n",[40,926,927],{"class":42,"line":283},[40,928,929],{},"        return END\n",[40,931,932],{"class":42,"line":288},[40,933,934],{},"    return \"reflect\"\n",[40,936,937],{"class":42,"line":294},[40,938,190],{"emptyLinePlaceholder":189},[40,940,941],{"class":42,"line":299},[40,942,190],{"emptyLinePlaceholder":189},[40,944,945],{"class":42,"line":305},[40,946,947],{},"builder = StateGraph(AgentState)\n",[40,949,950],{"class":42,"line":310},[40,951,952],{},"builder.add_node(\"plan\", planner)\n",[40,954,956],{"class":42,"line":955},30,[40,957,958],{},"builder.add_node(\"retrieve\", retriever, retry_policy=RetryPolicy(max_attempts=3))\n",[40,960,962],{"class":42,"line":961},31,[40,963,964],{},"builder.add_node(\"act\", tool_runner)\n",[40,966,968],{"class":42,"line":967},32,[40,969,970],{},"builder.add_node(\"reflect\", critic)\n",[40,972,974],{"class":42,"line":973},33,[40,975,190],{"emptyLinePlaceholder":189},[40,977,979],{"class":42,"line":978},34,[40,980,981],{},"builder.add_edge(START, \"plan\")\n",[40,983,985],{"class":42,"line":984},35,[40,986,987],{},"builder.add_conditional_edges(\"plan\", route_after_plan, [\"retrieve\", \"act\"])\n",[40,989,991],{"class":42,"line":990},36,[40,992,993],{},"builder.add_edge(\"retrieve\", \"act\")\n",[40,995,997],{"class":42,"line":996},37,[40,998,999],{},"builder.add_conditional_edges(\"act\", route_after_act, [\"reflect\", END])\n",[40,1001,1003],{"class":42,"line":1002},38,[40,1004,1005],{},"builder.add_edge(\"reflect\", \"plan\")\n",[11,1007,1008,1011,1012,1011,1015,485,1018,1021],{},[15,1009,1010],{},"planner",", ",[15,1013,1014],{},"retriever",[15,1016,1017],{},"tool_runner",[15,1019,1020],{},"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,1023,1024,1025,1028,1029,1032,1033,1036,1037,1040],{},"Limity są dwa i są niezależne. ",[15,1026,1027],{},"reflections"," to reguła biznesowa w stanie: najwyżej dwie rundy krytyki. ",[15,1030,1031],{},"recursion_limit"," w konfiguracji przebiegu to techniczny sufit na łączną liczbę kroków; po jego przekroczeniu leci ",[15,1034,1035],{},"GraphRecursionError"," zamiast pracy bez końca. Polityka ponowień dotyczy tylko węzła ",[15,1038,1039],{},"retrieve",", więc niestabilne wyszukiwanie jest ponawiane, a narzędzie z efektami ubocznymi nie.",[23,1042,1044],{"id":1043},"checkpointy-w-postgresie","Checkpointy w Postgresie",[11,1046,1047,1048,1051],{},"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 ",[15,1049,1050],{},"langgraph-checkpoint-postgres"," i korzysta z psycopg 3.",[31,1053,1055],{"className":812,"code":1054,"language":814,"meta":36,"style":36},"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\": \"ticket-4812\"}, \"recursion_limit\": 30}\n    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[15,1056,1057,1062,1066,1071,1075,1080,1085,1090,1094,1099],{"__ignoreMap":36},[40,1058,1059],{"class":42,"line":43},[40,1060,1061],{},"from langgraph.checkpoint.postgres import PostgresSaver\n",[40,1063,1064],{"class":42,"line":49},[40,1065,190],{"emptyLinePlaceholder":189},[40,1067,1068],{"class":42,"line":55},[40,1069,1070],{},"DB_URI = \"postgresql:\u002F\u002Fagent:secret@localhost:5432\u002Fagents\"\n",[40,1072,1073],{"class":42,"line":84},[40,1074,190],{"emptyLinePlaceholder":189},[40,1076,1077],{"class":42,"line":90},[40,1078,1079],{},"with PostgresSaver.from_conn_string(DB_URI) as checkpointer:\n",[40,1081,1082],{"class":42,"line":96},[40,1083,1084],{},"    checkpointer.setup()  # tworzy tabele checkpointów; uruchamiać raz, jak migrację\n",[40,1086,1087],{"class":42,"line":102},[40,1088,1089],{},"    graph = builder.compile(checkpointer=checkpointer)\n",[40,1091,1092],{"class":42,"line":193},[40,1093,190],{"emptyLinePlaceholder":189},[40,1095,1096],{"class":42,"line":199},[40,1097,1098],{},"    config = {\"configurable\": {\"thread_id\": \"ticket-4812\"}, \"recursion_limit\": 30}\n",[40,1100,1101],{"class":42,"line":204},[40,1102,1103],{},"    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[11,1105,1106,1107,1110,1111,1114,1115,1118,1119,1122],{},"Po awarii ",[15,1108,1109],{},"graph.invoke(None, config)"," z tym samym ",[15,1112,1113],{},"thread_id"," rusza od ostatniego zakończonego kroku. ",[15,1116,1117],{},"graph.get_state(config)"," zwraca bieżący stan i następny węzeł, a ",[15,1120,1121],{},"graph.get_state_history(config)"," wszystkie wcześniejsze snapshoty.",[11,1124,1125],{},"Dwie uwagi operacyjne. Każdy checkpoint zawiera stan, więc duże wartości (pełne pobrane dokumenty, pliki w base64) mnożą zajętość z każdym krokiem. W stanie trzymaj referencje, a treść gdzie indziej. Checkpointy przyrastają też w każdym wątku bez końca, dopóki nie dodasz zadania retencji, a w środowisku regulowanym ono i tak musi się zgadzać z polityką przechowywania danych.",[23,1127,1129],{"id":1128},"czekanie-na-człowieka","Czekanie na człowieka",[11,1131,1132,1135,1136,1139],{},[15,1133,1134],{},"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 ",[15,1137,1138],{},"Command(resume=...)",".",[31,1141,1143],{"className":812,"code":1142,"language":814,"meta":36,"style":36},"from langgraph.types import Command, interrupt\n\n\ndef approve_refund(state: AgentState) -> dict:\n    # Zakłada pola `amount` i `approved` w stanie.\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\": \"ticket-4812\"}})\n",[15,1144,1145,1150,1154,1158,1163,1168,1173,1178,1182,1186,1191],{"__ignoreMap":36},[40,1146,1147],{"class":42,"line":43},[40,1148,1149],{},"from langgraph.types import Command, interrupt\n",[40,1151,1152],{"class":42,"line":49},[40,1153,190],{"emptyLinePlaceholder":189},[40,1155,1156],{"class":42,"line":55},[40,1157,190],{"emptyLinePlaceholder":189},[40,1159,1160],{"class":42,"line":84},[40,1161,1162],{},"def approve_refund(state: AgentState) -> dict:\n",[40,1164,1165],{"class":42,"line":90},[40,1166,1167],{},"    # Zakłada pola `amount` i `approved` w stanie.\n",[40,1169,1170],{"class":42,"line":96},[40,1171,1172],{},"    answer = interrupt({\"question\": \"Zatwierdzić zwrot?\", \"amount\": state[\"amount\"]})\n",[40,1174,1175],{"class":42,"line":102},[40,1176,1177],{},"    return {\"approved\": answer == \"yes\"}\n",[40,1179,1180],{"class":42,"line":193},[40,1181,190],{"emptyLinePlaceholder":189},[40,1183,1184],{"class":42,"line":199},[40,1185,190],{"emptyLinePlaceholder":189},[40,1187,1188],{"class":42,"line":204},[40,1189,1190],{},"# Kilka godzin później, np. w handlerze HTTP ekranu akceptacji:\n",[40,1192,1193],{"class":42,"line":210},[40,1194,1195],{},"graph.invoke(Command(resume=\"yes\"), {\"configurable\": {\"thread_id\": \"ticket-4812\"}})\n",[11,1197,1198,1199,1201,1202,1205,1206,1208],{},"Przy wznowieniu węzeł wykonuje się ponownie od pierwszej linii, a ",[15,1200,1134],{}," zamiast zatrzymywać zwraca wartość z ",[15,1203,1204],{},"resume",". Cały kod przed ",[15,1207,1134],{}," wykona się więc dwa razy. Ta część nie może mieć efektów ubocznych; jeśli musi, przenieś efekt do osobnego węzła za akceptacją.",[23,1210,1212],{"id":1211},"przed-czym-checkpoint-nie-chroni","Przed czym checkpoint nie chroni",[11,1214,1215],{},"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,1217,1218],{},"Wnioski projektowe:",[703,1220,1221,1224,1234],{},[127,1222,1223],{},"jeden zewnętrzny efekt uboczny na węzeł, żeby jednostka powtórzenia była mała i znana;",[127,1225,1226,1227,1229,1230,1233],{},"klucz idempotencji z ",[15,1228,1113],{}," i nazwy węzła, przekazywany do systemu zewnętrznego, jeśli go obsługuje (Stripe na przykład przyjmuje nagłówek ",[15,1231,1232],{},"Idempotency-Key",");",[127,1235,1236],{},"strumień traktowany jako prezentacja: źródłem prawdy jest wartość, którą węzeł zwraca do stanu, a wznowiony przebieg nie strumieniuje ponownie tego, co klient już dostał.",[31,1238,1240],{"className":812,"code":1239,"language":814,"meta":36,"style":36},"from langchain_core.runnables import RunnableConfig\n\n\ndef send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n    thread_id = config[\"configurable\"][\"thread_id\"]\n    # `mailer` to twój klient; dostawca deduplikuje po tym kluczu.\n    mailer.send(\n        to=state[\"customer_email\"],\n        body=state[\"result\"],\n        idempotency_key=f\"{thread_id}:send_confirmation\",\n    )\n    return {\"confirmation_sent\": True}\n",[15,1241,1242,1247,1251,1255,1260,1265,1270,1275,1280,1285,1290,1295],{"__ignoreMap":36},[40,1243,1244],{"class":42,"line":43},[40,1245,1246],{},"from langchain_core.runnables import RunnableConfig\n",[40,1248,1249],{"class":42,"line":49},[40,1250,190],{"emptyLinePlaceholder":189},[40,1252,1253],{"class":42,"line":55},[40,1254,190],{"emptyLinePlaceholder":189},[40,1256,1257],{"class":42,"line":84},[40,1258,1259],{},"def send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n",[40,1261,1262],{"class":42,"line":90},[40,1263,1264],{},"    thread_id = config[\"configurable\"][\"thread_id\"]\n",[40,1266,1267],{"class":42,"line":96},[40,1268,1269],{},"    # `mailer` to twój klient; dostawca deduplikuje po tym kluczu.\n",[40,1271,1272],{"class":42,"line":102},[40,1273,1274],{},"    mailer.send(\n",[40,1276,1277],{"class":42,"line":193},[40,1278,1279],{},"        to=state[\"customer_email\"],\n",[40,1281,1282],{"class":42,"line":199},[40,1283,1284],{},"        body=state[\"result\"],\n",[40,1286,1287],{"class":42,"line":204},[40,1288,1289],{},"        idempotency_key=f\"{thread_id}:send_confirmation\",\n",[40,1291,1292],{"class":42,"line":210},[40,1293,1294],{},"    )\n",[40,1296,1297],{"class":42,"line":216},[40,1298,1299],{},"    return {\"confirmation_sent\": True}\n",[23,1301,1303],{"id":1302},"odtwarzanie-do-debugowania-i-regresji","Odtwarzanie do debugowania i regresji",[11,1305,1306,1307,1310,1311,1314,1315,1318],{},"Każdy snapshot z ",[15,1308,1309],{},"get_state_history"," ma własny config z ID checkpointu. Wywołanie grafu z ",[15,1312,1313],{},"None"," 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ę (",[15,1316,1317],{},"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, więc środowisko odtwarzania potrzebuje takiej samej kontroli dostępu jak produkcja.",[23,1320,1322],{"id":1321},"obserwowalność","Obserwowalność",[11,1324,1325],{},"Loguj jedno ustrukturyzowane zdarzenie na każde wykonanie węzła. Wrapper przy rejestracji obejmuje wszystkie węzły bez zmiany ich kodu:",[31,1327,1329],{"className":812,"code":1328,"language":814,"meta":36,"style":36},"import functools\nimport logging\nimport time\n\nlog = logging.getLogger(\"agent\")\n\n\ndef observed(name, fn):\n    # Zakłada, że funkcje węzłów przyjmują (state, config).\n    @functools.wraps(fn)\n    def wrapper(state, config):\n        started = time.perf_counter()\n        error = None\n        try:\n            return fn(state, config)\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",[15,1330,1331,1336,1341,1346,1350,1355,1359,1363,1368,1373,1378,1383,1388,1393,1398,1403,1408,1413,1418,1423,1428,1433,1438,1443,1448,1453,1458,1462,1466,1471],{"__ignoreMap":36},[40,1332,1333],{"class":42,"line":43},[40,1334,1335],{},"import functools\n",[40,1337,1338],{"class":42,"line":49},[40,1339,1340],{},"import logging\n",[40,1342,1343],{"class":42,"line":55},[40,1344,1345],{},"import time\n",[40,1347,1348],{"class":42,"line":84},[40,1349,190],{"emptyLinePlaceholder":189},[40,1351,1352],{"class":42,"line":90},[40,1353,1354],{},"log = logging.getLogger(\"agent\")\n",[40,1356,1357],{"class":42,"line":96},[40,1358,190],{"emptyLinePlaceholder":189},[40,1360,1361],{"class":42,"line":102},[40,1362,190],{"emptyLinePlaceholder":189},[40,1364,1365],{"class":42,"line":193},[40,1366,1367],{},"def observed(name, fn):\n",[40,1369,1370],{"class":42,"line":199},[40,1371,1372],{},"    # Zakłada, że funkcje węzłów przyjmują (state, config).\n",[40,1374,1375],{"class":42,"line":204},[40,1376,1377],{},"    @functools.wraps(fn)\n",[40,1379,1380],{"class":42,"line":210},[40,1381,1382],{},"    def wrapper(state, config):\n",[40,1384,1385],{"class":42,"line":216},[40,1386,1387],{},"        started = time.perf_counter()\n",[40,1389,1390],{"class":42,"line":222},[40,1391,1392],{},"        error = None\n",[40,1394,1395],{"class":42,"line":227},[40,1396,1397],{},"        try:\n",[40,1399,1400],{"class":42,"line":232},[40,1401,1402],{},"            return fn(state, config)\n",[40,1404,1405],{"class":42,"line":238},[40,1406,1407],{},"        except Exception as exc:\n",[40,1409,1410],{"class":42,"line":244},[40,1411,1412],{},"            error = type(exc).__name__\n",[40,1414,1415],{"class":42,"line":250},[40,1416,1417],{},"            raise\n",[40,1419,1420],{"class":42,"line":256},[40,1421,1422],{},"        finally:\n",[40,1424,1425],{"class":42,"line":261},[40,1426,1427],{},"            log.info(\"agent_step\", extra={\n",[40,1429,1430],{"class":42,"line":267},[40,1431,1432],{},"                \"thread_id\": config[\"configurable\"][\"thread_id\"],\n",[40,1434,1435],{"class":42,"line":272},[40,1436,1437],{},"                \"node\": name,\n",[40,1439,1440],{"class":42,"line":278},[40,1441,1442],{},"                \"latency_ms\": round((time.perf_counter() - started) * 1000),\n",[40,1444,1445],{"class":42,"line":283},[40,1446,1447],{},"                \"error\": error,\n",[40,1449,1450],{"class":42,"line":288},[40,1451,1452],{},"            })\n",[40,1454,1455],{"class":42,"line":294},[40,1456,1457],{},"    return wrapper\n",[40,1459,1460],{"class":42,"line":299},[40,1461,190],{"emptyLinePlaceholder":189},[40,1463,1464],{"class":42,"line":305},[40,1465,190],{"emptyLinePlaceholder":189},[40,1467,1468],{"class":42,"line":310},[40,1469,1470],{},"# Zamiast builder.add_node(\"act\", tool_runner); nazwę węzła można zarejestrować tylko raz.\n",[40,1472,1473],{"class":42,"line":955},[40,1474,1475],{},"builder.add_node(\"act\", observed(\"act\", tool_runner))\n",[11,1477,1478,1479,1482,1483,1485],{},"Liczniki tokenów należą do tego samego zdarzenia. W modelach czatowych LangChain są dostępne na wiadomości odpowiedzi jako ",[15,1480,1481],{},"usage_metadata","; zwróć je z węzła albo zaloguj tam, gdzie wołasz model. Mając w jednym rekordzie ",[15,1484,1113],{},", węzeł, czas, tokeny i błąd, koszt przebiegu, wolne narzędzie czy węzeł, który zaczął padać po deployu, to zwykłe zapytania, a nie śledztwo.",[23,1487,1489],{"id":1488},"kiedy-graf-to-przesada","Kiedy graf to przesada",[11,1491,1492],{},"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 się opłaca, gdy zachodzi co najmniej jedno: przebieg trwa na tyle długo, że może zostać przerwany, człowiek coś w trakcie zatwierdza albo nieudane przebiegi trzeba umieć odtworzyć.",[23,1494,701],{"id":700},[703,1496,1497,1500,1506,1509,1515,1521,1524,1527],{},[127,1498,1499],{},"Przepływ sterowania siedzi w krawędziach i funkcjach routujących, nie w prompcie.",[127,1501,1502,1503,1505],{},"Jest limit biznesowy w stanie i techniczny ",[15,1504,1031],{}," w konfiguracji.",[127,1507,1508],{},"Polityki ponowień są ustawione per węzeł, a węzły z efektami ubocznymi nie są ponawiane automatycznie.",[127,1510,1511,1512,1514],{},"Checkpointer zapisuje do trwałego magazynu, a ",[15,1513,1113],{}," odpowiada identyfikatorowi biznesowemu.",[127,1516,1517,1518,1520],{},"Kod przed ",[15,1519,1134],{}," nie ma efektów ubocznych.",[127,1522,1523],{},"Każdy zewnętrzny efekt uboczny ma klucz idempotencji.",[127,1525,1526],{},"Każde wykonanie węzła daje jedno ustrukturyzowane zdarzenie z tokenami i czasem.",[127,1528,1529],{},"Checkpointy mają politykę retencji.",[729,1531,731],{},{"title":36,"searchDepth":49,"depth":49,"links":1533},[1534,1535,1536,1537,1538,1539,1540,1541,1542],{"id":778,"depth":49,"text":779},{"id":808,"depth":49,"text":809},{"id":1043,"depth":49,"text":1044},{"id":1128,"depth":49,"text":1129},{"id":1211,"depth":49,"text":1212},{"id":1302,"depth":49,"text":1303},{"id":1321,"depth":49,"text":1322},{"id":1488,"depth":49,"text":1489},{"id":700,"depth":49,"text":701},"agents","2026-05-09",{},"\u002Fpl\u002Farticles\u002Fagent-graphs",{"x":1548,"y":1549,"depth":1550,"size":1551},0.66,0.27,1.5,"xl",[1553,1554],"microservice-cost","postgres-edge",{"title":766,"description":772},"orchestrator-graphs","pl\u002Farticles\u002Fagent-graphs",[1559,1560,1561,1562],"ai-agents","langgraph","state-machines","observability","v1.0.0","sagl9YeOnPEXIUW1LcIVy7FH5dlFsnTZ8rXuEeRfnME",{"id":1566,"title":1567,"articleId":1568,"body":1569,"category":2888,"codeLang":1613,"date":2889,"deploys":43,"description":1573,"excerpt":742,"extension":743,"lang":744,"meta":2890,"navigation":189,"path":2891,"pos":2892,"readMin":96,"related":2896,"seo":2897,"service":2898,"stem":2899,"tags":2900,"version":761,"__hash__":2907},"articles_pl\u002Fpl\u002Farticles\u002Fansible-production.md","Ansible na produkcji: idempotencja, wykrywanie dryftu i bezpieczne zmiany falami","ansible-production",{"type":8,"value":1570,"toc":2880},[1571,1574,1578,1609,1681,1688,1692,1695,1749,1755,1903,1912,1915,1937,1947,1951,1954,2499,2502,2575,2584,2588,2595,2635,2645,2660,2732,2735,2739,2742,2790,2793,2815,2819,2877],[11,1572,1573],{},"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 runie przerwanym w połowie. Pytanie nie brzmi więc, czy playbook działa, tylko czy doprowadzi host z nieznanego stanu do zadeklarowanego i czy powie, co po drodze musiał zmienić.",[23,1575,1577],{"id":1576},"idempotentny-to-nie-to-samo-co-zbieżny","Idempotentny to nie to samo co zbieżny",[11,1579,1580,1581,1584,1585,1011,1588,1011,1591,1011,1594,1597,1598,485,1601,1604,1605,1608],{},"Moduły Ansible są idempotentne w wąskim sensie: dwukrotne wykonanie zadania z tymi samymi argumentami zostawia system w tym samym stanie, a drugi run raportuje ",[15,1582,1583],{},"ok",". To działa dla modułów, które porównują stan bieżący z docelowym (",[15,1586,1587],{},"template",[15,1589,1590],{},"copy",[15,1592,1593],{},"service",[15,1595,1596],{},"apt","). Nie działa dla ",[15,1599,1600],{},"command",[15,1602,1603],{},"shell",", bo do ich wnętrza Ansible nie zagląda. Raportują ",[15,1606,1607],{},"changed"," przy każdym uruchomieniu, chyba że określisz to sam:",[31,1610,1614],{"className":1611,"code":1612,"language":1613,"meta":36,"style":36},"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",[15,1615,1616,1633,1643,1651,1661,1671],{"__ignoreMap":36},[40,1617,1618,1622,1626,1629],{"class":42,"line":43},[40,1619,1621],{"class":1620},"sVt8B","- ",[40,1623,1625],{"class":1624},"s9eBZ","name",[40,1627,1628],{"class":1620},": ",[40,1630,1632],{"class":1631},"sZZnC","Run database migrations\n",[40,1634,1635,1638,1640],{"class":42,"line":49},[40,1636,1637],{"class":1624},"  ansible.builtin.command",[40,1639,1628],{"class":1620},[40,1641,1642],{"class":1631},"php artisan migrate --force\n",[40,1644,1645,1648],{"class":42,"line":55},[40,1646,1647],{"class":1624},"  args",[40,1649,1650],{"class":1620},":\n",[40,1652,1653,1656,1658],{"class":42,"line":84},[40,1654,1655],{"class":1624},"    chdir",[40,1657,1628],{"class":1620},[40,1659,1660],{"class":1631},"\u002Fvar\u002Fwww\u002Fapp\n",[40,1662,1663,1666,1668],{"class":42,"line":90},[40,1664,1665],{"class":1624},"  register",[40,1667,1628],{"class":1620},[40,1669,1670],{"class":1631},"migrate\n",[40,1672,1673,1676,1678],{"class":42,"line":96},[40,1674,1675],{"class":1624},"  changed_when",[40,1677,1628],{"class":1620},[40,1679,1680],{"class":1631},"\"'Nothing to migrate' not in migrate.stdout\"\n",[11,1682,1683,1684,1687],{},"Bez ",[15,1685,1686],{},"changed_when"," każdy run pokazuje co najmniej jedną zmianę i licznik zmian przestaje cokolwiek znaczyć. A licznik zmian to najtańszy sygnał dryftu, jaki masz.",[23,1689,1691],{"id":1690},"jak-zobaczyć-dryft","Jak zobaczyć dryft",[11,1693,1694],{},"Kiedy Ansible po cichu naprawia host, ginie informacja, że ktoś go zmienił. Przykład: usługa, którą ktoś wyłączył ręcznie przy debugowaniu.",[31,1696,1698],{"className":1611,"code":1697,"language":1613,"meta":36,"style":36},"- name: Ensure application service is running\n  ansible.builtin.service:\n    name: myapp\n    state: started\n    enabled: true\n",[15,1699,1700,1711,1718,1728,1738],{"__ignoreMap":36},[40,1701,1702,1704,1706,1708],{"class":42,"line":43},[40,1703,1621],{"class":1620},[40,1705,1625],{"class":1624},[40,1707,1628],{"class":1620},[40,1709,1710],{"class":1631},"Ensure application service is running\n",[40,1712,1713,1716],{"class":42,"line":49},[40,1714,1715],{"class":1624},"  ansible.builtin.service",[40,1717,1650],{"class":1620},[40,1719,1720,1723,1725],{"class":42,"line":55},[40,1721,1722],{"class":1624},"    name",[40,1724,1628],{"class":1620},[40,1726,1727],{"class":1631},"myapp\n",[40,1729,1730,1733,1735],{"class":42,"line":84},[40,1731,1732],{"class":1624},"    state",[40,1734,1628],{"class":1620},[40,1736,1737],{"class":1631},"started\n",[40,1739,1740,1743,1745],{"class":42,"line":90},[40,1741,1742],{"class":1624},"    enabled",[40,1744,1628],{"class":1620},[40,1746,1748],{"class":1747},"sj4cs","true\n",[11,1750,1751,1752,1754],{},"Zadanie przywraca stan i raportuje ",[15,1753,1607],{},", ale z raportu nie wynika, czy to pierwsza instalacja, czy cofnięcie ręcznej zmiany. Jeśli to rozróżnienie jest potrzebne, odczytaj stan przed zmianą:",[31,1756,1758],{"className":1611,"code":1757,"language":1613,"meta":36,"style":36},"- name: Read current enablement state\n  ansible.builtin.command: systemctl is-enabled myapp\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: \"myapp 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: myapp\n    state: started\n    enabled: true\n",[15,1759,1760,1771,1780,1789,1798,1807,1816,1820,1831,1838,1848,1858,1862,1873,1879,1887,1895],{"__ignoreMap":36},[40,1761,1762,1764,1766,1768],{"class":42,"line":43},[40,1763,1621],{"class":1620},[40,1765,1625],{"class":1624},[40,1767,1628],{"class":1620},[40,1769,1770],{"class":1631},"Read current enablement state\n",[40,1772,1773,1775,1777],{"class":42,"line":49},[40,1774,1637],{"class":1624},[40,1776,1628],{"class":1620},[40,1778,1779],{"class":1631},"systemctl is-enabled myapp\n",[40,1781,1782,1784,1786],{"class":42,"line":55},[40,1783,1665],{"class":1624},[40,1785,1628],{"class":1620},[40,1787,1788],{"class":1631},"svc_enabled\n",[40,1790,1791,1793,1795],{"class":42,"line":84},[40,1792,1675],{"class":1624},[40,1794,1628],{"class":1620},[40,1796,1797],{"class":1747},"false\n",[40,1799,1800,1803,1805],{"class":42,"line":90},[40,1801,1802],{"class":1624},"  failed_when",[40,1804,1628],{"class":1620},[40,1806,1797],{"class":1747},[40,1808,1809,1812,1814],{"class":42,"line":96},[40,1810,1811],{"class":1624},"  check_mode",[40,1813,1628],{"class":1620},[40,1815,1797],{"class":1747},[40,1817,1818],{"class":42,"line":102},[40,1819,190],{"emptyLinePlaceholder":189},[40,1821,1822,1824,1826,1828],{"class":42,"line":193},[40,1823,1621],{"class":1620},[40,1825,1625],{"class":1624},[40,1827,1628],{"class":1620},[40,1829,1830],{"class":1631},"Report manual override\n",[40,1832,1833,1836],{"class":42,"line":199},[40,1834,1835],{"class":1624},"  ansible.builtin.debug",[40,1837,1650],{"class":1620},[40,1839,1840,1843,1845],{"class":42,"line":204},[40,1841,1842],{"class":1624},"    msg",[40,1844,1628],{"class":1620},[40,1846,1847],{"class":1631},"\"myapp is '{{ svc_enabled.stdout }}' on {{ inventory_hostname }}, expected 'enabled'\"\n",[40,1849,1850,1853,1855],{"class":42,"line":210},[40,1851,1852],{"class":1624},"  when",[40,1854,1628],{"class":1620},[40,1856,1857],{"class":1631},"svc_enabled.stdout != 'enabled'\n",[40,1859,1860],{"class":42,"line":216},[40,1861,190],{"emptyLinePlaceholder":189},[40,1863,1864,1866,1868,1870],{"class":42,"line":222},[40,1865,1621],{"class":1620},[40,1867,1625],{"class":1624},[40,1869,1628],{"class":1620},[40,1871,1872],{"class":1631},"Converge service state\n",[40,1874,1875,1877],{"class":42,"line":227},[40,1876,1715],{"class":1624},[40,1878,1650],{"class":1620},[40,1880,1881,1883,1885],{"class":42,"line":232},[40,1882,1722],{"class":1624},[40,1884,1628],{"class":1620},[40,1886,1727],{"class":1631},[40,1888,1889,1891,1893],{"class":42,"line":238},[40,1890,1732],{"class":1624},[40,1892,1628],{"class":1620},[40,1894,1737],{"class":1631},[40,1896,1897,1899,1901],{"class":42,"line":244},[40,1898,1742],{"class":1624},[40,1900,1628],{"class":1620},[40,1902,1748],{"class":1747},[11,1904,1905,1908,1909,1911],{},[15,1906,1907],{},"check_mode: false"," jest tu potrzebne, bo zadania ",[15,1910,1600],{}," są w check mode domyślnie pomijane. Bez tego odczyt nie wykona się przy próbnym uruchomieniu.",[11,1913,1914],{},"Dla całej floty praktycznym detektorem dryftu jest próbny run puszczany z harmonogramu:",[31,1916,1920],{"className":1917,"code":1918,"language":1919,"meta":36,"style":36},"language-bash shiki shiki-themes github-light github-dark","ansible-playbook site.yml --check --diff\n","bash",[15,1921,1922],{"__ignoreMap":36},[40,1923,1924,1928,1931,1934],{"class":42,"line":43},[40,1925,1927],{"class":1926},"sScJk","ansible-playbook",[40,1929,1930],{"class":1631}," site.yml",[40,1932,1933],{"class":1747}," --check",[40,1935,1936],{"class":1747}," --diff\n",[11,1938,1939,1940,1942,1943,1946],{},"Jeśli wszystkie playbooki mają poprawne ",[15,1941,1686],{},", każdy host z ",[15,1944,1945],{},"changed>0"," w podsumowaniu odjechał od stanu docelowego, a diff pokazuje, w których liniach. Są dwa ograniczenia. 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. Wynik traktuj jako listę hostów do obejrzenia, nie jako dokładny raport.",[23,1948,1950],{"id":1949},"zmiany-falami-z-rollbackiem","Zmiany falami z rollbackiem",[11,1952,1953],{},"Załóżmy taki scenariusz: deploy przerwał się w trakcie aktualizacji konfiguracji upstreamów nginx i część floty działa na starej konfiguracji, a 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. Taki playbook pisze się i testuje zawczasu:",[31,1955,1957],{"className":1611,"code":1956,"language":1613,"meta":36,"style":36},"- 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: 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:{{ app_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: 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",[15,1958,1959,1970,1980,1989,1999,2009,2013,2020,2032,2039,2049,2059,2063,2070,2081,2088,2100,2107,2117,2127,2137,2146,2156,2165,2175,2184,2188,2199,2209,2218,2222,2233,2243,2247,2258,2265,2275,2285,2295,2305,2316,2326,2336,2341,2349,2361,2369,2379,2388,2398,2407,2418,2423,2435,2443,2453,2463,2468,2480,2488],{"__ignoreMap":36},[40,1960,1961,1963,1965,1967],{"class":42,"line":43},[40,1962,1621],{"class":1620},[40,1964,1625],{"class":1624},[40,1966,1628],{"class":1620},[40,1968,1969],{"class":1631},"Converge nginx upstream config\n",[40,1971,1972,1975,1977],{"class":42,"line":49},[40,1973,1974],{"class":1624},"  hosts",[40,1976,1628],{"class":1620},[40,1978,1979],{"class":1631},"api_servers\n",[40,1981,1982,1985,1987],{"class":42,"line":55},[40,1983,1984],{"class":1624},"  become",[40,1986,1628],{"class":1620},[40,1988,1748],{"class":1747},[40,1990,1991,1994,1996],{"class":42,"line":84},[40,1992,1993],{"class":1624},"  serial",[40,1995,1628],{"class":1620},[40,1997,1998],{"class":1747},"2\n",[40,2000,2001,2004,2006],{"class":42,"line":90},[40,2002,2003],{"class":1624},"  max_fail_percentage",[40,2005,1628],{"class":1620},[40,2007,2008],{"class":1747},"0\n",[40,2010,2011],{"class":42,"line":96},[40,2012,190],{"emptyLinePlaceholder":189},[40,2014,2015,2018],{"class":42,"line":102},[40,2016,2017],{"class":1624},"  handlers",[40,2019,1650],{"class":1620},[40,2021,2022,2025,2027,2029],{"class":42,"line":193},[40,2023,2024],{"class":1620},"    - ",[40,2026,1625],{"class":1624},[40,2028,1628],{"class":1620},[40,2030,2031],{"class":1631},"Reload nginx\n",[40,2033,2034,2037],{"class":42,"line":199},[40,2035,2036],{"class":1624},"      ansible.builtin.service",[40,2038,1650],{"class":1620},[40,2040,2041,2044,2046],{"class":42,"line":204},[40,2042,2043],{"class":1624},"        name",[40,2045,1628],{"class":1620},[40,2047,2048],{"class":1631},"nginx\n",[40,2050,2051,2054,2056],{"class":42,"line":210},[40,2052,2053],{"class":1624},"        state",[40,2055,1628],{"class":1620},[40,2057,2058],{"class":1631},"reloaded\n",[40,2060,2061],{"class":42,"line":216},[40,2062,190],{"emptyLinePlaceholder":189},[40,2064,2065,2068],{"class":42,"line":222},[40,2066,2067],{"class":1624},"  tasks",[40,2069,1650],{"class":1620},[40,2071,2072,2074,2076,2078],{"class":42,"line":227},[40,2073,2024],{"class":1620},[40,2075,1625],{"class":1624},[40,2077,1628],{"class":1620},[40,2079,2080],{"class":1631},"Deploy, verify, roll back on failure\n",[40,2082,2083,2086],{"class":42,"line":232},[40,2084,2085],{"class":1624},"      block",[40,2087,1650],{"class":1620},[40,2089,2090,2093,2095,2097],{"class":42,"line":238},[40,2091,2092],{"class":1620},"        - ",[40,2094,1625],{"class":1624},[40,2096,1628],{"class":1620},[40,2098,2099],{"class":1631},"Render upstream config\n",[40,2101,2102,2105],{"class":42,"line":244},[40,2103,2104],{"class":1624},"          ansible.builtin.template",[40,2106,1650],{"class":1620},[40,2108,2109,2112,2114],{"class":42,"line":250},[40,2110,2111],{"class":1624},"            src",[40,2113,1628],{"class":1620},[40,2115,2116],{"class":1631},"templates\u002Fupstream.conf.j2\n",[40,2118,2119,2122,2124],{"class":42,"line":256},[40,2120,2121],{"class":1624},"            dest",[40,2123,1628],{"class":1620},[40,2125,2126],{"class":1631},"\u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n",[40,2128,2129,2132,2134],{"class":42,"line":261},[40,2130,2131],{"class":1624},"            owner",[40,2133,1628],{"class":1620},[40,2135,2136],{"class":1631},"root\n",[40,2138,2139,2142,2144],{"class":42,"line":267},[40,2140,2141],{"class":1624},"            group",[40,2143,1628],{"class":1620},[40,2145,2136],{"class":1631},[40,2147,2148,2151,2153],{"class":42,"line":272},[40,2149,2150],{"class":1624},"            mode",[40,2152,1628],{"class":1620},[40,2154,2155],{"class":1631},"'0644'\n",[40,2157,2158,2161,2163],{"class":42,"line":278},[40,2159,2160],{"class":1624},"            backup",[40,2162,1628],{"class":1620},[40,2164,1748],{"class":1747},[40,2166,2167,2170,2172],{"class":42,"line":283},[40,2168,2169],{"class":1624},"          register",[40,2171,1628],{"class":1620},[40,2173,2174],{"class":1631},"upstream_conf\n",[40,2176,2177,2180,2182],{"class":42,"line":288},[40,2178,2179],{"class":1624},"          notify",[40,2181,1628],{"class":1620},[40,2183,2031],{"class":1631},[40,2185,2186],{"class":42,"line":294},[40,2187,190],{"emptyLinePlaceholder":189},[40,2189,2190,2192,2194,2196],{"class":42,"line":299},[40,2191,2092],{"class":1620},[40,2193,1625],{"class":1624},[40,2195,1628],{"class":1620},[40,2197,2198],{"class":1631},"Test full nginx configuration\n",[40,2200,2201,2204,2206],{"class":42,"line":305},[40,2202,2203],{"class":1624},"          ansible.builtin.command",[40,2205,1628],{"class":1620},[40,2207,2208],{"class":1631},"nginx -t\n",[40,2210,2211,2214,2216],{"class":42,"line":310},[40,2212,2213],{"class":1624},"          changed_when",[40,2215,1628],{"class":1620},[40,2217,1797],{"class":1747},[40,2219,2220],{"class":42,"line":955},[40,2221,190],{"emptyLinePlaceholder":189},[40,2223,2224,2226,2228,2230],{"class":42,"line":961},[40,2225,2092],{"class":1620},[40,2227,1625],{"class":1624},[40,2229,1628],{"class":1620},[40,2231,2232],{"class":1631},"Reload now instead of at the end of the play\n",[40,2234,2235,2238,2240],{"class":42,"line":967},[40,2236,2237],{"class":1624},"          ansible.builtin.meta",[40,2239,1628],{"class":1620},[40,2241,2242],{"class":1631},"flush_handlers\n",[40,2244,2245],{"class":42,"line":973},[40,2246,190],{"emptyLinePlaceholder":189},[40,2248,2249,2251,2253,2255],{"class":42,"line":978},[40,2250,2092],{"class":1620},[40,2252,1625],{"class":1624},[40,2254,1628],{"class":1620},[40,2256,2257],{"class":1631},"Wait for health endpoint\n",[40,2259,2260,2263],{"class":42,"line":984},[40,2261,2262],{"class":1624},"          ansible.builtin.uri",[40,2264,1650],{"class":1620},[40,2266,2267,2270,2272],{"class":42,"line":990},[40,2268,2269],{"class":1624},"            url",[40,2271,1628],{"class":1620},[40,2273,2274],{"class":1631},"\"http:\u002F\u002F127.0.0.1:{{ app_port }}\u002Fhealth\"\n",[40,2276,2277,2280,2282],{"class":42,"line":996},[40,2278,2279],{"class":1624},"            status_code",[40,2281,1628],{"class":1620},[40,2283,2284],{"class":1747},"200\n",[40,2286,2287,2290,2292],{"class":42,"line":1002},[40,2288,2289],{"class":1624},"            timeout",[40,2291,1628],{"class":1620},[40,2293,2294],{"class":1747},"5\n",[40,2296,2298,2300,2302],{"class":42,"line":2297},39,[40,2299,2169],{"class":1624},[40,2301,1628],{"class":1620},[40,2303,2304],{"class":1631},"health\n",[40,2306,2308,2311,2313],{"class":42,"line":2307},40,[40,2309,2310],{"class":1624},"          until",[40,2312,1628],{"class":1620},[40,2314,2315],{"class":1631},"health.status == 200\n",[40,2317,2319,2322,2324],{"class":42,"line":2318},41,[40,2320,2321],{"class":1624},"          retries",[40,2323,1628],{"class":1620},[40,2325,2294],{"class":1747},[40,2327,2329,2332,2334],{"class":42,"line":2328},42,[40,2330,2331],{"class":1624},"          delay",[40,2333,1628],{"class":1620},[40,2335,1998],{"class":1747},[40,2337,2339],{"class":42,"line":2338},43,[40,2340,190],{"emptyLinePlaceholder":189},[40,2342,2344,2347],{"class":42,"line":2343},44,[40,2345,2346],{"class":1624},"      rescue",[40,2348,1650],{"class":1620},[40,2350,2352,2354,2356,2358],{"class":42,"line":2351},45,[40,2353,2092],{"class":1620},[40,2355,1625],{"class":1624},[40,2357,1628],{"class":1620},[40,2359,2360],{"class":1631},"Restore previous config\n",[40,2362,2364,2367],{"class":42,"line":2363},46,[40,2365,2366],{"class":1624},"          ansible.builtin.copy",[40,2368,1650],{"class":1620},[40,2370,2372,2374,2376],{"class":42,"line":2371},47,[40,2373,2111],{"class":1624},[40,2375,1628],{"class":1620},[40,2377,2378],{"class":1631},"\"{{ upstream_conf.backup_file }}\"\n",[40,2380,2382,2384,2386],{"class":42,"line":2381},48,[40,2383,2121],{"class":1624},[40,2385,1628],{"class":1620},[40,2387,2126],{"class":1631},[40,2389,2391,2394,2396],{"class":42,"line":2390},49,[40,2392,2393],{"class":1624},"            remote_src",[40,2395,1628],{"class":1620},[40,2397,1748],{"class":1747},[40,2399,2401,2403,2405],{"class":42,"line":2400},50,[40,2402,2150],{"class":1624},[40,2404,1628],{"class":1620},[40,2406,2155],{"class":1631},[40,2408,2410,2413,2415],{"class":42,"line":2409},51,[40,2411,2412],{"class":1624},"          when",[40,2414,1628],{"class":1620},[40,2416,2417],{"class":1631},"upstream_conf.backup_file is defined\n",[40,2419,2421],{"class":42,"line":2420},52,[40,2422,190],{"emptyLinePlaceholder":189},[40,2424,2426,2428,2430,2432],{"class":42,"line":2425},53,[40,2427,2092],{"class":1620},[40,2429,1625],{"class":1624},[40,2431,1628],{"class":1620},[40,2433,2434],{"class":1631},"Reload nginx with restored config\n",[40,2436,2438,2441],{"class":42,"line":2437},54,[40,2439,2440],{"class":1624},"          ansible.builtin.service",[40,2442,1650],{"class":1620},[40,2444,2446,2449,2451],{"class":42,"line":2445},55,[40,2447,2448],{"class":1624},"            name",[40,2450,1628],{"class":1620},[40,2452,2048],{"class":1631},[40,2454,2456,2459,2461],{"class":42,"line":2455},56,[40,2457,2458],{"class":1624},"            state",[40,2460,1628],{"class":1620},[40,2462,2058],{"class":1631},[40,2464,2466],{"class":42,"line":2465},57,[40,2467,190],{"emptyLinePlaceholder":189},[40,2469,2471,2473,2475,2477],{"class":42,"line":2470},58,[40,2472,2092],{"class":1620},[40,2474,1625],{"class":1624},[40,2476,1628],{"class":1620},[40,2478,2479],{"class":1631},"Mark host as failed\n",[40,2481,2483,2486],{"class":42,"line":2482},59,[40,2484,2485],{"class":1624},"          ansible.builtin.fail",[40,2487,1650],{"class":1620},[40,2489,2491,2494,2496],{"class":42,"line":2490},60,[40,2492,2493],{"class":1624},"            msg",[40,2495,1628],{"class":1620},[40,2497,2498],{"class":1631},"\"upstream.conf rolled back on {{ inventory_hostname }}\"\n",[11,2500,2501],{},"Co robią poszczególne elementy:",[703,2503,2504,2510,2516,2540,2547,2565],{},[127,2505,2506,2509],{},[15,2507,2508],{},"serial: 2"," dzieli inwentarz na paczki po dwa hosty. Przy ośmiu serwerach zmieniane są naraz najwyżej dwa.",[127,2511,2512,2515],{},[15,2513,2514],{},"max_fail_percentage: 0"," zatrzymuje wdrożenie po pierwszej paczce z błędem. 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.",[127,2517,2518,2521,2522,2525,2526,2529,2530,2533,2534,2536,2537,1139],{},[15,2519,2520],{},"nginx -t"," testuje całą konfigurację, a nie sam wyrenderowany plik. Fragment z ",[15,2523,2524],{},"conf.d"," z blokiem ",[15,2527,2528],{},"upstream"," nie jest samodzielną poprawną konfiguracją, więc opcja ",[15,2531,2532],{},"validate"," modułu ",[15,2535,1587],{}," tu nie zadziała. Nadaje się do plików, które nginx potrafi przetestować samodzielnie, jak ",[15,2538,2539],{},"nginx.conf",[127,2541,2542,2543,2546],{},"Handlery wykonują się standardowo na końcu playa (dla każdej paczki osobno). ",[15,2544,2545],{},"meta: flush_handlers"," przeładowuje nginx przed health checkiem, inaczej sprawdzałbyś starą konfigurację.",[127,2548,2549,2552,2553,2556,2557,2560,2561,2564],{},[15,2550,2551],{},"backup: true"," zostawia kopię z datą w nazwie i zwraca jej ścieżkę w ",[15,2554,2555],{},"backup_file",". Sekcja ",[15,2558,2559],{},"rescue"," przywraca kopię, przeładowuje nginx i jawnie oznacza host jako błędny, żeby ",[15,2562,2563],{},"max_fail_percentage"," zatrzymał wdrożenie.",[127,2566,2567,2570,2571,2574],{},[15,2568,2569],{},"reloaded"," zamiast ",[15,2572,2573],{},"restarted",": nginx obsługuje ruch starymi workerami, dopóki nie wstaną nowe, więc reload nie zrywa połączeń.",[11,2576,2577,2578,485,2581,1139],{},"Ten playbook nie zdejmuje hostów z load balancera. Przy reloadzie zwykle nie trzeba. Przy restarcie aplikacji trzeba, a kroki drain\u002Fundrain lądują w ",[15,2579,2580],{},"pre_tasks",[15,2582,2583],{},"post_tasks",[23,2585,2587],{"id":2586},"sekrety","Sekrety",[11,2589,2590,2591,2594],{},"Sekrety należące do playbooka trzymaj w zaszyfrowanym pliku zmiennych, do którego odwołuje się zwykły plik. Prefiks ",[15,2592,2593],{},"vault_",", konwencja z dokumentacji Ansible, od razu pokazuje, skąd pochodzi wartość:",[31,2596,2598],{"className":1611,"code":2597,"language":1613,"meta":36,"style":36},"# group_vars\u002Fprod\u002Fvars.yml\ndb_password: \"{{ vault_db_password }}\"\n\n# group_vars\u002Fprod\u002Fvault.yml, encrypted with: ansible-vault encrypt group_vars\u002Fprod\u002Fvault.yml\nvault_db_password: \"...\"\n",[15,2599,2600,2606,2616,2620,2625],{"__ignoreMap":36},[40,2601,2602],{"class":42,"line":43},[40,2603,2605],{"class":2604},"sJ8bj","# group_vars\u002Fprod\u002Fvars.yml\n",[40,2607,2608,2611,2613],{"class":42,"line":49},[40,2609,2610],{"class":1624},"db_password",[40,2612,1628],{"class":1620},[40,2614,2615],{"class":1631},"\"{{ vault_db_password }}\"\n",[40,2617,2618],{"class":42,"line":55},[40,2619,190],{"emptyLinePlaceholder":189},[40,2621,2622],{"class":42,"line":84},[40,2623,2624],{"class":2604},"# group_vars\u002Fprod\u002Fvault.yml, encrypted with: ansible-vault encrypt group_vars\u002Fprod\u002Fvault.yml\n",[40,2626,2627,2630,2632],{"class":42,"line":90},[40,2628,2629],{"class":1624},"vault_db_password",[40,2631,1628],{"class":1620},[40,2633,2634],{"class":1631},"\"...\"\n",[11,2636,2637,2638,485,2641,2644],{},"Sekrety współdzielone z innymi systemami lepiej czytać w trakcie runu z menedżera sekretów przez lookup plugin (dostarczają je kolekcje ",[15,2639,2640],{},"amazon.aws",[15,2642,2643],{},"community.hashi_vault","). Wtedy źródło prawdy jest jedno.",[11,2646,2647,2648,2651,2652,2655,2656,2659],{},"Każde zadanie, które renderuje albo przekazuje sekret, potrzebuje ",[15,2649,2650],{},"no_log: true",". Ukrywa ono wynik zadania w logach i w wyjściu ",[15,2653,2654],{},"--diff",". Jeśli chcesz wyłączyć tylko diff, użyj ",[15,2657,2658],{},"diff: false",":",[31,2661,2663],{"className":1611,"code":2662,"language":1613,"meta":36,"style":36},"- 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",[15,2664,2665,2676,2683,2693,2703,2713,2723],{"__ignoreMap":36},[40,2666,2667,2669,2671,2673],{"class":42,"line":43},[40,2668,1621],{"class":1620},[40,2670,1625],{"class":1624},[40,2672,1628],{"class":1620},[40,2674,2675],{"class":1631},"Render database config\n",[40,2677,2678,2681],{"class":42,"line":49},[40,2679,2680],{"class":1624},"  ansible.builtin.template",[40,2682,1650],{"class":1620},[40,2684,2685,2688,2690],{"class":42,"line":55},[40,2686,2687],{"class":1624},"    src",[40,2689,1628],{"class":1620},[40,2691,2692],{"class":1631},"templates\u002Fdatabase.env.j2\n",[40,2694,2695,2698,2700],{"class":42,"line":84},[40,2696,2697],{"class":1624},"    dest",[40,2699,1628],{"class":1620},[40,2701,2702],{"class":1631},"\u002Fvar\u002Fwww\u002Fapp\u002F.env.database\n",[40,2704,2705,2708,2710],{"class":42,"line":90},[40,2706,2707],{"class":1624},"    owner",[40,2709,1628],{"class":1620},[40,2711,2712],{"class":1631},"www-data\n",[40,2714,2715,2718,2720],{"class":42,"line":96},[40,2716,2717],{"class":1624},"    mode",[40,2719,1628],{"class":1620},[40,2721,2722],{"class":1631},"'0640'\n",[40,2724,2725,2728,2730],{"class":42,"line":102},[40,2726,2727],{"class":1624},"  no_log",[40,2729,1628],{"class":1620},[40,2731,1748],{"class":1747},[11,2733,2734],{},"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.",[23,2736,2738],{"id":2737},"testy-przed-produkcją","Testy przed produkcją",[11,2740,2741],{},"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. Z Testinfra jako weryfikatorem testy sprawdzają stan, który faktycznie powstał, a nie raport samego Ansible:",[31,2743,2745],{"className":812,"code":2744,"language":814,"meta":36,"style":36},"# 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",[15,2746,2747,2752,2757,2762,2767,2772,2776,2780,2785],{"__ignoreMap":36},[40,2748,2749],{"class":42,"line":43},[40,2750,2751],{},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\n",[40,2753,2754],{"class":42,"line":49},[40,2755,2756],{},"def test_nginx_running_and_enabled(host):\n",[40,2758,2759],{"class":42,"line":55},[40,2760,2761],{},"    nginx = host.service(\"nginx\")\n",[40,2763,2764],{"class":42,"line":84},[40,2765,2766],{},"    assert nginx.is_running\n",[40,2768,2769],{"class":42,"line":90},[40,2770,2771],{},"    assert nginx.is_enabled\n",[40,2773,2774],{"class":42,"line":96},[40,2775,190],{"emptyLinePlaceholder":189},[40,2777,2778],{"class":42,"line":102},[40,2779,190],{"emptyLinePlaceholder":189},[40,2781,2782],{"class":42,"line":193},[40,2783,2784],{},"def test_nginx_config_valid(host):\n",[40,2786,2787],{"class":42,"line":199},[40,2788,2789],{},"    assert host.run(\"nginx -t\").rc == 0\n",[11,2791,2792],{},"Przed runem na produkcji puść próbny run na jednym reprezentatywnym hoście. Trwa krótko i pokazuje dokładne diffy szablonów:",[31,2794,2796],{"className":1917,"code":2795,"language":1919,"meta":36,"style":36},"ansible-playbook site.yml --check --diff --limit 'api_servers[0]'\n",[15,2797,2798],{"__ignoreMap":36},[40,2799,2800,2802,2804,2806,2809,2812],{"class":42,"line":43},[40,2801,1927],{"class":1926},[40,2803,1930],{"class":1631},[40,2805,1933],{"class":1747},[40,2807,2808],{"class":1747}," --diff",[40,2810,2811],{"class":1747}," --limit",[40,2813,2814],{"class":1631}," 'api_servers[0]'\n",[23,2816,2818],{"id":2817},"lista-kontrolna-do-code-review","Lista kontrolna do code review",[703,2820,2821,2836,2841,2854,2860,2865,2874],{},[127,2822,2823,2824,485,2826,2828,2829,2831,2832,2835],{},"Każde zadanie ",[15,2825,1600],{},[15,2827,1603],{}," ma ",[15,2830,1686],{}," (oraz ",[15,2833,2834],{},"failed_when"," tam, gdzie kod wyjścia nic nie mówi).",[127,2837,2838,2839,1139],{},"Zadania tylko do odczytu, które mają działać w próbnym runie, mają ",[15,2840,1907],{},[127,2842,2843,2844,2847,2848,2851,2852,1139],{},"Żadnego ",[15,2845,2846],{},"ignore_errors: true"," w zadaniach infrastrukturalnych. Błąd ma zatrzymać play i zostawić host poza rotacją. Jeśli potrzebujesz jawnej obsługi błędu, użyj ",[15,2849,2850],{},"block","\u002F",[15,2853,2559],{},[127,2855,2856,2859],{},[15,2857,2858],{},"become: true"," tylko na playach i zadaniach, które potrzebują roota.",[127,2861,2862,2863,1139],{},"Każde zadanie dotykające sekretu ma ",[15,2864,2650],{},[127,2866,2867,2868,2871,2872,1139],{},"Zmiany na więcej niż jednym hoście idą przez ",[15,2869,2870],{},"serial"," i świadomie dobrany ",[15,2873,2563],{},[127,2875,2876],{},"Handlery, od których zależą późniejsze zadania, są jawnie flushowane.",[729,2878,2879],{},"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":36,"searchDepth":49,"depth":49,"links":2881},[2882,2883,2884,2885,2886,2887],{"id":1576,"depth":49,"text":1577},{"id":1690,"depth":49,"text":1691},{"id":1949,"depth":49,"text":1950},{"id":2586,"depth":49,"text":2587},{"id":2737,"depth":49,"text":2738},{"id":2817,"depth":49,"text":2818},"backend","2024-05-06",{},"\u002Fpl\u002Farticles\u002Fansible-production",{"x":2893,"y":2894,"depth":2895,"size":743},0.36,0.8,0.9,[1553,1554],{"title":1567,"description":1573},"infra-automation","pl\u002Farticles\u002Fansible-production",[2901,2902,2903,2904,2905,2906],"ansible","devops","infrastructure","automation","idempotency","configuration-management","8uWpeJcpoMeQzOicTvJB1KVc_Q5KNEh5eQ14YzWExjw",{"id":2909,"title":2910,"articleId":2911,"body":2912,"category":35,"codeLang":35,"date":3828,"deploys":43,"description":2916,"excerpt":742,"extension":743,"lang":744,"meta":3829,"navigation":189,"path":3830,"pos":3831,"readMin":96,"related":3834,"seo":3837,"service":3838,"stem":3839,"tags":3840,"version":761,"__hash__":3846},"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":2913,"toc":3820},[2914,2917,2921,2924,2932,2935,2938,2942,2945,3121,3140,3143,3337,3340,3344,3347,3376,3379,3494,3507,3511,3514,3572,3578,3581,3596,3610,3614,3617,3773,3776,3780,3783,3804,3807,3818],[11,2915,2916],{},"Bridge rozcina hierarchię klas wzdłuż dwóch osi, które zmieniają się niezależnie, i łączy je kompozycją. W podręcznikach pokazuje się go na kształtach i rendererach. System powiadomień jest przykładem bliższym codziennej pracy, bo ma dokładnie dwie osie: co mówi wiadomość i jak zostaje dostarczona.",[23,2918,2920],{"id":2919},"problem-m-n-klas","Problem: M × N klas",[11,2922,2923],{},"Przykład: system wysyła cztery typy powiadomień (potwierdzenie płatności, niski stan magazynu, zawieszenie konta, raport tygodniowy) trzema kanałami (mail, SMS, Slack). Jeśli każda kombinacja to osobna klasa, wychodzi:",[31,2925,2930],{"className":2926,"code":2928,"language":2929},[2927],"language-text","PaymentConfirmationEmail     PaymentConfirmationSms     PaymentConfirmationSlack\nLowInventoryAlertEmail       LowInventoryAlertSms       LowInventoryAlertSlack\nAccountSuspendedEmail        AccountSuspendedSms        AccountSuspendedSlack\nWeeklyReportEmail            WeeklyReportSms            WeeklyReportSlack\n","text",[15,2931,2928],{"__ignoreMap":36},[11,2933,2934],{},"Dwanaście klas. Czwarty kanał dokłada cztery, piąty typ powiadomienia dokłada trzy. Liczba rośnie jak M × N, a każda klasa powiela zarówno logikę treści swojego typu, jak i logikę dostarczania swojego kanału. Zmianę sposobu skracania SMS-ów trzeba wtedy wprowadzić w czterech miejscach.",[11,2936,2937],{},"Przyczyna jest taka, że dziedziczenie (albo kopiuj-wklej) skleja w jednej klasie dwie niezależne decyzje. Bridge trzyma je w osobnych hierarchiach.",[23,2939,2941],{"id":2940},"dwie-hierarchie","Dwie hierarchie",[11,2943,2944],{},"Strona implementacji (implementor) opisuje, jak dostarczyć wiadomość:",[31,2946,2948],{"className":33,"code":2947,"language":35,"meta":36,"style":36},"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 has no subject line, so it goes into the message as bold text\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",[15,2949,2950,2955,2959,2964,2968,2972,2977,2981,2986,2990,2995,2999,3004,3008,3012,3016,3021,3025,3030,3034,3038,3042,3047,3052,3056,3060,3064,3069,3073,3077,3082,3087,3091,3095,3099,3103,3108,3113,3117],{"__ignoreMap":36},[40,2951,2952],{"class":42,"line":43},[40,2953,2954],{},"interface NotificationChannel\n",[40,2956,2957],{"class":42,"line":49},[40,2958,76],{},[40,2960,2961],{"class":42,"line":55},[40,2962,2963],{},"    public function send(string $recipient, string $subject, string $body): void;\n",[40,2965,2966],{"class":42,"line":84},[40,2967,105],{},[40,2969,2970],{"class":42,"line":90},[40,2971,190],{"emptyLinePlaceholder":189},[40,2973,2974],{"class":42,"line":96},[40,2975,2976],{},"final class EmailChannel implements NotificationChannel\n",[40,2978,2979],{"class":42,"line":102},[40,2980,76],{},[40,2982,2983],{"class":42,"line":193},[40,2984,2985],{},"    public function __construct(private readonly Mailer $mailer) {}\n",[40,2987,2988],{"class":42,"line":199},[40,2989,190],{"emptyLinePlaceholder":189},[40,2991,2992],{"class":42,"line":204},[40,2993,2994],{},"    public function send(string $recipient, string $subject, string $body): void\n",[40,2996,2997],{"class":42,"line":210},[40,2998,241],{},[40,3000,3001],{"class":42,"line":216},[40,3002,3003],{},"        $this->mailer->send($recipient, $subject, $body);\n",[40,3005,3006],{"class":42,"line":222},[40,3007,253],{},[40,3009,3010],{"class":42,"line":227},[40,3011,105],{},[40,3013,3014],{"class":42,"line":232},[40,3015,190],{"emptyLinePlaceholder":189},[40,3017,3018],{"class":42,"line":238},[40,3019,3020],{},"final class SlackChannel implements NotificationChannel\n",[40,3022,3023],{"class":42,"line":244},[40,3024,76],{},[40,3026,3027],{"class":42,"line":250},[40,3028,3029],{},"    public function __construct(private readonly SlackClient $slack) {}\n",[40,3031,3032],{"class":42,"line":256},[40,3033,190],{"emptyLinePlaceholder":189},[40,3035,3036],{"class":42,"line":261},[40,3037,2994],{},[40,3039,3040],{"class":42,"line":267},[40,3041,241],{},[40,3043,3044],{"class":42,"line":272},[40,3045,3046],{},"        \u002F\u002F Slack has no subject line, so it goes into the message as bold text\n",[40,3048,3049],{"class":42,"line":278},[40,3050,3051],{},"        $this->slack->postMessage($recipient, \"*{$subject}*\\n{$body}\");\n",[40,3053,3054],{"class":42,"line":283},[40,3055,253],{},[40,3057,3058],{"class":42,"line":288},[40,3059,105],{},[40,3061,3062],{"class":42,"line":294},[40,3063,190],{"emptyLinePlaceholder":189},[40,3065,3066],{"class":42,"line":299},[40,3067,3068],{},"final class SmsChannel implements NotificationChannel\n",[40,3070,3071],{"class":42,"line":305},[40,3072,76],{},[40,3074,3075],{"class":42,"line":310},[40,3076,81],{},[40,3078,3079],{"class":42,"line":955},[40,3080,3081],{},"        private readonly SmsProvider $sms,\n",[40,3083,3084],{"class":42,"line":961},[40,3085,3086],{},"        private readonly int $maxLength = 70,\n",[40,3088,3089],{"class":42,"line":967},[40,3090,99],{},[40,3092,3093],{"class":42,"line":973},[40,3094,190],{"emptyLinePlaceholder":189},[40,3096,3097],{"class":42,"line":978},[40,3098,2994],{},[40,3100,3101],{"class":42,"line":984},[40,3102,241],{},[40,3104,3105],{"class":42,"line":990},[40,3106,3107],{},"        $text = \"{$subject}: \" . strip_tags($body);\n",[40,3109,3110],{"class":42,"line":996},[40,3111,3112],{},"        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));\n",[40,3114,3115],{"class":42,"line":1002},[40,3116,253],{},[40,3118,3119],{"class":42,"line":2297},[40,3120,105],{},[11,3122,3123,1011,3126,485,3129,3132,3133,1628,3136,3139],{},[15,3124,3125],{},"Mailer",[15,3127,3128],{},"SlackClient",[15,3130,3131],{},"SmsProvider"," to zastępstwo dla klientów, które aplikacja już ma. Limit SMS-a jest parametrem nie bez powodu. 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. Z tego samego powodu potrzebne jest ",[15,3134,3135],{},"mb_substr",[15,3137,3138],{},"substr"," liczy bajty i potrafi przeciąć wielobajtowy znak w połowie.",[11,3141,3142],{},"Strona abstrakcji opisuje, co wysłać, a samo dostarczenie oddaje kanałowi:",[31,3144,3146],{"className":33,"code":3145,"language":35,"meta":36,"style":36},"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",[15,3147,3148,3153,3157,3161,3166,3171,3176,3181,3185,3189,3193,3198,3202,3207,3211,3216,3220,3224,3229,3233,3237,3242,3247,3252,3257,3261,3265,3270,3274,3279,3284,3289,3294,3299,3304,3309,3314,3319,3324,3329,3333],{"__ignoreMap":36},[40,3149,3150],{"class":42,"line":43},[40,3151,3152],{},"final readonly class Payment\n",[40,3154,3155],{"class":42,"line":49},[40,3156,76],{},[40,3158,3159],{"class":42,"line":55},[40,3160,81],{},[40,3162,3163],{"class":42,"line":84},[40,3164,3165],{},"        public string $reference,\n",[40,3167,3168],{"class":42,"line":90},[40,3169,3170],{},"        public int $amountMinor,\n",[40,3172,3173],{"class":42,"line":96},[40,3174,3175],{},"        public string $currency,\n",[40,3177,3178],{"class":42,"line":102},[40,3179,3180],{},"        public DateTimeImmutable $paidAt,\n",[40,3182,3183],{"class":42,"line":193},[40,3184,99],{},[40,3186,3187],{"class":42,"line":199},[40,3188,105],{},[40,3190,3191],{"class":42,"line":204},[40,3192,190],{"emptyLinePlaceholder":189},[40,3194,3195],{"class":42,"line":210},[40,3196,3197],{},"abstract class Notification\n",[40,3199,3200],{"class":42,"line":216},[40,3201,76],{},[40,3203,3204],{"class":42,"line":222},[40,3205,3206],{},"    public function __construct(protected readonly NotificationChannel $channel) {}\n",[40,3208,3209],{"class":42,"line":227},[40,3210,190],{"emptyLinePlaceholder":189},[40,3212,3213],{"class":42,"line":232},[40,3214,3215],{},"    abstract public function send(string $recipient): void;\n",[40,3217,3218],{"class":42,"line":238},[40,3219,105],{},[40,3221,3222],{"class":42,"line":244},[40,3223,190],{"emptyLinePlaceholder":189},[40,3225,3226],{"class":42,"line":250},[40,3227,3228],{},"final class PaymentConfirmationNotification extends Notification\n",[40,3230,3231],{"class":42,"line":256},[40,3232,76],{},[40,3234,3235],{"class":42,"line":261},[40,3236,81],{},[40,3238,3239],{"class":42,"line":267},[40,3240,3241],{},"        NotificationChannel $channel,\n",[40,3243,3244],{"class":42,"line":272},[40,3245,3246],{},"        private readonly Payment $payment,\n",[40,3248,3249],{"class":42,"line":278},[40,3250,3251],{},"    ) {\n",[40,3253,3254],{"class":42,"line":283},[40,3255,3256],{},"        parent::__construct($channel);\n",[40,3258,3259],{"class":42,"line":288},[40,3260,253],{},[40,3262,3263],{"class":42,"line":294},[40,3264,190],{"emptyLinePlaceholder":189},[40,3266,3267],{"class":42,"line":299},[40,3268,3269],{},"    public function send(string $recipient): void\n",[40,3271,3272],{"class":42,"line":305},[40,3273,241],{},[40,3275,3276],{"class":42,"line":310},[40,3277,3278],{},"        $this->channel->send(\n",[40,3280,3281],{"class":42,"line":955},[40,3282,3283],{},"            $recipient,\n",[40,3285,3286],{"class":42,"line":961},[40,3287,3288],{},"            \"Payment confirmed: {$this->payment->reference}\",\n",[40,3290,3291],{"class":42,"line":967},[40,3292,3293],{},"            sprintf(\n",[40,3295,3296],{"class":42,"line":973},[40,3297,3298],{},"                \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n",[40,3300,3301],{"class":42,"line":978},[40,3302,3303],{},"                number_format($this->payment->amountMinor \u002F 100, 2),\n",[40,3305,3306],{"class":42,"line":984},[40,3307,3308],{},"                $this->payment->currency,\n",[40,3310,3311],{"class":42,"line":990},[40,3312,3313],{},"                $this->payment->reference,\n",[40,3315,3316],{"class":42,"line":996},[40,3317,3318],{},"                $this->payment->paidAt->format('Y-m-d H:i'),\n",[40,3320,3321],{"class":42,"line":1002},[40,3322,3323],{},"            ),\n",[40,3325,3326],{"class":42,"line":2297},[40,3327,3328],{},"        );\n",[40,3330,3331],{"class":42,"line":2307},[40,3332,253],{},[40,3334,3335],{"class":42,"line":2318},[40,3336,105],{},[11,3338,3339],{},"Teraz klas jest M + N: cztery klasy powiadomień i trzy klasy kanałów. Nowy kanał to jedna klasa, nowy typ powiadomienia to jedna klasa, i żadna z tych zmian nie dotyka drugiej hierarchii.",[23,3341,3343],{"id":3342},"gdzie-obie-strony-się-łączą","Gdzie obie strony się łączą",[11,3345,3346],{},"Kombinację wybiera się przy tworzeniu obiektu:",[31,3348,3350],{"className":33,"code":3349,"language":35,"meta":36,"style":36},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n    ->send($user->email);\n\n(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n    ->send($user->phone);\n",[15,3351,3352,3357,3362,3366,3371],{"__ignoreMap":36},[40,3353,3354],{"class":42,"line":43},[40,3355,3356],{},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n",[40,3358,3359],{"class":42,"line":49},[40,3360,3361],{},"    ->send($user->email);\n",[40,3363,3364],{"class":42,"line":55},[40,3365,190],{"emptyLinePlaceholder":189},[40,3367,3368],{"class":42,"line":84},[40,3369,3370],{},"(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n",[40,3372,3373],{"class":42,"line":90},[40,3374,3375],{},"    ->send($user->phone);\n",[11,3377,3378],{},"W aplikacji to powinno siedzieć w jednym miejscu, zwykle w dispatcherze, który zna dostępne kanały i wie, jak zbudować każdy typ powiadomienia:",[31,3380,3382],{"className":33,"code":3381,"language":35,"meta":36,"style":36},"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",[15,3383,3384,3389,3393,3398,3403,3408,3413,3417,3422,3427,3431,3435,3440,3444,3449,3454,3458,3463,3468,3473,3477,3482,3486,3490],{"__ignoreMap":36},[40,3385,3386],{"class":42,"line":43},[40,3387,3388],{},"final class NotificationDispatcher\n",[40,3390,3391],{"class":42,"line":49},[40,3392,76],{},[40,3394,3395],{"class":42,"line":55},[40,3396,3397],{},"    \u002F**\n",[40,3399,3400],{"class":42,"line":84},[40,3401,3402],{},"     * @param array\u003Cstring, NotificationChannel> $channels\n",[40,3404,3405],{"class":42,"line":90},[40,3406,3407],{},"     * @param array\u003Cstring, Closure(NotificationChannel, array): Notification> $factories\n",[40,3409,3410],{"class":42,"line":96},[40,3411,3412],{},"     *\u002F\n",[40,3414,3415],{"class":42,"line":102},[40,3416,81],{},[40,3418,3419],{"class":42,"line":193},[40,3420,3421],{},"        private readonly array $channels,\n",[40,3423,3424],{"class":42,"line":199},[40,3425,3426],{},"        private readonly array $factories,\n",[40,3428,3429],{"class":42,"line":204},[40,3430,99],{},[40,3432,3433],{"class":42,"line":210},[40,3434,190],{"emptyLinePlaceholder":189},[40,3436,3437],{"class":42,"line":216},[40,3438,3439],{},"    public function dispatch(string $type, array $payload, User $user): void\n",[40,3441,3442],{"class":42,"line":222},[40,3443,241],{},[40,3445,3446],{"class":42,"line":227},[40,3447,3448],{},"        $factory = $this->factories[$type]\n",[40,3450,3451],{"class":42,"line":232},[40,3452,3453],{},"            ?? throw new InvalidArgumentException(\"Unknown notification type: {$type}\");\n",[40,3455,3456],{"class":42,"line":238},[40,3457,190],{"emptyLinePlaceholder":189},[40,3459,3460],{"class":42,"line":244},[40,3461,3462],{},"        foreach ($user->enabledChannels() as $name) {\n",[40,3464,3465],{"class":42,"line":250},[40,3466,3467],{},"            $channel = $this->channels[$name]\n",[40,3469,3470],{"class":42,"line":256},[40,3471,3472],{},"                ?? throw new InvalidArgumentException(\"Unknown channel: {$name}\");\n",[40,3474,3475],{"class":42,"line":261},[40,3476,190],{"emptyLinePlaceholder":189},[40,3478,3479],{"class":42,"line":267},[40,3480,3481],{},"            $factory($channel, $payload)->send($user->contactFor($name));\n",[40,3483,3484],{"class":42,"line":272},[40,3485,353],{},[40,3487,3488],{"class":42,"line":278},[40,3489,253],{},[40,3491,3492],{"class":42,"line":283},[40,3493,105],{},[11,3495,3496,485,3499,3502,3503,3506],{},[15,3497,3498],{},"enabledChannels()",[15,3500,3501],{},"contactFor()"," to metody twojego modelu ",[15,3504,3505],{},"User",". Dispatcher jest jedyną klasą, która zna obie hierarchie.",[23,3508,3510],{"id":3509},"kiedy-wymiary-nie-są-niezależne","Kiedy wymiary nie są niezależne",[11,3512,3513],{},"Bridge zakłada, że każde powiadomienie ma sens w każdym kanale. To założenie przestaje działać, gdy jakiś typ ma treść pasującą tylko do jednego kanału, na przykład raport tygodniowy w postaci tabeli HTML. Objawem jest sprawdzanie typu w abstrakcji:",[31,3515,3517],{"className":33,"code":3516,"language":35,"meta":36,"style":36},"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        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n    }\n}\n",[15,3518,3519,3524,3528,3532,3536,3541,3546,3551,3555,3559,3564,3568],{"__ignoreMap":36},[40,3520,3521],{"class":42,"line":43},[40,3522,3523],{},"final class WeeklyReportNotification extends Notification\n",[40,3525,3526],{"class":42,"line":49},[40,3527,76],{},[40,3529,3530],{"class":42,"line":55},[40,3531,3269],{},[40,3533,3534],{"class":42,"line":84},[40,3535,241],{},[40,3537,3538],{"class":42,"line":90},[40,3539,3540],{},"        if ($this->channel instanceof SmsChannel) {\n",[40,3542,3543],{"class":42,"line":96},[40,3544,3545],{},"            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');\n",[40,3547,3548],{"class":42,"line":102},[40,3549,3550],{},"            return;\n",[40,3552,3553],{"class":42,"line":193},[40,3554,353],{},[40,3556,3557],{"class":42,"line":199},[40,3558,190],{"emptyLinePlaceholder":189},[40,3560,3561],{"class":42,"line":204},[40,3562,3563],{},"        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n",[40,3565,3566],{"class":42,"line":210},[40,3567,253],{},[40,3569,3570],{"class":42,"line":216},[40,3571,105],{},[11,3573,3574,3577],{},[15,3575,3576],{},"instanceof"," oznacza, że powiadomienie zależy od konkretnego kanału, a właśnie przed tym wzorzec miał chronić. Każdy nowy kanał wymaga teraz przejrzenia wszystkich powiadomień pod kątem takich gałęzi.",[11,3579,3580],{},"Są dwie rozsądne reakcje:",[124,3582,3583,3593],{},[127,3584,3585,3586,485,3589,3592],{},"Przyjąć, że ten przypadek ma kształt M × N, i napisać osobne klasy: ",[15,3587,3588],{},"WeeklyReportEmailNotification",[15,3590,3591],{},"WeeklyReportSmsSummaryNotification",". Jawne klasy per kanał czyta się łatwiej niż Bridge z wyjątkami.",[127,3594,3595],{},"Jeśli wariantów per kanał potrzebuje wiele typów, zmienić kontrakt. Powiadomienie buduje obiekt wiadomości z kilkoma reprezentacjami (pełna treść, krótki tekst), a kanał wybiera tę, którą umie dostarczyć. Decyzja należy wtedy do kanału i powiadomienie nie musi wiedzieć, z jakim kanałem rozmawia.",[11,3597,3598,3599,3602,3603,1011,3606,3609],{},"Dla porównania system powiadomień w Laravelu leży między tymi opcjami: klasa powiadomienia deklaruje kanały w ",[15,3600,3601],{},"via()"," i implementuje osobną metodę dla każdego z nich (",[15,3604,3605],{},"toMail()",[15,3607,3608],{},"toArray()"," itd.). To do M × N metod w M klasach, świadomy wybór frameworka, w którym treść częściej różni się między kanałami, niż jest wspólna.",[23,3611,3613],{"id":3612},"testy","Testy",[11,3615,3616],{},"Każdą hierarchię da się testować bez drugiej. Kanał testujesz z zamockowanym klientem, powiadomienie z zamockowanym kanałem:",[31,3618,3620],{"className":33,"code":3619,"language":35,"meta":36,"style":36},"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('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n\n        (new EmailChannel($mailer))->send('alice@example.com', '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                'alice@example.com',\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('alice@example.com');\n    }\n}\n",[15,3621,3622,3627,3631,3636,3640,3645,3649,3654,3659,3664,3669,3673,3678,3682,3686,3690,3695,3699,3704,3708,3713,3718,3722,3727,3732,3737,3742,3747,3751,3756,3760,3765,3769],{"__ignoreMap":36},[40,3623,3624],{"class":42,"line":43},[40,3625,3626],{},"use PHPUnit\\Framework\\TestCase;\n",[40,3628,3629],{"class":42,"line":49},[40,3630,190],{"emptyLinePlaceholder":189},[40,3632,3633],{"class":42,"line":55},[40,3634,3635],{},"final class EmailChannelTest extends TestCase\n",[40,3637,3638],{"class":42,"line":84},[40,3639,76],{},[40,3641,3642],{"class":42,"line":90},[40,3643,3644],{},"    public function testDelegatesToMailer(): void\n",[40,3646,3647],{"class":42,"line":96},[40,3648,241],{},[40,3650,3651],{"class":42,"line":102},[40,3652,3653],{},"        $mailer = $this->createMock(Mailer::class);\n",[40,3655,3656],{"class":42,"line":193},[40,3657,3658],{},"        $mailer->expects($this->once())\n",[40,3660,3661],{"class":42,"line":199},[40,3662,3663],{},"            ->method('send')\n",[40,3665,3666],{"class":42,"line":204},[40,3667,3668],{},"            ->with('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[40,3670,3671],{"class":42,"line":210},[40,3672,190],{"emptyLinePlaceholder":189},[40,3674,3675],{"class":42,"line":216},[40,3676,3677],{},"        (new EmailChannel($mailer))->send('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[40,3679,3680],{"class":42,"line":222},[40,3681,253],{},[40,3683,3684],{"class":42,"line":227},[40,3685,105],{},[40,3687,3688],{"class":42,"line":232},[40,3689,190],{"emptyLinePlaceholder":189},[40,3691,3692],{"class":42,"line":238},[40,3693,3694],{},"final class PaymentConfirmationNotificationTest extends TestCase\n",[40,3696,3697],{"class":42,"line":244},[40,3698,76],{},[40,3700,3701],{"class":42,"line":250},[40,3702,3703],{},"    public function testBuildsSubjectAndBody(): void\n",[40,3705,3706],{"class":42,"line":256},[40,3707,241],{},[40,3709,3710],{"class":42,"line":261},[40,3711,3712],{},"        $channel = $this->createMock(NotificationChannel::class);\n",[40,3714,3715],{"class":42,"line":267},[40,3716,3717],{},"        $channel->expects($this->once())\n",[40,3719,3720],{"class":42,"line":272},[40,3721,3663],{},[40,3723,3724],{"class":42,"line":278},[40,3725,3726],{},"            ->with(\n",[40,3728,3729],{"class":42,"line":283},[40,3730,3731],{},"                'alice@example.com',\n",[40,3733,3734],{"class":42,"line":288},[40,3735,3736],{},"                $this->stringContains('PAY-2024-001'),\n",[40,3738,3739],{"class":42,"line":294},[40,3740,3741],{},"                $this->stringContains('100.00'),\n",[40,3743,3744],{"class":42,"line":299},[40,3745,3746],{},"            );\n",[40,3748,3749],{"class":42,"line":305},[40,3750,190],{"emptyLinePlaceholder":189},[40,3752,3753],{"class":42,"line":310},[40,3754,3755],{},"        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));\n",[40,3757,3758],{"class":42,"line":955},[40,3759,190],{"emptyLinePlaceholder":189},[40,3761,3762],{"class":42,"line":961},[40,3763,3764],{},"        (new PaymentConfirmationNotification($channel, $payment))->send('alice@example.com');\n",[40,3766,3767],{"class":42,"line":967},[40,3768,253],{},[40,3770,3771],{"class":42,"line":973},[40,3772,105],{},[11,3774,3775],{},"Liczba testów rośnie jak M + N, tak samo jak liczba klas. Żaden test nie potrzebuje prawdziwego serwera pocztowego ani API Slacka.",[23,3777,3779],{"id":3778},"kiedy-stosować-a-kiedy-nie","Kiedy stosować, a kiedy nie",[11,3781,3782],{},"Bridge ma sens, gdy:",[703,3784,3785,3788,3801],{},[127,3786,3787],{},"są dwa wymiary i oba zmieniają się w czasie (nowe kanały i nowe typy wiadomości, nowe formaty eksportu i nowe typy raportów),",[127,3789,3790,3791,1011,3794,1011,3797,3800],{},"nazwy klas zaczynają sklejać dwa pojęcia: ",[15,3792,3793],{},"PaymentEmailNotification",[15,3795,3796],{},"PdfInvoiceExporter",[15,3798,3799],{},"CsvAuditLogFormatter",",",[127,3802,3803],{},"prawie każda kombinacja jest poprawna.",[11,3805,3806],{},"Nie stosuj go, gdy:",[703,3808,3809,3812,3815],{},[127,3810,3811],{},"zmienia się tylko jeden wymiar. Przy jednym kanale wystarczy interfejs po stronie powiadomień,",[127,3813,3814],{},"wiele kombinacji wymaga specjalnej obsługi. Wtedy problem ma kształt M × N i jawne klasy albo metody per kanał opisują go uczciwiej,",[127,3816,3817],{},"kombinacji jest w sumie dwie albo trzy. Dodatkowa warstwa pośrednia kosztuje więcej niż duplikacja, którą usuwa.",[729,3819,731],{},{"title":36,"searchDepth":49,"depth":49,"links":3821},[3822,3823,3824,3825,3826,3827],{"id":2919,"depth":49,"text":2920},{"id":2940,"depth":49,"text":2941},{"id":3342,"depth":49,"text":3343},{"id":3509,"depth":49,"text":3510},{"id":3612,"depth":49,"text":3613},{"id":3778,"depth":49,"text":3779},"2023-10-19",{},"\u002Fpl\u002Farticles\u002Fbridge-pattern",{"x":3832,"y":3833,"depth":2895,"size":743},0.86,0.18,[3835,3836],"factory-method","design-patterns-production",{"title":2910,"description":2916},"notification-hub","pl\u002Farticles\u002Fbridge-pattern",[35,3841,3842,3843,3844,3845],"design-patterns","bridge","architecture","notifications","abstraction","0l2RHwzygKXj2fBeADw6hhdwb1EGgg22EsZM99XH4J0",{"id":3848,"title":3849,"articleId":3850,"body":3851,"category":2888,"codeLang":1919,"date":4382,"deploys":43,"description":3855,"excerpt":742,"extension":743,"lang":744,"meta":4383,"navigation":189,"path":4384,"pos":4385,"readMin":96,"related":4388,"seo":4390,"service":4391,"stem":4392,"tags":4393,"version":1563,"__hash__":4398},"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":3852,"toc":4374},[3853,3856,3860,3867,3900,3903,3909,3924,3961,3964,3968,3971,3974,3988,3991,3998,4002,4009,4101,4108,4115,4150,4153,4157,4175,4182,4202,4215,4225,4240,4243,4315,4318,4322,4337,4341,4371],[11,3854,3855],{},"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. Poniżej opis, skąd ta rozbieżność i co zostało zmienione, żeby się nie powtórzyła.",[23,3857,3859],{"id":3858},"objaw-curl-mówi-200-przeglądarka-nie-ładuje-css","Objaw: curl mówi 200, przeglądarka nie ładuje CSS",[11,3861,3862,3863,3866],{},"Pierwsze sprawdzenie wyglądało dobrze. HTML wskazywał na arkusz ",[15,3864,3865],{},"\u002F_nuxt\u002Fentry.Czy_6vul.css",", a ten plik odpowiadał poprawnie:",[31,3868,3870],{"className":1917,"code":3869,"language":1919,"meta":36,"style":36},"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",[15,3871,3872,3895],{"__ignoreMap":36},[40,3873,3874,3877,3880,3883,3886,3889,3892],{"class":42,"line":43},[40,3875,3876],{"class":1926},"curl",[40,3878,3879],{"class":1747}," -s",[40,3881,3882],{"class":1747}," -o",[40,3884,3885],{"class":1631}," \u002Fdev\u002Fnull",[40,3887,3888],{"class":1747}," -w",[40,3890,3891],{"class":1631}," '%{http_code} %{content_type}\\n'",[40,3893,3894],{"class":1631}," https:\u002F\u002Ftkulesza.eu\u002F_nuxt\u002Fentry.Czy_6vul.css\n",[40,3896,3897],{"class":42,"line":49},[40,3898,3899],{"class":2604},"# 200 text\u002Fcss\n",[11,3901,3902],{},"Dopiero przeglądarka bez interfejsu (puppeteer) pokazała, co się dzieje. W konsoli pojawiał się błąd:",[31,3904,3907],{"className":3905,"code":3906,"language":2929,"meta":36},[2927],"Failed to load module script: Expected a JavaScript-or-Wasm module script\nbut the server responded with a MIME type of \"text\u002Fhtml\".\n",[15,3908,3906],{"__ignoreMap":36},[11,3910,3911,3912,3915,3916,3919,3920,3923],{},"Po dopisaniu do skryptu logowania odpowiedzi z katalogu ",[15,3913,3914],{},"_nuxt",", których ",[15,3917,3918],{},"Content-Type"," zawiera ",[15,3921,3922],{},"html",", wyszło, że ten sam adres CSS i kilka modułów JS przychodzą do przeglądarki jako HTML, ze statusem 200:",[31,3925,3929],{"className":3926,"code":3927,"language":3928,"meta":36,"style":36},"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",[15,3930,3931,3936,3941,3946,3951,3956],{"__ignoreMap":36},[40,3932,3933],{"class":42,"line":43},[40,3934,3935],{},"page.on('response', r => {\n",[40,3937,3938],{"class":42,"line":49},[40,3939,3940],{},"  const ct = r.headers()['content-type'] || ''\n",[40,3942,3943],{"class":42,"line":55},[40,3944,3945],{},"  if (r.url().includes('\u002F_nuxt\u002F') && ct.includes('html')) {\n",[40,3947,3948],{"class":42,"line":84},[40,3949,3950],{},"    console.log('BAD', r.status(), ct, r.url())\n",[40,3952,3953],{"class":42,"line":90},[40,3954,3955],{},"  }\n",[40,3957,3958],{"class":42,"line":96},[40,3959,3960],{},"})\n",[11,3962,3963],{},"Status był poprawny, typ nie. Sprawdzanie samego kodu odpowiedzi tego nie złapie.",[23,3965,3967],{"id":3966},"przyczyna-odpowiedź-awaryjna-z-kodem-200-i-cache-po-rozszerzeniu","Przyczyna: odpowiedź awaryjna z kodem 200 i cache po rozszerzeniu",[11,3969,3970],{},"Złożyły się na to dwie rzeczy.",[11,3972,3973],{},"Pierwsza to zachowanie serwera. Na tym hostingu żądanie o nieistniejący plik nie kończyło się błędem 404, tylko zwracało stronę główną z kodem 200. To ustawienie przychodzi z konfiguracji poza moim katalogiem, więc wcześniej go nie widziałem. Dla aplikacji jednostronicowej bywa to celowe, dla statycznej strony z prerenderowanymi plikami jest szkodliwe.",[11,3975,3976,3977,1011,3980,3983,3984,3987],{},"Druga to Cloudflare. Domyślnie zapisuje w cache odpowiedzi na podstawie rozszerzenia w adresie (",[15,3978,3979],{},".css",[15,3981,3982],{},".js",", obrazy), a nie typu treści, który zwrócił serwer. Jeśli serwer na adres ",[15,3985,3986],{},"entry.Czy_6vul.css"," odda HTML z kodem 200, Cloudflare zapisze ten HTML pod tym adresem i będzie go podawał dalej.",[11,3989,3990],{},"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,3992,3993,3994,3997],{},"Dlaczego curl dostawał poprawny plik? Przeglądarka i curl wysyłały różne nagłówki (przeglądarka między innymi prosi o kompresję) i trafiały w różne wpisy cache. Nagłówek ",[15,3995,3996],{},"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.",[23,3999,4001],{"id":4000},"naprawa","Naprawa",[11,4003,4004,4005,4008],{},"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 ",[15,4006,4007],{},".htaccess",", w którym brakujący plik kończy się kodem 404:",[31,4010,4014],{"className":4011,"code":4012,"language":4013,"meta":36,"style":36},"language-apache shiki shiki-themes github-light github-dark","DirectorySlash 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",[15,4015,4016,4021,4026,4031,4035,4040,4044,4049,4054,4059,4063,4068,4072,4077,4081,4086,4091,4096],{"__ignoreMap":36},[40,4017,4018],{"class":42,"line":43},[40,4019,4020],{},"DirectorySlash Off\n",[40,4022,4023],{"class":42,"line":49},[40,4024,4025],{},"DirectoryIndex index.html\n",[40,4027,4028],{"class":42,"line":55},[40,4029,4030],{},"ErrorDocument 404 \u002F404.html\n",[40,4032,4033],{"class":42,"line":84},[40,4034,190],{"emptyLinePlaceholder":189},[40,4036,4037],{"class":42,"line":90},[40,4038,4039],{},"RewriteEngine On\n",[40,4041,4042],{"class":42,"line":96},[40,4043,190],{"emptyLinePlaceholder":189},[40,4045,4046],{"class":42,"line":102},[40,4047,4048],{},"# \u002Fx\u002F → \u002Fx, gdy istnieje prerenderowana strona\n",[40,4050,4051],{"class":42,"line":193},[40,4052,4053],{},"RewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\n",[40,4055,4056],{"class":42,"line":199},[40,4057,4058],{},"RewriteRule ^(.+)\u002F$ https:\u002F\u002Ftkulesza.eu\u002F$1 [R=301,L,NE]\n",[40,4060,4061],{"class":42,"line":204},[40,4062,190],{"emptyLinePlaceholder":189},[40,4064,4065],{"class":42,"line":210},[40,4066,4067],{},"# \u002Fx → \u002Fx\u002Findex.html bez przekierowania\n",[40,4069,4070],{"class":42,"line":216},[40,4071,4053],{},[40,4073,4074],{"class":42,"line":222},[40,4075,4076],{},"RewriteRule ^(.+)$ $1\u002Findex.html [L]\n",[40,4078,4079],{"class":42,"line":227},[40,4080,190],{"emptyLinePlaceholder":189},[40,4082,4083],{"class":42,"line":232},[40,4084,4085],{},"# Wszystko inne, czego nie ma na dysku → 404\n",[40,4087,4088],{"class":42,"line":238},[40,4089,4090],{},"RewriteCond %{REQUEST_FILENAME} !-f\n",[40,4092,4093],{"class":42,"line":244},[40,4094,4095],{},"RewriteCond %{REQUEST_FILENAME} !-d\n",[40,4097,4098],{"class":42,"line":250},[40,4099,4100],{},"RewriteRule ^ - [R=404,L]\n",[11,4102,4103,4104,4107],{},"Najpierw próbowałem ",[15,4105,4106],{},"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 działa niezależnie od tego, co ustawił hosting.",[11,4109,4110,4111,4114],{},"Dla plików z hashem doszedł nagłówek, który pozwala przeglądarkom i CDN trzymać je długo. Wyjątkiem jest ",[15,4112,4113],{},"_nuxt\u002Fbuilds\u002F",", gdzie Nuxt trzyma plik z identyfikatorem bieżącego buildu:",[31,4116,4118],{"className":4011,"code":4117,"language":4013,"meta":36,"style":36},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F#\">\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",[15,4119,4120,4125,4130,4135,4140,4145],{"__ignoreMap":36},[40,4121,4122],{"class":42,"line":43},[40,4123,4124],{},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F#\">\n",[40,4126,4127],{"class":42,"line":49},[40,4128,4129],{},"  Header always set Cache-Control \"public, max-age=31536000, immutable\"\n",[40,4131,4132],{"class":42,"line":55},[40,4133,4134],{},"\u003C\u002FIf>\n",[40,4136,4137],{"class":42,"line":84},[40,4138,4139],{},"\u003CElse>\n",[40,4141,4142],{"class":42,"line":90},[40,4143,4144],{},"  Header always set Cache-Control \"no-cache\"\n",[40,4146,4147],{"class":42,"line":96},[40,4148,4149],{},"\u003C\u002FElse>\n",[11,4151,4152],{},"Kolejność wgrywania też ma znaczenie. Skrypt wdrożeniowy wgrywa teraz najpierw pliki z hashem, a dopiero potem HTML, który na nie wskazuje. Nie ma więc chwili, w której HTML odwołuje się do pliku, którego jeszcze nie ma.",[23,4154,4156],{"id":4155},"druga-sprawa-wpuszczanie-ruchu-tylko-przez-cloudflare","Druga sprawa: wpuszczanie ruchu tylko przez Cloudflare",[11,4158,4159,4160,4163,4164,4171,4172,1139],{},"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 ",[15,4161,4162],{},"Host"," domeny prosto na ten adres. Standardowe rozwiązanie to wpuszczać tylko adresy z ",[4165,4166,4170],"a",{"href":4167,"rel":4168},"https:\u002F\u002Fwww.cloudflare.com\u002Fips\u002F",[4169],"nofollow","listy Cloudflare",", na przykład przez ",[15,4173,4174],{},"Require ip",[11,4176,4177,4178,4181],{},"Przed wgraniem takiej reguły sprawdziłem, jaki adres widzi serwer. Tymczasowy skrypt PHP wypisał ",[15,4179,4180],{},"REMOTE_ADDR"," dla żądania przez Cloudflare:",[31,4183,4185],{"className":33,"code":4184,"language":35,"meta":36,"style":36},"\u003C?php\nheader('Content-Type: text\u002Fplain');\necho $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[15,4186,4187,4192,4197],{"__ignoreMap":36},[40,4188,4189],{"class":42,"line":43},[40,4190,4191],{},"\u003C?php\n",[40,4193,4194],{"class":42,"line":49},[40,4195,4196],{},"header('Content-Type: text\u002Fplain');\n",[40,4198,4199],{"class":42,"line":55},[40,4200,4201],{},"echo $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[11,4203,4204,4205,4208,4209,4211,4212,4214],{},"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 ",[15,4206,4207],{},"mod_remoteip",") i ",[15,4210,4180],{}," zawiera już IP odwiedzającego. Reguła ",[15,4213,4174],{}," z listą Cloudflare zablokowałaby wszystkich czytelników, a przepuściła tylko tych, którzy akurat mają adres z puli Cloudflare.",[11,4216,4217,4218,4221,4222,4224],{},"Apache udostępnia w wyrażeniach zmienną ",[15,4219,4220],{},"CONN_REMOTE_ADDR",", czyli adres faktycznego połączenia TCP, którego ",[15,4223,4207],{}," nie zmienia. Reguła sprawdza właśnie ją:",[31,4226,4228],{"className":4011,"code":4227,"language":4013,"meta":36,"style":36},"RewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\nRewriteRule ^ - [F,L]\n",[15,4229,4230,4235],{"__ignoreMap":36},[40,4231,4232],{"class":42,"line":43},[40,4233,4234],{},"RewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\n",[40,4236,4237],{"class":42,"line":49},[40,4238,4239],{},"RewriteRule ^ - [F,L]\n",[11,4241,4242],{},"Lista ma 15 zakresów IPv4 i 7 IPv6. Po wgraniu sprawdziłem obie drogi:",[31,4244,4246],{"className":1917,"code":4245,"language":1919,"meta":36,"style":36},"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",[15,4247,4248,4266,4271,4310],{"__ignoreMap":36},[40,4249,4250,4252,4254,4256,4258,4260,4263],{"class":42,"line":43},[40,4251,3876],{"class":1926},[40,4253,3879],{"class":1747},[40,4255,3882],{"class":1747},[40,4257,3885],{"class":1631},[40,4259,3888],{"class":1747},[40,4261,4262],{"class":1631}," '%{http_code}\\n'",[40,4264,4265],{"class":1631}," https:\u002F\u002Ftkulesza.eu\u002F\n",[40,4267,4268],{"class":42,"line":49},[40,4269,4270],{"class":2604},"# 200\n",[40,4272,4273,4275,4278,4281,4284,4288,4291,4294,4297,4300,4302,4304,4306,4308],{"class":42,"line":55},[40,4274,3876],{"class":1926},[40,4276,4277],{"class":1747}," -sk",[40,4279,4280],{"class":1747}," --resolve",[40,4282,4283],{"class":1631}," tkulesza.eu:443:",[40,4285,4287],{"class":4286},"szBVR","\u003C",[40,4289,4290],{"class":1631},"IP",[40,4292,4293],{"class":1631}," hosting",[40,4295,4296],{"class":1620},"u",[40,4298,4299],{"class":4286},">",[40,4301,3882],{"class":1747},[40,4303,3885],{"class":1631},[40,4305,3888],{"class":1747},[40,4307,4262],{"class":1631},[40,4309,4265],{"class":1631},[40,4311,4312],{"class":42,"line":84},[40,4313,4314],{"class":2604},"# 403\n",[11,4316,4317],{},"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.",[23,4319,4321],{"id":4320},"uwaga-o-skanowaniu-własnej-strony","Uwaga o skanowaniu własnej strony",[11,4323,4324,4325,4328,4329,4332,4333,4336],{},"Przy sprawdzaniu, czy z zewnątrz da się pobrać pliki typu ",[15,4326,4327],{},".git\u002Fconfig"," albo ",[15,4330,4331],{},".env",", odpytałem też kilka typowych ścieżek, między innymi ",[15,4334,4335],{},"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. Jeśli hosting ma taką ochronę, a sieć domowa i serwer wychodzą przez ten sam adres, warto o tym pamiętać.",[23,4338,4340],{"id":4339},"lista-kontrolna-przy-przenosinach-statycznej-strony-za-cdn","Lista kontrolna przy przenosinach statycznej strony za CDN",[703,4342,4343,4346,4356,4359,4362,4365],{},[127,4344,4345],{},"Brakujący plik musi zwracać 404, nie stronę główną z kodem 200. Sprawdź to na adresie, którego na pewno nie ma.",[127,4347,4348,4349,4351,4352,4355],{},"Sprawdzaj ",[15,4350,3918],{},", nie tylko kod odpowiedzi. Arkusz CSS z typem ",[15,4353,4354],{},"text\u002Fhtml"," ma kod 200.",[127,4357,4358],{},"Testuj w prawdziwej przeglądarce albo przez puppeteer. Curl wysyła inne nagłówki i może trafić w inny wpis cache.",[127,4360,4361],{},"Po przepięciu katalogu lub serwera wyczyść cache CDN, zanim uznasz, że wdrożenie się udało.",[127,4363,4364],{},"Wgrywaj pliki z hashem przed HTML, który na nie wskazuje.",[127,4366,4367,4368,4370],{},"Zanim ograniczysz dostęp do adresów CDN, sprawdź, czy ",[15,4369,4180],{}," nie jest już przepisany na adres klienta.",[729,4372,4373],{},"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":36,"searchDepth":49,"depth":49,"links":4375},[4376,4377,4378,4379,4380,4381],{"id":3858,"depth":49,"text":3859},{"id":3966,"depth":49,"text":3967},{"id":4000,"depth":49,"text":4001},{"id":4155,"depth":49,"text":4156},{"id":4320,"depth":49,"text":4321},{"id":4339,"depth":49,"text":4340},"2026-10-06",{},"\u002Fpl\u002Farticles\u002Fcdn-cached-fallback",{"x":4386,"y":4387,"depth":43,"size":743},0.28,0.14,[1568,4389],"dday-go-live",{"title":3849,"description":3855},"edge-cache","pl\u002Farticles\u002Fcdn-cached-fallback",[4394,4013,4395,4396,4397],"cloudflare","caching","deployment","htaccess","6HFoiV_VNm1aTbfO1F2D_GXqs2fqm1qStaonOQhxte8",{"id":4400,"title":4401,"articleId":4389,"body":4402,"category":4675,"codeLang":742,"date":4676,"deploys":43,"description":4406,"excerpt":742,"extension":743,"lang":744,"meta":4677,"navigation":189,"path":4678,"pos":4679,"readMin":90,"related":4681,"seo":4682,"service":4683,"stem":4684,"tags":4685,"version":761,"__hash__":4689},"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":4403,"toc":4667},[4404,4407,4410,4414,4417,4420,4424,4427,4430,4592,4601,4605,4608,4611,4622,4626,4629,4632,4636,4639,4642,4644,4664],[11,4405,4406],{},"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 na plażach wylądowało ponad 150 tysięcy żołnierzy.",[11,4408,4409],{},"To dobrze udokumentowany przykład decyzji go\u002Fno-go, a jej budowa przekłada się wprost na wdrażanie oprogramowania. Jest okno, na które nie masz wpływu. Są kryteria ustalone przed dniem decyzji. Dane są niepełne i sprzeczne. Decyzję podejmuje jedna osoba. A czekanie też kosztuje i ten koszt trzeba policzyć, zamiast zakładać, że wynosi zero.",[23,4411,4413],{"id":4412},"okno-wynikało-z-ograniczeń-nie-z-planu","Okno wynikało z ograniczeń, nie z planu",[11,4415,4416],{},"Datę wyznaczyły dwa warunki fizyczne. Desant powietrzny potrzebował księżyca blisko pełni, żeby w nocy widzieć strefy zrzutu. Marynarka i saperzy potrzebowali odpływu 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ępny akceptowalny odpływ wypadał około 18–20 czerwca, już bez księżyca.",[11,4418,4419],{},"Wdrożenia mają takie samo 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. Wniosek praktyczny: zapisz okno jako ograniczenie odpowiednio wcześnie, razem z następnym oknem. Zdanie „jeśli nie zdążymy w czwartek, następny termin jest po freezie, 19-go” to informacja potrzebna w dniu decyzji, nie po niej.",[23,4421,4423],{"id":4422},"kryteria-istniały-przed-prognozą","Kryteria istniały przed prognozą",[11,4425,4426],{},"Planiści nie zastanawiali się 4 czerwca, 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,4428,4429],{},"Ten podział warto skopiować. Kryteria ustalane rano w dniu wdrożenia zostaną nagięte do terminu. Zdefiniuj je przy planowaniu zmiany i trzymaj obok niej. Przykład takiej listy (format jest umowny, to nie konfiguracja konkretnego narzędzia):",[31,4431,4433],{"className":1611,"code":4432,"language":1613,"meta":36,"style":36},"release: billing-v4\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",[15,4434,4435,4445,4452,4462,4472,4479,4491,4502,4513,4524,4535,4546,4558,4570,4582],{"__ignoreMap":36},[40,4436,4437,4440,4442],{"class":42,"line":43},[40,4438,4439],{"class":1624},"release",[40,4441,1628],{"class":1620},[40,4443,4444],{"class":1631},"billing-v4\n",[40,4446,4447,4450],{"class":42,"line":49},[40,4448,4449],{"class":1624},"window",[40,4451,1650],{"class":1620},[40,4453,4454,4457,4459],{"class":42,"line":55},[40,4455,4456],{"class":1624},"  primary",[40,4458,1628],{"class":1620},[40,4460,4461],{"class":1631},"2026-06-18T07:00\u002F10:00\n",[40,4463,4464,4467,4469],{"class":42,"line":84},[40,4465,4466],{"class":1624},"  next",[40,4468,1628],{"class":1620},[40,4470,4471],{"class":1631},"2026-06-25T07:00\u002F10:00\n",[40,4473,4474,4477],{"class":42,"line":90},[40,4475,4476],{"class":1624},"go_criteria",[40,4478,1650],{"class":1620},[40,4480,4481,4484,4487,4489],{"class":42,"line":96},[40,4482,4483],{"class":1620},"  - ",[40,4485,4486],{"class":1624},"ci_green_on_release_commit",[40,4488,1628],{"class":1620},[40,4490,1748],{"class":1747},[40,4492,4493,4495,4498,4500],{"class":42,"line":102},[40,4494,4483],{"class":1620},[40,4496,4497],{"class":1624},"migration_tested_on_prod_size_copy",[40,4499,1628],{"class":1620},[40,4501,1748],{"class":1747},[40,4503,4504,4506,4509,4511],{"class":42,"line":193},[40,4505,4483],{"class":1620},[40,4507,4508],{"class":1624},"migration_duration_max_minutes",[40,4510,1628],{"class":1620},[40,4512,2294],{"class":1747},[40,4514,4515,4517,4520,4522],{"class":42,"line":199},[40,4516,4483],{"class":1620},[40,4518,4519],{"class":1624},"rollback_rehearsed",[40,4521,1628],{"class":1620},[40,4523,1748],{"class":1747},[40,4525,4526,4528,4531,4533],{"class":42,"line":204},[40,4527,4483],{"class":1620},[40,4529,4530],{"class":1624},"error_rate_baseline_known",[40,4532,1628],{"class":1620},[40,4534,1748],{"class":1747},[40,4536,4537,4540,4543],{"class":42,"line":210},[40,4538,4539],{"class":1624},"abort_criteria",[40,4541,4542],{"class":1620},":            ",[40,4544,4545],{"class":2604},"# sprawdzane w trakcie canary\n",[40,4547,4548,4550,4553,4555],{"class":42,"line":216},[40,4549,4483],{"class":1620},[40,4551,4552],{"class":1624},"error_rate_above_baseline_pct",[40,4554,1628],{"class":1620},[40,4556,4557],{"class":1747},"50\n",[40,4559,4560,4562,4565,4567],{"class":42,"line":222},[40,4561,4483],{"class":1620},[40,4563,4564],{"class":1624},"p95_latency_above_ms",[40,4566,1628],{"class":1620},[40,4568,4569],{"class":1747},"800\n",[40,4571,4572,4574,4577,4579],{"class":42,"line":227},[40,4573,4483],{"class":1620},[40,4575,4576],{"class":1624},"failed_payment_jobs",[40,4578,1628],{"class":1620},[40,4580,4581],{"class":1747},"1\n",[40,4583,4584,4587,4589],{"class":42,"line":232},[40,4585,4586],{"class":1624},"owner",[40,4588,1628],{"class":1620},[40,4590,4591],{"class":1631},"jedna wskazana osoba\n",[11,4593,4594,4595,4597,4598,4600],{},"Blok ",[15,4596,4539],{}," jest tak samo ważny jak ",[15,4599,4476],{},". Przesunięcie z 4 czerwca to proces, który zadziałał: warunki były poza limitami, więc odpowiedź brzmiała „nie”.",[23,4602,4604],{"id":4603},"lepsza-decyzja-wynikała-z-szerszych-danych","Lepsza decyzja wynikała z szerszych danych",[11,4606,4607],{},"Niemieccy meteorolodzy widzieli ten sam sztorm i uznali, że sztormowa pogoda potrwa około dwóch tygodni i desant będzie niemożliwy. Nie byli niedbali. Brakowało im obserwacji z Atlantyku. 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,4609,4610],{},"Dla wdrożeń wynika z tego prosta rzecz: go\u002Fno-go jest tak dobre jak sygnały, na których stoi. W praktyce:",[703,4612,4613,4616,4619],{},[127,4614,4615],{},"Canary nie da się ocenić bez punktu odniesienia. Przed deployem znaj normalny poziom błędów i opóźnień dla endpointów, których dotyczy zmiana.",[127,4617,4618],{},"Ryzykowne elementy mierz bezpośrednio. Migracja zmierzona na kopii bazy wielkości produkcyjnej to dane. „Na stagingu poszło szybko” to nie są dane.",[127,4620,4621],{},"Patrz też na sygnały biznesowe. Proces płatności może zwracać 200 i nie tworzyć żadnych zamówień.",[23,4623,4625],{"id":4624},"sprzeczne-prognozy-wymagają-jednego-właściciela","Sprzeczne prognozy wymagają jednego właściciela",[11,4627,4628],{},"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. Zadaniem Eisenhowera było zdecydować.",[11,4630,4631],{},"Dane przed wdrożeniem bywają sprzeczne w ten sam sposób. Testy obciążeniowe przechodzą, ale jedna zależność prawie wyczerpała budżet błędów. Funkcja działa, ale support zgłasza coś, czego nikt nie umie wyjaśnić. Proces powinien jasno określać dwie rzeczy: kto zbiera sygnały i je przedstawia oraz kto, z imienia i nazwiska, podejmuje decyzję. Decyzja podjęta na czacie zespołu o 16:55 zwykle nie ma właściciela, gdy trzeba ją odwrócić.",[23,4633,4635],{"id":4634},"czekanie-też-ma-koszt","Czekanie też ma koszt",[11,4637,4638],{},"Alternatywą dla 6 czerwca było następne okno, około 18–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,4640,4641],{},"To jest wiedza po fakcie. 5 czerwca nikt nie mógł przewidzieć tego sztormu. Wniosek jest węższy: odłożenie wdrożenia to decyzja z własnym ryzykiem i to ryzyko trzeba 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, podczas gdy okno się zwęża. Czasem czekanie nadal jest właściwe. Powinno jednak wynikać z nazwanych powodów, a nie być domyślnym wyborem, bo wydaje się bezpieczniejsze.",[23,4643,701],{"id":700},[703,4645,4646,4649,4652,4655,4658,4661],{},[127,4647,4648],{},"Zapisz okno wdrożenia i następne okno po nim.",[127,4650,4651],{},"Ustal kryteria go i abort przy planowaniu zmiany, nie w dniu wdrożenia.",[127,4653,4654],{},"Miej punkt odniesienia dla każdej metryki, którą chcesz obserwować podczas rolloutu.",[127,4656,4657],{},"Przećwicz rollback, zanim będzie potrzebny.",[127,4659,4660],{},"Wskaż jedną osobę, która decyduje, i zapisz, dlaczego decyzja brzmiała go albo no-go.",[127,4662,4663],{},"Zapisz koszt odłożenia obok ryzyka wdrożenia.",[729,4665,4666],{},"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":36,"searchDepth":49,"depth":49,"links":4668},[4669,4670,4671,4672,4673,4674],{"id":4412,"depth":49,"text":4413},{"id":4422,"depth":49,"text":4423},{"id":4603,"depth":49,"text":4604},{"id":4624,"depth":49,"text":4625},{"id":4634,"depth":49,"text":4635},{"id":700,"depth":49,"text":701},"arch","2026-06-06",{},"\u002Fpl\u002Farticles\u002Fdday-go-live",{"x":2895,"y":4680,"depth":43,"size":743},0.85,[1553,3836],{"title":4401,"description":4406},"go-no-go","pl\u002Farticles\u002Fdday-go-live",[4686,4396,4687,4688,1562],"go-live","decision-making","release-management","RlDE26NUjJke9-Wnn8UWu-G5aAhsbb_oeUzL1ar0qco",{"id":4691,"title":4692,"articleId":3836,"body":4693,"category":35,"codeLang":35,"date":5428,"deploys":43,"description":5429,"excerpt":742,"extension":743,"lang":744,"meta":5430,"navigation":189,"path":5431,"pos":5432,"readMin":193,"related":5435,"seo":5437,"service":5438,"stem":5439,"tags":5440,"version":761,"__hash__":5443},"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":4694,"toc":5410},[4695,4702,4705,4708,4712,4718,4786,4789,4793,4811,4820,4826,4832,4860,4864,4868,4871,4980,4991,4994,4997,5088,5091,5116,5123,5133,5137,5147,5151,5154,5158,5162,5173,5231,5234,5256,5259,5262,5339,5345,5347,5354,5358,5361,5365,5371,5377,5383,5385,5408],[11,4696,4697,4698,4701],{},"Wzorzec projektowy to nazwa dla powtarzalnego kształtu kodu. Jego główna wartość to komunikacja. Kiedy recenzent widzi ",[15,4699,4700],{},"CachingUserRepository implements UserRepositoryInterface",", słowo „dekorator” mówi mu, jak klasa jest podpięta, co może, czego nie może i jak ją testować, zanim przeczyta choć jedną linię metody.",[11,4703,4704],{},"Kosztem jest warstwa pośrednia. Każdy wzorzec dokłada co najmniej jeden interfejs albo jedną klasę między wywołującym a kodem, który robi robotę. Ten koszt płacisz od razu i przy każdym czytaniu kodu. Korzyść dostajesz dopiero wtedy, gdy zmiana, pod którą wzorzec powstał, naprawdę nastąpi.",[11,4706,4707],{},"Poniżej wzorce GoF z perspektywy aplikacyjnego PHP: Laravel albo Symfony, procesy żyjące przez jedno żądanie, dostępny kontener DI. Dla każdego: jaki problem rozwiązuje, jak typowo bywa nadużywany i czy kontener albo sam język nie robi już tego za niego.",[23,4709,4711],{"id":4710},"test-przed-użyciem-wzorca","Test przed użyciem wzorca",[11,4713,4714,4715],{},"Odpowiedz na jedno pytanie: ",[130,4716,4717],{},"jaką przyszłą zmianę ten wzorzec robi tańszą?",[4719,4720,4721,4734],"table",{},[4722,4723,4724],"thead",{},[4725,4726,4727,4731],"tr",{},[4728,4729,4730],"th",{},"Wzorzec",[4728,4732,4733],{},"Zmiana, którą ułatwia",[4735,4736,4737,4746,4754,4762,4770,4778],"tbody",{},[4725,4738,4739,4743],{},[4740,4741,4742],"td",{},"Adapter",[4740,4744,4745],{},"Wymiana albo aktualizacja zewnętrznej zależności",[4725,4747,4748,4751],{},[4740,4749,4750],{},"Decorator",[4740,4752,4753],{},"Dodanie lub zdjęcie zachowania przekrojowego (cache, logowanie, rate limiting)",[4725,4755,4756,4759],{},[4740,4757,4758],{},"Strategy",[4740,4760,4761],{},"Nowy wariant algorytmu bez ruszania wywołującego",[4725,4763,4764,4767],{},[4740,4765,4766],{},"Observer \u002F Event",[4740,4768,4769],{},"Nowa reakcja na zmianę stanu bez ruszania jej źródła",[4725,4771,4772,4775],{},[4740,4773,4774],{},"Command",[4740,4776,4777],{},"Przeniesienie pracy na kolejkę, ponawianie, audyt",[4725,4779,4780,4783],{},[4740,4781,4782],{},"Factory Method",[4740,4784,4785],{},"Nowa implementacja wybierana w czasie wykonania",[11,4787,4788],{},"Jeśli nie umiesz nazwać tej zmiany albo jest ona hipotetyczna („może kiedyś zmienimy bazę”), wzorzec jest na zapas. Napisz wersję bezpośrednią, a wzorzec wprowadź w commicie, który go potrzebuje. Refaktoryzacja do wzorca w momencie, gdy pojawia się drugi wariant, jest tania. Utrzymywanie nieużywanej abstrakcji kosztuje stale.",[23,4790,4792],{"id":4791},"wzorce-kreacyjne","Wzorce kreacyjne",[11,4794,4795,4798,4799,4802,4803,4806,4807],{},[130,4796,4797],{},"Singleton."," Ma sens dla niezmiennego, kosztownego w budowie stanu współdzielonego w obrębie jednego procesu. We frameworku z kontenerem zarejestruj klasę jako współdzielone wiązanie (",[15,4800,4801],{},"$this->app->singleton(...)"," w Laravelu), zamiast pisać statyczne ",[15,4804,4805],{},"getInstance()",". Wersję z kontenera da się wstrzyknąć i podmienić w testach, statycznej nie. ",[4165,4808,4810],{"href":4809},"\u002Fpl\u002Farticles\u002Fsingleton-pattern","Osobny artykuł.",[11,4812,4813,4816,4817],{},[130,4814,4815],{},"Factory Method."," Potrzebna, gdy konkretny typ to decyzja w czasie wykonania: bramka płatności zależna od kraju, parser zależny od sygnatury pliku, kanał powiadomień zależny od ustawień użytkownika. Dobra fabryka wybiera typ, konstrukcję zostawia kontenerowi i zwraca interfejs. ",[4165,4818,4810],{"href":4819},"\u002Fpl\u002Farticles\u002Ffactory-method",[11,4821,4822,4825],{},[130,4823,4824],{},"Builder."," Pasuje do obiektów składanych krokami i na końcu zamrażanych: query builder zbiera warunki i produkuje jedno zapytanie. W testach buildery do fixture’ów są w porządku i poprawiają czytelność. Jeśli kod produkcyjny potrzebuje buildera, żeby złożyć encję, to konstruktor encji zwykle przyjmuje za dużo argumentów. Wtedy lepiej podzielić encję albo wprowadzić value objecty.",[11,4827,4828,4831],{},[130,4829,4830],{},"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 pokrywa kontekstowe wiązanie w kontenerze, więc ręcznie pisana abstrakcyjna fabryka rzadko jest potrzebna.",[11,4833,4834,4837,4838,485,4841,4844,4845,4848,4849,4852,4853,4856,4857,4859],{},[130,4835,4836],{},"Prototype."," PHP ma wbudowane ",[15,4839,4840],{},"clone",[15,4842,4843],{},"__clone()",". Osobny interfejs ",[15,4846,4847],{},"Prototype"," z metodą ",[15,4850,4851],{},"copy()",", która woła ",[15,4854,4855],{},"clone $this",", dokłada warstwę bez żadnego zachowania. Jeśli potrzebujesz głębokiej kopii zagnieżdżonych obiektów, zaimplementuj ",[15,4858,4843],{}," i na tym koniec.",[23,4861,4863],{"id":4862},"wzorce-strukturalne","Wzorce strukturalne",[4865,4866,4742],"h3",{"id":4867},"adapter",[11,4869,4870],{},"Każda integracja z zewnętrznym API jest adapterem: tłumaczy typy i błędy dostawcy na typy i błędy twojej domeny. Adapter zostaje użyteczny tylko wtedy, gdy jest cienki. Polityka ponowień, liczenie prowizji i decyzje biznesowe należą do serwisu, który zależy od interfejsu adaptera, a nie do samego adaptera.",[31,4872,4874],{"className":33,"code":4873,"language":35,"meta":36,"style":36},"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 expects the smallest currency unit\n                'currency'             => strtolower($amount->currency),\n                'payment_method'       => $paymentMethodId,\n                'payment_method_types' => ['card'],\n                'confirm'              => true,                 \u002F\u002F without it nothing is charged and no card error is raised\n            ]);\n\n            return ChargeResult::pending($intent->id);\n        } catch (\\Stripe\\Exception\\CardException $e) {\n            return ChargeResult::declined($e->getMessage());\n        }\n    }\n}\n",[15,4875,4876,4881,4885,4890,4894,4899,4903,4908,4913,4921,4926,4931,4936,4944,4949,4953,4958,4963,4968,4972,4976],{"__ignoreMap":36},[40,4877,4878],{"class":42,"line":43},[40,4879,4880],{},"final class StripePaymentGateway implements PaymentGatewayInterface\n",[40,4882,4883],{"class":42,"line":49},[40,4884,76],{},[40,4886,4887],{"class":42,"line":55},[40,4888,4889],{},"    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n",[40,4891,4892],{"class":42,"line":84},[40,4893,190],{"emptyLinePlaceholder":189},[40,4895,4896],{"class":42,"line":90},[40,4897,4898],{},"    public function charge(Money $amount, string $paymentMethodId): ChargeResult\n",[40,4900,4901],{"class":42,"line":96},[40,4902,241],{},[40,4904,4905],{"class":42,"line":102},[40,4906,4907],{},"        try {\n",[40,4909,4910],{"class":42,"line":193},[40,4911,4912],{},"            $intent = $this->stripe->paymentIntents->create([\n",[40,4914,4915,4918],{"class":42,"line":199},[40,4916,4917],{},"                'amount'               => $amount->minorUnits,",[40,4919,4920],{},"  \u002F\u002F Stripe expects the smallest currency unit\n",[40,4922,4923],{"class":42,"line":204},[40,4924,4925],{},"                'currency'             => strtolower($amount->currency),\n",[40,4927,4928],{"class":42,"line":210},[40,4929,4930],{},"                'payment_method'       => $paymentMethodId,\n",[40,4932,4933],{"class":42,"line":216},[40,4934,4935],{},"                'payment_method_types' => ['card'],\n",[40,4937,4938,4941],{"class":42,"line":222},[40,4939,4940],{},"                'confirm'              => true,",[40,4942,4943],{},"                 \u002F\u002F without it nothing is charged and no card error is raised\n",[40,4945,4946],{"class":42,"line":227},[40,4947,4948],{},"            ]);\n",[40,4950,4951],{"class":42,"line":232},[40,4952,190],{"emptyLinePlaceholder":189},[40,4954,4955],{"class":42,"line":238},[40,4956,4957],{},"            return ChargeResult::pending($intent->id);\n",[40,4959,4960],{"class":42,"line":244},[40,4961,4962],{},"        } catch (\\Stripe\\Exception\\CardException $e) {\n",[40,4964,4965],{"class":42,"line":250},[40,4966,4967],{},"            return ChargeResult::declined($e->getMessage());\n",[40,4969,4970],{"class":42,"line":256},[40,4971,353],{},[40,4973,4974],{"class":42,"line":261},[40,4975,253],{},[40,4977,4978],{"class":42,"line":267},[40,4979,105],{},[11,4981,4982,4983,4986,4987,4990],{},"Adapter robi trzy rzeczy: mapuje ",[15,4984,4985],{},"Money"," na tablicę żądania, mapuje odpowiedź na ",[15,4988,4989],{},"ChargeResult"," i zamienia wyjątek dostawcy na wynik domenowy. Wszystko ponad to znaczy, że klasa zamienia się w serwis.",[4865,4992,4750],{"id":4993},"decorator",[11,4995,4996],{},"Dekorator opakowuje obiekt implementujący ten sam interfejs i dokłada zachowanie przed delegacją albo po niej. Typowe zastosowania to cache, logowanie, metryki i ograniczanie ruchu.",[31,4998,5000],{"className":33,"code":4999,"language":35,"meta":36,"style":36},"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",[15,5001,5002,5007,5011,5016,5020,5024,5029,5034,5039,5043,5047,5052,5056,5061,5066,5071,5076,5080,5084],{"__ignoreMap":36},[40,5003,5004],{"class":42,"line":43},[40,5005,5006],{},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n",[40,5008,5009],{"class":42,"line":49},[40,5010,190],{"emptyLinePlaceholder":189},[40,5012,5013],{"class":42,"line":55},[40,5014,5015],{},"final class CachingUserRepository implements UserRepositoryInterface\n",[40,5017,5018],{"class":42,"line":84},[40,5019,76],{},[40,5021,5022],{"class":42,"line":90},[40,5023,81],{},[40,5025,5026],{"class":42,"line":96},[40,5027,5028],{},"        private readonly UserRepositoryInterface $inner,\n",[40,5030,5031],{"class":42,"line":102},[40,5032,5033],{},"        private readonly Cache $cache,\n",[40,5035,5036],{"class":42,"line":193},[40,5037,5038],{},"        private readonly int $ttlSeconds = 300,\n",[40,5040,5041],{"class":42,"line":199},[40,5042,99],{},[40,5044,5045],{"class":42,"line":204},[40,5046,190],{"emptyLinePlaceholder":189},[40,5048,5049],{"class":42,"line":210},[40,5050,5051],{},"    public function findById(int $id): ?User\n",[40,5053,5054],{"class":42,"line":216},[40,5055,241],{},[40,5057,5058],{"class":42,"line":222},[40,5059,5060],{},"        return $this->cache->remember(\n",[40,5062,5063],{"class":42,"line":227},[40,5064,5065],{},"            \"user.{$id}\",\n",[40,5067,5068],{"class":42,"line":232},[40,5069,5070],{},"            $this->ttlSeconds,\n",[40,5072,5073],{"class":42,"line":238},[40,5074,5075],{},"            fn () => $this->inner->findById($id),\n",[40,5077,5078],{"class":42,"line":244},[40,5079,3328],{},[40,5081,5082],{"class":42,"line":250},[40,5083,253],{},[40,5085,5086],{"class":42,"line":256},[40,5087,105],{},[11,5089,5090],{},"Podpięcie w service providerze:",[31,5092,5094],{"className":33,"code":5093,"language":35,"meta":36,"style":36},"$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n    $app->make(EloquentUserRepository::class),\n    $app->make(Cache::class),\n));\n",[15,5095,5096,5101,5106,5111],{"__ignoreMap":36},[40,5097,5098],{"class":42,"line":43},[40,5099,5100],{},"$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n",[40,5102,5103],{"class":42,"line":49},[40,5104,5105],{},"    $app->make(EloquentUserRepository::class),\n",[40,5107,5108],{"class":42,"line":55},[40,5109,5110],{},"    $app->make(Cache::class),\n",[40,5112,5113],{"class":42,"line":84},[40,5114,5115],{},"));\n",[11,5117,5118,5119,5122],{},"Każdą warstwę testujesz osobno. Test dekoratora sprawdza, że przy pudle woła ",[15,5120,5121],{},"inner",", a przy trafieniu go pomija. Test repozytorium sprawdza dostęp do danych.",[11,5124,5125,5126,5129,5130,5132],{},"Typowa awaria to długie łańcuchy dekoratorów składane w różnej kolejności w różnych kontekstach. 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. Szczegół tego konkretnego dekoratora: ",[15,5127,5128],{},"remember()"," w Laravelu traktuje zapisany ",[15,5131,114],{}," jak brak wpisu, więc zapytania o nieistniejące ID zawsze trafiają do bazy. Jeśli ten ruch ma znaczenie, cache’uj jawny znacznik „nie znaleziono”.",[4865,5134,5136],{"id":5135},"facade","Facade",[11,5138,5139,5140,4328,5143,5146],{},"Fasada daje jeden punkt wejścia do podsystemu złożonego z wielu klas, gdy wywołującym potrzeba z niego tylko kilku operacji. Statyczne fasady Laravela to inny mechanizm o tej samej nazwie: statyczne proxy do wiązania w kontenerze. W kodzie aplikacji są wygodne i da się je testować przez ",[15,5141,5142],{},"::fake()",[15,5144,5145],{},"::shouldReceive()",", ale chowają zależności przed sygnaturą konstruktora. W serwisach domenowych lepiej wstrzykiwać przez konstruktor, żeby lista zależności była widoczna.",[4865,5148,5150],{"id":5149},"proxy","Proxy",[11,5152,5153],{},"W PHP proxy spotyka się głównie jako klasy generowane przez ORM-y i kontenery do leniwego ładowania (encje Doctrine, lazy services w Symfony, a od PHP 8.4 natywne lazy objects). Pisanie własnego rzadko ma uzasadnienie. Do przechwytywania wywołań dekorator robi to samo jawnie i łatwiej go przetestować.",[23,5155,5157],{"id":5156},"wzorce-behawioralne","Wzorce behawioralne",[4865,5159,5161],{"id":5160},"observer-i-zdarzenia","Observer i zdarzenia",[11,5163,5164,5165,5168,5169,5172],{},"Kiedy ",[15,5166,5167],{},"Order"," zostaje opłacone, kod oznaczający płatność wysyła zdarzenie ",[15,5170,5171],{},"OrderPaid",". Listenery od maili, stanów magazynowych i analityki subskrybują je niezależnie, a dołożenie czwartego nie zmienia kodu zamówienia.",[31,5174,5176],{"className":33,"code":5175,"language":35,"meta":36,"style":36},"final 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 load the order by id and reserve stock\n    }\n}\n",[15,5177,5178,5183,5187,5192,5196,5200,5205,5209,5214,5218,5223,5227],{"__ignoreMap":36},[40,5179,5180],{"class":42,"line":43},[40,5181,5182],{},"final class OrderPaid\n",[40,5184,5185],{"class":42,"line":49},[40,5186,76],{},[40,5188,5189],{"class":42,"line":55},[40,5190,5191],{},"    public function __construct(public readonly int $orderId) {}\n",[40,5193,5194],{"class":42,"line":84},[40,5195,105],{},[40,5197,5198],{"class":42,"line":90},[40,5199,190],{"emptyLinePlaceholder":189},[40,5201,5202],{"class":42,"line":96},[40,5203,5204],{},"final class ReserveInventory implements ShouldQueue\n",[40,5206,5207],{"class":42,"line":102},[40,5208,76],{},[40,5210,5211],{"class":42,"line":193},[40,5212,5213],{},"    public function handle(OrderPaid $event): void\n",[40,5215,5216],{"class":42,"line":199},[40,5217,241],{},[40,5219,5220],{"class":42,"line":204},[40,5221,5222],{},"        \u002F\u002F load the order by id and reserve stock\n",[40,5224,5225],{"class":42,"line":210},[40,5226,253],{},[40,5228,5229],{"class":42,"line":216},[40,5230,105],{},[11,5232,5233],{},"Ryzyko to kaskady zdarzeń. Listener wysyła kolejne zdarzenie, jego listener trzecie i powstaje graf wywołań, którego nikt nigdzie nie zapisał. 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. Trzy zasady trzymają to w ryzach:",[703,5235,5236,5242,5245],{},[127,5237,5238,5239,1139],{},"Listenery z efektami ubocznymi poza bazą (mail, wywołania HTTP) implementują ",[15,5240,5241],{},"ShouldQueue",[127,5243,5244],{},"Zdarzenia niosą identyfikatory, nie modele. Listener sam ładuje aktualny stan.",[127,5246,5247,5248,5251,5252,5255],{},"Listenery, które nie mogą ruszyć przed commitem transakcji, są tak oznaczone (",[15,5249,5250],{},"ShouldDispatchAfterCommit"," na zdarzeniu albo ",[15,5253,5254],{},"$afterCommit = true"," na listenerze kolejkowym).",[4865,5257,4758],{"id":5258},"strategy",[11,5260,5261],{},"Strategy oddziela wybór algorytmu od jego użycia. Podręcznikowy przykład to kalkulator wysyłki ze stawką ryczałtową, wagową i strefową.",[31,5263,5265],{"className":33,"code":5264,"language":35,"meta":36,"style":36},"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",[15,5266,5267,5272,5276,5281,5285,5289,5294,5298,5303,5308,5312,5317,5321,5326,5331,5335],{"__ignoreMap":36},[40,5268,5269],{"class":42,"line":43},[40,5270,5271],{},"interface ShippingPricing\n",[40,5273,5274],{"class":42,"line":49},[40,5275,76],{},[40,5277,5278],{"class":42,"line":55},[40,5279,5280],{},"    public function price(Parcel $parcel): Money;\n",[40,5282,5283],{"class":42,"line":84},[40,5284,105],{},[40,5286,5287],{"class":42,"line":90},[40,5288,190],{"emptyLinePlaceholder":189},[40,5290,5291],{"class":42,"line":96},[40,5292,5293],{},"final class ShippingQuote\n",[40,5295,5296],{"class":42,"line":102},[40,5297,76],{},[40,5299,5300],{"class":42,"line":193},[40,5301,5302],{},"    \u002F** @param array\u003Cstring, ShippingPricing> $pricings *\u002F\n",[40,5304,5305],{"class":42,"line":199},[40,5306,5307],{},"    public function __construct(private readonly array $pricings) {}\n",[40,5309,5310],{"class":42,"line":204},[40,5311,190],{"emptyLinePlaceholder":189},[40,5313,5314],{"class":42,"line":210},[40,5315,5316],{},"    public function for(Carrier $carrier, Parcel $parcel): Money\n",[40,5318,5319],{"class":42,"line":216},[40,5320,241],{},[40,5322,5323],{"class":42,"line":222},[40,5324,5325],{},"        return ($this->pricings[$carrier->value] ?? throw new UnsupportedCarrier($carrier))\n",[40,5327,5328],{"class":42,"line":227},[40,5329,5330],{},"            ->price($parcel);\n",[40,5332,5333],{"class":42,"line":232},[40,5334,253],{},[40,5336,5337],{"class":42,"line":238},[40,5338,105],{},[11,5340,5341,5342,5344],{},"Strategię wybieraj raz, na brzegu operacji. Jeśli wybór dzieje się w pętli po pozycjach, kod płaci za niego w każdej iteracji i trudniej go śledzić. Przy dwóch wariantach i braku trzeciego na horyzoncie wyrażenie ",[15,5343,403],{}," jest krótsze i równie czytelne.",[4865,5346,4774],{"id":1600},[11,5348,5349,5350,5353],{},"Komenda to serializowalny opis intencji: ",[15,5351,5352],{},"ChargeCustomer(customerId: 42, amountMinor: 1999)",". Sama niczego nie robi. Ponieważ jest danymi, można ją wrzucić na kolejkę, opóźnić, ponowić, zalogować i odtworzyć. Kolejkowane joby w Laravelu to komendy z podpiętym handlerem. Cała praca projektowa siedzi w payloadzie: tylko identyfikatory i skalary, a handler idempotentny, bo kolejka z ponowieniami dostarczy tę samą komendę więcej niż raz.",[4865,5355,5357],{"id":5356},"template-method","Template Method",[11,5359,5360],{},"Dwie klasy mają wspólną, stałą sekwencję kroków i różnią się jednym: raport budowany identycznie, eksportowany raz do CSV, raz do PDF. Klasa bazowa definiuje sekwencję, a zmienny krok deklaruje jako abstrakcyjny. To jest dopuszczalne dziedziczenie. Gdy różnic robi się więcej niż jedna albo warianty trzeba łączyć, przejdź na kompozycję i wstrzyknij eksporter jako strategię.",[23,5362,5364],{"id":5363},"wzorce-które-rzadko-pasują-do-kodu-aplikacji","Wzorce, które rzadko pasują do kodu aplikacji",[11,5366,5367,5370],{},[130,5368,5369],{},"Interpreter."," Parser i ewaluator własnego języka. Zanim go napiszesz, sprawdź, czy wystarczy gotowy silnik wyrażeń. Symfony ExpressionLanguage obsługuje większość reguł biznesowych (warunki cenowe, predykaty feature flag) przy znacznie mniejszej ilości kodu do utrzymania.",[11,5372,5373,5376],{},[130,5374,5375],{},"Mediator."," Centralny obiekt koordynujący komponenty, które inaczej odwoływałyby się do siebie nawzajem. We frameworku z dispatcherem zdarzeń i kontenerem dispatcher pełni tę rolę w większości przypadków. Własny mediator ma sens, gdy sama logika koordynacji jest złożona i stanowa, na przykład w formularzu, w którym pola wpływają na siebie nawzajem.",[11,5378,5379,5382],{},[130,5380,5381],{},"Flyweight."," Współdzieli niezmienny stan między wieloma drobnymi obiektami, żeby oszczędzić pamięć. Procesy PHP żyją krótko i w obrębie żądania, więc rzadko ma to znaczenie. Może pomóc w długo działających zadaniach CLI, które tworzą bardzo dużo identycznych value objectów, na przykład tokenów w parserze.",[23,5384,2818],{"id":2817},[703,5386,5387,5390,5393,5396,5399,5402],{},[127,5388,5389],{},"Czy autor umie nazwać konkretną zmianę, którą wzorzec ułatwia, i czy ta zmiana jest zaplanowana?",[127,5391,5392],{},"Czy nowy interfejs ma więcej niż jedną implementację albo dublera w testach, który go potrzebuje?",[127,5394,5395],{},"Czy adapter zawiera wyłącznie tłumaczenie, bez reguł biznesowych?",[127,5397,5398],{},"Czy stos dekoratorów jest podpięty w jednym miejscu i ma najwyżej dwie, trzy warstwy?",[127,5400,5401],{},"Czy listenery z zewnętrznymi efektami idą przez kolejkę i po commicie?",[127,5403,5404,5405,5407],{},"Czy wyrażenie ",[15,5406,403],{}," albo zwykła funkcja nie załatwiłyby tego samego mniejszym kodem?",[729,5409,731],{},{"title":36,"searchDepth":49,"depth":49,"links":5411},[5412,5413,5414,5420,5426,5427],{"id":4710,"depth":49,"text":4711},{"id":4791,"depth":49,"text":4792},{"id":4862,"depth":49,"text":4863,"children":5415},[5416,5417,5418,5419],{"id":4867,"depth":55,"text":4742},{"id":4993,"depth":55,"text":4750},{"id":5135,"depth":55,"text":5136},{"id":5149,"depth":55,"text":5150},{"id":5156,"depth":49,"text":5157,"children":5421},[5422,5423,5424,5425],{"id":5160,"depth":55,"text":5161},{"id":5258,"depth":55,"text":4758},{"id":1600,"depth":55,"text":4774},{"id":5356,"depth":55,"text":5357},{"id":5363,"depth":49,"text":5364},{"id":2817,"depth":49,"text":2818},"2023-02-10","Wzorzec projektowy to nazwa dla powtarzalnego kształtu kodu. Jego główna wartość to komunikacja. Kiedy recenzent widzi CachingUserRepository implements UserRepositoryInterface, słowo „dekorator” mówi mu, jak klasa jest podpięta, co może, czego nie może i jak ją testować, zanim przeczyta choć jedną linię metody.",{},"\u002Fpl\u002Farticles\u002Fdesign-patterns-production",{"x":5433,"y":4680,"depth":43,"size":5434},0.62,"lg",[5436,1553,3835],"singleton-pattern",{"title":4692,"description":5429},"pattern-reference","pl\u002Farticles\u002Fdesign-patterns-production",[3841,3843,35,5441,5442],"refactoring","code-review","VNCGfQcrXXq1rJnQRchCQPyvNuuLd4RxqRcmYK3HZNk",{"id":5445,"title":5446,"articleId":3835,"body":5447,"category":35,"codeLang":35,"date":6108,"deploys":43,"description":5451,"excerpt":742,"extension":743,"lang":744,"meta":6109,"navigation":189,"path":4819,"pos":6110,"readMin":90,"related":6113,"seo":6114,"service":6115,"stem":6116,"tags":6117,"version":761,"__hash__":6120},"articles_pl\u002Fpl\u002Farticles\u002Ffactory-method.md","Factory Method w PHP: jedno miejsce na wybór implementacji w runtime",{"type":8,"value":5448,"toc":6098},[5449,5452,5456,5459,5558,5565,5569,5572,5607,5610,5614,5628,5706,5709,5795,5798,5846,5849,5883,5890,5908,5912,5922,5935,5939,5945,5947,5953,5994,6001,6055,6059,6076,6078,6096],[11,5450,5451],{},"Fabryka odpowiada na jedno pytanie: jaką implementację utworzyć dla danego wejścia. Przydaje się, gdy odpowiedź zależy od danych z runtime (pole requestu, kolumna w bazie) i gdy ta sama odpowiedź jest potrzebna w więcej niż jednym miejscu. Jeśli brakuje któregoś z tych warunków, fabryka dokłada warstwę i niczego nie usuwa.",[23,5453,5455],{"id":5454},"terminologia","Terminologia",[11,5457,5458],{},"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.",[31,5460,5462],{"className":33,"code":5461,"language":35,"meta":36,"style":36},"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",[15,5463,5464,5469,5473,5478,5482,5487,5491,5496,5501,5506,5510,5515,5519,5523,5527,5532,5536,5541,5545,5550,5554],{"__ignoreMap":36},[40,5465,5466],{"class":42,"line":43},[40,5467,5468],{},"abstract class ReportExporter\n",[40,5470,5471],{"class":42,"line":49},[40,5472,76],{},[40,5474,5475],{"class":42,"line":55},[40,5476,5477],{},"    abstract protected function createWriter(): ReportWriter;\n",[40,5479,5480],{"class":42,"line":84},[40,5481,190],{"emptyLinePlaceholder":189},[40,5483,5484],{"class":42,"line":90},[40,5485,5486],{},"    public function export(Report $report): string\n",[40,5488,5489],{"class":42,"line":96},[40,5490,241],{},[40,5492,5493],{"class":42,"line":102},[40,5494,5495],{},"        $writer = $this->createWriter();\n",[40,5497,5498],{"class":42,"line":193},[40,5499,5500],{},"        foreach ($report->rows() as $row) {\n",[40,5502,5503],{"class":42,"line":199},[40,5504,5505],{},"            $writer->addRow($row);\n",[40,5507,5508],{"class":42,"line":204},[40,5509,353],{},[40,5511,5512],{"class":42,"line":210},[40,5513,5514],{},"        return $writer->finish();\n",[40,5516,5517],{"class":42,"line":216},[40,5518,253],{},[40,5520,5521],{"class":42,"line":222},[40,5522,105],{},[40,5524,5525],{"class":42,"line":227},[40,5526,190],{"emptyLinePlaceholder":189},[40,5528,5529],{"class":42,"line":232},[40,5530,5531],{},"final class CsvReportExporter extends ReportExporter\n",[40,5533,5534],{"class":42,"line":238},[40,5535,76],{},[40,5537,5538],{"class":42,"line":244},[40,5539,5540],{},"    protected function createWriter(): ReportWriter\n",[40,5542,5543],{"class":42,"line":250},[40,5544,241],{},[40,5546,5547],{"class":42,"line":256},[40,5548,5549],{},"        return new CsvWriter();\n",[40,5551,5552],{"class":42,"line":261},[40,5553,253],{},[40,5555,5556],{"class":42,"line":267},[40,5557,105],{},[11,5559,5560,5561,5564],{},"W aplikacjach PHP częściej spotyka się fabrykę parametryzowaną: jeden obiekt z metodą ",[15,5562,5563],{},"make()",", która przyjmuje klucz i zwraca implementację interfejsu. Dalej piszę o tej formie, bo to ona zastępuje powielone warunki.",[23,5566,5568],{"id":5567},"problem-ta-sama-gałąź-w-kilku-miejscach","Problem: ta sama gałąź w kilku miejscach",[11,5570,5571],{},"Przykład: aplikacja przyjmuje płatności kartą, BLIK-iem, przelewem i na raty. Każdy dostawca ma własne API, własny model błędów i własny format webhooków. Bez jednego punktu decyzji w kontrolerze pojawia się taki kod:",[31,5573,5575],{"className":33,"code":5574,"language":35,"meta":36,"style":36},"$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    default => throw new InvalidArgumentException('Unknown payment method'),\n};\n",[15,5576,5577,5582,5587,5592,5597,5602],{"__ignoreMap":36},[40,5578,5579],{"class":42,"line":43},[40,5580,5581],{},"$gateway = match ($request->input('payment_method')) {\n",[40,5583,5584],{"class":42,"line":49},[40,5585,5586],{},"    'card' => new StripeGateway(config('services.stripe.secret')),\n",[40,5588,5589],{"class":42,"line":55},[40,5590,5591],{},"    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),\n",[40,5593,5594],{"class":42,"line":84},[40,5595,5596],{},"    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),\n",[40,5598,5599],{"class":42,"line":90},[40,5600,5601],{},"    default => throw new InvalidArgumentException('Unknown payment method'),\n",[40,5603,5604],{"class":42,"line":96},[40,5605,5606],{},"};\n",[11,5608,5609],{},"Ten sam wybór jest potrzebny 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. Każda kopia powtarza też argumenty konstruktorów. Nowy dostawca albo nowa zależność istniejącego oznacza szukanie i edycję wszystkich kopii.",[23,5611,5613],{"id":5612},"interfejs-enum-fabryka","Interfejs, enum, fabryka",[11,5615,5616,5617,1011,5619,1011,5621,485,5624,5627],{},"Zaczynam od interfejsu wyrażonego w pojęciach aplikacji. ",[15,5618,4985],{},[15,5620,4989],{},[15,5622,5623],{},"RefundResult",[15,5625,5626],{},"WebhookEvent"," to value objecty aplikacji.",[31,5629,5631],{"className":33,"code":5630,"language":35,"meta":36,"style":36},"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",[15,5632,5633,5638,5642,5647,5652,5657,5662,5666,5670,5675,5679,5684,5688,5693,5697,5702],{"__ignoreMap":36},[40,5634,5635],{"class":42,"line":43},[40,5636,5637],{},"enum PaymentMethod: string\n",[40,5639,5640],{"class":42,"line":49},[40,5641,76],{},[40,5643,5644],{"class":42,"line":55},[40,5645,5646],{},"    case Card = 'card';\n",[40,5648,5649],{"class":42,"line":84},[40,5650,5651],{},"    case Blik = 'blik';\n",[40,5653,5654],{"class":42,"line":90},[40,5655,5656],{},"    case Transfer = 'transfer';\n",[40,5658,5659],{"class":42,"line":96},[40,5660,5661],{},"    case Financing = 'financing';\n",[40,5663,5664],{"class":42,"line":102},[40,5665,105],{},[40,5667,5668],{"class":42,"line":193},[40,5669,190],{"emptyLinePlaceholder":189},[40,5671,5672],{"class":42,"line":199},[40,5673,5674],{},"interface PaymentGateway\n",[40,5676,5677],{"class":42,"line":204},[40,5678,76],{},[40,5680,5681],{"class":42,"line":210},[40,5682,5683],{},"    public function charge(Money $amount, array $metadata): ChargeResult;\n",[40,5685,5686],{"class":42,"line":216},[40,5687,190],{"emptyLinePlaceholder":189},[40,5689,5690],{"class":42,"line":222},[40,5691,5692],{},"    public function refund(string $chargeId, Money $amount): RefundResult;\n",[40,5694,5695],{"class":42,"line":227},[40,5696,190],{"emptyLinePlaceholder":189},[40,5698,5699],{"class":42,"line":232},[40,5700,5701],{},"    public function parseWebhook(string $payload, array $headers): WebhookEvent;\n",[40,5703,5704],{"class":42,"line":238},[40,5705,105],{},[11,5707,5708],{},"Fabryka mapuje metodę na klasę, a budowę obiektu zostawia kontenerowi:",[31,5710,5712],{"className":33,"code":5711,"language":35,"meta":36,"style":36},"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",[15,5713,5714,5719,5723,5728,5732,5737,5741,5746,5751,5755,5759,5764,5768,5773,5778,5782,5787,5791],{"__ignoreMap":36},[40,5715,5716],{"class":42,"line":43},[40,5717,5718],{},"use Illuminate\\Contracts\\Container\\Container;\n",[40,5720,5721],{"class":42,"line":49},[40,5722,190],{"emptyLinePlaceholder":189},[40,5724,5725],{"class":42,"line":55},[40,5726,5727],{},"final class PaymentGatewayFactory\n",[40,5729,5730],{"class":42,"line":84},[40,5731,76],{},[40,5733,5734],{"class":42,"line":90},[40,5735,5736],{},"    \u002F** @param array\u003Cstring, class-string\u003CPaymentGateway>> $gateways *\u002F\n",[40,5738,5739],{"class":42,"line":96},[40,5740,81],{},[40,5742,5743],{"class":42,"line":102},[40,5744,5745],{},"        private readonly Container $container,\n",[40,5747,5748],{"class":42,"line":193},[40,5749,5750],{},"        private readonly array $gateways,\n",[40,5752,5753],{"class":42,"line":199},[40,5754,99],{},[40,5756,5757],{"class":42,"line":204},[40,5758,190],{"emptyLinePlaceholder":189},[40,5760,5761],{"class":42,"line":210},[40,5762,5763],{},"    public function make(PaymentMethod $method): PaymentGateway\n",[40,5765,5766],{"class":42,"line":216},[40,5767,241],{},[40,5769,5770],{"class":42,"line":222},[40,5771,5772],{},"        $class = $this->gateways[$method->value]\n",[40,5774,5775],{"class":42,"line":227},[40,5776,5777],{},"            ?? throw new UnsupportedPaymentMethod($method->value);\n",[40,5779,5780],{"class":42,"line":232},[40,5781,190],{"emptyLinePlaceholder":189},[40,5783,5784],{"class":42,"line":238},[40,5785,5786],{},"        return $this->container->make($class);\n",[40,5788,5789],{"class":42,"line":244},[40,5790,253],{},[40,5792,5793],{"class":42,"line":250},[40,5794,105],{},[11,5796,5797],{},"Rejestracja odbywa się raz, w service providerze. Zależności konstruktorów poszczególnych bramek (dane dostępowe, klient HTTP, logger) są bindowane osobno i rozwiązuje je kontener.",[31,5799,5801],{"className":33,"code":5800,"language":35,"meta":36,"style":36},"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",[15,5802,5803,5808,5812,5817,5822,5827,5832,5837,5842],{"__ignoreMap":36},[40,5804,5805],{"class":42,"line":43},[40,5806,5807],{},"public function register(): void\n",[40,5809,5810],{"class":42,"line":49},[40,5811,76],{},[40,5813,5814],{"class":42,"line":55},[40,5815,5816],{},"    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [\n",[40,5818,5819],{"class":42,"line":84},[40,5820,5821],{},"        PaymentMethod::Card->value => StripeGateway::class,\n",[40,5823,5824],{"class":42,"line":90},[40,5825,5826],{},"        PaymentMethod::Blik->value => BlikGateway::class,\n",[40,5828,5829],{"class":42,"line":96},[40,5830,5831],{},"        PaymentMethod::Transfer->value => BankTransferGateway::class,\n",[40,5833,5834],{"class":42,"line":102},[40,5835,5836],{},"        PaymentMethod::Financing->value => FinancingGateway::class,\n",[40,5838,5839],{"class":42,"line":193},[40,5840,5841],{},"    ]));\n",[40,5843,5844],{"class":42,"line":199},[40,5845,105],{},[11,5847,5848],{},"Miejsca wywołania podejmują decyzję tylko na podstawie własnego wejścia:",[31,5850,5852],{"className":33,"code":5851,"language":35,"meta":36,"style":36},"\u002F\u002F controller: the input is validated with 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 refund job: the input is a column cast to PaymentMethod\n$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);\n",[15,5853,5854,5859,5864,5869,5873,5878],{"__ignoreMap":36},[40,5855,5856],{"class":42,"line":43},[40,5857,5858],{},"\u002F\u002F controller: the input is validated with Rule::enum(PaymentMethod::class)\n",[40,5860,5861],{"class":42,"line":49},[40,5862,5863],{},"$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));\n",[40,5865,5866],{"class":42,"line":55},[40,5867,5868],{},"$result = $gateway->charge($amount, ['order_id' => $order->id]);\n",[40,5870,5871],{"class":42,"line":84},[40,5872,190],{"emptyLinePlaceholder":189},[40,5874,5875],{"class":42,"line":90},[40,5876,5877],{},"\u002F\u002F refund job: the input is a column cast to PaymentMethod\n",[40,5879,5880],{"class":42,"line":96},[40,5881,5882],{},"$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);\n",[11,5884,5885,5886,5889],{},"Nowy dostawca wymaga klasy implementującej ",[15,5887,5888],{},"PaymentGateway",", nowego przypadku w enumie i jednej linii w mapie.",[11,5891,5892,5893,5896,5897,1011,5900,5903,5904,5907],{},"Laravel stosuje ten sam pomysł wewnętrznie w ",[15,5894,5895],{},"Illuminate\\Support\\Manager"," (",[15,5898,5899],{},"driver()",[15,5901,5902],{},"extend()","), na którym opierają się m.in. sterowniki sesji, hashowania i kanałów powiadomień. Rozszerzenie ",[15,5905,5906],{},"Manager"," ma sens, gdy implementacje wybiera się po nazwie z plików konfiguracyjnych. Przy wyborze domenowym, takim jak ten, osobna fabryka jest czytelniejsza.",[23,5909,5911],{"id":5910},"dwa-typowe-błędy","Dwa typowe błędy",[11,5913,5914,5917,5918,5921],{},[130,5915,5916],{},"Fabryka sama konstruuje obiekty."," Fabryka, która woła ",[15,5919,5920],{},"new StripeGateway(config(...))",", musi się zmieniać przy każdej zmianie konstruktora bramki i ukrywa zależności bramki przed kontenerem. Konstrukcję trzeba oddać kontenerowi. Bez kontenera przekaż do konstruktora fabryki gotowe instancje albo closure, które je budują.",[11,5923,5924,5927,5928,5930,5931,5934],{},[130,5925,5926],{},"Interfejs odwzorowuje jednego dostawcę."," Jeśli ",[15,5929,5888],{}," ma metody w rodzaju ",[15,5932,5933],{},"createPaymentIntent()",", bo pierwszą integracją był Stripe, każda kolejna implementacja musi emulować model Stripe'a, a w tej emulacji zbierają się błędy. Nazwij operacje według tego, co robi aplikacja (obciążenie, zwrot, interpretacja webhooka), a tłumaczenie na model dostawcy trzymaj w jego klasie.",[23,5936,5938],{"id":5937},"fabryka-czy-strategy","Fabryka czy Strategy",[11,5940,5941,5942,5944],{},"Te dwa wzorce często występują razem. Strategy opisuje obiekt, którego zachowanie da się podmienić za interfejsem: każda ",[15,5943,5888],{}," jest strategią przetwarzania płatności. Fabryka to miejsce, które decyduje, której strategii użyć dla danego wejścia. Jeśli strategia jest znana przy starcie aplikacji (z konfiguracji), fabryka jest zbędna: zbinduj interfejs na implementację w kontenerze i wstrzyknij ją.",[23,5946,3613],{"id":3612},[11,5948,5949,5950,5952],{},"Sama fabryka potrzebuje jednego testu: każdy przypadek enuma daje ",[15,5951,5888],{},". Test zawiedzie, gdy ktoś doda przypadek i zapomni o wpisie w mapie.",[31,5954,5956],{"className":33,"code":5955,"language":35,"meta":36,"style":36},"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",[15,5957,5958,5963,5967,5972,5976,5981,5986,5990],{"__ignoreMap":36},[40,5959,5960],{"class":42,"line":43},[40,5961,5962],{},"public function test_every_payment_method_has_a_gateway(): void\n",[40,5964,5965],{"class":42,"line":49},[40,5966,76],{},[40,5968,5969],{"class":42,"line":55},[40,5970,5971],{},"    $factory = $this->app->make(PaymentGatewayFactory::class);\n",[40,5973,5974],{"class":42,"line":84},[40,5975,190],{"emptyLinePlaceholder":189},[40,5977,5978],{"class":42,"line":90},[40,5979,5980],{},"    foreach (PaymentMethod::cases() as $method) {\n",[40,5982,5983],{"class":42,"line":96},[40,5984,5985],{},"        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));\n",[40,5987,5988],{"class":42,"line":102},[40,5989,253],{},[40,5991,5992],{"class":42,"line":193},[40,5993,105],{},[11,5995,5996,5997,6000],{},"Więcej daje wspólny test kontraktu, który rozszerza każda bramka. Każda podklasa dostarcza bramkę ze sfałszowanym transportem, na przykład przez ",[15,5998,5999],{},"Http::fake()",", jeśli bramka używa klienta HTTP Laravela.",[31,6002,6004],{"className":33,"code":6003,"language":35,"meta":36,"style":36},"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",[15,6005,6006,6011,6015,6020,6024,6029,6033,6038,6042,6047,6051],{"__ignoreMap":36},[40,6007,6008],{"class":42,"line":43},[40,6009,6010],{},"abstract class PaymentGatewayContractTest extends TestCase\n",[40,6012,6013],{"class":42,"line":49},[40,6014,76],{},[40,6016,6017],{"class":42,"line":55},[40,6018,6019],{},"    abstract protected function gateway(): PaymentGateway;\n",[40,6021,6022],{"class":42,"line":84},[40,6023,190],{"emptyLinePlaceholder":189},[40,6025,6026],{"class":42,"line":90},[40,6027,6028],{},"    public function test_charge_returns_a_charge_id(): void\n",[40,6030,6031],{"class":42,"line":96},[40,6032,241],{},[40,6034,6035],{"class":42,"line":102},[40,6036,6037],{},"        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);\n",[40,6039,6040],{"class":42,"line":193},[40,6041,190],{"emptyLinePlaceholder":189},[40,6043,6044],{"class":42,"line":199},[40,6045,6046],{},"        $this->assertNotSame('', $result->chargeId);\n",[40,6048,6049],{"class":42,"line":204},[40,6050,253],{},[40,6052,6053],{"class":42,"line":210},[40,6054,105],{},[23,6056,6058],{"id":6057},"kiedy-nie-używać-fabryki","Kiedy nie używać fabryki",[703,6060,6061,6064,6070],{},[127,6062,6063],{},"Jest jedna implementacja albo wybór jest stały dla danego wdrożenia. Wystarczy binding w kontenerze.",[127,6065,6066,6067,6069],{},"Wybór zapada w dokładnie jednym miejscu. ",[15,6068,403],{}," na enumie w tym miejscu jest czytelniejszy niż dodatkowa klasa.",[127,6071,6072,6073,6075],{},"Implementacje nie mają wspólnego interfejsu, którego da się używać bez sprawdzania ",[15,6074,3576],{},". Najpierw popraw interfejs.",[23,6077,701],{"id":700},[703,6079,6080,6083,6086,6093],{},[127,6081,6082],{},"Decyzja zależy od danych z runtime i jest potrzebna w więcej niż jednym miejscu.",[127,6084,6085],{},"Klucze to enum, nie dowolne stringi, a test sprawdza każdy przypadek.",[127,6087,6088,6089,6092],{},"Fabryka rozwiązuje klasy przez kontener i nie woła ",[15,6090,6091],{},"new"," z konfiguracją.",[127,6094,6095],{},"Zwracany interfejs używa słownika aplikacji, nie dostawcy.",[729,6097,731],{},{"title":36,"searchDepth":49,"depth":49,"links":6099},[6100,6101,6102,6103,6104,6105,6106,6107],{"id":5454,"depth":49,"text":5455},{"id":5567,"depth":49,"text":5568},{"id":5612,"depth":49,"text":5613},{"id":5910,"depth":49,"text":5911},{"id":5937,"depth":49,"text":5938},{"id":3612,"depth":49,"text":3613},{"id":6057,"depth":49,"text":6058},{"id":700,"depth":49,"text":701},"2024-07-15",{},{"x":6111,"y":6112,"depth":43,"size":743},0.58,0.48,[5436,1553],{"title":5446,"description":5451},"object-creation","pl\u002Farticles\u002Ffactory-method",[35,3841,6118,6119,3843],"factory","dependency-injection","ZoSDk8ndt-zLE6Z34y1Sni6CMDHdO75MJRNFlN55Tt0",{"id":6122,"title":6123,"articleId":6124,"body":6125,"category":1543,"codeLang":35,"date":7400,"deploys":43,"description":6129,"excerpt":742,"extension":743,"lang":744,"meta":7401,"navigation":189,"path":7402,"pos":7403,"readMin":204,"related":7406,"seo":7407,"service":7408,"stem":7409,"tags":7410,"version":761,"__hash__":7416},"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":6126,"toc":7389},[6127,6130,6133,6137,6140,6163,6166,6170,6317,6320,6327,6331,6334,6355,6358,6569,6583,6587,6608,6871,6882,6886,6897,7106,7109,7124,7128,7131,7191,7295,7313,7319,7322,7326,7336,7350,7353,7357,7360,7362,7387],[11,6128,6129],{},"Model językowy udostępniany przez dostawcę (Anthropic, OpenAI, Mistral) to endpoint HTTPS, który przyjmuje i zwraca JSON. Język aplikacji, która go woła, nie ma dla niego znaczenia. Przewaga Pythona jest realna 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 są problemy kolejek, bazy danych i kontraktów, a aplikacja PHP ma już na nie narzędzia.",[11,6131,6132],{},"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. U innych dostawców struktura jest taka sama.",[23,6134,6136],{"id":6135},"biblioteka-czy-zwykłe-http","Biblioteka czy zwykłe HTTP",[11,6138,6139],{},"Są trzy sensowne drogi:",[703,6141,6142,6149,6156],{},[127,6143,6144,6145,6148],{},"Zwykłe HTTP przez Guzzle albo fasadę ",[15,6146,6147],{},"Http",". Jeden endpoint, kilka nagłówków, zero nowych zależności. Wystarcza, gdy wołasz jednego dostawcę w kilku miejscach.",[127,6150,6151,6152,6155],{},"Biblioteka kliencka: ",[15,6153,6154],{},"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.",[127,6157,6158,6159,6162],{},"Komponenty AI Symfony (projekt ",[15,6160,6161],{},"symfony\u002Fai","), jeśli aplikacja stoi na Symfony i chcesz integracji z kontenerem i konfiguracją frameworka.",[11,6164,6165],{},"Niezależnie od wyboru sprawdź przed produkcją cztery rzeczy: 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. Biblioteka, która ukrywa którąkolwiek z tych rzeczy, zabierze więcej czasu, niż oszczędzi.",[23,6167,6169],{"id":6168},"cienki-klient","Cienki klient",[31,6171,6173],{"className":33,"code":6172,"language":35,"meta":36,"style":36},"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",[15,6174,6175,6180,6185,6190,6194,6199,6203,6207,6212,6217,6221,6225,6230,6235,6239,6244,6249,6254,6259,6264,6269,6274,6279,6284,6289,6294,6299,6304,6309,6313],{"__ignoreMap":36},[40,6176,6177],{"class":42,"line":43},[40,6178,6179],{},"use Illuminate\\Http\\Client\\ConnectionException;\n",[40,6181,6182],{"class":42,"line":49},[40,6183,6184],{},"use Illuminate\\Http\\Client\\RequestException;\n",[40,6186,6187],{"class":42,"line":55},[40,6188,6189],{},"use Illuminate\\Support\\Facades\\Http;\n",[40,6191,6192],{"class":42,"line":84},[40,6193,190],{"emptyLinePlaceholder":189},[40,6195,6196],{"class":42,"line":90},[40,6197,6198],{},"final class AnthropicClient\n",[40,6200,6201],{"class":42,"line":96},[40,6202,76],{},[40,6204,6205],{"class":42,"line":102},[40,6206,81],{},[40,6208,6209],{"class":42,"line":193},[40,6210,6211],{},"        private readonly string $apiKey,\n",[40,6213,6214],{"class":42,"line":199},[40,6215,6216],{},"        private readonly string $model,\n",[40,6218,6219],{"class":42,"line":204},[40,6220,99],{},[40,6222,6223],{"class":42,"line":210},[40,6224,190],{"emptyLinePlaceholder":189},[40,6226,6227],{"class":42,"line":216},[40,6228,6229],{},"    \u002F** @return array\u003Cstring, mixed> *\u002F\n",[40,6231,6232],{"class":42,"line":222},[40,6233,6234],{},"    public function messages(array $payload): array\n",[40,6236,6237],{"class":42,"line":227},[40,6238,241],{},[40,6240,6241],{"class":42,"line":232},[40,6242,6243],{},"        return Http::baseUrl('https:\u002F\u002Fapi.anthropic.com\u002Fv1')\n",[40,6245,6246],{"class":42,"line":238},[40,6247,6248],{},"            ->withHeaders([\n",[40,6250,6251],{"class":42,"line":244},[40,6252,6253],{},"                'x-api-key' => $this->apiKey,\n",[40,6255,6256],{"class":42,"line":250},[40,6257,6258],{},"                'anthropic-version' => '2023-06-01',\n",[40,6260,6261],{"class":42,"line":256},[40,6262,6263],{},"            ])\n",[40,6265,6266],{"class":42,"line":261},[40,6267,6268],{},"            ->connectTimeout(5)\n",[40,6270,6271],{"class":42,"line":267},[40,6272,6273],{},"            ->timeout(30)\n",[40,6275,6276],{"class":42,"line":272},[40,6277,6278],{},"            ->retry(2, 2000, fn (\\Throwable $e): bool =>\n",[40,6280,6281],{"class":42,"line":278},[40,6282,6283],{},"                $e instanceof ConnectionException\n",[40,6285,6286],{"class":42,"line":283},[40,6287,6288],{},"                || ($e instanceof RequestException\n",[40,6290,6291],{"class":42,"line":288},[40,6292,6293],{},"                    && in_array($e->response->status(), [429, 500, 529], true))\n",[40,6295,6296],{"class":42,"line":294},[40,6297,6298],{},"            )\n",[40,6300,6301],{"class":42,"line":299},[40,6302,6303],{},"            ->post('\u002Fmessages', ['model' => $this->model, ...$payload])\n",[40,6305,6306],{"class":42,"line":305},[40,6307,6308],{},"            ->json();\n",[40,6310,6311],{"class":42,"line":310},[40,6312,253],{},[40,6314,6315],{"class":42,"line":955},[40,6316,105],{},[11,6318,6319],{},"Nazwa modelu pochodzi z konfiguracji, nie z kodu, bo 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 błędne żądanie i powtórka niczego nie zmieni.",[11,6321,6322,6323,6326],{},"Timeout odczytu musi pasować do najdłuższej odpowiedzi, o jaką prosisz. Czas generowania rośnie z ",[15,6324,6325],{},"max_tokens",". Klasyfikacja ograniczona do 256 tokenów kończy się w kilka sekund, długie streszczenie może nie zmieścić się w 30 sekundach i wtedy potrzebny jest wyższy timeout albo streaming.",[23,6328,6330],{"id":6329},"wywołania-modelu-w-jobach-kolejki","Wywołania modelu w jobach kolejki",[11,6332,6333],{},"Większość pracy z modelem powinna iść przez kolejkę: użytkownik nie musi czekać kilku sekund na odpowiedź HTTP, a worker może ponowić próbę. Kłopoty robią tu dwa mechanizmy.",[11,6335,6336,6337,6340,6341,6344,6345,6347,6348,6351,6352,6354],{},"Pierwszy to zależność między timeoutami. W Laravelu job, który działa dłużej niż ",[15,6338,6339],{},"retry_after"," połączenia (w ",[15,6342,6343],{},"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ż ",[15,6346,6339],{},", a ",[15,6349,6350],{},"--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 ",[15,6353,6339],{}," 120 s są spójne. Liczby są przykładowe, liczą się nierówności.",[11,6356,6357],{},"Drugi to niedeterminizm. To samo wejście przy kolejnym wywołaniu może dać inną klasyfikację. Ponowienie nie jest więc powtórzeniem 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:",[31,6359,6361],{"className":33,"code":6360,"language":35,"meta":36,"style":36},"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",[15,6362,6363,6368,6373,6378,6383,6388,6392,6397,6401,6406,6410,6415,6420,6425,6430,6434,6439,6443,6448,6452,6457,6461,6465,6470,6474,6479,6483,6488,6492,6496,6500,6505,6509,6514,6519,6524,6529,6534,6539,6543,6547,6552,6557,6561,6565],{"__ignoreMap":36},[40,6364,6365],{"class":42,"line":43},[40,6366,6367],{},"use Illuminate\\Contracts\\Queue\\ShouldBeUnique;\n",[40,6369,6370],{"class":42,"line":49},[40,6371,6372],{},"use Illuminate\\Contracts\\Queue\\ShouldQueue;\n",[40,6374,6375],{"class":42,"line":55},[40,6376,6377],{},"use Illuminate\\Foundation\\Bus\\Dispatchable;\n",[40,6379,6380],{"class":42,"line":84},[40,6381,6382],{},"use Illuminate\\Foundation\\Queue\\Queueable;\n",[40,6384,6385],{"class":42,"line":90},[40,6386,6387],{},"use Illuminate\\Queue\\InteractsWithQueue;\n",[40,6389,6390],{"class":42,"line":96},[40,6391,190],{"emptyLinePlaceholder":189},[40,6393,6394],{"class":42,"line":102},[40,6395,6396],{},"final class TriageTicket implements ShouldQueue, ShouldBeUnique\n",[40,6398,6399],{"class":42,"line":193},[40,6400,76],{},[40,6402,6403],{"class":42,"line":199},[40,6404,6405],{},"    use Dispatchable, InteractsWithQueue, Queueable;\n",[40,6407,6408],{"class":42,"line":204},[40,6409,190],{"emptyLinePlaceholder":189},[40,6411,6412],{"class":42,"line":210},[40,6413,6414],{},"    public int $timeout = 90;\n",[40,6416,6417],{"class":42,"line":216},[40,6418,6419],{},"    public int $tries = 3;\n",[40,6421,6422],{"class":42,"line":222},[40,6423,6424],{},"    public array $backoff = [10, 60];\n",[40,6426,6427],{"class":42,"line":227},[40,6428,6429],{},"    public int $uniqueFor = 600;\n",[40,6431,6432],{"class":42,"line":232},[40,6433,190],{"emptyLinePlaceholder":189},[40,6435,6436],{"class":42,"line":238},[40,6437,6438],{},"    public function __construct(public readonly int $ticketId) {}\n",[40,6440,6441],{"class":42,"line":244},[40,6442,190],{"emptyLinePlaceholder":189},[40,6444,6445],{"class":42,"line":250},[40,6446,6447],{},"    public function uniqueId(): string\n",[40,6449,6450],{"class":42,"line":256},[40,6451,241],{},[40,6453,6454],{"class":42,"line":261},[40,6455,6456],{},"        return (string) $this->ticketId;\n",[40,6458,6459],{"class":42,"line":267},[40,6460,253],{},[40,6462,6463],{"class":42,"line":272},[40,6464,190],{"emptyLinePlaceholder":189},[40,6466,6467],{"class":42,"line":278},[40,6468,6469],{},"    public function handle(TicketClassifier $classifier): void\n",[40,6471,6472],{"class":42,"line":283},[40,6473,241],{},[40,6475,6476],{"class":42,"line":288},[40,6477,6478],{},"        $ticket = Ticket::findOrFail($this->ticketId);\n",[40,6480,6481],{"class":42,"line":294},[40,6482,190],{"emptyLinePlaceholder":189},[40,6484,6485],{"class":42,"line":299},[40,6486,6487],{},"        if ($ticket->triaged_at !== null) {\n",[40,6489,6490],{"class":42,"line":305},[40,6491,3550],{},[40,6493,6494],{"class":42,"line":310},[40,6495,353],{},[40,6497,6498],{"class":42,"line":955},[40,6499,190],{"emptyLinePlaceholder":189},[40,6501,6502],{"class":42,"line":961},[40,6503,6504],{},"        $result = $classifier->classify($ticket->body);\n",[40,6506,6507],{"class":42,"line":967},[40,6508,190],{"emptyLinePlaceholder":189},[40,6510,6511],{"class":42,"line":973},[40,6512,6513],{},"        $updated = Ticket::whereKey($ticket->id)\n",[40,6515,6516],{"class":42,"line":978},[40,6517,6518],{},"            ->whereNull('triaged_at')\n",[40,6520,6521],{"class":42,"line":984},[40,6522,6523],{},"            ->update([\n",[40,6525,6526],{"class":42,"line":990},[40,6527,6528],{},"                'department' => $result->department->value,\n",[40,6530,6531],{"class":42,"line":996},[40,6532,6533],{},"                'priority' => $result->priority->value,\n",[40,6535,6536],{"class":42,"line":1002},[40,6537,6538],{},"                'triaged_at' => now(),\n",[40,6540,6541],{"class":42,"line":2297},[40,6542,4948],{},[40,6544,6545],{"class":42,"line":2307},[40,6546,190],{"emptyLinePlaceholder":189},[40,6548,6549],{"class":42,"line":2318},[40,6550,6551],{},"        if ($updated === 1) {\n",[40,6553,6554],{"class":42,"line":2328},[40,6555,6556],{},"            TicketTriaged::dispatch($ticket->id);\n",[40,6558,6559],{"class":42,"line":2338},[40,6560,353],{},[40,6562,6563],{"class":42,"line":2343},[40,6564,253],{},[40,6566,6567],{"class":42,"line":2351},[40,6568,105],{},[11,6570,6571,6574,6575,6578,6579,6582],{},[15,6572,6573],{},"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 ",[15,6576,6577],{},"uniqueFor","). Warunek ",[15,6580,6581],{},"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 nie wywoła drugiego skutku.",[23,6584,6586],{"id":6585},"ustrukturyzowana-odpowiedź-i-walidacja","Ustrukturyzowana odpowiedź i walidacja",[11,6588,6589,6590,6593,6594,6596,6597,6600,6601,6604,6605,6607],{},"Wolny tekst to słaby interfejs między modelem a kodem. Przy klasyfikacji i ekstrakcji użyj structured outputs: przekaż schemat JSON Schema w ",[15,6591,6592],{},"output_config.format",", a model zwróci blok ",[15,6595,2929],{}," z JSON-em zgodnym ze schematem. Starszy sposób, czyli wymuszenie wywołania narzędzia przez ",[15,6598,6599],{},"tool_choice",", najnowsze modele Claude (Opus 5.5, Sonnet 5.5) odrzucają błędem 400. Dokumentacja wymienia przypadki, w których wynik nie pasuje do schematu: odmowa (",[15,6602,6603],{},"stop_reason: refusal","), odpowiedź ucięta przez ",[15,6606,6325],{}," i wartości enuma zwrócone z inną wielkością liter. Dlatego i tak go waliduj.",[31,6609,6611],{"className":33,"code":6610,"language":35,"meta":36,"style":36},"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",[15,6612,6613,6618,6622,6627,6632,6637,6641,6645,6650,6654,6659,6664,6669,6673,6677,6682,6686,6691,6695,6700,6704,6709,6713,6718,6723,6728,6733,6738,6743,6748,6753,6758,6763,6768,6773,6778,6783,6788,6793,6798,6803,6808,6812,6817,6822,6826,6831,6836,6840,6845,6850,6854,6858,6863,6867],{"__ignoreMap":36},[40,6614,6615],{"class":42,"line":43},[40,6616,6617],{},"enum Department: string\n",[40,6619,6620],{"class":42,"line":49},[40,6621,76],{},[40,6623,6624],{"class":42,"line":55},[40,6625,6626],{},"    case Billing = 'billing';\n",[40,6628,6629],{"class":42,"line":84},[40,6630,6631],{},"    case Technical = 'technical';\n",[40,6633,6634],{"class":42,"line":90},[40,6635,6636],{},"    case Sales = 'sales';\n",[40,6638,6639],{"class":42,"line":96},[40,6640,105],{},[40,6642,6643],{"class":42,"line":102},[40,6644,190],{"emptyLinePlaceholder":189},[40,6646,6647],{"class":42,"line":193},[40,6648,6649],{},"enum Priority: string\n",[40,6651,6652],{"class":42,"line":199},[40,6653,76],{},[40,6655,6656],{"class":42,"line":204},[40,6657,6658],{},"    case Low = 'low';\n",[40,6660,6661],{"class":42,"line":210},[40,6662,6663],{},"    case Normal = 'normal';\n",[40,6665,6666],{"class":42,"line":216},[40,6667,6668],{},"    case Urgent = 'urgent';\n",[40,6670,6671],{"class":42,"line":222},[40,6672,105],{},[40,6674,6675],{"class":42,"line":227},[40,6676,190],{"emptyLinePlaceholder":189},[40,6678,6679],{"class":42,"line":232},[40,6680,6681],{},"final class TicketClassifier\n",[40,6683,6684],{"class":42,"line":238},[40,6685,76],{},[40,6687,6688],{"class":42,"line":244},[40,6689,6690],{},"    public function __construct(private readonly AnthropicClient $client) {}\n",[40,6692,6693],{"class":42,"line":250},[40,6694,190],{"emptyLinePlaceholder":189},[40,6696,6697],{"class":42,"line":256},[40,6698,6699],{},"    public function classify(string $body): TicketClassification\n",[40,6701,6702],{"class":42,"line":261},[40,6703,241],{},[40,6705,6706],{"class":42,"line":267},[40,6707,6708],{},"        $values = fn (array $cases): array => array_map(fn ($c) => $c->value, $cases);\n",[40,6710,6711],{"class":42,"line":272},[40,6712,190],{"emptyLinePlaceholder":189},[40,6714,6715],{"class":42,"line":278},[40,6716,6717],{},"        $response = $this->client->messages([\n",[40,6719,6720],{"class":42,"line":283},[40,6721,6722],{},"            'max_tokens' => 256,\n",[40,6724,6725],{"class":42,"line":288},[40,6726,6727],{},"            'system' => 'Classify the customer support ticket by department and priority.',\n",[40,6729,6730],{"class":42,"line":294},[40,6731,6732],{},"            'messages' => [['role' => 'user', 'content' => $body]],\n",[40,6734,6735],{"class":42,"line":299},[40,6736,6737],{},"            'output_config' => [\n",[40,6739,6740],{"class":42,"line":305},[40,6741,6742],{},"                'format' => [\n",[40,6744,6745],{"class":42,"line":310},[40,6746,6747],{},"                    'type' => 'json_schema',\n",[40,6749,6750],{"class":42,"line":955},[40,6751,6752],{},"                    'schema' => [\n",[40,6754,6755],{"class":42,"line":961},[40,6756,6757],{},"                        'type' => 'object',\n",[40,6759,6760],{"class":42,"line":967},[40,6761,6762],{},"                        'properties' => [\n",[40,6764,6765],{"class":42,"line":973},[40,6766,6767],{},"                            'department' => ['type' => 'string', 'enum' => $values(Department::cases())],\n",[40,6769,6770],{"class":42,"line":978},[40,6771,6772],{},"                            'priority' => ['type' => 'string', 'enum' => $values(Priority::cases())],\n",[40,6774,6775],{"class":42,"line":984},[40,6776,6777],{},"                        ],\n",[40,6779,6780],{"class":42,"line":990},[40,6781,6782],{},"                        'required' => ['department', 'priority'],\n",[40,6784,6785],{"class":42,"line":996},[40,6786,6787],{},"                        'additionalProperties' => false,\n",[40,6789,6790],{"class":42,"line":1002},[40,6791,6792],{},"                    ],\n",[40,6794,6795],{"class":42,"line":2297},[40,6796,6797],{},"                ],\n",[40,6799,6800],{"class":42,"line":2307},[40,6801,6802],{},"            ],\n",[40,6804,6805],{"class":42,"line":2318},[40,6806,6807],{},"        ]);\n",[40,6809,6810],{"class":42,"line":2328},[40,6811,190],{"emptyLinePlaceholder":189},[40,6813,6814],{"class":42,"line":2338},[40,6815,6816],{},"        $text = collect($response['content'])->firstWhere('type', 'text')['text'] ?? '';\n",[40,6818,6819],{"class":42,"line":2343},[40,6820,6821],{},"        $output = (array) json_decode($text, true);\n",[40,6823,6824],{"class":42,"line":2351},[40,6825,190],{"emptyLinePlaceholder":189},[40,6827,6828],{"class":42,"line":2363},[40,6829,6830],{},"        $department = Department::tryFrom(strtolower((string) ($output['department'] ?? '')));\n",[40,6832,6833],{"class":42,"line":2371},[40,6834,6835],{},"        $priority = Priority::tryFrom(strtolower((string) ($output['priority'] ?? '')));\n",[40,6837,6838],{"class":42,"line":2381},[40,6839,190],{"emptyLinePlaceholder":189},[40,6841,6842],{"class":42,"line":2390},[40,6843,6844],{},"        if ($department === null || $priority === null) {\n",[40,6846,6847],{"class":42,"line":2400},[40,6848,6849],{},"            throw new InvalidModelOutput('ticket_classification', $response);\n",[40,6851,6852],{"class":42,"line":2409},[40,6853,353],{},[40,6855,6856],{"class":42,"line":2420},[40,6857,190],{"emptyLinePlaceholder":189},[40,6859,6860],{"class":42,"line":2425},[40,6861,6862],{},"        return new TicketClassification($department, $priority, $response['usage']);\n",[40,6864,6865],{"class":42,"line":2437},[40,6866,253],{},[40,6868,6869],{"class":42,"line":2445},[40,6870,105],{},[11,6872,6873,6874,6877,6878,6881],{},"Enumy są jedynym źródłem dozwolonych wartości: z nich powstaje schemat i nimi sprawdzana jest odpowiedź. Wartości są zamieniane na małe litery przed ",[15,6875,6876],{},"tryFrom()",", bo wielkość liter w wartościach enuma nie jest gwarantowana. Niepoprawna odpowiedź rzuca wyjątek, job idzie do ponowienia, a po ostatniej próbie zgłoszenie zostaje niesklasyfikowane, a job trafia do ",[15,6879,6880],{},"failed_jobs",". To bezpieczniejsze zachowanie domyślne niż zapisanie zgadniętej wartości.",[23,6883,6885],{"id":6884},"narzędzia-działają-z-uprawnieniami-użytkownika","Narzędzia działają z uprawnieniami użytkownika",[11,6887,6888,6889,6892,6893,6896],{},"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 realizuje protokół Messages API. Model odpowiada z ",[15,6890,6891],{},"stop_reason: tool_use",", aplikacja wykonuje narzędzia i odsyła bloki ",[15,6894,6895],{},"tool_result",", i tak do chwili, gdy model zwróci tekst.",[31,6898,6900],{"className":33,"code":6899,"language":35,"meta":36,"style":36},"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",[15,6901,6902,6907,6911,6916,6920,6925,6929,6934,6939,6944,6948,6952,6957,6962,6967,6972,6977,6981,6985,6990,6994,6999,7004,7009,7014,7019,7024,7029,7034,7039,7044,7048,7053,7057,7061,7066,7070,7074,7079,7083,7088,7093,7098,7102],{"__ignoreMap":36},[40,6903,6904],{"class":42,"line":43},[40,6905,6906],{},"public function answer(User $user, string $question): string\n",[40,6908,6909],{"class":42,"line":49},[40,6910,76],{},[40,6912,6913],{"class":42,"line":55},[40,6914,6915],{},"    $messages = [['role' => 'user', 'content' => $question]];\n",[40,6917,6918],{"class":42,"line":84},[40,6919,190],{"emptyLinePlaceholder":189},[40,6921,6922],{"class":42,"line":90},[40,6923,6924],{},"    for ($step = 0; $step \u003C 5; $step++) {\n",[40,6926,6927],{"class":42,"line":96},[40,6928,6717],{},[40,6930,6931],{"class":42,"line":102},[40,6932,6933],{},"            'max_tokens' => 1024,\n",[40,6935,6936],{"class":42,"line":193},[40,6937,6938],{},"            'tools' => $this->tools,\n",[40,6940,6941],{"class":42,"line":199},[40,6942,6943],{},"            'messages' => $messages,\n",[40,6945,6946],{"class":42,"line":204},[40,6947,6807],{},[40,6949,6950],{"class":42,"line":210},[40,6951,190],{"emptyLinePlaceholder":189},[40,6953,6954],{"class":42,"line":216},[40,6955,6956],{},"        if ($response['stop_reason'] !== 'tool_use') {\n",[40,6958,6959],{"class":42,"line":222},[40,6960,6961],{},"            return collect($response['content'])\n",[40,6963,6964],{"class":42,"line":227},[40,6965,6966],{},"                ->where('type', 'text')\n",[40,6968,6969],{"class":42,"line":232},[40,6970,6971],{},"                ->pluck('text')\n",[40,6973,6974],{"class":42,"line":238},[40,6975,6976],{},"                ->implode(\"\\n\");\n",[40,6978,6979],{"class":42,"line":244},[40,6980,353],{},[40,6982,6983],{"class":42,"line":250},[40,6984,190],{"emptyLinePlaceholder":189},[40,6986,6987],{"class":42,"line":256},[40,6988,6989],{},"        $messages[] = ['role' => 'assistant', 'content' => $response['content']];\n",[40,6991,6992],{"class":42,"line":261},[40,6993,190],{"emptyLinePlaceholder":189},[40,6995,6996],{"class":42,"line":267},[40,6997,6998],{},"        $results = [];\n",[40,7000,7001],{"class":42,"line":272},[40,7002,7003],{},"        foreach ($response['content'] as $block) {\n",[40,7005,7006],{"class":42,"line":278},[40,7007,7008],{},"            if ($block['type'] !== 'tool_use') {\n",[40,7010,7011],{"class":42,"line":283},[40,7012,7013],{},"                continue;\n",[40,7015,7016],{"class":42,"line":288},[40,7017,7018],{},"            }\n",[40,7020,7021],{"class":42,"line":294},[40,7022,7023],{},"            $results[] = [\n",[40,7025,7026],{"class":42,"line":299},[40,7027,7028],{},"                'type' => 'tool_result',\n",[40,7030,7031],{"class":42,"line":305},[40,7032,7033],{},"                'tool_use_id' => $block['id'],\n",[40,7035,7036],{"class":42,"line":310},[40,7037,7038],{},"                'content' => json_encode($this->runTool($user, $block['name'], $block['input']), JSON_THROW_ON_ERROR),\n",[40,7040,7041],{"class":42,"line":955},[40,7042,7043],{},"            ];\n",[40,7045,7046],{"class":42,"line":961},[40,7047,353],{},[40,7049,7050],{"class":42,"line":967},[40,7051,7052],{},"        $messages[] = ['role' => 'user', 'content' => $results];\n",[40,7054,7055],{"class":42,"line":973},[40,7056,253],{},[40,7058,7059],{"class":42,"line":978},[40,7060,190],{"emptyLinePlaceholder":189},[40,7062,7063],{"class":42,"line":984},[40,7064,7065],{},"    throw new ToolLoopLimitExceeded($step);\n",[40,7067,7068],{"class":42,"line":990},[40,7069,105],{},[40,7071,7072],{"class":42,"line":996},[40,7073,190],{"emptyLinePlaceholder":189},[40,7075,7076],{"class":42,"line":1002},[40,7077,7078],{},"private function runTool(User $user, string $name, array $input): array\n",[40,7080,7081],{"class":42,"line":2297},[40,7082,76],{},[40,7084,7085],{"class":42,"line":2307},[40,7086,7087],{},"    return match ($name) {\n",[40,7089,7090],{"class":42,"line":2318},[40,7091,7092],{},"        'get_order_status' => $this->orders->statusFor($user, (string) ($input['order_id'] ?? '')),\n",[40,7094,7095],{"class":42,"line":2328},[40,7096,7097],{},"        default => ['error' => \"Unknown tool: {$name}\"],\n",[40,7099,7100],{"class":42,"line":2338},[40,7101,474],{},[40,7103,7104],{"class":42,"line":2343},[40,7105,105],{},[11,7107,7108],{},"Z tego kodu wynikają trzy zasady:",[703,7110,7111,7114,7121],{},[127,7112,7113],{},"Limit kroków jest obowiązkowy. Bez niego model, który wciąż prosi o narzędzia, kręci się aż do timeoutu, a każda iteracja jest płatna.",[127,7115,7116,7117,7120],{},"Argumenty od modelu to niezaufane wejście, tak jak pole formularza. ",[15,7118,7119],{},"statusFor($user, ...)"," ogranicza zapytanie do zamówień tego użytkownika. Model nie zobaczy cudzego zamówienia, nawet jeśli ktoś w rozmowie każe mu o nie zapytać.",[127,7122,7123],{},"Narzędzia zmieniające stan (zwroty, anulowania) nie powinny wykonywać się wprost na prośbę modelu. Niech narzędzie przygotuje propozycję, a zatwierdzenie odbywa się poza modelem: przyciskiem, osobnym żądaniem albo przez człowieka.",[23,7125,7127],{"id":7126},"wyszukiwanie-w-postgresie-z-pgvector","Wyszukiwanie w Postgresie z pgvector",[11,7129,7130],{},"Retrieval-augmented generation nie wymaga osobnej bazy wektorowej, jeśli masz już Postgresa. Najdroższa część, liczenie embeddingów, dzieje się przy indeksowaniu dokumentów. W chwili zapytania jest jedno wywołanie po embedding i jedno zapytanie SQL.",[31,7132,7136],{"className":7133,"code":7134,"language":7135,"meta":36,"style":36},"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",[15,7137,7138,7143,7147,7152,7157,7162,7167,7172,7177,7181,7186],{"__ignoreMap":36},[40,7139,7140],{"class":42,"line":43},[40,7141,7142],{},"CREATE EXTENSION IF NOT EXISTS vector;\n",[40,7144,7145],{"class":42,"line":49},[40,7146,190],{"emptyLinePlaceholder":189},[40,7148,7149],{"class":42,"line":55},[40,7150,7151],{},"CREATE TABLE document_chunks (\n",[40,7153,7154],{"class":42,"line":84},[40,7155,7156],{},"    id          bigserial PRIMARY KEY,\n",[40,7158,7159],{"class":42,"line":90},[40,7160,7161],{},"    document_id bigint NOT NULL REFERENCES documents (id) ON DELETE CASCADE,\n",[40,7163,7164],{"class":42,"line":96},[40,7165,7166],{},"    content     text   NOT NULL,\n",[40,7168,7169],{"class":42,"line":102},[40,7170,7171],{},"    embedding   vector(1536) NOT NULL\n",[40,7173,7174],{"class":42,"line":193},[40,7175,7176],{},");\n",[40,7178,7179],{"class":42,"line":199},[40,7180,190],{"emptyLinePlaceholder":189},[40,7182,7183],{"class":42,"line":204},[40,7184,7185],{},"CREATE INDEX document_chunks_embedding_idx\n",[40,7187,7188],{"class":42,"line":210},[40,7189,7190],{},"    ON document_chunks USING hnsw (embedding vector_cosine_ops);\n",[31,7192,7194],{"className":33,"code":7193,"language":35,"meta":36,"style":36},"$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, zgodnie z kolumną\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",[15,7195,7196,7201,7206,7211,7219,7224,7229,7234,7239,7243,7248,7252,7257,7262,7267,7272,7277,7282,7286,7290],{"__ignoreMap":36},[40,7197,7198],{"class":42,"line":43},[40,7199,7200],{},"$embedding = Http::withToken(config('services.openai.key'))\n",[40,7202,7203],{"class":42,"line":49},[40,7204,7205],{},"    ->timeout(15)\n",[40,7207,7208],{"class":42,"line":55},[40,7209,7210],{},"    ->post('https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fembeddings', [\n",[40,7212,7213,7216],{"class":42,"line":84},[40,7214,7215],{},"        'model' => 'text-embedding-3-small',",[40,7217,7218],{}," \u002F\u002F 1536 wymiarów, zgodnie z kolumną\n",[40,7220,7221],{"class":42,"line":90},[40,7222,7223],{},"        'input' => $question,\n",[40,7225,7226],{"class":42,"line":96},[40,7227,7228],{},"    ])\n",[40,7230,7231],{"class":42,"line":102},[40,7232,7233],{},"    ->throw()\n",[40,7235,7236],{"class":42,"line":193},[40,7237,7238],{},"    ->json('data.0.embedding');\n",[40,7240,7241],{"class":42,"line":199},[40,7242,190],{"emptyLinePlaceholder":189},[40,7244,7245],{"class":42,"line":204},[40,7246,7247],{},"$vector = '[' . implode(',', $embedding) . ']';\n",[40,7249,7250],{"class":42,"line":210},[40,7251,190],{"emptyLinePlaceholder":189},[40,7253,7254],{"class":42,"line":216},[40,7255,7256],{},"$chunks = DB::select(\n",[40,7258,7259],{"class":42,"line":222},[40,7260,7261],{},"    'SELECT id, content, embedding \u003C=> ?::vector AS distance\n",[40,7263,7264],{"class":42,"line":227},[40,7265,7266],{},"       FROM document_chunks\n",[40,7268,7269],{"class":42,"line":232},[40,7270,7271],{},"      ORDER BY embedding \u003C=> ?::vector\n",[40,7273,7274],{"class":42,"line":238},[40,7275,7276],{},"      LIMIT 5',\n",[40,7278,7279],{"class":42,"line":244},[40,7280,7281],{},"    [$vector, $vector],\n",[40,7283,7284],{"class":42,"line":250},[40,7285,7176],{},[40,7287,7288],{"class":42,"line":256},[40,7289,190],{"emptyLinePlaceholder":189},[40,7291,7292],{"class":42,"line":261},[40,7293,7294],{},"$relevant = array_filter($chunks, fn (object $c): bool => $c->distance \u003C= $maxDistance);\n",[11,7296,7297,7300,7301,7304,7305,7308,7309,7312],{},[15,7298,7299],{},"\u003C=>"," to odległość kosinusowa, a indeks ",[15,7302,7303],{},"hnsw"," z ",[15,7306,7307],{},"vector_cosine_ops"," obsługuje dokładnie takie sortowanie. Próg odległości jest filtrowany w PHP po ",[15,7310,7311],{},"LIMIT",", dzięki czemu zapytanie zachowuje kształt, z którego indeks potrafi skorzystać.",[11,7314,7315,7318],{},[15,7316,7317],{},"$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,7320,7321],{},"Gdy nic nie przechodzi przez próg, powiedz to użytkownikowi zamiast wołać model z pustym kontekstem. Pusty kontekst zachęca model do odpowiedzi z ogólnej wiedzy, a temu właśnie miało zapobiec wyszukiwanie.",[23,7323,7325],{"id":7324},"co-mierzyć","Co mierzyć",[11,7327,7328,7329,1011,7332,7335],{},"Każde wywołanie zapisuj w tabeli albo w systemie metryk z polami: nazwa funkcji, model, ",[15,7330,7331],{},"usage.input_tokens",[15,7333,7334],{},"usage.output_tokens",", czas odpowiedzi i wynik (sukces, niepoprawna odpowiedź, błąd API). To odpowiada na pytania, które w praktyce padają:",[703,7337,7338,7341,7344,7347],{},[127,7339,7340],{},"Koszt w rozbiciu na funkcje. Koszt rośnie z tokenami, nie z liczbą żądań. Długi prompt systemowy wysyłany przy każdym wywołaniu często dominuje w rachunku, a grupowanie po funkcji pokazuje, gdzie.",[127,7342,7343],{},"Percentyle czasu odpowiedzi (p50, p95). Średnia ukrywa wolne wywołania, które wpadają w timeout.",[127,7345,7346],{},"Odsetek niepoprawnych odpowiedzi. Alarmuj przy zmianie względem jego własnej linii bazowej. Skok zwykle oznacza zmianę promptu, inny rozkład danych wejściowych albo nową wersję modelu.",[127,7348,7349],{},"Czas oczekiwania jobów LLM w kolejce. Pokazuje, czy liczba workerów nadąża, zanim zauważą to użytkownicy.",[11,7351,7352],{},"Jeśli prompty zawierają dane osobowe albo finansowe, loguj identyfikatory i liczby, nie pełną treść promptu.",[23,7354,7356],{"id":7355},"kiedy-osobny-serwis-w-pythonie-ma-sens","Kiedy osobny serwis w Pythonie ma sens",[11,7358,7359],{},"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ł.",[23,7361,701],{"id":700},[703,7363,7364,7372,7375,7378,7381,7384],{},[127,7365,7366,7369,7370,1139],{},[15,7367,7368],{},"$timeout"," joba obejmuje całe wywołanie klienta z ponowieniami i jest krótszy niż ",[15,7371,6339],{},[127,7373,7374],{},"Wynik zapisywany jest warunkowo, efekty uboczne uruchamia tylko próba, która zapisała.",[127,7376,7377],{},"Odpowiedź modelu jest walidowana enumami albo schematem, niepoprawna kończy joba błędem.",[127,7379,7380],{},"Pętla narzędzi ma limit kroków, narzędzia sprawdzają uprawnienia, narzędzia zmieniające stan wymagają zatwierdzenia.",[127,7382,7383],{},"Próg wyszukiwania jest zmierzony na własnych danych i mierzony ponownie po zmianie modelu.",[127,7385,7386],{},"Tokeny, czas odpowiedzi i wynik są zapisywane dla każdej funkcji osobno.",[729,7388,731],{},{"title":36,"searchDepth":49,"depth":49,"links":7390},[7391,7392,7393,7394,7395,7396,7397,7398,7399],{"id":6135,"depth":49,"text":6136},{"id":6168,"depth":49,"text":6169},{"id":6329,"depth":49,"text":6330},{"id":6585,"depth":49,"text":6586},{"id":6884,"depth":49,"text":6885},{"id":7126,"depth":49,"text":7127},{"id":7324,"depth":49,"text":7325},{"id":7355,"depth":49,"text":7356},{"id":700,"depth":49,"text":701},"2024-10-28",{},"\u002Fpl\u002Farticles\u002Fllm-in-php",{"x":7404,"y":3833,"depth":7405,"size":5434},0.47,1.3,[767,5436],{"title":6123,"description":6129},"llm-integration","pl\u002Farticles\u002Fllm-in-php",[35,7411,7412,7413,7414,7415],"laravel","llm","anthropic-api","pgvector","queues","cI4jY8e1SvLmXu00UWdYx5pvw6y_5bEj3Bs3_4i08-U",{"id":7418,"title":7419,"articleId":1553,"body":7420,"category":4675,"codeLang":2929,"date":8161,"deploys":43,"description":7424,"excerpt":742,"extension":743,"lang":744,"meta":8162,"navigation":189,"path":8163,"pos":8164,"readMin":102,"related":8168,"seo":8169,"service":8170,"stem":8171,"tags":8172,"version":8176,"__hash__":8177},"articles_pl\u002Fpl\u002Farticles\u002Fmicroservice-cost.md","Ukryty koszt granic między mikroserwisami: jak wycenić granicę, zanim ją narysujesz",{"type":8,"value":7421,"toc":8152},[7422,7425,7432,7436,7439,7477,7480,7484,7490,7493,7522,7531,7535,7540,7841,7847,7850,7854,7857,7892,7895,7899,7902,8084,8087,8101,8104,8108,8111,8125,8129,8149],[11,7423,7424],{},"Podział systemu na serwisy nie usuwa złożoności, tylko ją przenosi. 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. Granicę najtrudniej potem przesunąć, bo przesunięcie oznacza migrację danych i przepisanie kontraktu.",[11,7426,7427,7428,7431],{},"Pytanie, które trzeba zadać przed jej narysowaniem: ",[130,7429,7430],{},"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 i żadne narzędzia tego nie potanią.",[23,7433,7435],{"id":7434},"co-kosztuje-granica","Co kosztuje granica",[11,7437,7438],{},"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 wymaga:",[703,7440,7441,7447,7453,7459,7465,7471],{},[127,7442,7443,7446],{},[130,7444,7445],{},"Wersjonowania."," Producent musi obsługiwać stary i nowy kontrakt jednocześnie, bo oba serwisy nie wdrażają się w tej samej chwili.",[127,7448,7449,7452],{},[130,7450,7451],{},"Wdrożeń w kolejności."," Rozszerz producenta i wdróż, przenieś konsumentów i wdróż, potem usuń stary kontrakt i wdróż ponownie. Jedna zmiana logiczna to trzy wydania.",[127,7454,7455,7458],{},[130,7456,7457],{},"Testów kontraktowych albo środowiska integracyjnego."," Analiza statyczna nie widzi już obu stron. Coś innego musi wykryć, że pole zmieniło nazwę.",[127,7460,7461,7464],{},[130,7462,7463],{},"Obsługi awarii rozproszonych."," Wywołanie funkcji zwraca wynik albo rzuca wyjątek. Wywołanie sieciowe może też skończyć się timeoutem, kiedy druga strona już zrobiła commit. Każdy zapis przez granicę wymaga decyzji o ponowieniach, kluczach idempotencji i o tym, co się dzieje ze stanem częściowym.",[127,7466,7467,7470],{},[130,7468,7469],{},"Danych, które były joinem."," Raport łączący dwie tabele potrzebuje teraz wywołania API na wiersz, replikowanego modelu do odczytu albo strumienia zdarzeń zasilającego osobny magazyn.",[127,7472,7473,7476],{},[130,7474,7475],{},"Obserwowalności."," Żądanie przechodzące przez trzy serwisy bez distributed tracingu jest praktycznie niedebugowalne.",[11,7478,7479],{},"Żaden z tych kosztów osobno nie jest duży. Płacisz je jednak przy każdej zmianie przekraczającej linię, więc suma zależy od częstotliwości.",[23,7481,7483],{"id":7482},"model-kosztu","Model kosztu",[31,7485,7488],{"className":7486,"code":7487,"language":2929,"meta":36},[2927],"cost(boundary) =\n      f_change  * cost_per_change      # coordinated changes across the line\n    + f_failure * blast_radius         # how often and how badly one side breaks the other\n    - autonomy_gained                  # independent deploys, scaling, ownership\n\n# first term dominates   -> the boundary is in the wrong place\n# second term dominates  -> the boundary may be right; invest in isolation (timeouts, queues, fallbacks)\n# third term dominates   -> the boundary pays for itself\n",[15,7489,7487],{"__ignoreMap":36},[11,7491,7492],{},"Tych składników nie liczy się do jednej liczby. Szacuje się je zgrubnie i na piśmie, przed decyzją:",[703,7494,7495,7501,7507,7516],{},[127,7496,7497,7500],{},[15,7498,7499],{},"f_change",": ile zmian miesięcznie wymaga ruszenia obu stron.",[127,7502,7503,7506],{},[15,7504,7505],{},"cost_per_change",": narzut z listy wyżej, w godzinach pracy na jedną skoordynowaną zmianę.",[127,7508,7509,485,7512,7515],{},[15,7510,7511],{},"f_failure",[15,7513,7514],{},"blast_radius",": jak często awaria albo spowolnienie jednej strony dotyka drugiej i jak daleko się to rozlewa.",[127,7517,7518,7521],{},[15,7519,7520],{},"autonomy_gained",": co każda strona może teraz zrobić samodzielnie, czego wcześniej nie mogła. Ten składnik jest bliski zera, gdy oba serwisy należą do tego samego zespołu, bo zespół i tak musi koordynować się sam ze sobą.",[11,7523,7524,7525,7527,7528,7530],{},"Przykład: dwa serwisy jednego zespołu, przy czym większość funkcji dotyka obu. ",[15,7526,7499],{}," jest wysokie, ",[15,7529,7520],{}," bliskie zera. Granica dokłada wersjonowanie i wdrożenia w kolejności do większości funkcji i nic nie daje w zamian. To granica modułu narysowana jako granica sieciowa.",[23,7532,7534],{"id":7533},"pomiar-sprzężenia-zmian-przed-narysowaniem-linii","Pomiar sprzężenia zmian przed narysowaniem linii",[11,7536,7537,7539],{},[15,7538,7499],{}," da się zmierzyć z historii repozytorium zamiast zgadywać. Jeśli system jest jeszcze monolitem z katalogiem na moduł, policz, jak często commity dotykają obu kandydatów.",[31,7541,7543],{"className":1917,"code":7542,"language":1919,"meta":36,"style":36},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# Change coupling between two directories over a time window.\n# Usage: .\u002Fcoupling.sh src\u002FBilling src\u002FFraud '6 months ago'\na=\"$1\"; b=\"$2\"; since=\"${3:-6 months ago}\"\n\ntotal_a=$(git log --since=\"$since\" --format='%H' -- \"$a\" | wc -l)\nboth=0\nfor h in $(git log --since=\"$since\" --format='%H' -- \"$a\"); do\n  if git show --name-only --format= \"$h\" | grep -q \"^$b\u002F\"; then\n    both=$((both + 1))\n  fi\ndone\n\necho \"commits touching $a: $total_a\"\necho \"of which also touch $b: $both\"\n",[15,7544,7545,7550,7555,7560,7615,7619,7674,7683,7727,7773,7794,7799,7804,7808,7825],{"__ignoreMap":36},[40,7546,7547],{"class":42,"line":43},[40,7548,7549],{"class":2604},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[40,7551,7552],{"class":42,"line":49},[40,7553,7554],{"class":2604},"# Change coupling between two directories over a time window.\n",[40,7556,7557],{"class":42,"line":55},[40,7558,7559],{"class":2604},"# Usage: .\u002Fcoupling.sh src\u002FBilling src\u002FFraud '6 months ago'\n",[40,7561,7562,7564,7567,7570,7573,7575,7578,7580,7582,7585,7587,7590,7592,7594,7597,7600,7603,7606,7609,7612],{"class":42,"line":84},[40,7563,4165],{"class":1620},[40,7565,7566],{"class":4286},"=",[40,7568,7569],{"class":1631},"\"",[40,7571,7572],{"class":1747},"$1",[40,7574,7569],{"class":1631},[40,7576,7577],{"class":1620},"; b",[40,7579,7566],{"class":4286},[40,7581,7569],{"class":1631},[40,7583,7584],{"class":1747},"$2",[40,7586,7569],{"class":1631},[40,7588,7589],{"class":1620},"; since",[40,7591,7566],{"class":4286},[40,7593,7569],{"class":1631},[40,7595,7596],{"class":1747},"${3",[40,7598,7599],{"class":4286},":-",[40,7601,7602],{"class":1747},"6",[40,7604,7605],{"class":1620}," months",[40,7607,7608],{"class":1620}," ago",[40,7610,7611],{"class":1747},"}",[40,7613,7614],{"class":1631},"\"\n",[40,7616,7617],{"class":42,"line":90},[40,7618,190],{"emptyLinePlaceholder":189},[40,7620,7621,7624,7626,7629,7632,7635,7638,7640,7643,7645,7648,7651,7654,7657,7660,7662,7665,7668,7671],{"class":42,"line":96},[40,7622,7623],{"class":1620},"total_a",[40,7625,7566],{"class":4286},[40,7627,7628],{"class":1620},"$(",[40,7630,7631],{"class":1926},"git",[40,7633,7634],{"class":1631}," log",[40,7636,7637],{"class":1747}," --since=",[40,7639,7569],{"class":1631},[40,7641,7642],{"class":1620},"$since",[40,7644,7569],{"class":1631},[40,7646,7647],{"class":1747}," --format=",[40,7649,7650],{"class":1631},"'%H'",[40,7652,7653],{"class":1747}," --",[40,7655,7656],{"class":1631}," \"",[40,7658,7659],{"class":1620},"$a",[40,7661,7569],{"class":1631},[40,7663,7664],{"class":4286}," |",[40,7666,7667],{"class":1926}," wc",[40,7669,7670],{"class":1747}," -l",[40,7672,7673],{"class":1620},")\n",[40,7675,7676,7679,7681],{"class":42,"line":102},[40,7677,7678],{"class":1620},"both",[40,7680,7566],{"class":4286},[40,7682,2008],{"class":1631},[40,7684,7685,7688,7691,7694,7697,7699,7701,7703,7705,7707,7709,7711,7713,7715,7717,7719,7721,7724],{"class":42,"line":193},[40,7686,7687],{"class":4286},"for",[40,7689,7690],{"class":1620}," h ",[40,7692,7693],{"class":4286},"in",[40,7695,7696],{"class":1620}," $(",[40,7698,7631],{"class":1926},[40,7700,7634],{"class":1631},[40,7702,7637],{"class":1747},[40,7704,7569],{"class":1631},[40,7706,7642],{"class":1620},[40,7708,7569],{"class":1631},[40,7710,7647],{"class":1747},[40,7712,7650],{"class":1631},[40,7714,7653],{"class":1747},[40,7716,7656],{"class":1631},[40,7718,7659],{"class":1620},[40,7720,7569],{"class":1631},[40,7722,7723],{"class":1620},"); ",[40,7725,7726],{"class":4286},"do\n",[40,7728,7729,7732,7735,7738,7741,7743,7745,7748,7750,7752,7755,7758,7761,7764,7767,7770],{"class":42,"line":199},[40,7730,7731],{"class":4286},"  if",[40,7733,7734],{"class":1926}," git",[40,7736,7737],{"class":1631}," show",[40,7739,7740],{"class":1747}," --name-only",[40,7742,7647],{"class":1747},[40,7744,7656],{"class":1631},[40,7746,7747],{"class":1620},"$h",[40,7749,7569],{"class":1631},[40,7751,7664],{"class":4286},[40,7753,7754],{"class":1926}," grep",[40,7756,7757],{"class":1747}," -q",[40,7759,7760],{"class":1631}," \"^",[40,7762,7763],{"class":1620},"$b",[40,7765,7766],{"class":1631},"\u002F\"",[40,7768,7769],{"class":1620},"; ",[40,7771,7772],{"class":4286},"then\n",[40,7774,7775,7778,7780,7783,7785,7788,7791],{"class":42,"line":204},[40,7776,7777],{"class":1620},"    both",[40,7779,7566],{"class":4286},[40,7781,7782],{"class":1620},"$((",[40,7784,7678],{"class":1926},[40,7786,7787],{"class":1631}," +",[40,7789,7790],{"class":1747}," 1",[40,7792,7793],{"class":1620},"))\n",[40,7795,7796],{"class":42,"line":210},[40,7797,7798],{"class":4286},"  fi\n",[40,7800,7801],{"class":42,"line":216},[40,7802,7803],{"class":4286},"done\n",[40,7805,7806],{"class":42,"line":222},[40,7807,190],{"emptyLinePlaceholder":189},[40,7809,7810,7813,7816,7818,7820,7823],{"class":42,"line":227},[40,7811,7812],{"class":1747},"echo",[40,7814,7815],{"class":1631}," \"commits touching ",[40,7817,7659],{"class":1620},[40,7819,1628],{"class":1631},[40,7821,7822],{"class":1620},"$total_a",[40,7824,7614],{"class":1631},[40,7826,7827,7829,7832,7834,7836,7839],{"class":42,"line":232},[40,7828,7812],{"class":1747},[40,7830,7831],{"class":1631}," \"of which also touch ",[40,7833,7763],{"class":1620},[40,7835,1628],{"class":1631},[40,7837,7838],{"class":1620},"$both",[40,7840,7614],{"class":1631},[11,7842,7843,7844,1139],{},"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 ",[15,7845,7846],{},"git log",[11,7848,7849],{},"Orientacyjny próg na start, nie reguła: jeśli ponad jedna trzecia zmian po jednej stronie dotyka też drugiej, obie strony są jedną jednostką zmiany i powinny zostać w jednym deployu.",[23,7851,7853],{"id":7852},"kiedy-granica-się-opłaca","Kiedy granica się opłaca",[11,7855,7856],{},"Granica zarabia na siebie, gdy obie strony różnią się czymś, co ma znaczenie operacyjne:",[703,7858,7859,7865,7874,7880,7886],{},[127,7860,7861,7864],{},[130,7862,7863],{},"Rytm wdrożeń."," Przykład: serwis płatności wypuszczany kilka razy w tygodniu i model scoringu fraudów wypuszczany dopiero po kilkutygodniowej walidacji. Rozdzielenie pozwala płatnościom wychodzić na produkcję bez czekania na walidację modelu.",[127,7866,7867,7870,7871,7873],{},[130,7868,7869],{},"Własność."," Inne zespoły, inne dyżury, inne priorytety. To ten składnik sprawia, że ",[15,7872,7520],{}," jest realne. Granica, która nie pokrywa się z granicą zespołu, rzadko daje autonomię.",[127,7875,7876,7879],{},[130,7877,7878],{},"Profil skalowania."," Parser dokumentów obciążający CPU i API wrażliwe na opóźnienia mają inne potrzeby. Osobne procesy pozwalają każdemu skalować się i padać niezależnie.",[127,7881,7882,7885],{},[130,7883,7884],{},"Izolacja awarii."," Komponent wołający niestabilną usługę zewnętrzną można schować za kolejką, żeby jej awarie nie blokowały ścieżki żądania.",[127,7887,7888,7891],{},[130,7889,7890],{},"Technologia."," Model serwowany z Pythona obok aplikacji w PHP to naturalna granica procesu, bo alternatywą jest osadzanie jednego runtime’u w drugim.",[11,7893,7894],{},"Jeśli żaden z tych warunków nie zachodzi, kandydat na granicę to granica modułu i jej miejsce jest w jednym deployu.",[23,7896,7898],{"id":7897},"zacznij-od-modułów","Zacznij od modułów",[11,7900,7901],{},"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:",[31,7903,7905],{"className":1611,"code":7904,"language":1613,"meta":36,"style":36},"# 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",[15,7906,7907,7912,7919,7926,7933,7940,7951,7958,7970,7980,7991,7997,8007,8016,8027,8033,8043,8052,8059,8073],{"__ignoreMap":36},[40,7908,7909],{"class":42,"line":43},[40,7910,7911],{"class":2604},"# deptrac.yaml\n",[40,7913,7914,7917],{"class":42,"line":49},[40,7915,7916],{"class":1624},"deptrac",[40,7918,1650],{"class":1620},[40,7920,7921,7924],{"class":42,"line":55},[40,7922,7923],{"class":1624},"  paths",[40,7925,1650],{"class":1620},[40,7927,7928,7930],{"class":42,"line":84},[40,7929,2024],{"class":1620},[40,7931,7932],{"class":1631},".\u002Fsrc\n",[40,7934,7935,7938],{"class":42,"line":90},[40,7936,7937],{"class":1624},"  layers",[40,7939,1650],{"class":1620},[40,7941,7942,7944,7946,7948],{"class":42,"line":96},[40,7943,2024],{"class":1620},[40,7945,1625],{"class":1624},[40,7947,1628],{"class":1620},[40,7949,7950],{"class":1631},"Billing\n",[40,7952,7953,7956],{"class":42,"line":102},[40,7954,7955],{"class":1624},"      collectors",[40,7957,1650],{"class":1620},[40,7959,7960,7962,7965,7967],{"class":42,"line":193},[40,7961,2092],{"class":1620},[40,7963,7964],{"class":1624},"type",[40,7966,1628],{"class":1620},[40,7968,7969],{"class":1631},"directory\n",[40,7971,7972,7975,7977],{"class":42,"line":199},[40,7973,7974],{"class":1624},"          value",[40,7976,1628],{"class":1620},[40,7978,7979],{"class":1631},"src\u002FBilling\u002F.*\n",[40,7981,7982,7984,7986,7988],{"class":42,"line":204},[40,7983,2024],{"class":1620},[40,7985,1625],{"class":1624},[40,7987,1628],{"class":1620},[40,7989,7990],{"class":1631},"Fraud\n",[40,7992,7993,7995],{"class":42,"line":210},[40,7994,7955],{"class":1624},[40,7996,1650],{"class":1620},[40,7998,7999,8001,8003,8005],{"class":42,"line":216},[40,8000,2092],{"class":1620},[40,8002,7964],{"class":1624},[40,8004,1628],{"class":1620},[40,8006,7969],{"class":1631},[40,8008,8009,8011,8013],{"class":42,"line":222},[40,8010,7974],{"class":1624},[40,8012,1628],{"class":1620},[40,8014,8015],{"class":1631},"src\u002FFraud\u002F.*\n",[40,8017,8018,8020,8022,8024],{"class":42,"line":227},[40,8019,2024],{"class":1620},[40,8021,1625],{"class":1624},[40,8023,1628],{"class":1620},[40,8025,8026],{"class":1631},"Shared\n",[40,8028,8029,8031],{"class":42,"line":232},[40,8030,7955],{"class":1624},[40,8032,1650],{"class":1620},[40,8034,8035,8037,8039,8041],{"class":42,"line":238},[40,8036,2092],{"class":1620},[40,8038,7964],{"class":1624},[40,8040,1628],{"class":1620},[40,8042,7969],{"class":1631},[40,8044,8045,8047,8049],{"class":42,"line":244},[40,8046,7974],{"class":1624},[40,8048,1628],{"class":1620},[40,8050,8051],{"class":1631},"src\u002FShared\u002F.*\n",[40,8053,8054,8057],{"class":42,"line":250},[40,8055,8056],{"class":1624},"  ruleset",[40,8058,1650],{"class":1620},[40,8060,8061,8064,8067,8070],{"class":42,"line":256},[40,8062,8063],{"class":1624},"    Billing",[40,8065,8066],{"class":1620},": [",[40,8068,8069],{"class":1631},"Shared",[40,8071,8072],{"class":1620},"]\n",[40,8074,8075,8078,8080,8082],{"class":42,"line":261},[40,8076,8077],{"class":1624},"    Fraud",[40,8079,8066],{"class":1620},[40,8081,8069],{"class":1631},[40,8083,8072],{"class":1620},[11,8085,8086],{},"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. Dwie dodatkowe zasady sprawiają, że późniejsze wydzielenie jest tanie:",[703,8088,8089,8095],{},[127,8090,8091,8094],{},[130,8092,8093],{},"Żadnych joinów między modułami."," Moduł czyta dane innego modułu wyłącznie przez jego publiczny interfejs. Po wydzieleniu ten interfejs staje się API i nic więcej nie trzeba zmieniać.",[127,8096,8097,8100],{},[130,8098,8099],{},"Efekty uboczne między modułami przez zdarzenia."," Jeśli Billing potrzebuje reakcji Fraud na płatność, wysyła zdarzenie zamiast wołać Fraud bezpośrednio. Przeniesienie tego zdarzenia później na brokera wiadomości to zmiana transportu.",[11,8102,8103],{},"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ą.",[23,8105,8107],{"id":8106},"najpierw-platforma-potem-serwisy","Najpierw platforma, potem serwisy",[11,8109,8110],{},"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, a nie z ilością logiki biznesowej. Przed podziałem miej gotowe:",[703,8112,8113,8116,8119,8122],{},[127,8114,8115],{},"jeden szablon CI\u002FCD, który nowy serwis przyjmuje bez zmian;",[127,8117,8118],{},"strukturalne logi z correlation ID przekazywanym między wywołaniami;",[127,8120,8121],{},"distributed tracing;",[127,8123,8124],{},"standard timeoutów, ponowień i kluczy idempotencji dla każdego wywołania między serwisami.",[23,8126,8128],{"id":8127},"lista-kontrolna-przed-narysowaniem-granicy","Lista kontrolna przed narysowaniem granicy",[703,8130,8131,8134,8137,8140,8143,8146],{},[127,8132,8133],{},"Jaka jest najmniejsza zmiana, która ją przekracza, i ile takich zmian było w ostatnich sześciu miesiącach (zmierzone, nie oszacowane)?",[127,8135,8136],{},"Czy każda strona należy do innego zespołu?",[127,8138,8139],{},"Czy strony różnią się rytmem wdrożeń, profilem skalowania, trybem awarii albo technologią?",[127,8141,8142],{},"Czy granica jest już wymuszona jako granica modułu w monolicie, bez joinów między modułami?",[127,8144,8145],{},"Czy platforma (szablon CI\u002FCD, logi, tracing, polityka ponowień) jest gotowa na kolejny serwis?",[127,8147,8148],{},"Jeśli odpowiedź na pierwsze pytanie to „większość funkcji”, zostaw to jako moduł.",[729,8150,8151],{},"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":36,"searchDepth":49,"depth":49,"links":8153},[8154,8155,8156,8157,8158,8159,8160],{"id":7434,"depth":49,"text":7435},{"id":7482,"depth":49,"text":7483},{"id":7533,"depth":49,"text":7534},{"id":7852,"depth":49,"text":7853},{"id":7897,"depth":49,"text":7898},{"id":8106,"depth":49,"text":8107},{"id":8127,"depth":49,"text":8128},"2026-05-14",{},"\u002Fpl\u002Farticles\u002Fmicroservice-cost",{"x":8165,"y":8166,"depth":8167,"size":5434},0.72,0.68,1.1,[767,3836],{"title":7419,"description":7424},"service-boundaries","pl\u002Farticles\u002Fmicroservice-cost",[3843,8173,8174,8175],"organisation","platform","devex","v6.0.0","QRiCxGb47s2OCZhUMsROx0EWhhTsYn_zjGWRVLYGYnk",{"id":8179,"title":8180,"articleId":751,"body":8181,"category":1543,"codeLang":3928,"date":9061,"deploys":43,"description":8185,"excerpt":742,"extension":743,"lang":744,"meta":9062,"navigation":189,"path":9063,"pos":9064,"readMin":199,"related":9067,"seo":9068,"service":9069,"stem":9070,"tags":9071,"version":761,"__hash__":9076},"articles_pl\u002Fpl\u002Farticles\u002Fn8n-rag-data-quality.md","Zasilanie RAG w n8n: wersje dokumentów, kasowanie i metadane świeżości",{"type":8,"value":8182,"toc":9049},[8183,8186,8189,8193,8196,8224,8227,8272,8275,8279,8282,8299,8303,8332,8549,8556,8559,8563,8586,8686,8689,8693,8696,8763,8766,8770,8790,8823,8826,8830,8833,8960,8970,8974,8977,9012,9015,9019,9022,9024,9047],[11,8184,8185],{},"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: płynnie, z odwołaniem do prawdziwego dokumentu i błędnie. W logach nie ma błędu, detektor halucynacji milczy, bo model niczego nie wymyślił.",[11,8187,8188],{},"Usterka siedzi w przepływie zasilania, nie w modelu ani w prompcie. Ten tekst opisuje niezmienniki, które pipeline zasilania powinien utrzymywać, i ich implementację w n8n z Pinecone. Te same zasady dotyczą Qdranta, Weaviate i pgvectora.",[23,8190,8192],{"id":8191},"skąd-biorą-się-stare-chunki","Skąd biorą się stare chunki",[11,8194,8195],{},"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. Duplikaty powstają zwykle na trzy sposoby:",[124,8197,8198,8208,8218],{},[127,8199,8200,8203,8204,8207],{},[130,8201,8202],{},"ID wyliczane z nazwy pliku albo z treści."," Nowa wersja zapisana jako ",[15,8205,8206],{},"cennik-2026-v2.pdf"," albo wersja ze zmienionym pierwszym akapitem dostaje nowe ID. Stare rekordy zostają.",[127,8209,8210,8213,8214,8217],{},[130,8211,8212],{},"Krótsza nowa wersja."," Jeśli wersja 1 dała 12 chunków, a wersja 2 daje 9, upsert po ",[15,8215,8216],{},"docId + indeks"," nadpisze chunki 0–8, a chunki 9–11 ze starej wersji zostaną.",[127,8219,8220,8223],{},[130,8221,8222],{},"Usunięcie u źródła."," Przeniesienie pliku do archiwum albo skasowanie go na Google Drive niczego nie zmienia w bazie wektorowej. Bez jawnego delete chunki da się wyszukać bez końca.",[11,8225,8226],{},"Typowa pierwsza wersja przepływu (trigger Drive, splitter tekstu, upsert do Pinecone) ma wszystkie trzy problemy:",[31,8228,8230],{"className":3926,"code":8229,"language":3928,"meta":36,"style":36},"\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",[15,8231,8232,8237,8242,8247,8252,8257,8262,8267],{"__ignoreMap":36},[40,8233,8234],{"class":42,"line":43},[40,8235,8236],{},"\u002F\u002F n8n Code node: naiwne przygotowanie chunków\n",[40,8238,8239],{"class":42,"line":49},[40,8240,8241],{},"return $input.all().map(chunk => ({\n",[40,8243,8244],{"class":42,"line":55},[40,8245,8246],{},"  json: {\n",[40,8248,8249],{"class":42,"line":84},[40,8250,8251],{},"    id: chunk.json.metadata.loc.pageNumber + '_' + chunk.json.pageContent.slice(0, 32),\n",[40,8253,8254],{"class":42,"line":90},[40,8255,8256],{},"    text: chunk.json.pageContent,\n",[40,8258,8259],{"class":42,"line":96},[40,8260,8261],{},"    metadata: { source: chunk.json.metadata.source },\n",[40,8263,8264],{"class":42,"line":102},[40,8265,8266],{},"  },\n",[40,8268,8269],{"class":42,"line":193},[40,8270,8271],{},"}));\n",[11,8273,8274],{},"ID zależy od treści strony, jedyną metadaną jest nazwa pliku, a w całym przepływie nie ma kroku kasowania.",[23,8276,8278],{"id":8277},"niezmiennik","Niezmiennik",[11,8280,8281],{},"Dla każdego dokumentu w folderze źródłowym indeks zawiera dokładnie chunki jego bieżącej wersji i nic więcej. Z tego zdania wynikają wszystkie dalsze decyzje:",[703,8283,8284,8287,8290,8293,8296],{},[127,8285,8286],{},"ID dokumentu pochodzi z systemu źródłowego (file ID z Drive), a nie z nazwy pliku, bo file ID nie zmienia się przy zmianie nazwy ani przy wgraniu nowej rewizji;",[127,8288,8289],{},"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;",[127,8291,8292],{},"podmiana dokumentu to zapis nowych chunków, a potem usunięcie wszystkich chunków z tym samym prefiksem dokumentu i innym hashem;",[127,8294,8295],{},"uzgadnianie uruchamiane z harmonogramu usuwa dokumenty, których nie ma już u źródła;",[127,8297,8298],{},"świeżość jest zapisana jako liczby, bo na liczbach działają filtry zakresowe w metadanych.",[23,8300,8302],{"id":8301},"przygotowanie-chunków","Przygotowanie chunków",[11,8304,8305,8306,1011,8309,1011,8311,1011,8314,8317,8318,485,8320,8323,8324,8327,8328,8331],{},"Poniższy węzeł działa w trybie „Run Once for All Items”. Oczekuje jednego itemu na plik z Drive z polami ",[15,8307,8308],{},"id",[15,8310,1625],{},[15,8312,8313],{},"modifiedTime",[15,8315,8316],{},"mimeType",", wyciągniętym ",[15,8319,2929],{},[15,8321,8322],{},"docType"," (na przykład wyliczonym z podfolderu). Na self-hosted n8n ",[15,8325,8326],{},"require('crypto')"," działa tylko wtedy, gdy instancja ma ustawione ",[15,8329,8330],{},"NODE_FUNCTION_ALLOW_BUILTIN=crypto"," (przy zewnętrznych task runnerach zmienną ustawia się na runnerze, nie na głównej instancji).",[31,8333,8335],{"className":3926,"code":8334,"language":3928,"meta":36,"style":36},"\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",[15,8336,8337,8342,8347,8351,8356,8361,8366,8371,8376,8381,8385,8390,8395,8400,8405,8410,8414,8419,8423,8427,8432,8437,8442,8447,8451,8456,8461,8466,8471,8476,8481,8485,8490,8495,8500,8505,8510,8515,8520,8525,8530,8535,8540,8544],{"__ignoreMap":36},[40,8338,8339],{"class":42,"line":43},[40,8340,8341],{},"\u002F\u002F n8n Code node, tryb: Run Once for All Items\n",[40,8343,8344],{"class":42,"line":49},[40,8345,8346],{},"const crypto = require('crypto');\n",[40,8348,8349],{"class":42,"line":55},[40,8350,190],{"emptyLinePlaceholder":189},[40,8352,8353],{"class":42,"line":84},[40,8354,8355],{},"const CHUNK_SIZE = 1500; \u002F\u002F znaki\n",[40,8357,8358],{"class":42,"line":90},[40,8359,8360],{},"const OVERLAP = 200;\n",[40,8362,8363],{"class":42,"line":96},[40,8364,8365],{},"\u002F\u002F Liczba dni od ostatniej modyfikacji, po której dokument przestaje być zwracany.\n",[40,8367,8368],{"class":42,"line":102},[40,8369,8370],{},"\u002F\u002F null oznacza, że dany typ dokumentu nie wygasa.\n",[40,8372,8373],{"class":42,"line":193},[40,8374,8375],{},"const TTL_DAYS = { pricing: 90, changelog: 180, adr: null, general: null };\n",[40,8377,8378],{"class":42,"line":199},[40,8379,8380],{},"const NEVER = 4102444800; \u002F\u002F 2100-01-01 jako Unix timestamp\n",[40,8382,8383],{"class":42,"line":204},[40,8384,190],{"emptyLinePlaceholder":189},[40,8386,8387],{"class":42,"line":210},[40,8388,8389],{},"function split(text) {\n",[40,8391,8392],{"class":42,"line":216},[40,8393,8394],{},"  const chunks = [];\n",[40,8396,8397],{"class":42,"line":222},[40,8398,8399],{},"  for (let start = 0; start \u003C text.length; start += CHUNK_SIZE - OVERLAP) {\n",[40,8401,8402],{"class":42,"line":227},[40,8403,8404],{},"    chunks.push(text.slice(start, start + CHUNK_SIZE));\n",[40,8406,8407],{"class":42,"line":232},[40,8408,8409],{},"    if (start + CHUNK_SIZE >= text.length) break;\n",[40,8411,8412],{"class":42,"line":238},[40,8413,3955],{},[40,8415,8416],{"class":42,"line":244},[40,8417,8418],{},"  return chunks;\n",[40,8420,8421],{"class":42,"line":250},[40,8422,105],{},[40,8424,8425],{"class":42,"line":256},[40,8426,190],{"emptyLinePlaceholder":189},[40,8428,8429],{"class":42,"line":261},[40,8430,8431],{},"const out = [];\n",[40,8433,8434],{"class":42,"line":267},[40,8435,8436],{},"for (const item of $input.all()) {\n",[40,8438,8439],{"class":42,"line":272},[40,8440,8441],{},"  const { id: docId, name, modifiedTime, mimeType, text } = item.json;\n",[40,8443,8444],{"class":42,"line":278},[40,8445,8446],{},"  if (typeof text !== 'string' || text.trim().length \u003C 50) continue;\n",[40,8448,8449],{"class":42,"line":283},[40,8450,190],{"emptyLinePlaceholder":189},[40,8452,8453],{"class":42,"line":288},[40,8454,8455],{},"  const docType = item.json.docType ?? 'general';\n",[40,8457,8458],{"class":42,"line":294},[40,8459,8460],{},"  const body = text.trim();\n",[40,8462,8463],{"class":42,"line":299},[40,8464,8465],{},"  const version = crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);\n",[40,8467,8468],{"class":42,"line":305},[40,8469,8470],{},"  const modifiedTs = Math.floor(Date.parse(modifiedTime) \u002F 1000);\n",[40,8472,8473],{"class":42,"line":310},[40,8474,8475],{},"  const ttl = TTL_DAYS[docType] ?? null;\n",[40,8477,8478],{"class":42,"line":955},[40,8479,8480],{},"  const expiresTs = ttl === null ? NEVER : modifiedTs + ttl * 86400;\n",[40,8482,8483],{"class":42,"line":961},[40,8484,190],{"emptyLinePlaceholder":189},[40,8486,8487],{"class":42,"line":967},[40,8488,8489],{},"  split(body).forEach((chunk, i) => {\n",[40,8491,8492],{"class":42,"line":973},[40,8493,8494],{},"    out.push({\n",[40,8496,8497],{"class":42,"line":978},[40,8498,8499],{},"      json: {\n",[40,8501,8502],{"class":42,"line":984},[40,8503,8504],{},"        id: `${docId}#${version}#${String(i).padStart(4, '0')}`,\n",[40,8506,8507],{"class":42,"line":990},[40,8508,8509],{},"        docId,\n",[40,8511,8512],{"class":42,"line":996},[40,8513,8514],{},"        version,\n",[40,8516,8517],{"class":42,"line":1002},[40,8518,8519],{},"        text: chunk,\n",[40,8521,8522],{"class":42,"line":2297},[40,8523,8524],{},"        metadata: { docId, version, docType, source: name, mimeType, modifiedTs, expiresTs, text: chunk },\n",[40,8526,8527],{"class":42,"line":2307},[40,8528,8529],{},"      },\n",[40,8531,8532],{"class":42,"line":2318},[40,8533,8534],{},"    });\n",[40,8536,8537],{"class":42,"line":2328},[40,8538,8539],{},"  });\n",[40,8541,8542],{"class":42,"line":2338},[40,8543,105],{},[40,8545,8546],{"class":42,"line":2343},[40,8547,8548],{},"return out;\n",[11,8550,8551,8552,8555],{},"Hash w ID ma dwa zastosowania. Jeśli dokument się nie zmienił, 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 ",[15,8553,8554],{},"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,8557,8558],{},"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.",[23,8560,8562],{"id":8561},"podmiana-wersji","Podmiana wersji",[11,8564,8565,8566,8569,8570,8573,8574,8577,8578,8581,8582,8585],{},"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 (",[15,8567,8568],{},"GET \u002Fvectors\u002Flist?prefix=\u003CdocId>%23",", ze stronicowaniem) i kasowanie po ID (",[15,8571,8572],{},"POST \u002Fvectors\u002Fdelete"," z tablicą ",[15,8575,8576],{},"ids","). Listowanie po prefiksie działa tylko w indeksach serverless. Pinecone przyjmuje też delete po filtrze metadanych (na przykład ",[15,8579,8580],{},"docId"," równe dokumentowi i ",[15,8583,8584],{},"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:",[31,8587,8589],{"className":3926,"code":8588,"language":3928,"meta":36,"style":36},"\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",[15,8590,8591,8596,8601,8606,8611,8616,8620,8624,8629,8634,8639,8644,8649,8653,8657,8662,8667,8672,8677,8681],{"__ignoreMap":36},[40,8592,8593],{"class":42,"line":43},[40,8594,8595],{},"\u002F\u002F n8n Code node: wybór ID nieaktualnych wersji\n",[40,8597,8598],{"class":42,"line":49},[40,8599,8600],{},"\u002F\u002F Wejście: itemy z węzła \"List IDs by prefix\", każdy z polem { id }\n",[40,8602,8603],{"class":42,"line":55},[40,8604,8605],{},"\u002F\u002F Bieżąca wersja każdego dokumentu pochodzi z węzła \"Prepare chunks\"\n",[40,8607,8608],{"class":42,"line":84},[40,8609,8610],{},"const current = new Map(\n",[40,8612,8613],{"class":42,"line":90},[40,8614,8615],{},"  $('Prepare chunks').all().map(i => [i.json.docId, i.json.version])\n",[40,8617,8618],{"class":42,"line":96},[40,8619,7176],{},[40,8621,8622],{"class":42,"line":102},[40,8623,190],{"emptyLinePlaceholder":189},[40,8625,8626],{"class":42,"line":193},[40,8627,8628],{},"const stale = $input.all()\n",[40,8630,8631],{"class":42,"line":199},[40,8632,8633],{},"  .map(i => i.json.id)\n",[40,8635,8636],{"class":42,"line":204},[40,8637,8638],{},"  .filter(id => {\n",[40,8640,8641],{"class":42,"line":210},[40,8642,8643],{},"    const [docId, version] = id.split('#');\n",[40,8645,8646],{"class":42,"line":216},[40,8647,8648],{},"    return current.has(docId) && current.get(docId) !== version;\n",[40,8650,8651],{"class":42,"line":222},[40,8652,8539],{},[40,8654,8655],{"class":42,"line":227},[40,8656,190],{"emptyLinePlaceholder":189},[40,8658,8659],{"class":42,"line":232},[40,8660,8661],{},"\u002F\u002F Pinecone przyjmuje do 1000 ID w jednym wywołaniu delete\n",[40,8663,8664],{"class":42,"line":238},[40,8665,8666],{},"const batches = [];\n",[40,8668,8669],{"class":42,"line":244},[40,8670,8671],{},"for (let i = 0; i \u003C stale.length; i += 1000) {\n",[40,8673,8674],{"class":42,"line":250},[40,8675,8676],{},"  batches.push({ json: { ids: stale.slice(i, i + 1000) } });\n",[40,8678,8679],{"class":42,"line":256},[40,8680,105],{},[40,8682,8683],{"class":42,"line":261},[40,8684,8685],{},"return batches;\n",[11,8687,8688],{},"Kolejność „najpierw upsert, potem delete” jest celowa. Odwrotna 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.",[23,8690,8692],{"id":8691},"uzgadnianie-usunięć","Uzgadnianie usunięć",[11,8694,8695],{},"Osobny przepływ, uruchamiany z harmonogramu, porównuje ID dokumentów w indeksie z plikami w folderze źródłowym i kasuje sieroty.",[31,8697,8699],{"className":3926,"code":8698,"language":3928,"meta":36,"style":36},"\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",[15,8700,8701,8706,8711,8716,8720,8725,8730,8735,8740,8744,8748,8753,8758],{"__ignoreMap":36},[40,8702,8703],{"class":42,"line":43},[40,8704,8705],{},"\u002F\u002F n8n Code node: dokumenty usunięte u źródła\n",[40,8707,8708],{"class":42,"line":49},[40,8709,8710],{},"const live = new Set($('List Drive files').all().map(i => i.json.id));\n",[40,8712,8713],{"class":42,"line":55},[40,8714,8715],{},"const stored = new Set($('List index IDs').all().map(i => i.json.id.split('#')[0]));\n",[40,8717,8718],{"class":42,"line":84},[40,8719,190],{"emptyLinePlaceholder":189},[40,8721,8722],{"class":42,"line":90},[40,8723,8724],{},"\u002F\u002F Zabezpieczenie: pusta albo niepełna lista z Drive (wygasłe credentials,\n",[40,8726,8727],{"class":42,"line":96},[40,8728,8729],{},"\u002F\u002F zły folder, pominięte stronicowanie) skasowałaby cały indeks.\n",[40,8731,8732],{"class":42,"line":102},[40,8733,8734],{},"if (live.size === 0 || live.size \u003C stored.size * 0.8) {\n",[40,8736,8737],{"class":42,"line":193},[40,8738,8739],{},"  throw new Error(`Drive zwrócił ${live.size} plików przy ${stored.size} dokumentach w indeksie, przerywam`);\n",[40,8741,8742],{"class":42,"line":199},[40,8743,105],{},[40,8745,8746],{"class":42,"line":204},[40,8747,190],{"emptyLinePlaceholder":189},[40,8749,8750],{"class":42,"line":210},[40,8751,8752],{},"return [...stored]\n",[40,8754,8755],{"class":42,"line":216},[40,8756,8757],{},"  .filter(docId => !live.has(docId))\n",[40,8759,8760],{"class":42,"line":222},[40,8761,8762],{},"  .map(docId => ({ json: { docId } }));\n",[11,8764,8765],{},"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.",[23,8767,8769],{"id":8768},"filtr-świeżości-przy-zapytaniu","Filtr świeżości przy zapytaniu",[11,8771,8772,8773,1011,8776,1011,8779,1011,8782,8785,8786,8789],{},"Operatory zakresowe w metadanych Pinecone (",[15,8774,8775],{},"$gt",[15,8777,8778],{},"$gte",[15,8780,8781],{},"$lt",[15,8783,8784],{},"$lte",") działają na liczbach, nie na datach w formacie ISO. Dlatego chunk niesie ",[15,8787,8788],{},"expiresTs"," jako Unix timestamp. Body zapytania w węźle HTTP Request z włączonymi expressions:",[31,8791,8793],{"className":3926,"code":8792,"language":3928,"meta":36,"style":36},"{\n  \"vector\": {{ JSON.stringify($json.embedding) }},\n  \"topK\": 6,\n  \"includeMetadata\": true,\n  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n}\n",[15,8794,8795,8799,8804,8809,8814,8819],{"__ignoreMap":36},[40,8796,8797],{"class":42,"line":43},[40,8798,76],{},[40,8800,8801],{"class":42,"line":49},[40,8802,8803],{},"  \"vector\": {{ JSON.stringify($json.embedding) }},\n",[40,8805,8806],{"class":42,"line":55},[40,8807,8808],{},"  \"topK\": 6,\n",[40,8810,8811],{"class":42,"line":84},[40,8812,8813],{},"  \"includeMetadata\": true,\n",[40,8815,8816],{"class":42,"line":90},[40,8817,8818],{},"  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n",[40,8820,8821],{"class":42,"line":96},[40,8822,105],{},[11,8824,8825],{},"Wyliczenie daty wygaśnięcia przy zasilaniu trzyma politykę per typ dokumentu w jednym miejscu, a zapytanie zostaje proste. Koszt: poprawny cennik, którego nikt nie edytował od 90 dni, też wypada. Przy danych wrażliwych na czas to lepszy sposób awarii. 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ł.",[23,8827,8829],{"id":8828},"logowanie-wyszukiwań","Logowanie wyszukiwań",[11,8831,8832],{},"Loguj każde wyszukiwanie, nie próbkę: zapytanie, zwrócone ID, wyniki podobieństwa, wersje dokumentów i daty modyfikacji. Z takim logiem opisane wyżej awarie stają się zapytaniami, które da się uruchomić.",[31,8834,8836],{"className":3926,"code":8835,"language":3928,"meta":36,"style":36},"\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",[15,8837,8838,8843,8848,8853,8858,8862,8867,8871,8876,8881,8886,8891,8896,8901,8906,8911,8916,8921,8926,8931,8936,8941,8946,8951,8955],{"__ignoreMap":36},[40,8839,8840],{"class":42,"line":43},[40,8841,8842],{},"\u002F\u002F n8n Code node: ustrukturyzowany log wyszukiwania\n",[40,8844,8845],{"class":42,"line":49},[40,8846,8847],{},"const query = $('User query').first().json.text;\n",[40,8849,8850],{"class":42,"line":55},[40,8851,8852],{},"const matches = $('Pinecone query').first().json.matches ?? [];\n",[40,8854,8855],{"class":42,"line":84},[40,8856,8857],{},"const now = Date.now() \u002F 1000;\n",[40,8859,8860],{"class":42,"line":90},[40,8861,190],{"emptyLinePlaceholder":189},[40,8863,8864],{"class":42,"line":96},[40,8865,8866],{},"return [{\n",[40,8868,8869],{"class":42,"line":102},[40,8870,8246],{},[40,8872,8873],{"class":42,"line":193},[40,8874,8875],{},"    ts: new Date().toISOString(),\n",[40,8877,8878],{"class":42,"line":199},[40,8879,8880],{},"    query,\n",[40,8882,8883],{"class":42,"line":204},[40,8884,8885],{},"    matches: matches.map(m => ({\n",[40,8887,8888],{"class":42,"line":210},[40,8889,8890],{},"      id: m.id,\n",[40,8892,8893],{"class":42,"line":216},[40,8894,8895],{},"      score: m.score,\n",[40,8897,8898],{"class":42,"line":222},[40,8899,8900],{},"      docId: m.metadata.docId,\n",[40,8902,8903],{"class":42,"line":227},[40,8904,8905],{},"      version: m.metadata.version,\n",[40,8907,8908],{"class":42,"line":232},[40,8909,8910],{},"      ageDays: Math.round((now - m.metadata.modifiedTs) \u002F 86400),\n",[40,8912,8913],{"class":42,"line":238},[40,8914,8915],{},"    })),\n",[40,8917,8918],{"class":42,"line":244},[40,8919,8920],{},"    topScore: matches.length ? Math.max(...matches.map(m => m.score)) : null,\n",[40,8922,8923],{"class":42,"line":250},[40,8924,8925],{},"    versionsPerDoc: Object.values(\n",[40,8927,8928],{"class":42,"line":256},[40,8929,8930],{},"      matches.reduce((acc, m) => {\n",[40,8932,8933],{"class":42,"line":261},[40,8934,8935],{},"        (acc[m.metadata.docId] ??= new Set()).add(m.metadata.version);\n",[40,8937,8938],{"class":42,"line":267},[40,8939,8940],{},"        return acc;\n",[40,8942,8943],{"class":42,"line":272},[40,8944,8945],{},"      }, {})\n",[40,8947,8948],{"class":42,"line":278},[40,8949,8950],{},"    ).reduce((max, s) => Math.max(max, s.size), 0),\n",[40,8952,8953],{"class":42,"line":283},[40,8954,8266],{},[40,8956,8957],{"class":42,"line":288},[40,8958,8959],{},"}];\n",[11,8961,8962,8965,8966,8969],{},[15,8963,8964],{},"versionsPerDoc"," większe od 1 znaczy, że podmiana wersji nie zadziałała dla co najmniej jednego dokumentu. Stale niski ",[15,8967,8968],{},"topScore"," dla jakiejś grupy pytań znaczy, że baza wiedzy ich nie pokrywa. Progi podobieństwa zależą od modelu embeddingów i korpusu, więc wyznacz je z rozkładu we własnym logu, zamiast przepisywać liczbę z cudzej konfiguracji.",[23,8971,8973],{"id":8972},"pgvector-jako-alternatywa","pgvector jako alternatywa",[11,8975,8976],{},"Jeśli baza wektorowa siedzi w Postgresie, podmiana wersji staje się transakcją, a sztuczki z prefiksem i hashem są zbędne:",[31,8978,8980],{"className":7133,"code":8979,"language":7135,"meta":36,"style":36},"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",[15,8981,8982,8987,8992,8997,9002,9007],{"__ignoreMap":36},[40,8983,8984],{"class":42,"line":43},[40,8985,8986],{},"BEGIN;\n",[40,8988,8989],{"class":42,"line":49},[40,8990,8991],{},"DELETE FROM rag_chunks WHERE doc_id = $1;\n",[40,8993,8994],{"class":42,"line":55},[40,8995,8996],{},"INSERT INTO rag_chunks (doc_id, version, chunk_no, body, embedding, modified_at, expires_at)\n",[40,8998,8999],{"class":42,"line":84},[40,9000,9001],{},"VALUES ($1, $2, 0, $3, $4, $5, $6),\n",[40,9003,9004],{"class":42,"line":90},[40,9005,9006],{},"       ($1, $2, 1, $7, $8, $5, $6);  -- jeden wiersz na chunk\n",[40,9008,9009],{"class":42,"line":96},[40,9010,9011],{},"COMMIT;\n",[11,9013,9014],{},"Odczyt widzi albo starą wersję, albo nową, nigdy obie naraz i nigdy żadnej. Uzgadnianie sprowadza się do anti-joina z tabelą aktywnych ID dokumentów. Koszt jest operacyjny: bazę i indeksy wektorowe utrzymujesz i stroisz sam.",[23,9016,9018],{"id":9017},"kiedy-to-zbędne","Kiedy to zbędne",[11,9020,9021],{},"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 zwracają się wtedy, gdy dokumenty edytują, przemianowują i archiwizują ludzie, którzy o indeksie nie myślą. Na współdzielonym dysku to stan normalny.",[23,9023,701],{"id":700},[703,9025,9026,9029,9032,9035,9038,9041,9044],{},[127,9027,9028],{},"ID dokumentu pochodzi z systemu źródłowego i przeżywa zmianę nazwy.",[127,9030,9031],{},"ID chunka zawiera ID dokumentu i hash wersji, więc cały dokument da się wylistować po prefiksie.",[127,9033,9034],{},"Nowa wersja jest zapisywana najpierw, starsze wersje tego samego dokumentu są kasowane potem.",[127,9036,9037],{},"Uzgadnianie z harmonogramu kasuje dokumenty usunięte u źródła i przerywa pracę przy podejrzanej liście.",[127,9039,9040],{},"Świeżość jest zapisana jako liczbowe timestampy, a polityka wygasania ustawiana per typ dokumentu przy zasilaniu.",[127,9042,9043],{},"Każde wyszukiwanie trafia do logu z wersjami i wiekiem dokumentów.",[127,9045,9046],{},"Pytanie na review: jeśli ktoś teraz zmieni dokument, co stanie się z chunkami poprzedniej wersji i który węzeł za to odpowiada?",[729,9048,731],{},{"title":36,"searchDepth":49,"depth":49,"links":9050},[9051,9052,9053,9054,9055,9056,9057,9058,9059,9060],{"id":8191,"depth":49,"text":8192},{"id":8277,"depth":49,"text":8278},{"id":8301,"depth":49,"text":8302},{"id":8561,"depth":49,"text":8562},{"id":8691,"depth":49,"text":8692},{"id":8768,"depth":49,"text":8769},{"id":8828,"depth":49,"text":8829},{"id":8972,"depth":49,"text":8973},{"id":9017,"depth":49,"text":9018},{"id":700,"depth":49,"text":701},"2026-05-22",{},"\u002Fpl\u002Farticles\u002Fn8n-rag-data-quality",{"x":9065,"y":9066,"depth":8167,"size":5434},0.4,0.6,[767,6124],{"title":8180,"description":8185},"n8n-rag-pipeline","pl\u002Farticles\u002Fn8n-rag-data-quality",[9072,9073,9074,9075,757],"n8n","rag","vector-db","pinecone","kHbUQXBI7wahHpohZ-qMZ9NyPU8PypryyIE-sDn6Ra8",{"id":9078,"title":9079,"articleId":752,"body":9080,"category":2888,"codeLang":35,"date":9635,"deploys":43,"description":9636,"excerpt":742,"extension":743,"lang":744,"meta":9637,"navigation":189,"path":9638,"pos":9639,"readMin":90,"related":9642,"seo":9643,"service":9644,"stem":9645,"tags":9646,"version":761,"__hash__":9652},"articles_pl\u002Fpl\u002Farticles\u002Fphp-references.md","Referencje w PHP: co naprawdę robi & i gdzie psuje kod",{"type":8,"value":9081,"toc":9626},[9082,9089,9093,9100,9107,9155,9166,9170,9226,9247,9250,9272,9275,9308,9312,9315,9383,9400,9403,9407,9410,9448,9457,9461,9464,9533,9542,9546,9578,9581,9583,9624],[11,9083,9084,9085,9088],{},"Dokumentacja PHP mówi dwie rzeczy: referencje to nie wskaźniki i nie należy zwracać wartości przez referencję dla wydajności. Obie są prawdziwe i obie regularnie się ignoruje. Ta notatka opisuje, co ",[15,9086,9087],{},"&"," robi na poziomie silnika, jakie błędy powoduje najczęściej i w których kilku przypadkach jest właściwym narzędziem.",[23,9090,9092],{"id":9091},"wartości-copy-on-write-i-referencje","Wartości, copy-on-write i referencje",[11,9094,9095,9096,9099],{},"Domyślnie PHP przypisuje i przekazuje wartości. Tablice i stringi mają licznik referencji (refcount), więc ",[15,9097,9098],{},"$b = $a"," niczego nie kopiuje: obie zmienne wskazują tę samą wartość, a licznik rośnie. Kopia (separacja) powstaje dopiero wtedy, gdy jedna ze zmiennych jest zapisywana, a licznik jest większy od jednego. To jest copy-on-write.",[11,9101,9102,9103,9106],{},"Referencja działa inaczej. ",[15,9104,9105],{},"$b = &$a"," opakowuje wartość w kontener referencji, który obie nazwy współdzielą. Zapis przez dowolną z nich zmienia wartość widzianą przez obie i separacja nigdy nie następuje.",[31,9108,9110],{"className":33,"code":9109,"language":35,"meta":36,"style":36},"$a = 'original';\n$b = $a;           \u002F\u002F no copy, both names share one value\n$b = 'modified';   \u002F\u002F $b gets its own value\nvar_dump($a);      \u002F\u002F string(8) \"original\"\n\n$a = 'original';\n$b = &$a;          \u002F\u002F $a and $b are now one variable with two names\n$b = 'modified';\nvar_dump($a);      \u002F\u002F string(8) \"modified\"\n",[15,9111,9112,9117,9122,9127,9132,9136,9140,9145,9150],{"__ignoreMap":36},[40,9113,9114],{"class":42,"line":43},[40,9115,9116],{},"$a = 'original';\n",[40,9118,9119],{"class":42,"line":49},[40,9120,9121],{},"$b = $a;           \u002F\u002F no copy, both names share one value\n",[40,9123,9124],{"class":42,"line":55},[40,9125,9126],{},"$b = 'modified';   \u002F\u002F $b gets its own value\n",[40,9128,9129],{"class":42,"line":84},[40,9130,9131],{},"var_dump($a);      \u002F\u002F string(8) \"original\"\n",[40,9133,9134],{"class":42,"line":90},[40,9135,190],{"emptyLinePlaceholder":189},[40,9137,9138],{"class":42,"line":96},[40,9139,9116],{},[40,9141,9142],{"class":42,"line":102},[40,9143,9144],{},"$b = &$a;          \u002F\u002F $a and $b are now one variable with two names\n",[40,9146,9147],{"class":42,"line":193},[40,9148,9149],{},"$b = 'modified';\n",[40,9151,9152],{"class":42,"line":199},[40,9153,9154],{},"var_dump($a);      \u002F\u002F string(8) \"modified\"\n",[11,9156,9157,9158,9160,9161,9163,9164,1139],{},"Po przypisaniu nic w kodzie nie oznacza ",[15,9159,7763],{}," jako referencji. Żeby wiedzieć, jak zachowa się zapis do ",[15,9162,7763],{},", czytający musi znaleźć linię, w której użyto ",[15,9165,9087],{},[23,9167,9169],{"id":9168},"błąd-1-referencja-zostawiona-przez-foreach","Błąd 1: referencja zostawiona przez foreach",[31,9171,9173],{"className":33,"code":9172,"language":35,"meta":36,"style":36},"$prices = [100, 200, 300, 400, 500];\n\nforeach ($prices as &$price) {\n    $price = $price * 0.9;\n}\n\u002F\u002F $prices is [90, 180, 270, 360, 450]\n\nforeach ($prices as $price) {\n    echo $price, ' ';\n}\n\u002F\u002F Output: 90 180 270 360 360\n",[15,9174,9175,9180,9184,9189,9194,9198,9203,9207,9212,9217,9221],{"__ignoreMap":36},[40,9176,9177],{"class":42,"line":43},[40,9178,9179],{},"$prices = [100, 200, 300, 400, 500];\n",[40,9181,9182],{"class":42,"line":49},[40,9183,190],{"emptyLinePlaceholder":189},[40,9185,9186],{"class":42,"line":55},[40,9187,9188],{},"foreach ($prices as &$price) {\n",[40,9190,9191],{"class":42,"line":84},[40,9192,9193],{},"    $price = $price * 0.9;\n",[40,9195,9196],{"class":42,"line":90},[40,9197,105],{},[40,9199,9200],{"class":42,"line":96},[40,9201,9202],{},"\u002F\u002F $prices is [90, 180, 270, 360, 450]\n",[40,9204,9205],{"class":42,"line":102},[40,9206,190],{"emptyLinePlaceholder":189},[40,9208,9209],{"class":42,"line":193},[40,9210,9211],{},"foreach ($prices as $price) {\n",[40,9213,9214],{"class":42,"line":199},[40,9215,9216],{},"    echo $price, ' ';\n",[40,9218,9219],{"class":42,"line":204},[40,9220,105],{},[40,9222,9223],{"class":42,"line":210},[40,9224,9225],{},"\u002F\u002F Output: 90 180 270 360 360\n",[11,9227,9228,9229,9232,9233,9236,9237,9239,9240,9242,9243,9246],{},"Po pierwszej pętli ",[15,9230,9231],{},"$price"," nadal jest referencją do ",[15,9234,9235],{},"$prices[4]",". Druga pętla przypisuje każdy element do ",[15,9238,9231],{},", czyli w praktyce zapisuje każdy element pod ",[15,9241,9235],{},". W czwartej iteracji zapisuje tam 360, więc piąta odczytuje 360 zamiast 450. W PHP 8 to nadal działa w ten sposób. Zmiana semantyki ",[15,9244,9245],{},"foreach"," w PHP 7 tego nie usunęła.",[11,9248,9249],{},"Są dwie poprawki. Minimalna to zerwanie referencji zaraz po pętli:",[31,9251,9253],{"className":33,"code":9252,"language":35,"meta":36,"style":36},"foreach ($prices as &$price) {\n    $price = $price * 0.9;\n}\nunset($price); \u002F\u002F removes the name $price; $prices[4] keeps its value\n",[15,9254,9255,9259,9263,9267],{"__ignoreMap":36},[40,9256,9257],{"class":42,"line":43},[40,9258,9188],{},[40,9260,9261],{"class":42,"line":49},[40,9262,9193],{},[40,9264,9265],{"class":42,"line":55},[40,9266,105],{},[40,9268,9269],{"class":42,"line":84},[40,9270,9271],{},"unset($price); \u002F\u002F removes the name $price; $prices[4] keeps its value\n",[11,9273,9274],{},"Lepsza to obejście się bez referencji:",[31,9276,9278],{"className":33,"code":9277,"language":35,"meta":36,"style":36},"foreach ($prices as $i => $price) {\n    $prices[$i] = $price * 0.9;\n}\n\n\u002F\u002F or, when a new array is acceptable\n$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);\n",[15,9279,9280,9285,9290,9294,9298,9303],{"__ignoreMap":36},[40,9281,9282],{"class":42,"line":43},[40,9283,9284],{},"foreach ($prices as $i => $price) {\n",[40,9286,9287],{"class":42,"line":49},[40,9288,9289],{},"    $prices[$i] = $price * 0.9;\n",[40,9291,9292],{"class":42,"line":55},[40,9293,105],{},[40,9295,9296],{"class":42,"line":84},[40,9297,190],{"emptyLinePlaceholder":189},[40,9299,9300],{"class":42,"line":90},[40,9301,9302],{},"\u002F\u002F or, when a new array is acceptable\n",[40,9304,9305],{"class":42,"line":96},[40,9306,9307],{},"$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);\n",[23,9309,9311],{"id":9310},"błąd-2-parametr-przez-referencję-zmienia-kontrakt-funkcji","Błąd 2: parametr przez referencję zmienia kontrakt funkcji",[11,9313,9314],{},"Przykład: funkcja normalizująca dostaje duże tablice, więc ktoś przestawia ją na parametr przez referencję, „żeby nie kopiować”.",[31,9316,9318],{"className":33,"code":9317,"language":35,"meta":36,"style":36},"\u002F\u002F Before: pure function, the caller's array is untouched\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 After: mutates the argument and returns nothing\nfunction normaliseProduct(array &$product): void\n{\n    $product['title'] = trim(strtolower($product['title']));\n    $product['price'] = round($product['price'], 2);\n}\n",[15,9319,9320,9325,9330,9334,9339,9344,9349,9353,9357,9362,9367,9371,9375,9379],{"__ignoreMap":36},[40,9321,9322],{"class":42,"line":43},[40,9323,9324],{},"\u002F\u002F Before: pure function, the caller's array is untouched\n",[40,9326,9327],{"class":42,"line":49},[40,9328,9329],{},"function normaliseProduct(array $product): array\n",[40,9331,9332],{"class":42,"line":55},[40,9333,76],{},[40,9335,9336],{"class":42,"line":84},[40,9337,9338],{},"    $product['title'] = trim(strtolower($product['title']));\n",[40,9340,9341],{"class":42,"line":90},[40,9342,9343],{},"    $product['price'] = round($product['price'], 2);\n",[40,9345,9346],{"class":42,"line":96},[40,9347,9348],{},"    return $product;\n",[40,9350,9351],{"class":42,"line":102},[40,9352,105],{},[40,9354,9355],{"class":42,"line":193},[40,9356,190],{"emptyLinePlaceholder":189},[40,9358,9359],{"class":42,"line":199},[40,9360,9361],{},"\u002F\u002F After: mutates the argument and returns nothing\n",[40,9363,9364],{"class":42,"line":204},[40,9365,9366],{},"function normaliseProduct(array &$product): void\n",[40,9368,9369],{"class":42,"line":210},[40,9370,76],{},[40,9372,9373],{"class":42,"line":216},[40,9374,9338],{},[40,9376,9377],{"class":42,"line":222},[40,9378,9343],{},[40,9380,9381],{"class":42,"line":227},[40,9382,105],{},[11,9384,9385,9386,9389,9390,6347,9392,9395,9396,9399],{},"Każde istniejące wywołanie w postaci ",[15,9387,9388],{},"$normalised = normaliseProduct($product)"," zapisuje teraz ",[15,9391,114],{},[15,9393,9394],{},"$product"," zmienia się jako efekt uboczny. Samo PHP nie zgłasza użycia wyniku funkcji ",[15,9397,9398],{},"void",". Wyłapie to dopiero analiza statyczna.",[11,9401,9402],{},"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 pozostają 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.",[23,9404,9406],{"id":9405},"błąd-3-referencje-przeżywają-w-tablicach","Błąd 3: referencje przeżywają w tablicach",[11,9408,9409],{},"Referencja do elementu tablicy zostaje przypięta do tego elementu, a skopiowanie tablicy kopiuje referencję, nie wartość.",[31,9411,9413],{"className":33,"code":9412,"language":35,"meta":36,"style":36},"$a = [1, 2];\n$ref = &$a[0];\n\n$b = $a;        \u002F\u002F looks like a copy\n$b[0] = 99;\n\nvar_dump($a[0]); \u002F\u002F int(99)\n",[15,9414,9415,9420,9425,9429,9434,9439,9443],{"__ignoreMap":36},[40,9416,9417],{"class":42,"line":43},[40,9418,9419],{},"$a = [1, 2];\n",[40,9421,9422],{"class":42,"line":49},[40,9423,9424],{},"$ref = &$a[0];\n",[40,9426,9427],{"class":42,"line":55},[40,9428,190],{"emptyLinePlaceholder":189},[40,9430,9431],{"class":42,"line":84},[40,9432,9433],{},"$b = $a;        \u002F\u002F looks like a copy\n",[40,9435,9436],{"class":42,"line":90},[40,9437,9438],{},"$b[0] = 99;\n",[40,9440,9441],{"class":42,"line":96},[40,9442,190],{"emptyLinePlaceholder":189},[40,9444,9445],{"class":42,"line":102},[40,9446,9447],{},"var_dump($a[0]); \u002F\u002F int(99)\n",[11,9449,9450,9451,9453,9454,9456],{},"Dokumentacja opisuje to zachowanie, ale z tych trzech błędów ten jest najtrudniejszy do zauważenia, bo ",[15,9452,9087],{}," może być daleko od miejsca kopii. Zwykle pojawia się po ",[15,9455,9245],{}," przez referencję, gdy zmienna pętli nie została zwolniona.",[23,9458,9460],{"id":9459},"obiekty-nie-są-przekazywane-przez-referencję","Obiekty nie są przekazywane przez referencję",[11,9462,9463],{},"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.",[31,9465,9467],{"className":33,"code":9466,"language":35,"meta":36,"style":36},"final class Counter\n{\n    public int $count = 0;\n}\n\nfunction increment(Counter $counter): void\n{\n    $counter->count++;       \u002F\u002F visible to the caller\n    $counter = new Counter(); \u002F\u002F not visible to the caller\n}\n\n$c = new Counter();\nincrement($c);\nvar_dump($c->count); \u002F\u002F int(1)\n",[15,9468,9469,9474,9478,9483,9487,9491,9496,9500,9505,9510,9514,9518,9523,9528],{"__ignoreMap":36},[40,9470,9471],{"class":42,"line":43},[40,9472,9473],{},"final class Counter\n",[40,9475,9476],{"class":42,"line":49},[40,9477,76],{},[40,9479,9480],{"class":42,"line":55},[40,9481,9482],{},"    public int $count = 0;\n",[40,9484,9485],{"class":42,"line":84},[40,9486,105],{},[40,9488,9489],{"class":42,"line":90},[40,9490,190],{"emptyLinePlaceholder":189},[40,9492,9493],{"class":42,"line":96},[40,9494,9495],{},"function increment(Counter $counter): void\n",[40,9497,9498],{"class":42,"line":102},[40,9499,76],{},[40,9501,9502],{"class":42,"line":193},[40,9503,9504],{},"    $counter->count++;       \u002F\u002F visible to the caller\n",[40,9506,9507],{"class":42,"line":199},[40,9508,9509],{},"    $counter = new Counter(); \u002F\u002F not visible to the caller\n",[40,9511,9512],{"class":42,"line":204},[40,9513,105],{},[40,9515,9516],{"class":42,"line":210},[40,9517,190],{"emptyLinePlaceholder":189},[40,9519,9520],{"class":42,"line":216},[40,9521,9522],{},"$c = new Counter();\n",[40,9524,9525],{"class":42,"line":222},[40,9526,9527],{},"increment($c);\n",[40,9529,9530],{"class":42,"line":227},[40,9531,9532],{},"var_dump($c->count); \u002F\u002F int(1)\n",[11,9534,9535,9537,9538,9541],{},[15,9536,9087],{}," przy parametrze obiektowym (",[15,9539,9540],{},"Counter &$counter",") jest potrzebne tylko wtedy, gdy funkcja ma podmienić obiekt wołającego. To prawie nigdy nie jest dobry projekt.",[23,9543,9545],{"id":9544},"kiedy-referencja-ma-uzasadnienie","Kiedy referencja ma uzasadnienie",[703,9547,9548,9561,9568],{},[127,9549,9550,9551,1011,9554,1011,9557,9560],{},"Parametry wyjściowe funkcji wbudowanych: ",[15,9552,9553],{},"preg_match($pattern, $subject, $matches)",[15,9555,9556],{},"sort($array)",[15,9558,9559],{},"array_push()"," i inne przyjmują argument przez referencję z założenia.",[127,9562,9563,9564,9567],{},"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 ",[15,9565,9566],{},"memory_get_peak_usage()",". Dla danych wielkości typowego requestu różnica jest pomijalna.",[127,9569,9570,9571,9574,9575,1139],{},"Budowanie zagnieżdżonej struktury przez wskaźnik na bieżący węzeł, częste przy zamianie płaskiej listy z ",[15,9572,9573],{},"parent_id"," na drzewo. Trzymaj to w jednej funkcji i zwolnij referencję przed ",[15,9576,9577],{},"return",[11,9579,9580],{},"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ść.",[23,9582,2818],{"id":2817},[703,9584,9585,9599,9609,9615,9618],{},[127,9586,9587,9588,9591,9592,9595,9596,1139],{},"Po ",[15,9589,9590],{},"foreach (... as &$x)"," jest ",[15,9593,9594],{},"unset($x)"," albo pętla jest przepisana na klucze lub ",[15,9597,9598],{},"array_map()",[127,9600,9601,9602,1011,9605,9608],{},"Parametr przez referencję jest częścią opisanego kontraktu funkcji, a nazwa mówi, że funkcja modyfikuje argument (",[15,9603,9604],{},"sortInPlace",[15,9606,9607],{},"applyDefaults",").",[127,9610,9611,9612,9614],{},"Żadnego „wydajnościowego” ",[15,9613,9087],{}," bez pomiaru.",[127,9616,9617],{},"Żadnego kopiowania tablicy, do której elementu wzięto wcześniej referencję, chyba że współdzielony element jest zamierzony.",[127,9619,9620,9621,9623],{},"PHPStan albo Psalm w CI, żeby przypisanie wyniku funkcji ",[15,9622,9398],{}," do zmiennej było zgłaszane.",[729,9625,731],{},{"title":36,"searchDepth":49,"depth":49,"links":9627},[9628,9629,9630,9631,9632,9633,9634],{"id":9091,"depth":49,"text":9092},{"id":9168,"depth":49,"text":9169},{"id":9310,"depth":49,"text":9311},{"id":9405,"depth":49,"text":9406},{"id":9459,"depth":49,"text":9460},{"id":9544,"depth":49,"text":9545},{"id":2817,"depth":49,"text":2818},"2023-09-30","Dokumentacja PHP mówi dwie rzeczy: referencje to nie wskaźniki i nie należy zwracać wartości przez referencję dla wydajności. Obie są prawdziwe i obie regularnie się ignoruje. Ta notatka opisuje, co & robi na poziomie silnika, jakie błędy powoduje najczęściej i w których kilku przypadkach jest właściwym narzędziem.",{},"\u002Fpl\u002Farticles\u002Fphp-references",{"x":9640,"y":9641,"depth":2894,"size":743},0.12,0.5,[5436,3835],{"title":9079,"description":9636},"memory-management","pl\u002Farticles\u002Fphp-references",[35,9647,9648,9649,9650,9651],"memory","debugging","references","performance","footguns","RD_LvG8bLgk9y2A4fsrRmoUzV_XmSph_pNpjEY_sJOQ",{"id":9654,"title":9655,"articleId":1554,"body":9656,"category":2888,"codeLang":7135,"date":10328,"deploys":193,"description":10329,"excerpt":742,"extension":743,"lang":744,"meta":10330,"navigation":189,"path":10331,"pos":10332,"readMin":102,"related":10335,"seo":10337,"service":10338,"stem":10339,"tags":10340,"version":761,"__hash__":10345},"articles_pl\u002Fpl\u002Farticles\u002Fpostgres-edge.md","Postgres z więcej niż jednym writerem: klucze główne i ich migracja",{"type":8,"value":9657,"toc":10322},[9658,9668,9672,9675,9678,9689,9695,9699,9705,9730,9733,9739,9749,9804,9822,9825,9836,9840,9854,9860,9880,9890,9975,9985,9994,10028,10038,10050,10145,10161,10171,10271,10282,10299,10301,10320],[11,9659,9660,9661,4328,9664,9667],{},"Kolumna ",[15,9662,9663],{},"bigserial",[15,9665,9666],{},"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 właśnie jest i typ klucza nie jest problemem. Staje się nim, gdy zapisy do tej samej tabeli przyjmuje więcej niż jeden węzeł.",[23,9669,9671],{"id":9670},"kiedy-typ-klucza-ma-znaczenie","Kiedy typ klucza ma znaczenie",[11,9673,9674],{},"Zmiana typu klucza nie skraca czasu zapisu przy jednym primary. Klient z innego regionu i tak wysyła całą transakcję do primary, a wywołanie sekwencji to pomijalna część tej drogi.",[11,9676,9677],{},"Typ klucza ma znaczenie, gdy:",[703,9679,9680,9683,9686],{},[127,9681,9682],{},"kilka węzłów przyjmuje zapisy do tej samej tabeli (dwukierunkowa replikacja logiczna, rozszerzenia multi-master, sharding po regionach),",[127,9684,9685],{},"identyfikator musi istnieć, zanim wiersz trafi do bazy (klienci offline, klucze idempotencji generowane po stronie klienta, zdarzenia publikowane przed insertem),",[127,9687,9688],{},"dane z kilku niezależnych baz są scalane w jednym miejscu.",[11,9690,9691,9692,1139],{},"W każdym z tych przypadków dwa węzły bez koordynacji mogą wygenerować ten sam ",[15,9693,9694],{},"bigint",[23,9696,9698],{"id":9697},"opcje","Opcje",[11,9700,9701,9704],{},[130,9702,9703],{},"Sekwencja per węzeł."," Każdy węzeł ma własną sekwencję z innym startem i wspólnym krokiem:",[31,9706,9708],{"className":7133,"code":9707,"language":7135,"meta":36,"style":36},"-- 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",[15,9709,9710,9715,9720,9725],{"__ignoreMap":36},[40,9711,9712],{"class":42,"line":43},[40,9713,9714],{},"-- węzeł 1\n",[40,9716,9717],{"class":42,"line":49},[40,9718,9719],{},"create sequence orders_id_seq start with 1 increment by 16;\n",[40,9721,9722],{"class":42,"line":55},[40,9723,9724],{},"-- węzeł 2\n",[40,9726,9727],{"class":42,"line":84},[40,9728,9729],{},"create sequence orders_id_seq start with 2 increment by 16;\n",[11,9731,9732],{},"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,9734,9735,9738],{},[130,9736,9737],{},"UUIDv4."," Unikalny bez koordynacji, ale losowy. Każdy insert trafia na losową stronę liścia w indeksie B-tree, więc w pamięci trzeba trzymać 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. Przy tabeli mieszczącej się w pamięci da się z tym żyć. Przy dużej oznacza to więcej I\u002FO na każdy insert.",[11,9740,9741,9744,9745,9748],{},[130,9742,9743],{},"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ę ",[15,9746,9747],{},"uuidv7()",". Na starszych wersjach można ją napisać w SQL:",[31,9750,9752],{"className":7133,"code":9751,"language":7135,"meta":36,"style":36},"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((extract(epoch from ts) * 1000)::bigint) from 3)\n              from 1 for 6),\n      52, 1), 53, 1),\n    'hex')::uuid;\n$$;\n",[15,9753,9754,9759,9764,9769,9774,9779,9784,9789,9794,9799],{"__ignoreMap":36},[40,9755,9756],{"class":42,"line":43},[40,9757,9758],{},"create function uuidv7_at(ts timestamptz) returns uuid\n",[40,9760,9761],{"class":42,"line":49},[40,9762,9763],{},"language sql volatile as $$\n",[40,9765,9766],{"class":42,"line":55},[40,9767,9768],{},"  select encode(\n",[40,9770,9771],{"class":42,"line":84},[40,9772,9773],{},"    set_bit(set_bit(\n",[40,9775,9776],{"class":42,"line":90},[40,9777,9778],{},"      overlay(uuid_send(gen_random_uuid())\n",[40,9780,9781],{"class":42,"line":96},[40,9782,9783],{},"              placing substring(int8send((extract(epoch from ts) * 1000)::bigint) from 3)\n",[40,9785,9786],{"class":42,"line":102},[40,9787,9788],{},"              from 1 for 6),\n",[40,9790,9791],{"class":42,"line":193},[40,9792,9793],{},"      52, 1), 53, 1),\n",[40,9795,9796],{"class":42,"line":199},[40,9797,9798],{},"    'hex')::uuid;\n",[40,9800,9801],{"class":42,"line":204},[40,9802,9803],{},"$$;\n",[11,9805,9806,9807,9810,9811,9814,9815,9818,9819,9821],{},"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 ",[15,9808,9809],{},"ts"," nadaje się też do uzupełniania starych wierszy na podstawie ",[15,9812,9813],{},"created_at",". Na Postgresie 18 ten sam efekt daje ",[15,9816,9817],{},"uuidv7(created_at - clock_timestamp())",", bo ",[15,9820,9747],{}," przyjmuje interwał przesuwający znacznik czasu.",[11,9823,9824],{},"Dla nowych tabel z więcej niż jednym writerem wybrałbym UUIDv7. Koszty, które trzeba przyjąć:",[703,9826,9827,9830,9833],{},[127,9828,9829],{},"16 bajtów zamiast 8 w kluczu głównym oraz w każdym kluczu obcym i indeksie, który go zawiera,",[127,9831,9832],{},"identyfikator zdradza czas utworzenia wiersza, co ma znaczenie, jeśli ID pojawia się w publicznych URL-ach,",[127,9834,9835],{},"sortowanie po ID jest chronologiczne tylko w przybliżeniu między węzłami, bo zależy od ich zegarów.",[23,9837,9839],{"id":9838},"migracja-istniejącej-tabeli-expand-backfill-swap","Migracja istniejącej tabeli: expand, backfill, swap",[11,9841,9842,9843,9846,9847,9849,9850,9853],{},"Przykład: tabela ",[15,9844,9845],{},"orders"," z kluczem ",[15,9848,9694],{}," i tabela ",[15,9851,9852],{},"order_items",", która się do niej odwołuje. Cel to klucz główny UUIDv7 bez blokowania którejkolwiek tabeli na dłużej niż kilka sekund.",[11,9855,9856,9859],{},[130,9857,9858],{},"1. Kolumna i domyślna wartość dla nowych wierszy."," Dodanie kolumny nullable bez wartości domyślnej zmienia tylko katalog. Ustawienie domyślnej wartości później dotyczy wyłącznie nowych wierszy.",[31,9861,9863],{"className":7133,"code":9862,"language":7135,"meta":36,"style":36},"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());  -- starsze wersje\n",[15,9864,9865,9870,9875],{"__ignoreMap":36},[40,9866,9867],{"class":42,"line":43},[40,9868,9869],{},"alter table orders add column id_v2 uuid;\n",[40,9871,9872],{"class":42,"line":49},[40,9873,9874],{},"alter table orders alter column id_v2 set default uuidv7();            -- Postgres 18\n",[40,9876,9877],{"class":42,"line":55},[40,9878,9879],{},"-- alter table orders alter column id_v2 set default uuidv7_at(clock_timestamp());  -- starsze wersje\n",[11,9881,9882,9885,9886,9889],{},[130,9883,9884],{},"2. Backfill w paczkach."," Jeden ",[15,9887,9888],{},"update"," na całej tabeli trzyma blokady wierszy przez cały czas trwania i zostawia naraz ogromną liczbę martwych krotek. Paczki z commitem pomiędzy nimi omijają oba problemy:",[31,9891,9893],{"className":7133,"code":9892,"language":7135,"meta":36,"style":36},"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",[15,9894,9895,9900,9905,9910,9915,9920,9925,9930,9935,9940,9945,9950,9955,9960,9965,9970],{"__ignoreMap":36},[40,9896,9897],{"class":42,"line":43},[40,9898,9899],{},"do $$\n",[40,9901,9902],{"class":42,"line":49},[40,9903,9904],{},"declare\n",[40,9906,9907],{"class":42,"line":55},[40,9908,9909],{},"  batch_size constant bigint := 10000;\n",[40,9911,9912],{"class":42,"line":84},[40,9913,9914],{},"  from_id bigint := 0;\n",[40,9916,9917],{"class":42,"line":90},[40,9918,9919],{},"  max_id bigint;\n",[40,9921,9922],{"class":42,"line":96},[40,9923,9924],{},"begin\n",[40,9926,9927],{"class":42,"line":102},[40,9928,9929],{},"  select max(id) into max_id from orders;\n",[40,9931,9932],{"class":42,"line":193},[40,9933,9934],{},"  while from_id \u003C= max_id loop\n",[40,9936,9937],{"class":42,"line":199},[40,9938,9939],{},"    update orders\n",[40,9941,9942],{"class":42,"line":204},[40,9943,9944],{},"       set id_v2 = uuidv7_at(created_at)\n",[40,9946,9947],{"class":42,"line":210},[40,9948,9949],{},"     where id >= from_id and id \u003C from_id + batch_size\n",[40,9951,9952],{"class":42,"line":216},[40,9953,9954],{},"       and id_v2 is null;\n",[40,9956,9957],{"class":42,"line":222},[40,9958,9959],{},"    commit;\n",[40,9961,9962],{"class":42,"line":227},[40,9963,9964],{},"    from_id := from_id + batch_size;\n",[40,9966,9967],{"class":42,"line":232},[40,9968,9969],{},"  end loop;\n",[40,9971,9972],{"class":42,"line":238},[40,9973,9974],{},"end $$;\n",[11,9976,9977,9980,9981,9984],{},[15,9978,9979],{},"commit"," w bloku ",[15,9982,9983],{},"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 zatrzyma się, gdy rośnie opóźnienie replikacji.",[11,9986,9987],{},[130,9988,9989,9990,9993],{},"3. Indeks i ",[15,9991,9992],{},"not null"," bez długiej blokady.",[31,9995,9997],{"className":7133,"code":9996,"language":7135,"meta":36,"style":36},"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",[15,9998,9999,10004,10008,10013,10018,10023],{"__ignoreMap":36},[40,10000,10001],{"class":42,"line":43},[40,10002,10003],{},"create unique index concurrently orders_id_v2_uq on orders (id_v2);\n",[40,10005,10006],{"class":42,"line":49},[40,10007,190],{"emptyLinePlaceholder":189},[40,10009,10010],{"class":42,"line":55},[40,10011,10012],{},"alter table orders add constraint orders_id_v2_nn check (id_v2 is not null) not valid;\n",[40,10014,10015],{"class":42,"line":84},[40,10016,10017],{},"alter table orders validate constraint orders_id_v2_nn;\n",[40,10019,10020],{"class":42,"line":90},[40,10021,10022],{},"alter table orders alter column id_v2 set not null;   -- korzysta ze zwalidowanego checka, bez pełnego skanu\n",[40,10024,10025],{"class":42,"line":96},[40,10026,10027],{},"alter table orders drop constraint orders_id_v2_nn;\n",[11,10029,10030,10033,10034,10037],{},[15,10031,10032],{},"set not null"," pomija skan tabeli, gdy zwalidowany check constraint już dowodzi warunku (Postgres 12 i nowsze). ",[15,10035,10036],{},"validate constraint"," bierze blokadę, która nie wstrzymuje odczytów ani zapisów.",[11,10039,10040,10043,10044,10047,10048,2659],{},[130,10041,10042],{},"4. To samo dla tabel podrzędnych."," Dodaj ",[15,10045,10046],{},"order_id_v2",", nowe wiersze uzupełniaj triggerem, stare uzupełnij w paczkach przez złączenie z ",[15,10049,9845],{},[31,10051,10053],{"className":7133,"code":10052,"language":7135,"meta":36,"style":36},"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",[15,10054,10055,10060,10064,10069,10074,10078,10083,10088,10092,10096,10101,10106,10111,10115,10120,10125,10130,10135,10140],{"__ignoreMap":36},[40,10056,10057],{"class":42,"line":43},[40,10058,10059],{},"alter table order_items add column order_id_v2 uuid;\n",[40,10061,10062],{"class":42,"line":49},[40,10063,190],{"emptyLinePlaceholder":189},[40,10065,10066],{"class":42,"line":55},[40,10067,10068],{},"create function order_items_sync_order_id_v2() returns trigger\n",[40,10070,10071],{"class":42,"line":84},[40,10072,10073],{},"language plpgsql as $$\n",[40,10075,10076],{"class":42,"line":90},[40,10077,9924],{},[40,10079,10080],{"class":42,"line":96},[40,10081,10082],{},"  select o.id_v2 into new.order_id_v2 from orders o where o.id = new.order_id;\n",[40,10084,10085],{"class":42,"line":102},[40,10086,10087],{},"  return new;\n",[40,10089,10090],{"class":42,"line":193},[40,10091,9974],{},[40,10093,10094],{"class":42,"line":199},[40,10095,190],{"emptyLinePlaceholder":189},[40,10097,10098],{"class":42,"line":204},[40,10099,10100],{},"create trigger order_items_sync_order_id_v2\n",[40,10102,10103],{"class":42,"line":210},[40,10104,10105],{},"  before insert or update of order_id on order_items\n",[40,10107,10108],{"class":42,"line":216},[40,10109,10110],{},"  for each row execute function order_items_sync_order_id_v2();\n",[40,10112,10113],{"class":42,"line":222},[40,10114,190],{"emptyLinePlaceholder":189},[40,10116,10117],{"class":42,"line":227},[40,10118,10119],{},"update order_items i\n",[40,10121,10122],{"class":42,"line":232},[40,10123,10124],{},"   set order_id_v2 = o.id_v2\n",[40,10126,10127],{"class":42,"line":238},[40,10128,10129],{},"  from orders o\n",[40,10131,10132],{"class":42,"line":244},[40,10133,10134],{}," where o.id = i.order_id\n",[40,10136,10137],{"class":42,"line":250},[40,10138,10139],{},"   and i.order_id_v2 is null\n",[40,10141,10142],{"class":42,"line":256},[40,10143,10144],{},"   and i.id >= 0 and i.id \u003C 10000;   -- powtarzane dla każdej paczki\n",[11,10146,10147,10148,10151,10152,10154,10155,10157,10158,1139],{},"Potem ta sama sekwencja ",[15,10149,10150],{},"not valid"," \u002F ",[15,10153,2532],{}," dla ",[15,10156,9992],{}," na ",[15,10159,10160],{},"order_items.order_id_v2",[11,10162,10163,10166,10167,10170],{},[130,10164,10165],{},"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 ",[15,10168,10169],{},"orders (id)",", bo usunięcie klucza głównego usuwa stary indeks.",[31,10172,10174],{"className":7133,"code":10173,"language":7135,"meta":36,"style":36},"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\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",[15,10175,10176,10181,10186,10190,10195,10200,10204,10209,10214,10218,10223,10228,10233,10238,10243,10247,10252,10257,10262,10266],{"__ignoreMap":36},[40,10177,10178],{"class":42,"line":43},[40,10179,10180],{},"begin;\n",[40,10182,10183],{"class":42,"line":49},[40,10184,10185],{},"set local lock_timeout = '5s';\n",[40,10187,10188],{"class":42,"line":55},[40,10189,190],{"emptyLinePlaceholder":189},[40,10191,10192],{"class":42,"line":84},[40,10193,10194],{},"drop trigger order_items_sync_order_id_v2 on order_items;\n",[40,10196,10197],{"class":42,"line":90},[40,10198,10199],{},"alter table order_items drop constraint order_items_order_id_fkey;\n",[40,10201,10202],{"class":42,"line":96},[40,10203,190],{"emptyLinePlaceholder":189},[40,10205,10206],{"class":42,"line":102},[40,10207,10208],{},"alter table orders drop constraint orders_pkey;\n",[40,10210,10211],{"class":42,"line":193},[40,10212,10213],{},"alter table orders add constraint orders_pkey primary key using index orders_id_v2_uq;\n",[40,10215,10216],{"class":42,"line":199},[40,10217,190],{"emptyLinePlaceholder":189},[40,10219,10220],{"class":42,"line":204},[40,10221,10222],{},"alter table orders rename column id to legacy_id;\n",[40,10224,10225],{"class":42,"line":210},[40,10226,10227],{},"alter table orders rename column id_v2 to id;\n",[40,10229,10230],{"class":42,"line":216},[40,10231,10232],{},"alter table order_items rename column order_id to legacy_order_id;\n",[40,10234,10235],{"class":42,"line":222},[40,10236,10237],{},"alter table order_items rename column order_id_v2 to order_id;\n",[40,10239,10240],{"class":42,"line":227},[40,10241,10242],{},"alter table order_items alter column legacy_order_id drop not null;  -- nowe wiersze już jej nie wypełniają\n",[40,10244,10245],{"class":42,"line":232},[40,10246,190],{"emptyLinePlaceholder":189},[40,10248,10249],{"class":42,"line":238},[40,10250,10251],{},"alter table order_items add constraint order_items_order_id_fkey\n",[40,10253,10254],{"class":42,"line":244},[40,10255,10256],{},"  foreign key (order_id) references orders (id) not valid;\n",[40,10258,10259],{"class":42,"line":250},[40,10260,10261],{},"commit;\n",[40,10263,10264],{"class":42,"line":256},[40,10265,190],{"emptyLinePlaceholder":189},[40,10267,10268],{"class":42,"line":261},[40,10269,10270],{},"alter table order_items validate constraint order_items_order_id_fkey;\n",[11,10272,10273,10274,10277,10278,10281],{},"Każda instrukcja w tej transakcji zmienia tylko katalog, więc blokada ",[15,10275,10276],{},"access exclusive"," trwa krótko. ",[15,10279,10280],{},"lock_timeout"," sprawia, że transakcja szybko się wycofa, zamiast czekać w kolejce za długim zapytaniem i blokować wszystko, co ustawi się za nią. Trigger jest usuwany w tej samej transakcji, bo jego treść odwołuje się do kolumn, którym zmieniamy nazwy.",[11,10283,10284,10285,10288,10289,10292,10293,485,10296,1139],{},"Aplikacja musi być na ten moment gotowa: po zmianie nazw ",[15,10286,10287],{},"orders.id"," jest typu ",[15,10290,10291],{},"uuid",". W praktyce to oznacza release, który przed podmianą czyta i zapisuje obie kolumny, oraz release sprzątający, który po podmianie usuwa ",[15,10294,10295],{},"legacy_id",[15,10297,10298],{},"legacy_order_id",[23,10300,701],{"id":700},[703,10302,10303,10308,10311,10314,10317],{},[127,10304,10305,10306,1139],{},"Upewnij się, że naprawdę jest więcej niż jeden writer. Przy jednym primary zostań przy ",[15,10307,9694],{},[127,10309,10310],{},"Przed startem spisz każdą tabelę, widok, widok zmaterializowany, zapytanie raportowe i system zewnętrzny, który przechowuje stare ID.",[127,10312,10313],{},"Tabele podrzędne uzupełniaj tak samo jak nadrzędną. Często są większe i nie mają kolumny z czasem, więc potrzebują złączenia.",[127,10315,10316],{},"W trakcie backfillu pilnuj opóźnienia replikacji i autovacuum.",[127,10318,10319],{},"Trzymaj stare kolumny, dopóki wszyscy konsumenci się nie przełączą, a potem usuń je osobną zmianą.",[729,10321,731],{},{"title":36,"searchDepth":49,"depth":49,"links":10323},[10324,10325,10326,10327],{"id":9670,"depth":49,"text":9671},{"id":9697,"depth":49,"text":9698},{"id":9838,"depth":49,"text":9839},{"id":700,"depth":49,"text":701},"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 właśnie jest i typ klucza nie jest problemem. Staje się nim, gdy zapisy do tej samej tabeli przyjmuje więcej niż jeden węzeł.",{},"\u002Fpl\u002Farticles\u002Fpostgres-edge",{"x":10333,"y":10334,"depth":2895,"size":743},0.21,0.71,[1553,10336],"state-machine",{"title":9655,"description":10329},"pg-primary-keys","pl\u002Farticles\u002Fpostgres-edge",[10341,10342,10343,10344],"postgres","distributed-systems","uuidv7","migrations","4dk8T3pTtkTvVj4KQYOBXYKvQV6qowJJrgOwl0tHzzk",{"id":10347,"title":10348,"articleId":5436,"body":10349,"category":35,"codeLang":35,"date":10959,"deploys":55,"description":10960,"excerpt":742,"extension":743,"lang":744,"meta":10961,"navigation":189,"path":4809,"pos":10962,"readMin":90,"related":10966,"seo":10967,"service":10968,"stem":10969,"tags":10970,"version":10972,"__hash__":10973},"articles_pl\u002Fpl\u002Farticles\u002Fsingleton-pattern.md","Singleton w PHP: co znaczy „jedna instancja” pod PHP-FPM, Octane i PHPUnit",{"type":8,"value":10350,"toc":10951},[10351,10357,10360,10364,10416,10419,10423,10426,10521,10528,10539,10559,10563,10566,10573,10576,10628,10636,10640,10647,10658,10725,10789,10792,10796,10799,10813,10816,10896,10910,10917,10921,10928,10949],[11,10352,10353,10354,10356],{},"Klasyczny Singleton trzyma instancję we właściwości statycznej i wydaje ją przez ",[15,10355,4805],{},". Wzorzec łączy dwie rzeczy: leniwe tworzenie obiektu i globalny dostęp do niego. Leniwe tworzenie bywa przydatne. Problemy robi globalny dostęp, a wzorzec daje oba, czy ich potrzebujesz, czy nie.",[11,10358,10359],{},"W PHP ważniejsze jest jednak pytanie, co znaczy „jedna”. Właściwość statyczna żyje tak długo, jak proces PHP trzyma załadowaną klasę, a to zależy od środowiska uruchomieniowego.",[23,10361,10363],{"id":10362},"ile-faktycznie-żyje-właściwość-statyczna","Ile faktycznie żyje właściwość statyczna",[4719,10365,10366,10379],{},[4722,10367,10368],{},[4725,10369,10370,10373],{},[4728,10371,10372],{},"Środowisko",[4728,10374,10375,10376],{},"Czas życia ",[15,10377,10378],{},"self::$instance",[4735,10380,10381,10389,10400,10408],{},[4725,10382,10383,10386],{},[4740,10384,10385],{},"PHP-FPM, mod_php",[4740,10387,10388],{},"Jeden request. Silnik czyści cały stan statyczny na końcu żądania.",[4725,10390,10391,10397],{},[4740,10392,10393,10394],{},"Laravel Octane (Swoole, RoadRunner, FrankenPHP), ",[15,10395,10396],{},"queue:work",[4740,10398,10399],{},"Jeden proces workera, przez wiele requestów lub jobów.",[4725,10401,10402,10405],{},[4740,10403,10404],{},"Skrypt CLI, komenda z crona",[4740,10406,10407],{},"Jedno uruchomienie.",[4725,10409,10410,10413],{},[4740,10411,10412],{},"PHPUnit",[4740,10414,10415],{},"Cały przebieg testów w tym procesie, przez wszystkie przypadki testowe.",[11,10417,10418],{},"Ta sama klasa zachowuje się więc inaczej na produkcji, w queue workerze i w testach. To główny powód, żeby uważać.",[23,10420,10422],{"id":10421},"php-fpm-jedna-instancja-na-request","PHP-FPM: jedna instancja na request",[11,10424,10425],{},"Przykład: rate limiter napisany jako Singleton.",[31,10427,10429],{"className":33,"code":10428,"language":35,"meta":36,"style":36},"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",[15,10430,10431,10436,10440,10445,10449,10454,10459,10463,10468,10472,10477,10481,10485,10490,10494,10499,10504,10508,10513,10517],{"__ignoreMap":36},[40,10432,10433],{"class":42,"line":43},[40,10434,10435],{},"final class RateLimiter\n",[40,10437,10438],{"class":42,"line":49},[40,10439,76],{},[40,10441,10442],{"class":42,"line":55},[40,10443,10444],{},"    private static ?self $instance = null;\n",[40,10446,10447],{"class":42,"line":84},[40,10448,190],{"emptyLinePlaceholder":189},[40,10450,10451],{"class":42,"line":90},[40,10452,10453],{},"    \u002F** @var array\u003Cstring, int> *\u002F\n",[40,10455,10456],{"class":42,"line":96},[40,10457,10458],{},"    private array $hits = [];\n",[40,10460,10461],{"class":42,"line":102},[40,10462,190],{"emptyLinePlaceholder":189},[40,10464,10465],{"class":42,"line":193},[40,10466,10467],{},"    public static function getInstance(): self\n",[40,10469,10470],{"class":42,"line":199},[40,10471,241],{},[40,10473,10474],{"class":42,"line":204},[40,10475,10476],{},"        return self::$instance ??= new self();\n",[40,10478,10479],{"class":42,"line":210},[40,10480,253],{},[40,10482,10483],{"class":42,"line":216},[40,10484,190],{"emptyLinePlaceholder":189},[40,10486,10487],{"class":42,"line":222},[40,10488,10489],{},"    public function allow(string $ip, int $limitPerMinute): bool\n",[40,10491,10492],{"class":42,"line":227},[40,10493,241],{},[40,10495,10496],{"class":42,"line":232},[40,10497,10498],{},"        $key = $ip . ':' . intdiv(time(), 60);\n",[40,10500,10501],{"class":42,"line":238},[40,10502,10503],{},"        $this->hits[$key] = ($this->hits[$key] ?? 0) + 1;\n",[40,10505,10506],{"class":42,"line":244},[40,10507,190],{"emptyLinePlaceholder":189},[40,10509,10510],{"class":42,"line":250},[40,10511,10512],{},"        return $this->hits[$key] \u003C= $limitPerMinute;\n",[40,10514,10515],{"class":42,"line":256},[40,10516,253],{},[40,10518,10519],{"class":42,"line":261},[40,10520,105],{},[11,10522,10523,10524,10527],{},"Pod PHP-FPM każdy request zaczyna z pustą tablicą ",[15,10525,10526],{},"$hits",", więc licznik dochodzi do 1 i limit nigdy nie działa. Pod Octane liczyłby osobno w każdym workerze. Faktyczny limit to wtedy skonfigurowana wartość razy liczba workerów, a requesty z jednego IP rozkładają się między workery w nieprzewidywalny sposób.",[11,10529,10530,10531,10534,10535,10538],{},"Wymaganie „100 requestów na minutę na IP w całej aplikacji” dotyczy stanu współdzielonego między procesami i serwerami. Żaden obiekt w pamięci procesu go nie spełni. Stan musi leżeć w Redisie, Memcached albo w bazie. W Laravelu to już jest: middleware ",[15,10532,10533],{},"throttle"," i fasada ",[15,10536,10537],{},"RateLimiter"," trzymają liczniki w cache store.",[31,10540,10542],{"className":33,"code":10541,"language":35,"meta":36,"style":36},"Route::middleware('throttle:100,1')->group(function () {\n    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n});\n",[15,10543,10544,10549,10554],{"__ignoreMap":36},[40,10545,10546],{"class":42,"line":43},[40,10547,10548],{},"Route::middleware('throttle:100,1')->group(function () {\n",[40,10550,10551],{"class":42,"line":49},[40,10552,10553],{},"    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n",[40,10555,10556],{"class":42,"line":55},[40,10557,10558],{},"});\n",[23,10560,10562],{"id":10561},"workery-długo-żyjące-stan-przecieka-między-requestami","Workery długo żyjące: stan przecieka między requestami",[11,10564,10565],{},"W Octane i w queue workerze instancja statyczna przeżywa request, co daje problem odwrotny. Wszystko, co Singleton zapamiętał z jednego requestu, widzi następny request obsłużony przez ten sam worker.",[11,10567,10568,10569,10572],{},"Przykład: singleton ",[15,10570,10571],{},"TenantContext",", który przy pierwszym requeście zapisuje bieżącego tenanta. 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,10574,10575],{},"Kontener Laravela pozwala jawnie określić czas życia:",[31,10577,10579],{"className":33,"code":10578,"language":35,"meta":36,"style":36},"\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",[15,10580,10581,10586,10590,10595,10600,10604,10609,10614,10618,10623],{"__ignoreMap":36},[40,10582,10583],{"class":42,"line":43},[40,10584,10585],{},"\u002F\u002F AppServiceProvider::register()\n",[40,10587,10588],{"class":42,"line":49},[40,10589,190],{"emptyLinePlaceholder":189},[40,10591,10592],{"class":42,"line":55},[40,10593,10594],{},"\u002F\u002F Jedna instancja na instancję aplikacji. Bezpieczne tylko dla obiektów niezmiennych i niezależnych od requestu.\n",[40,10596,10597],{"class":42,"line":84},[40,10598,10599],{},"$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));\n",[40,10601,10602],{"class":42,"line":90},[40,10603,190],{"emptyLinePlaceholder":189},[40,10605,10606],{"class":42,"line":96},[40,10607,10608],{},"\u002F\u002F Jedna instancja na request albo job; Octane i queue worker czyszczą instancje scoped.\n",[40,10610,10611],{"class":42,"line":102},[40,10612,10613],{},"$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));\n",[40,10615,10616],{"class":42,"line":193},[40,10617,190],{"emptyLinePlaceholder":189},[40,10619,10620],{"class":42,"line":199},[40,10621,10622],{},"\u002F\u002F Nowa instancja przy każdym resolve.\n",[40,10624,10625],{"class":42,"line":204},[40,10626,10627],{},"$this->app->bind(PaymentGateway::class, StripeGateway::class);\n",[11,10629,10630,1011,10633,10635],{},[15,10631,10632],{},"ExchangeRateTable",[15,10634,10571],{}," i klasy gatewayów to przykładowe nazwy. Chodzi o to, że czas życia jest zadeklarowany w jednym miejscu i można go zmienić bez ruszania klas, które z zależności korzystają.",[23,10637,10639],{"id":10638},"phpunit-jedna-instancja-na-cały-zestaw-testów","PHPUnit: jedna instancja na cały zestaw testów",[11,10641,10642,10643,10646],{},"PHPUnit domyślnie uruchamia testy w jednym procesie. ",[15,10644,10645],{},"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 zachowuje ten stan w następnym i wynik zależy od kolejności testów.",[11,10648,10649,10650,10653,10654,10657],{},"Typowe obejście to metoda ",[15,10651,10652],{},"resetInstance()"," wołana w ",[15,10655,10656],{},"tearDown()",". 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ść:",[31,10659,10661],{"className":33,"code":10660,"language":35,"meta":36,"style":36},"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",[15,10662,10663,10668,10672,10677,10681,10685,10690,10694,10699,10703,10708,10712,10717,10721],{"__ignoreMap":36},[40,10664,10665],{"class":42,"line":43},[40,10666,10667],{},"interface Clock\n",[40,10669,10670],{"class":42,"line":49},[40,10671,76],{},[40,10673,10674],{"class":42,"line":55},[40,10675,10676],{},"    public function now(): DateTimeImmutable;\n",[40,10678,10679],{"class":42,"line":84},[40,10680,105],{},[40,10682,10683],{"class":42,"line":90},[40,10684,190],{"emptyLinePlaceholder":189},[40,10686,10687],{"class":42,"line":96},[40,10688,10689],{},"final class InvoiceDueDate\n",[40,10691,10692],{"class":42,"line":102},[40,10693,76],{},[40,10695,10696],{"class":42,"line":193},[40,10697,10698],{},"    public function __construct(private readonly Clock $clock) {}\n",[40,10700,10701],{"class":42,"line":199},[40,10702,190],{"emptyLinePlaceholder":189},[40,10704,10705],{"class":42,"line":204},[40,10706,10707],{},"    public function forTermDays(int $days): DateTimeImmutable\n",[40,10709,10710],{"class":42,"line":210},[40,10711,241],{},[40,10713,10714],{"class":42,"line":216},[40,10715,10716],{},"        return $this->clock->now()->modify(\"+{$days} days\");\n",[40,10718,10719],{"class":42,"line":222},[40,10720,253],{},[40,10722,10723],{"class":42,"line":227},[40,10724,105],{},[31,10726,10728],{"className":33,"code":10727,"language":35,"meta":36,"style":36},"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",[15,10729,10730,10735,10739,10744,10749,10754,10759,10763,10767,10771,10776,10780,10785],{"__ignoreMap":36},[40,10731,10732],{"class":42,"line":43},[40,10733,10734],{},"public function test_due_date_is_counted_from_today(): void\n",[40,10736,10737],{"class":42,"line":49},[40,10738,76],{},[40,10740,10741],{"class":42,"line":55},[40,10742,10743],{},"    $clock = new class implements Clock {\n",[40,10745,10746],{"class":42,"line":84},[40,10747,10748],{},"        public function now(): DateTimeImmutable\n",[40,10750,10751],{"class":42,"line":90},[40,10752,10753],{},"        {\n",[40,10755,10756],{"class":42,"line":96},[40,10757,10758],{},"            return new DateTimeImmutable('2024-10-15');\n",[40,10760,10761],{"class":42,"line":102},[40,10762,353],{},[40,10764,10765],{"class":42,"line":193},[40,10766,474],{},[40,10768,10769],{"class":42,"line":199},[40,10770,190],{"emptyLinePlaceholder":189},[40,10772,10773],{"class":42,"line":204},[40,10774,10775],{},"    $dueDate = (new InvoiceDueDate($clock))->forTermDays(14);\n",[40,10777,10778],{"class":42,"line":210},[40,10779,190],{"emptyLinePlaceholder":189},[40,10781,10782],{"class":42,"line":216},[40,10783,10784],{},"    $this->assertSame('2024-10-29', $dueDate->format('Y-m-d'));\n",[40,10786,10787],{"class":42,"line":222},[40,10788,105],{},[11,10790,10791],{},"Bez stanu statycznego, bez teardownu, a test kontroluje jedyne wejście, które ma znaczenie.",[23,10793,10795],{"id":10794},"kiedy-statyczny-singleton-da-się-obronić","Kiedy statyczny Singleton da się obronić",[11,10797,10798],{},"Wzorzec się sprawdza, gdy obiekt spełnia wszystkie te warunki:",[703,10800,10801,10804,10807,10810],{},[127,10802,10803],{},"cały stan ustawia konstruktor i nic go potem nie zmienia,",[127,10805,10806],{},"stan nie zależy od requestu, użytkownika ani tenanta,",[127,10808,10809],{},"budowa obiektu jest na tyle droga, że ma sens robić ją raz na proces,",[127,10811,10812],{},"testy nigdy nie potrzebują innej instancji.",[11,10814,10815],{},"Pasuje do tego niezmienna konfiguracja czytana ze zmiennych środowiskowych:",[31,10817,10819],{"className":33,"code":10818,"language":35,"meta":36,"style":36},"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",[15,10820,10821,10826,10830,10834,10838,10842,10847,10852,10856,10860,10865,10869,10874,10879,10884,10888,10892],{"__ignoreMap":36},[40,10822,10823],{"class":42,"line":43},[40,10824,10825],{},"final class AppConfig\n",[40,10827,10828],{"class":42,"line":49},[40,10829,76],{},[40,10831,10832],{"class":42,"line":55},[40,10833,10444],{},[40,10835,10836],{"class":42,"line":84},[40,10837,190],{"emptyLinePlaceholder":189},[40,10839,10840],{"class":42,"line":90},[40,10841,207],{},[40,10843,10844],{"class":42,"line":96},[40,10845,10846],{},"        public readonly string $appEnv,\n",[40,10848,10849],{"class":42,"line":102},[40,10850,10851],{},"        public readonly string $databaseUrl,\n",[40,10853,10854],{"class":42,"line":193},[40,10855,99],{},[40,10857,10858],{"class":42,"line":199},[40,10859,190],{"emptyLinePlaceholder":189},[40,10861,10862],{"class":42,"line":204},[40,10863,10864],{},"    public static function get(): self\n",[40,10866,10867],{"class":42,"line":210},[40,10868,241],{},[40,10870,10871],{"class":42,"line":216},[40,10872,10873],{},"        return self::$instance ??= new self(\n",[40,10875,10876],{"class":42,"line":222},[40,10877,10878],{},"            appEnv: getenv('APP_ENV') ?: 'production',\n",[40,10880,10881],{"class":42,"line":227},[40,10882,10883],{},"            databaseUrl: getenv('DATABASE_URL') ?: throw new RuntimeException('DATABASE_URL is not set'),\n",[40,10885,10886],{"class":42,"line":232},[40,10887,3328],{},[40,10889,10890],{"class":42,"line":238},[40,10891,253],{},[40,10893,10894],{"class":42,"line":244},[40,10895,105],{},[11,10897,10898,10899,10902,10903,10906,10907,1139],{},"Właściwości ",[15,10900,10901],{},"readonly"," blokują zmiany, a brak zmiennej wywala się przy pierwszym wywołaniu, a nie później w niezwiązanym kawałku kodu. W aplikacji Laravelowej lepiej zarejestrować ten sam obiekt przez ",[15,10904,10905],{},"$this->app->singleton()",", bo wtedy w teście można go podmienić przez ",[15,10908,10909],{},"$this->app->instance()",[11,10911,10912,10913,10916],{},"Do zapamiętania wyniku drogiego obliczenia Laravel od wersji 11 ma helper ",[15,10914,10915],{},"once()",", który cache'uje wynik domknięcia per obiekt i miejsce wywołania. Octane czyści go między requestami, więc nie ma problemu z workerami opisanego wyżej.",[23,10918,10920],{"id":10919},"lista-kontrolna-na-code-review","Lista kontrolna na code review",[11,10922,10923,10924,10927],{},"Gdy w review pojawia się Singleton albo ",[15,10925,10926],{},"private static"," z instancją:",[124,10929,10930,10933,10940,10943,10946],{},[127,10931,10932],{},"Czy obiekt trzyma zmienny stan? Jeśli nie, wzorzec jest do przyjęcia, choć rejestracja w kontenerze i tak ułatwia testy.",[127,10934,10935,10936,10939],{},"Czy jakaś część stanu zależy od requestu, użytkownika albo tenanta? Wtedy potrzebny jest czas życia ",[15,10937,10938],{},"scoped"," albo jawne przekazywanie.",[127,10941,10942],{},"Czy stan ma być wspólny dla wielu procesów lub serwerów (liczniki, locki, cache)? Wtedy jego miejsce jest w Redisie albo w bazie, nie w pamięci PHP.",[127,10944,10945],{},"Czy ten kod będzie działał pod Octane albo w queue workerze? Sprawdź zachowanie w tym środowisku, nie tylko pod PHP-FPM.",[127,10947,10948],{},"Czy test może go podmienić bez wołania metody resetującej? Jeśli nie, wstrzyknij go.",[729,10950,731],{},{"title":36,"searchDepth":49,"depth":49,"links":10952},[10953,10954,10955,10956,10957,10958],{"id":10362,"depth":49,"text":10363},{"id":10421,"depth":49,"text":10422},{"id":10561,"depth":49,"text":10562},{"id":10638,"depth":49,"text":10639},{"id":10794,"depth":49,"text":10795},{"id":10919,"depth":49,"text":10920},"2024-10-15","Klasyczny Singleton trzyma instancję we właściwości statycznej i wydaje ją przez getInstance(). Wzorzec łączy dwie rzeczy: leniwe tworzenie obiektu i globalny dostęp do niego. Leniwe tworzenie bywa przydatne. Problemy robi globalny dostęp, a wzorzec daje oba, czy ich potrzebujesz, czy nie.",{},{"x":10963,"y":10964,"depth":10965,"size":5434},0.22,0.34,1.2,[1553,767],{"title":10348,"description":10960},"global-state-mgmt","pl\u002Farticles\u002Fsingleton-pattern",[35,3841,6119,7411,760,10971],"php-fpm","v5.0.0","GQejTz6Qxz1eyFmEbijEfCvAiDcqA98rDyz0-53Gssk",{"id":10975,"title":10976,"articleId":10336,"body":10977,"category":35,"codeLang":35,"date":11864,"deploys":43,"description":11865,"excerpt":742,"extension":743,"lang":744,"meta":11866,"navigation":189,"path":11867,"pos":11868,"readMin":102,"related":11870,"seo":11871,"service":11872,"stem":11873,"tags":11874,"version":761,"__hash__":11877},"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":10978,"toc":11854},[10979,10990,10994,11079,11089,11093,11096,11255,11267,11274,11278,11289,11423,11437,11440,11478,11481,11485,11499,11518,11525,11541,11545,11548,11554,11675,11682,11686,11689,11794,11797,11801,11830,11832,11852],[11,10980,10981,10982,10985,10986,10989],{},"Każdy obiekt, który ma cykl życia (zamówienie, subskrypcja, wniosek kredytowy), jest maszyną stanów. W większości projektów ta maszyna jest niejawna: kolumna ",[15,10983,10984],{},"status"," i warunki ",[15,10987,10988],{},"if"," w serwisach, które ją zmieniają. Ten tekst pokazuje, jak zrobić ją jawną w PHP 8.3 i Laravelu, jak bezpiecznie wykonywać przejścia przy współbieżności i gdzie umieścić efekty uboczne.",[23,10991,10993],{"id":10992},"wersja-niejawna","Wersja niejawna",[31,10995,10997],{"className":33,"code":10996,"language":35,"meta":36,"style":36},"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",[15,10998,10999,11004,11008,11013,11017,11022,11027,11031,11036,11040,11044,11049,11053,11058,11063,11067,11071,11075],{"__ignoreMap":36},[40,11000,11001],{"class":42,"line":43},[40,11002,11003],{},"class OrderService\n",[40,11005,11006],{"class":42,"line":49},[40,11007,76],{},[40,11009,11010],{"class":42,"line":55},[40,11011,11012],{},"    public function markPaid(Order $order): void\n",[40,11014,11015],{"class":42,"line":84},[40,11016,241],{},[40,11018,11019],{"class":42,"line":90},[40,11020,11021],{},"        if ($order->status !== 'payment_pending') {\n",[40,11023,11024],{"class":42,"line":96},[40,11025,11026],{},"            throw new \\LogicException(\"Cannot mark order {$order->id} as paid\");\n",[40,11028,11029],{"class":42,"line":102},[40,11030,353],{},[40,11032,11033],{"class":42,"line":193},[40,11034,11035],{},"        \u002F\u002F ...\n",[40,11037,11038],{"class":42,"line":199},[40,11039,253],{},[40,11041,11042],{"class":42,"line":204},[40,11043,190],{"emptyLinePlaceholder":189},[40,11045,11046],{"class":42,"line":210},[40,11047,11048],{},"    public function cancel(Order $order): void\n",[40,11050,11051],{"class":42,"line":216},[40,11052,241],{},[40,11054,11055],{"class":42,"line":222},[40,11056,11057],{},"        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {\n",[40,11059,11060],{"class":42,"line":227},[40,11061,11062],{},"            throw new \\LogicException(\"Cannot cancel order {$order->id}\");\n",[40,11064,11065],{"class":42,"line":232},[40,11066,353],{},[40,11068,11069],{"class":42,"line":238},[40,11070,11035],{},[40,11072,11073],{"class":42,"line":244},[40,11074,253],{},[40,11076,11077],{"class":42,"line":250},[40,11078,105],{},[11,11080,11081,11082,11084,11085,11088],{},"Każda metoda sprawdza to, co sama uważa za zabronione. Pełnej listy dozwolonych przejść nie ma nigdzie, jest tylko suma warunków ze wszystkich metod. Wynikają z tego dwa problemy. Nowe miejsce, które zmienia ",[15,11083,10984],{}," (kontroler, komenda importu, panel admina), nie ma żadnej reguły do zastosowania, chyba że autor znajdzie i skopiuje właściwy warunek. Do tego ",[15,11086,11087],{},"cancel()"," używa listy zakazów, więc status dodany później domyślnie da się anulować, a biznes raczej tego nie chce.",[23,11090,11092],{"id":11091},"przejścia-w-jednym-miejscu","Przejścia w jednym miejscu",[11,11094,11095],{},"Enum z wartościami może trzymać zarówno stany, jak i graf przejść:",[31,11097,11099],{"className":33,"code":11098,"language":35,"meta":36,"style":36},"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",[15,11100,11101,11106,11110,11115,11120,11125,11130,11135,11140,11145,11149,11154,11159,11163,11168,11173,11178,11183,11188,11193,11198,11203,11207,11211,11216,11220,11225,11229,11233,11238,11242,11247,11251],{"__ignoreMap":36},[40,11102,11103],{"class":42,"line":43},[40,11104,11105],{},"enum OrderStatus: string\n",[40,11107,11108],{"class":42,"line":49},[40,11109,76],{},[40,11111,11112],{"class":42,"line":55},[40,11113,11114],{},"    case Draft = 'draft';\n",[40,11116,11117],{"class":42,"line":84},[40,11118,11119],{},"    case PaymentPending = 'payment_pending';\n",[40,11121,11122],{"class":42,"line":90},[40,11123,11124],{},"    case Paid = 'paid';\n",[40,11126,11127],{"class":42,"line":96},[40,11128,11129],{},"    case Shipped = 'shipped';\n",[40,11131,11132],{"class":42,"line":102},[40,11133,11134],{},"    case Delivered = 'delivered';\n",[40,11136,11137],{"class":42,"line":193},[40,11138,11139],{},"    case Cancelled = 'cancelled';\n",[40,11141,11142],{"class":42,"line":199},[40,11143,11144],{},"    case Refunded = 'refunded';\n",[40,11146,11147],{"class":42,"line":204},[40,11148,190],{"emptyLinePlaceholder":189},[40,11150,11151],{"class":42,"line":210},[40,11152,11153],{},"    \u002F** @return list\u003Cself> *\u002F\n",[40,11155,11156],{"class":42,"line":216},[40,11157,11158],{},"    public function allowedTransitions(): array\n",[40,11160,11161],{"class":42,"line":222},[40,11162,241],{},[40,11164,11165],{"class":42,"line":227},[40,11166,11167],{},"        return match ($this) {\n",[40,11169,11170],{"class":42,"line":232},[40,11171,11172],{},"            self::Draft => [self::PaymentPending, self::Cancelled],\n",[40,11174,11175],{"class":42,"line":238},[40,11176,11177],{},"            self::PaymentPending => [self::Paid, self::Cancelled],\n",[40,11179,11180],{"class":42,"line":244},[40,11181,11182],{},"            self::Paid => [self::Shipped, self::Refunded],\n",[40,11184,11185],{"class":42,"line":250},[40,11186,11187],{},"            self::Shipped => [self::Delivered],\n",[40,11189,11190],{"class":42,"line":256},[40,11191,11192],{},"            self::Delivered => [self::Refunded],\n",[40,11194,11195],{"class":42,"line":261},[40,11196,11197],{},"            self::Cancelled, self::Refunded => [],\n",[40,11199,11200],{"class":42,"line":267},[40,11201,11202],{},"        };\n",[40,11204,11205],{"class":42,"line":272},[40,11206,253],{},[40,11208,11209],{"class":42,"line":278},[40,11210,190],{"emptyLinePlaceholder":189},[40,11212,11213],{"class":42,"line":283},[40,11214,11215],{},"    public function canTransitionTo(self $to): bool\n",[40,11217,11218],{"class":42,"line":288},[40,11219,241],{},[40,11221,11222],{"class":42,"line":294},[40,11223,11224],{},"        return in_array($to, $this->allowedTransitions(), true);\n",[40,11226,11227],{"class":42,"line":299},[40,11228,253],{},[40,11230,11231],{"class":42,"line":305},[40,11232,190],{"emptyLinePlaceholder":189},[40,11234,11235],{"class":42,"line":310},[40,11236,11237],{},"    public function isFinal(): bool\n",[40,11239,11240],{"class":42,"line":955},[40,11241,241],{},[40,11243,11244],{"class":42,"line":961},[40,11245,11246],{},"        return $this->allowedTransitions() === [];\n",[40,11248,11249],{"class":42,"line":967},[40,11250,253],{},[40,11252,11253],{"class":42,"line":973},[40,11254,105],{},[11,11256,11257,11258,11260,11261,11263,11264,11266],{},"Reguły są teraz listą dozwolonych przejść. Nowy case bez własnej gałęzi w ",[15,11259,403],{}," rzuci ",[15,11262,407],{}," w czasie działania, a PHPStan (od poziomu reguł 4) albo Psalm zgłoszą niewyczerpujący ",[15,11265,403],{}," wcześniej. Kto dodaje status, musi świadomie zdecydować o jego przejściach.",[11,11268,11269,11270,11273],{},"W modelu ustaw rzutowanie kolumny na enum (",[15,11271,11272],{},"protected $casts = ['status' => OrderStatus::class];","), żeby reszta kodu nigdy nie porównywała gołych stringów.",[23,11275,11277],{"id":11276},"wykonanie-przejścia","Wykonanie przejścia",[11,11279,11280,11281,11284,11285,11288],{},"Samo sprawdzenie nie wystarczy. Dwa żądania mogą odczytać to samo zamówienie w ",[15,11282,11283],{},"payment_pending",", oba przejdą ",[15,11286,11287],{},"canTransitionTo(Paid)"," i oba zapiszą zmianę. Przejście musi czytać i zapisywać wiersz pod blokadą:",[31,11290,11292],{"className":33,"code":11291,"language":35,"meta":36,"style":36},"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",[15,11293,11294,11299,11303,11308,11312,11317,11322,11327,11331,11336,11341,11345,11349,11354,11359,11364,11368,11373,11378,11383,11388,11392,11396,11401,11405,11410,11415,11419],{"__ignoreMap":36},[40,11295,11296],{"class":42,"line":43},[40,11297,11298],{},"final class OrderTransitions\n",[40,11300,11301],{"class":42,"line":49},[40,11302,76],{},[40,11304,11305],{"class":42,"line":55},[40,11306,11307],{},"    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order\n",[40,11309,11310],{"class":42,"line":84},[40,11311,241],{},[40,11313,11314],{"class":42,"line":90},[40,11315,11316],{},"        return DB::transaction(function () use ($orderId, $to, $reason): Order {\n",[40,11318,11319],{"class":42,"line":96},[40,11320,11321],{},"            $order = Order::query()->lockForUpdate()->findOrFail($orderId);\n",[40,11323,11324],{"class":42,"line":102},[40,11325,11326],{},"            $from = $order->status;\n",[40,11328,11329],{"class":42,"line":193},[40,11330,190],{"emptyLinePlaceholder":189},[40,11332,11333],{"class":42,"line":199},[40,11334,11335],{},"            if (! $from->canTransitionTo($to)) {\n",[40,11337,11338],{"class":42,"line":204},[40,11339,11340],{},"                throw InvalidTransition::between($order->id, $from, $to);\n",[40,11342,11343],{"class":42,"line":210},[40,11344,7018],{},[40,11346,11347],{"class":42,"line":216},[40,11348,190],{"emptyLinePlaceholder":189},[40,11350,11351],{"class":42,"line":222},[40,11352,11353],{},"            $order->status = $to;\n",[40,11355,11356],{"class":42,"line":227},[40,11357,11358],{},"            $order->status_changed_at = now();\n",[40,11360,11361],{"class":42,"line":232},[40,11362,11363],{},"            $order->save();\n",[40,11365,11366],{"class":42,"line":238},[40,11367,190],{"emptyLinePlaceholder":189},[40,11369,11370],{"class":42,"line":244},[40,11371,11372],{},"            $order->statusHistory()->create([\n",[40,11374,11375],{"class":42,"line":250},[40,11376,11377],{},"                'from' => $from->value,\n",[40,11379,11380],{"class":42,"line":256},[40,11381,11382],{},"                'to' => $to->value,\n",[40,11384,11385],{"class":42,"line":261},[40,11386,11387],{},"                'reason' => $reason,\n",[40,11389,11390],{"class":42,"line":267},[40,11391,4948],{},[40,11393,11394],{"class":42,"line":272},[40,11395,190],{"emptyLinePlaceholder":189},[40,11397,11398],{"class":42,"line":278},[40,11399,11400],{},"            OrderStatusChanged::dispatch($order->id, $from, $to);\n",[40,11402,11403],{"class":42,"line":283},[40,11404,190],{"emptyLinePlaceholder":189},[40,11406,11407],{"class":42,"line":288},[40,11408,11409],{},"            return $order;\n",[40,11411,11412],{"class":42,"line":294},[40,11413,11414],{},"        });\n",[40,11416,11417],{"class":42,"line":299},[40,11418,253],{},[40,11420,11421],{"class":42,"line":305},[40,11422,105],{},[11,11424,11425,11428,11429,11432,11433,11436],{},[15,11426,11427],{},"lockForUpdate()"," dokłada ",[15,11430,11431],{},"FOR UPDATE"," do zapytania ",[15,11434,11435],{},"SELECT",". Druga transakcja, która chce ten sam wiersz, czeka na commit pierwszej, potem czyta nowy status, a sprawdzenie odrzuca przejście. Tabela historii zapisuje, co się zmieniło, kiedy i dlaczego (jeśli chcesz wiedzieć kto, dodaj id użytkownika). To pierwsza rzecz, której potrzebujesz, gdy klient reklamuje zamówienie.",[11,11438,11439],{},"Jeśli nie chcesz trzymać blokady, warunkowy update daje tę samą gwarancję dla pojedynczego przejścia:",[31,11441,11443],{"className":33,"code":11442,"language":35,"meta":36,"style":36},"$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 inne żądanie zmieniło status wcześniej\n}\n",[15,11444,11445,11450,11455,11460,11464,11469,11474],{"__ignoreMap":36},[40,11446,11447],{"class":42,"line":43},[40,11448,11449],{},"$affected = Order::whereKey($orderId)\n",[40,11451,11452],{"class":42,"line":49},[40,11453,11454],{},"    ->where('status', OrderStatus::PaymentPending->value)\n",[40,11456,11457],{"class":42,"line":55},[40,11458,11459],{},"    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);\n",[40,11461,11462],{"class":42,"line":84},[40,11463,190],{"emptyLinePlaceholder":189},[40,11465,11466],{"class":42,"line":90},[40,11467,11468],{},"if ($affected === 0) {\n",[40,11470,11471],{"class":42,"line":96},[40,11472,11473],{},"    \u002F\u002F inne żądanie zmieniło status wcześniej\n",[40,11475,11476],{"class":42,"line":102},[40,11477,105],{},[11,11479,11480],{},"Sprawdza się to na gorących ścieżkach, takich jak webhooki płatności. Wersję z blokadą łatwiej rozbudować, gdy przejście musi też odczytać inne dane albo zapisać kilka wierszy.",[23,11482,11484],{"id":11483},"powtórzone-wywołania-z-systemów-zewnętrznych","Powtórzone wywołania z systemów zewnętrznych",[11,11486,11487,11488,11491,11492,11495,11496,11498],{},"Operatorzy płatności dostarczają webhooki co najmniej raz, więc to samo zdarzenie „płatność udana” może przyjść dwa razy. Druga dostawa zastaje zamówienie już w ",[15,11489,11490],{},"paid",", a z ",[15,11493,11494],{},"Paid"," do ",[15,11497,11494],{}," nie ma przejścia. Dla takich wywołań traktuj „już jest w stanie docelowym” jako sukces:",[31,11500,11502],{"className":33,"code":11501,"language":35,"meta":36,"style":36},"if ($from === $to) {\n    return $order; \u002F\u002F idempotentne powtórzenie, nic do zrobienia\n}\n",[15,11503,11504,11509,11514],{"__ignoreMap":36},[40,11505,11506],{"class":42,"line":43},[40,11507,11508],{},"if ($from === $to) {\n",[40,11510,11511],{"class":42,"line":49},[40,11512,11513],{},"    return $order; \u002F\u002F idempotentne powtórzenie, nic do zrobienia\n",[40,11515,11516],{"class":42,"line":55},[40,11517,105],{},[11,11519,11520,11521,11524],{},"Ten warunek należy do ścieżki dla wywołań idempotentnych (webhooki, ponawiane joby), nie do ogólnej metody ",[15,11522,11523],{},"apply()",". Gdy użytkownik klika „Anuluj” przy zamówieniu, które już jest anulowane, poprawną odpowiedzią jest komunikat o błędzie.",[11,11526,11527,11528,11530,11531,11534,11535,11538,11539,9608],{},"Maszyna stanów gwarantuje, że zamówienie przejdzie do ",[15,11529,11490],{}," raz. Nie powstrzyma operatora płatności przed podwójnym obciążeniem. Przykład: klient klika „Zapłać” dwa razy i każde z dwóch żądań tworzy płatność u operatora. Trzeba temu zapobiec tam, gdzie płatność powstaje. Twórz ją wyłącznie w przejściu ",[15,11532,11533],{},"Draft → PaymentPending",", pod tą samą blokadą, a drugie żądanie niech zastanie ",[15,11536,11537],{},"PaymentPending"," i zwróci istniejącą płatność zamiast tworzyć nową. Dodatkowo wysyłaj klucz idempotencji przy tworzeniu płatności, jeśli operator to obsługuje (Stripe przyjmuje na przykład nagłówek ",[15,11540,1232],{},[23,11542,11544],{"id":11543},"efekty-uboczne-idą-do-zdarzeń","Efekty uboczne idą do zdarzeń",[11,11546,11547],{},"Wysyłka maila, rezerwacja towaru albo powiadomienie analityki wewnątrz przejścia wiąże maszynę stanów z tymi systemami i rodzi pytanie bez dobrej odpowiedzi: jeśli mail się nie wyśle, czy cofnąć status płatności? Nie. Zamówienie jest opłacone niezależnie od maila.",[11,11549,11550,11551,11553],{},"Przejście tylko wysyła zdarzenie. Zdarzenie implementuje ",[15,11552,5250],{},", więc listenery nie ruszą, jeśli transakcja zostanie wycofana, i nie zobaczą starego stanu wiersza, jeśli czytają przez inne połączenie:",[31,11555,11557],{"className":33,"code":11556,"language":35,"meta":36,"style":36},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\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",[15,11558,11559,11564,11569,11573,11578,11582,11587,11591,11595,11600,11605,11610,11614,11618,11622,11627,11631,11636,11640,11645,11649,11653,11657,11662,11667,11671],{"__ignoreMap":36},[40,11560,11561],{"class":42,"line":43},[40,11562,11563],{},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\n",[40,11565,11566],{"class":42,"line":49},[40,11567,11568],{},"use Illuminate\\Foundation\\Events\\Dispatchable;\n",[40,11570,11571],{"class":42,"line":55},[40,11572,190],{"emptyLinePlaceholder":189},[40,11574,11575],{"class":42,"line":84},[40,11576,11577],{},"final class OrderStatusChanged implements ShouldDispatchAfterCommit\n",[40,11579,11580],{"class":42,"line":90},[40,11581,76],{},[40,11583,11584],{"class":42,"line":96},[40,11585,11586],{},"    use Dispatchable;\n",[40,11588,11589],{"class":42,"line":102},[40,11590,190],{"emptyLinePlaceholder":189},[40,11592,11593],{"class":42,"line":193},[40,11594,81],{},[40,11596,11597],{"class":42,"line":199},[40,11598,11599],{},"        public readonly int $orderId,\n",[40,11601,11602],{"class":42,"line":204},[40,11603,11604],{},"        public readonly OrderStatus $from,\n",[40,11606,11607],{"class":42,"line":210},[40,11608,11609],{},"        public readonly OrderStatus $to,\n",[40,11611,11612],{"class":42,"line":216},[40,11613,99],{},[40,11615,11616],{"class":42,"line":222},[40,11617,105],{},[40,11619,11620],{"class":42,"line":227},[40,11621,190],{"emptyLinePlaceholder":189},[40,11623,11624],{"class":42,"line":232},[40,11625,11626],{},"final class SendPaymentConfirmation implements ShouldQueue\n",[40,11628,11629],{"class":42,"line":238},[40,11630,76],{},[40,11632,11633],{"class":42,"line":244},[40,11634,11635],{},"    public function handle(OrderStatusChanged $event): void\n",[40,11637,11638],{"class":42,"line":250},[40,11639,241],{},[40,11641,11642],{"class":42,"line":256},[40,11643,11644],{},"        if ($event->to !== OrderStatus::Paid) {\n",[40,11646,11647],{"class":42,"line":261},[40,11648,3550],{},[40,11650,11651],{"class":42,"line":267},[40,11652,353],{},[40,11654,11655],{"class":42,"line":272},[40,11656,190],{"emptyLinePlaceholder":189},[40,11658,11659],{"class":42,"line":278},[40,11660,11661],{},"        $order = Order::findOrFail($event->orderId);\n",[40,11663,11664],{"class":42,"line":283},[40,11665,11666],{},"        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));\n",[40,11668,11669],{"class":42,"line":288},[40,11670,253],{},[40,11672,11673],{"class":42,"line":294},[40,11674,105],{},[11,11676,11677,11678,11681],{},"Każdy listener w kolejce działa jako osobny job z własnymi próbami (Laravel wykonuje job raz, chyba że ustawisz ",[15,11679,11680],{},"$tries","). Awaria serwera pocztowego opóźnia mail z potwierdzeniem i nie dotyka statusu zamówienia.",[23,11683,11685],{"id":11684},"testy-które-sprawdzają-graf","Testy, które sprawdzają graf",[11,11687,11688],{},"Testowanie każdej pary stanów względem kopii tej samej tabeli tylko powiela definicję. Testuj własności grafu i te reguły biznesowe, które mają znaczenie:",[31,11690,11692],{"className":33,"code":11691,"language":35,"meta":36,"style":36},"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",[15,11693,11694,11699,11703,11708,11713,11717,11722,11727,11732,11737,11742,11746,11750,11754,11758,11763,11768,11772,11776,11781,11785,11790],{"__ignoreMap":36},[40,11695,11696],{"class":42,"line":43},[40,11697,11698],{},"public function test_every_status_is_reachable_from_draft(): void\n",[40,11700,11701],{"class":42,"line":49},[40,11702,76],{},[40,11704,11705],{"class":42,"line":55},[40,11706,11707],{},"    $seen = [OrderStatus::Draft];\n",[40,11709,11710],{"class":42,"line":84},[40,11711,11712],{},"    $queue = [OrderStatus::Draft];\n",[40,11714,11715],{"class":42,"line":90},[40,11716,190],{"emptyLinePlaceholder":189},[40,11718,11719],{"class":42,"line":96},[40,11720,11721],{},"    while ($queue !== []) {\n",[40,11723,11724],{"class":42,"line":102},[40,11725,11726],{},"        foreach (array_shift($queue)->allowedTransitions() as $next) {\n",[40,11728,11729],{"class":42,"line":193},[40,11730,11731],{},"            if (! in_array($next, $seen, true)) {\n",[40,11733,11734],{"class":42,"line":199},[40,11735,11736],{},"                $seen[] = $next;\n",[40,11738,11739],{"class":42,"line":204},[40,11740,11741],{},"                $queue[] = $next;\n",[40,11743,11744],{"class":42,"line":210},[40,11745,7018],{},[40,11747,11748],{"class":42,"line":216},[40,11749,353],{},[40,11751,11752],{"class":42,"line":222},[40,11753,253],{},[40,11755,11756],{"class":42,"line":227},[40,11757,190],{"emptyLinePlaceholder":189},[40,11759,11760],{"class":42,"line":232},[40,11761,11762],{},"    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);\n",[40,11764,11765],{"class":42,"line":238},[40,11766,11767],{},"    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));\n",[40,11769,11770],{"class":42,"line":244},[40,11771,105],{},[40,11773,11774],{"class":42,"line":250},[40,11775,190],{"emptyLinePlaceholder":189},[40,11777,11778],{"class":42,"line":256},[40,11779,11780],{},"public function test_shipped_order_cannot_be_cancelled(): void\n",[40,11782,11783],{"class":42,"line":261},[40,11784,76],{},[40,11786,11787],{"class":42,"line":267},[40,11788,11789],{},"    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));\n",[40,11791,11792],{"class":42,"line":272},[40,11793,105],{},[11,11795,11796],{},"Pierwszy test wyłapie status dodany do enuma, do którego nie prowadzi żadne przejście. Drugi dokumentuje regułę, którą ktoś może kiedyś chcieć zmienić, więc zmiana będzie musiała być świadoma.",[23,11798,11800],{"id":11799},"ograniczenia-i-alternatywy","Ograniczenia i alternatywy",[703,11802,11803,11806,11813,11820],{},[127,11804,11805],{},"Kilka niezależnych wymiarów (płatność, realizacja, fakturowanie) lepiej opisać kilkoma małymi maszynami niż jednym statusem ze wszystkimi kombinacjami. Siedem stanów razy cztery stany płatności daje graf, którego nikt nie przejrzy w review.",[127,11807,11808,11809,11812],{},"Flaga logiczna bez żadnych reguł (na przykład ",[15,11810,11811],{},"is_archived",") nie potrzebuje maszyny stanów.",[127,11814,11815,11816,11819],{},"Ograniczenie ",[15,11817,11818],{},"CHECK"," w bazie zawęzi zbiór wartości, ale nie przejścia. Pilnowanie przejść triggerem jest możliwe, tylko że przenosi reguły biznesowe w miejsce, którego większość zespołów PHP nie przegląda.",[127,11821,11822,11823,4208,11826,11829],{},"Istnieją gotowe komponenty: Symfony Workflow (z typem ",[15,11824,11825],{},"state_machine",[15,11827,11828],{},"spatie\u002Flaravel-model-states"," dla Laravela. Symfony Workflow dokłada guardy, zdarzenia przejść i komendę do zrzutu grafu, a pakiet Spatie klasy stanów, własne klasy przejść i zdarzenia przejść. Enum z tego tekstu wystarcza, dopóki graf mieści się na jednym ekranie.",[23,11831,701],{"id":700},[703,11833,11834,11837,11840,11843,11846,11849],{},[127,11835,11836],{},"Dozwolone przejścia są zdefiniowane w jednym miejscu jako lista dozwolonych.",[127,11838,11839],{},"Każda zmiana statusu przechodzi przez jedną metodę, która sprawdza graf pod blokadą albo warunkowym update'em.",[127,11841,11842],{},"Powtórzone wywołania z idempotentnych źródeł są obsłużone jawnie.",[127,11844,11845],{},"Zewnętrzne skutki (płatności, maile) są zabezpieczone osobno, bo maszyna stanów nie zapobiega podwójnemu obciążeniu u operatora.",[127,11847,11848],{},"Efekty uboczne działają w listenerach po commicie.",[127,11850,11851],{},"Przejścia są zapisywane w tabeli historii.",[729,11853,731],{},{"title":36,"searchDepth":49,"depth":49,"links":11855},[11856,11857,11858,11859,11860,11861,11862,11863],{"id":10992,"depth":49,"text":10993},{"id":11091,"depth":49,"text":11092},{"id":11276,"depth":49,"text":11277},{"id":11483,"depth":49,"text":11484},{"id":11543,"depth":49,"text":11544},{"id":11684,"depth":49,"text":11685},{"id":11799,"depth":49,"text":11800},{"id":700,"depth":49,"text":701},"2024-02-10","Każdy obiekt, który ma cykl życia (zamówienie, subskrypcja, wniosek kredytowy), jest maszyną stanów. W większości projektów ta maszyna jest niejawna: kolumna status i warunki if w serwisach, które ją zmieniają. Ten tekst pokazuje, jak zrobić ją jawną w PHP 8.3 i Laravelu, jak bezpiecznie wykonywać przejścia przy współbieżności i gdzie umieścić efekty uboczne.",{},"\u002Fpl\u002Farticles\u002Fstate-machine",{"x":11869,"y":9065,"depth":8167,"size":743},0.82,[5436,3835],{"title":10976,"description":11865},"order-lifecycle","pl\u002Farticles\u002Fstate-machine",[35,7411,3841,10336,11875,11876],"fsm","order-management","aaK2IYYXUvSFuTR76V9Cbgr67HT_Z81UkbOSWGRnSio",{"id":4,"title":5,"articleId":6,"body":11879,"category":739,"codeLang":35,"date":740,"deploys":43,"description":741,"excerpt":742,"extension":743,"lang":744,"meta":12434,"navigation":189,"path":746,"pos":12435,"readMin":90,"related":12436,"seo":12437,"service":754,"stem":755,"tags":12438,"version":761,"__hash__":762},{"type":8,"value":11880,"toc":12427},[11881,11885,11887,11889,11891,11907,11909,11941,11943,11947,11949,11951,11965,11969,12089,12091,12163,12169,12229,12235,12237,12239,12241,12313,12315,12317,12319,12403,12405,12407,12409,12425],[11,11882,13,11883,18],{},[15,11884,17],{},[11,11886,21],{},[23,11888,26],{"id":25},[11,11890,29],{},[31,11892,11893],{"className":33,"code":34,"language":35,"meta":36,"style":36},[15,11894,11895,11899,11903],{"__ignoreMap":36},[40,11896,11897],{"class":42,"line":43},[40,11898,46],{},[40,11900,11901],{"class":42,"line":49},[40,11902,52],{},[40,11904,11905],{"class":42,"line":55},[40,11906,58],{},[11,11908,61],{},[31,11910,11911],{"className":33,"code":64,"language":35,"meta":36,"style":36},[15,11912,11913,11917,11921,11925,11929,11933,11937],{"__ignoreMap":36},[40,11914,11915],{"class":42,"line":43},[40,11916,71],{},[40,11918,11919],{"class":42,"line":49},[40,11920,76],{},[40,11922,11923],{"class":42,"line":55},[40,11924,81],{},[40,11926,11927],{"class":42,"line":84},[40,11928,87],{},[40,11930,11931],{"class":42,"line":90},[40,11932,93],{},[40,11934,11935],{"class":42,"line":96},[40,11936,99],{},[40,11938,11939],{"class":42,"line":102},[40,11940,105],{},[11,11942,108],{},[11,11944,111,11945,115],{},[15,11946,114],{},[23,11948,119],{"id":118},[11,11950,122],{},[124,11952,11953,11957,11961],{},[127,11954,11955,133],{},[130,11956,132],{},[127,11958,11959,139],{},[130,11960,138],{},[127,11962,11963,145],{},[130,11964,144],{},[11,11966,148,11967,151],{},[15,11968,17],{},[31,11970,11971],{"className":33,"code":154,"language":35,"meta":36,"style":36},[15,11972,11973,11977,11981,11985,11989,11993,11997,12001,12005,12009,12013,12017,12021,12025,12029,12033,12037,12041,12045,12049,12053,12057,12061,12065,12069,12073,12077,12081,12085],{"__ignoreMap":36},[40,11974,11975],{"class":42,"line":43},[40,11976,161],{},[40,11978,11979],{"class":42,"line":49},[40,11980,76],{},[40,11982,11983],{"class":42,"line":55},[40,11984,170],{},[40,11986,11987],{"class":42,"line":84},[40,11988,175],{},[40,11990,11991],{"class":42,"line":90},[40,11992,180],{},[40,11994,11995],{"class":42,"line":96},[40,11996,105],{},[40,11998,11999],{"class":42,"line":102},[40,12000,190],{"emptyLinePlaceholder":189},[40,12002,12003],{"class":42,"line":193},[40,12004,196],{},[40,12006,12007],{"class":42,"line":199},[40,12008,76],{},[40,12010,12011],{"class":42,"line":204},[40,12012,207],{},[40,12014,12015],{"class":42,"line":210},[40,12016,213],{},[40,12018,12019],{"class":42,"line":216},[40,12020,219],{},[40,12022,12023],{"class":42,"line":222},[40,12024,99],{},[40,12026,12027],{"class":42,"line":227},[40,12028,190],{"emptyLinePlaceholder":189},[40,12030,12031],{"class":42,"line":232},[40,12032,235],{},[40,12034,12035],{"class":42,"line":238},[40,12036,241],{},[40,12038,12039],{"class":42,"line":244},[40,12040,247],{},[40,12042,12043],{"class":42,"line":250},[40,12044,253],{},[40,12046,12047],{"class":42,"line":256},[40,12048,190],{"emptyLinePlaceholder":189},[40,12050,12051],{"class":42,"line":261},[40,12052,264],{},[40,12054,12055],{"class":42,"line":267},[40,12056,241],{},[40,12058,12059],{"class":42,"line":272},[40,12060,275],{},[40,12062,12063],{"class":42,"line":278},[40,12064,253],{},[40,12066,12067],{"class":42,"line":283},[40,12068,190],{"emptyLinePlaceholder":189},[40,12070,12071],{"class":42,"line":288},[40,12072,291],{},[40,12074,12075],{"class":42,"line":294},[40,12076,241],{},[40,12078,12079],{"class":42,"line":299},[40,12080,302],{},[40,12082,12083],{"class":42,"line":305},[40,12084,253],{},[40,12086,12087],{"class":42,"line":310},[40,12088,105],{},[11,12090,315],{},[31,12092,12093],{"className":33,"code":318,"language":35,"meta":36,"style":36},[15,12094,12095,12099,12103,12107,12111,12115,12119,12123,12127,12131,12135,12139,12143,12147,12151,12155,12159],{"__ignoreMap":36},[40,12096,12097],{"class":42,"line":43},[40,12098,325],{},[40,12100,12101],{"class":42,"line":49},[40,12102,76],{},[40,12104,12105],{"class":42,"line":55},[40,12106,334],{},[40,12108,12109],{"class":42,"line":84},[40,12110,241],{},[40,12112,12113],{"class":42,"line":90},[40,12114,343],{},[40,12116,12117],{"class":42,"line":96},[40,12118,348],{},[40,12120,12121],{"class":42,"line":102},[40,12122,353],{},[40,12124,12125],{"class":42,"line":193},[40,12126,190],{"emptyLinePlaceholder":189},[40,12128,12129],{"class":42,"line":199},[40,12130,362],{},[40,12132,12133],{"class":42,"line":204},[40,12134,190],{"emptyLinePlaceholder":189},[40,12136,12137],{"class":42,"line":210},[40,12138,371],{},[40,12140,12141],{"class":42,"line":216},[40,12142,376],{},[40,12144,12145],{"class":42,"line":222},[40,12146,353],{},[40,12148,12149],{"class":42,"line":227},[40,12150,190],{"emptyLinePlaceholder":189},[40,12152,12153],{"class":42,"line":232},[40,12154,389],{},[40,12156,12157],{"class":42,"line":238},[40,12158,253],{},[40,12160,12161],{"class":42,"line":244},[40,12162,105],{},[11,12164,400,12165,404,12167,408],{},[15,12166,403],{},[15,12168,407],{},[31,12170,12171],{"className":33,"code":411,"language":35,"meta":36,"style":36},[15,12172,12173,12177,12181,12185,12189,12193,12197,12201,12205,12209,12213,12217,12221,12225],{"__ignoreMap":36},[40,12174,12175],{"class":42,"line":43},[40,12176,418],{},[40,12178,12179],{"class":42,"line":49},[40,12180,76],{},[40,12182,12183],{"class":42,"line":55},[40,12184,427],{},[40,12186,12187],{"class":42,"line":84},[40,12188,432],{},[40,12190,12191],{"class":42,"line":90},[40,12192,437],{},[40,12194,12195],{"class":42,"line":96},[40,12196,105],{},[40,12198,12199],{"class":42,"line":102},[40,12200,190],{"emptyLinePlaceholder":189},[40,12202,12203],{"class":42,"line":193},[40,12204,450],{},[40,12206,12207],{"class":42,"line":199},[40,12208,76],{},[40,12210,12211],{"class":42,"line":204},[40,12212,459],{},[40,12214,12215],{"class":42,"line":210},[40,12216,464],{},[40,12218,12219],{"class":42,"line":216},[40,12220,469],{},[40,12222,12223],{"class":42,"line":222},[40,12224,474],{},[40,12226,12227],{"class":42,"line":227},[40,12228,105],{},[11,12230,481,12231,485,12233,489],{},[15,12232,484],{},[15,12234,488],{},[11,12236,492],{},[23,12238,496],{"id":495},[11,12240,499],{},[31,12242,12243],{"className":33,"code":502,"language":35,"meta":36,"style":36},[15,12244,12245,12249,12253,12257,12261,12265,12269,12273,12277,12281,12285,12289,12293,12297,12301,12305,12309],{"__ignoreMap":36},[40,12246,12247],{"class":42,"line":43},[40,12248,509],{},[40,12250,12251],{"class":42,"line":49},[40,12252,76],{},[40,12254,12255],{"class":42,"line":55},[40,12256,518],{},[40,12258,12259],{"class":42,"line":84},[40,12260,523],{},[40,12262,12263],{"class":42,"line":90},[40,12264,190],{"emptyLinePlaceholder":189},[40,12266,12267],{"class":42,"line":96},[40,12268,532],{},[40,12270,12271],{"class":42,"line":102},[40,12272,190],{"emptyLinePlaceholder":189},[40,12274,12275],{"class":42,"line":193},[40,12276,541],{},[40,12278,12279],{"class":42,"line":199},[40,12280,546],{},[40,12282,12283],{"class":42,"line":204},[40,12284,105],{},[40,12286,12287],{"class":42,"line":210},[40,12288,190],{"emptyLinePlaceholder":189},[40,12290,12291],{"class":42,"line":216},[40,12292,559],{},[40,12294,12295],{"class":42,"line":222},[40,12296,76],{},[40,12298,12299],{"class":42,"line":227},[40,12300,568],{},[40,12302,12303],{"class":42,"line":232},[40,12304,190],{"emptyLinePlaceholder":189},[40,12306,12307],{"class":42,"line":238},[40,12308,577],{},[40,12310,12311],{"class":42,"line":244},[40,12312,105],{},[11,12314,584],{},[23,12316,588],{"id":587},[11,12318,591],{},[31,12320,12321],{"className":33,"code":594,"language":35,"meta":36,"style":36},[15,12322,12323,12327,12331,12335,12339,12343,12347,12351,12355,12359,12363,12367,12371,12375,12379,12383,12387,12391,12395,12399],{"__ignoreMap":36},[40,12324,12325],{"class":42,"line":43},[40,12326,601],{},[40,12328,12329],{"class":42,"line":49},[40,12330,606],{},[40,12332,12333],{"class":42,"line":55},[40,12334,611],{},[40,12336,12337],{"class":42,"line":84},[40,12338,616],{},[40,12340,12341],{"class":42,"line":90},[40,12342,621],{},[40,12344,12345],{"class":42,"line":96},[40,12346,76],{},[40,12348,12349],{"class":42,"line":102},[40,12350,630],{},[40,12352,12353],{"class":42,"line":193},[40,12354,635],{},[40,12356,12357],{"class":42,"line":199},[40,12358,253],{},[40,12360,12361],{"class":42,"line":204},[40,12362,190],{"emptyLinePlaceholder":189},[40,12364,12365],{"class":42,"line":210},[40,12366,648],{},[40,12368,12369],{"class":42,"line":216},[40,12370,190],{"emptyLinePlaceholder":189},[40,12372,12373],{"class":42,"line":222},[40,12374,657],{},[40,12376,12377],{"class":42,"line":227},[40,12378,662],{},[40,12380,12381],{"class":42,"line":232},[40,12382,667],{},[40,12384,12385],{"class":42,"line":238},[40,12386,672],{},[40,12388,12389],{"class":42,"line":244},[40,12390,677],{},[40,12392,12393],{"class":42,"line":250},[40,12394,682],{},[40,12396,12397],{"class":42,"line":256},[40,12398,687],{},[40,12400,12401],{"class":42,"line":261},[40,12402,105],{},[11,12404,694],{},[11,12406,697],{},[23,12408,701],{"id":700},[703,12410,12411,12417,12419,12421,12423],{},[127,12412,707,12413,711,12415,715],{},[15,12414,710],{},[15,12416,714],{},[127,12418,718],{},[127,12420,721],{},[127,12422,724],{},[127,12424,727],{},[729,12426,731],{},{"title":36,"searchDepth":49,"depth":49,"links":12428},[12429,12430,12431,12432,12433],{"id":25,"depth":49,"text":26},{"id":118,"depth":49,"text":119},{"id":495,"depth":49,"text":496},{"id":587,"depth":49,"text":588},{"id":700,"depth":49,"text":701},{},{"x":748,"y":749,"depth":43,"size":743},[751,752],{"title":5,"description":741},[35,757,758,759,760],1791270178656]