Dlaczego Haskell i języki funkcyjne wracają do łask w projektach wysokiej niezawodności

0
26
Rate this post

Nawigacja:

Krótka scenka z dyżuru SRE

Jest 2:37 w nocy. Płatności stoją, bo proces walidacji adresu wysłał w świat „nulla”. Hotfix naprawia objawy, ale nikt nie jest pewien, czy nie zepsuje czegoś obok. W podobnym zespole obok — z usługą napisaną w Haskellu — kompilator nie przepuściłby takiego kodu do wdrożenia, bo „nulla” w typach po prostu nie ma.

Brief: pytania, na które trzeba znać odpowiedź

  • Jak Haskell i paradygmat funkcyjny redukują klasy błędów jeszcze przed uruchomieniem programu?
  • Czy czystość funkcji i niezmienność faktycznie pomagają w niezawodności i współbieżności?
  • W jaki sposób modelować efekty (I/O, błędy, stan), by nie rozlewały się po całym kodzie?
  • Jak testować właściwości systemu zamiast przepisywać scenariusze z Jiry?
  • Czy da się wymusić w kompilacji dodatkowe gwarancje (np. brak pustych list, zakresy wartości)?
  • Jak Haskell wspiera izolowanie i kontrolę konkurencji (STM, Async, kolejki)?
  • Kiedy Haskell ma sens w projektach high-availability, a kiedy lepiej wziąć inny język?
  • Od czego zacząć, by nie przepalić budżetu na naukę i migrację?

1. Uszczelnij granice błędów typami zamiast konwencji

Model domeny przez typy algebraiczne

W wysokiej niezawodności pierwszym krokiem jest zamknięcie „pól minowych” w typach. Haskell oferuje typy algebraiczne (sumy i produkty), które pozwalają modelować dziedziny tak, by nie dało się wyrazić stanów niedozwolonych.

-- Przykład: pewna waluta i kwota > 0
newtype PLN = PLN { unPLN :: Int }    -- grosze
newtype Amount = Amount { unAmount :: Int }

mkAmount :: Int -> Maybe Amount
mkAmount x | x > 0     = Just (Amount x)
           | otherwise = Nothing

data PaymentMethod = Card CardToken | Blik BlikCode | Transfer Iban

data Payment = Payment
  { pmAmount  :: Amount
  , pmMethod  :: PaymentMethod
  , pmUserId  :: UserId
  }

Nawet jeśli ktoś spróbuje utworzyć „pustą” płatność, nie przejdzie przez smart constructor. Brak wartości reprezentujemy jawnie (Maybe/Either), a nie „magicznym” null.

Rozróżniaj etapy życia danych

Użyj oddzielnych typów dla faz: „niezweryfikowane” vs „zweryfikowane”. Dzięki temu kompilator przypilnuje, by np. w operacjach finansowych nie przeszły dane jeszcze nieprzetworzone.

newtype RawIban = RawIban Text
newtype ValidIban = ValidIban Text

validateIban :: RawIban -> Either ValidationError ValidIban

Nowe typy dla jednostek i ról

„Grosze” to nie to samo co „sekundy”. Dwie wartości Int można przypadkowo pomylić — dwa newtype’y już nie:

newtype Milliseconds = Ms Int
newtype Cents        = Cents Int

delay :: Milliseconds -> IO ()
charge :: Cents -> UserId -> IO ()

Mini-wniosek: typy algebraiczne i newtype’y ograniczają przestrzeń błędów do tego stopnia, że całe klasy defektów znikają jeszcze przed runtime.

2. Postaw na czystość funkcji i niezmienność

Referencyjna przeźroczystość i deterministyczne testy

Funkcje czyste mają zawsze ten sam wynik dla tych samych argumentów i nie mają skutków ubocznych. Dzięki temu:

Dlaczego Haskell i języki funkcyjne wracają do łask w projektach wysokiej niezawodności
Źródło: Pexels | Autor: Rafael Minguet Delgado
  • testy stają się szybkie i deterministyczne,
  • łatwiej używać cache i memoizacji,
  • debugowanie to analiza danych wejściowych i kodu, a nie stanów globalnych.
-- Czysta walidacja, bez I/O:
validatePayment :: Payment -> Either ValidationError ValidPayment

Niezmienność upraszcza współbieżność

Brak modyfikowalnego stanu eliminuje potrzebę blokad w większości kodu. Gdy dane są niezmienne, współdzielenie polega na przekazywaniu referencji do niezmiennych struktur, a nie na „kto pierwszy ten lepszy”.

Separacja „czystego” i „brudnego” świata

Haskell wymusza izolację efektów poprzez typ IO. Reguła projektowa: maksymalnie dużo logiki w czystym kodzie, „na brzegu” cienka warstwa I/O. Dzięki temu funkcje domenowe nie zależą od bazy czy sieci i są łatwe do sprawdzenia.

Mini-wniosek: czystość i niezmienność to tańsze testy, mniej blokad i mniej „wyścigów” w nocy.

3. Efekty pod kontrolą: Either, ExceptT, Reader, State

Sygnalizowanie błędów typem zamiast wyjątkami

W projektach o wysokiej niezawodności błąd to wartość. Zamiast „rzucać”, funkcja zwraca Either. To zmusza autora wywołania do obsługi błędu — kompilator nie przepuści pominięcia.

type AppM = ReaderT Env (ExceptT AppError IO)

runPayment :: PaymentRequest -> AppM PaymentId
runPayment req = do
  valid  <- withExceptT ValidationError (validate req)
  user   <- findUser (prUserReq req)      -- Reader: dostęp do Env
  charge <- bill user valid               -- ExceptT: kontrolowane błędy
  pure (paymentId charge)

Wstrzykiwanie zależności typem Reader

ReaderT pozwala przekazać konfigurację i uchwyty do serwisów bez globalnego stanu. To typowa „szyna” w aplikacjach Haskella: ReaderT Env (ExceptT e IO).

Stan lokalny bez wycieków

Jeśli stan jest konieczny, kapsułuj go w State lub w kontrolowanym IORef/STM. Nigdy nie „rozwijaj” stanu po całym kodzie.

Mini-wniosek: jawny podpis funkcji (z efektami) to kontrakt, który nie pozwala dyskretnie wprowadzić niekontrolowanych wyjątków i ukrytych zależności.

4. Testuj właściwości, a nie przypadki: QuickCheck/Hedgehog

Generatory i inwarianty

Property-based testing pozwala zwięźle wyrazić reguły, które mają obowiązywać dla szerokiego zakresu danych. Zamiast dziesiątek przypadków testowych — generator i kilka asercji właściwości.

prop_totalAmountNonNegative :: Property
prop_totalAmountNonNegative =
  property $ do
    payments <- forAll genValidPayments
    total payments >= 0 === True

Idempotencja, łączność, odwracalność

W niezawodnych systemach krytyczne są własności: idempotencja (powtórzenie operacji nie zmienia wyniku), łączność (zachowanie przy agregacji) czy odwracalność (dekodowanie po kodowaniu).

prop_encodeDecodeRoundtrip :: Value -> Bool
prop_encodeDecodeRoundtrip v = decode (encode v) == Just v

Minimalne przykłady błędów

QuickCheck i Hedgehog minimalizują (shrink) dane, gdy test zawiedzie. Otrzymujesz możliwie najprostszy przykład, który łamie regułę — idealne do szybkiej diagnozy.

Mini-wniosek: właściwości to „sieć bezpieczeństwa” nad dynamicznie zmieniającą się domeną, pewniejsza niż zestaw ręcznie dobranych przypadków.

5. Gwarancje kompilacyjne: „NonEmpty”, LiquidHaskell, totalność

Eliminuj częściowe funkcje

„Head” pustej listy? W niezawodnych projektach to niedopuszczalne. Użyj typów, które blokują takie sytuacje:

Dlaczego Haskell i języki funkcyjne wracają do łask w projektach wysokiej niezawodności
Źródło: Pexels | Autor: Jorge Jesus
import Data.List.NonEmpty (NonEmpty(..))

safeHead :: NonEmpty a -> a
safeHead (x :| _) = x

Kontrakty i typy wzbogacone

LiquidHaskell dodaje „typy udoskonalone” przez predykaty (refinement types). Możesz opisać warunki wartości i sprawdzać je na etapie kompilacji.

{-@ type Positive = {v:Int | v > 0} @-}
{-@ mkAmountLH :: Positive -> Amount @-}
mkAmountLH :: Int -> Amount
mkAmountLH x = Amount x

Wymuszaj kompletność dopasowań

Włącz ostrzeżenia GHC o niepełnym pattern matchingu i traktuj je jak błędy. Dzięki temu każda nowa gałąź w typie sumy zmusi Cię do przemyślenia konsekwencji w całym kodzie.

Mini-wniosek: kompilator niech będzie bramkarzem, który nie wpuszcza wątpliwych piłek na boisko produkcji.

6. Współbieżność przewidywalna: STM, Async i backpressure

Transakcje pamięciowe zamiast blokad

STM (Software Transactional Memory) w Haskellu pozwala pisać sekcje krytyczne jak transakcje: atomowo, bez ręcznych blokad i bez ryzyka zakleszczeń przy poprawnej kompozycji.

Async bez zombie wątków: withAsync, link, race

W nocy padł serwis autoryzacji, a w workersach zostały „osierocone” wątki. To klasyk przy ręcznej orkiestracji. Użyj prymitywów z async, które dają strukturę i propagację błędów.

import Control.Concurrent.Async (withAsync, link, race, waitAnyCancel)
import Control.Concurrent.STM

worker :: TBQueue Job -> IO ()
worker q = forever $ do
  job <- atomically (readTBQueue q)
  process job

run :: TBQueue Job -> IO ()
run q =
  withAsync (worker q) $ a1 ->
  withAsync (worker q) $ a2 -> do
    link a1  -- propaguj wyjątki do rodzica
    link a2
    -- czekaj na pierwszy błąd i anuluj pozostałe
    _ <- waitAnyCancel [a1, a2]
    pure ()

Praktycznie: withAsync dba o sprzątanie, link o to, by błąd nie zniknął w logach, a race pozwala wybrać „zwycięzcę” (np. cache lub sieć) i anulować resztę.

Kolejki z ciśnieniem zwrotnym: TBQueue zamiast „niech leci”

Jeśli producent jest szybszy niż konsument, to albo zagłodzisz RAM, albo zjesz opóźnienia. Bounded kolejki w STM wymuszają backpressure.

import Control.Concurrent.STM

setup :: Int -> IO (TBQueue Job)
setup cap = atomically (newTBQueue (fromIntegral cap))

producer :: TBQueue Job -> [Job] -> IO ()
producer q jobs = forM_ jobs $ j ->
  atomically (writeTBQueue q j)  -- zablokuje się, gdy pełna

consumer :: TBQueue Job -> IO ()
consumer q = forever $ do
  j <- atomically (readTBQueue q)
  handleJob j

Jeżeli nie możesz blokować producenta (np. przyjmujesz ruch z Internetu) — zamiast porzucać dane po cichu, odrzuć świadomie z kodem 429 i metryką.

Timeouty, anulowanie i retry z limitem

Długi czas odpowiedzi partnera zjada pule wątków. Nakładaj budżety czasu i przerwij działanie elegancko.

import System.Timeout (timeout)
import Control.Concurrent.Async (race)

callPartner :: IO Result

safeCall :: IO (Maybe Result)
safeCall = timeout (3 * 1000000) callPartner  -- 3s

-- alternatywnie
firstWins :: IO (Either A B)
firstWins = race fastLocal slowRemote
import Control.Retry

policy = limitRetries 3 <> exponentialBackoff 100000
resilient :: IO Result
resilient = recovering policy [const $ Handler isTransient] (const callPartner)

Retry musi mieć limit i warunek; brak jednej z tych rzeczy to proszenie się o lawinę.

Brak przecieków zasobów: bracket i maskowanie przerwań

W świecie wyjątków asynchronicznych zwalnianie zasobów „po try/finally” robi bracket. To obowiązek przy plikach, socketach, połączeniach DB.

import Control.Exception (bracket)

withConn :: (Conn -> IO a) -> IO a
withConn = bracket openConn closeConn

use :: IO ()
use = withConn $ c -> do
  -- nawet jeśli tu poleci wyjątek, closeConn się wykona
  send c payload

Mini-wniosek: połączenie STM + Async + kontrola czasu i zasobów daje przewidywalną współbieżność bez tarcia i „dziur” pamięci.

7. Interfejsy typowane: Servant, Aeson i ewolucja protokołów

Nowy partner wdrożył „niewinne” pole w JSON i nagle parsowanie w prodzie zaczęło wracać 500. Najwięcej błędów czai się na granicach — dlatego je typujemy.

API jako typ, nie jako wiki

{-# LANGUAGE DataKinds, TypeOperators #-}
import Servant

type PaymentsAPI =
       "payments" :> ReqBody '[JSON] PaymentReq :> PostCreated '[JSON] PaymentId
  :<|> "payments" :> Capture "pid" PaymentId :> Get '[JSON] Payment

server :: Server PaymentsAPI
server = createPayment :<|> getPayment

Zmiana w trasie lub modelu — brak kompilacji. Ta sama definicja służy serwerowi i klientowi, co obniża ryzyko „dryfującej” dokumentacji.

Serializacja defensywna i wersjonowanie

data PaymentReqV1 = ...
data PaymentReqV2 = ...
data PaymentReqWire = V1 PaymentReqV1 | V2 PaymentReqV2

instance FromJSON PaymentReqWire where
  -- dekoder przyjmuje kilka kształtów; dodanie V3 nie psuje V1/V2

encodeStable :: DomainPayment -> PaymentReqWire
  • Oddziel model domenowy od „drutu” (wire). Nie mieszaj kompromisów protokołu z logiką.
  • Dodawaj pola jako opcjonalne; usuwanie jest trudniejsze niż ignorowanie nadmiaru.
  • Round-trip testy na kontrakt: dekodowanie–enkodowanie nie gubi informacji.
prop_roundtripWire :: DomainPayment -> Bool
prop_roundtripWire d = decode (encode (encodeStable d)) == Just (encodeStable d)

Idempotencja na styku sieci i porządne retry

newtype IdemKey = IdemKey Text

createPayment :: IdemKey -> PaymentReq -> AppM PaymentId
createPayment k req = do
  -- unikalny indeks po (IdemKey)
  existing <- findByKey k
  case existing of
    Just pid -> pure pid
    Nothing  -> do
      pid <- process req
      persistKey k pid  -- UPSERT
      pure pid

Łącz to z retry tylko dla błędów tymczasowych (sieć, 5xx), nigdy dla błędów biznesowych (4xx). Inaczej powielisz skutki.

8. Narzędzia i praktyki zespołowe, które robią różnicę

Mały zespół dorzucił Haskell do monorepo i po tygodniu zderzył się z buildami i stylem. Kilka drobiazgów robi ogromną różnicę w ergonomii.

Szybkie sprzężenie zwrotne: ghcid, -Wall i budżet ostrzeżeń

-- ghci na sterydach: przebudowuje po zapisie pliku
ghcid --command="cabal repl app:exe-name" --warnings
  • Włącz -Wall -Wcompat -Werror na CI, lokalnie możesz luzować.
  • Pilnuj pełnych dopasowań i nieużywanych importów — to realne błędy jutra.

Styl i higiena: formatter i statyczna analiza

  • Formatter bez dyskusji: ormolu lub fourmolu.
  • hlint do prostych refaktorów, weeder na martwy kod.
  • Pre-commit: format + lint + szybkie testy właściwości krytycznych.

Zarządzanie zależnościami i powtarzalny build

  • ghcup do wersji GHC i narzędzi, cabal z cabal.project.freeze dla deterministycznych buildów.
  • Jeśli infra na to pozwala — hermetyzacja przez Nix/Flakes.

9. Testy własności zamiast katalogu przypadków: QuickCheck/Hedgehog

Po incydencie w bankowości mobilnej audyt poprosił o dowody, że „to już się nie powtórzy”. Zamiast dopisywać setki przykładów, zespół zbudował siatkę własności, które muszą zawsze trzymać inwarianty. Błędy wracały szybciej na CI niż do użytkowników.

Formułuj reguły, nie przykłady

Zamiast „dla wejścia X zwróć Y”, zapisz co ma być zawsze prawdą: idempotencja, zachowanie sum, stabilność sortowania.

import Test.QuickCheck
import Data.List (sort, nub)

-- sortowanie jest idempotentne
prop_sortIdem :: [Int] -> Bool
prop_sortIdem xs = sort (sort xs) == sort xs

-- deduplikacja nie zmienia się przy powtórnym użyciu
prop_nubIdem :: [Int] -> Bool
prop_nubIdem xs = nub (nub xs) == nub xs

Mini-wniosek: własność testuje nieskończoną rodzinę przypadków, przykłady — tylko to, co akurat pamiętasz.

Generatory z ograniczeniami domenowymi

Wiele reguł obowiązuje tylko dla sensownych danych. QuickCheck i Hedgehog pozwalają generować je z filtrami i konstruktorami „smart”.

import qualified Data.List.NonEmpty as NE
import Test.QuickCheck

-- zawsze niepusta lista
prop_minLeHead :: NonEmptyList Int -> Bool
prop_minLeHead (NonEmpty xs) =
  let ys = NE.fromList xs
  in minimum xs <= NE.head ys

-- dzielenie tylko przez niezero
newtype NonZero = NonZero Int deriving Show
instance Arbitrary NonZero where
  arbitrary = NonZero . getNonZero <$> arbitrary

Mini-wniosek: dobre generatory są jak kontrola jakości na wejściu — filtrują śmieci i przyspieszają realne wykrywanie błędów.

Shrinking i pokrycie — diagnoza, nie tylko czerwony krzyżyk

Własności bez „shrinkingu” i pokrycia często zostawiają Cię z przypadkowym, trudnym wejściem. Dodaj kryteria.

prop_dedupeKeepsOrder :: [Int] -> Property
prop_dedupeKeepsOrder xs =
  checkCoverage $
  cover 30 (length xs > length (nub xs)) "miały się zdarzyć duplikaty" $
    let ys = nub xs
    in all (x -> elem x xs) ys  -- prosty kontrakt zachowania elementów

Mini-wniosek: kiedy test pęka, chcesz dostać minimalny kontrprzykład i informację, czy w ogóle dotknąłeś trudnych gałęzi danych.

10. Modelowanie domeny jako zbroja: newtype, smart konstruktory, phantom types

W środę klient wysłał „email” w polu telefonu; produkcja przyjęła, a downstream spadł dopiero przy notyfikacji. Typ, który nie pozwala skonstruować złej wartości, ucina błąd już przy wejściu.

newtype zamiast aliasów — zamień klasy błędów na błędy kompilacji

Podczas migracji do nowego PSP ktoś pomylił UserId z PaymentId. UUID-e są „kompatybilne”, tylko że zgoda poszła nie tam. Kompilator może to zatrzymać.

{-# LANGUAGE DerivingStrategies, GeneralizedNewtypeDeriving #-}
import Data.Aeson (ToJSON, FromJSON)
import Data.Text (Text)
import Data.UUID (UUID)

newtype UserId    = UserId UUID
  deriving stock (Eq, Ord, Show)
  deriving newtype (ToJSON, FromJSON)

newtype PaymentId = PaymentId UUID
  deriving stock (Eq, Ord, Show)
  deriving newtype (ToJSON, FromJSON)

-- kompilacja odmówi:
-- sendReceipt :: PaymentId -> IO ()
-- sendReceipt (UserId u) = ...

Praktyczny sens: mylisz typy — nie ma deployu. JSON i logi wciąż proste dzięki deriving newtype.

Smart konstruktory i walidacja na krawędzi

Webhook zwrócił „email”: "admin at example dot com". Parser puścił, błąd wrócił z innego systemu po godzinie. Waliduj przy tworzeniu.

import qualified Data.Text as T
import Data.Aeson

newtype Email = Email { unEmail :: Text } deriving Show

data ValidationError = InvalidEmail deriving Show

mkEmail :: Text -> Either ValidationError Email
mkEmail t | "@" `T.isInfixOf` t = Right (Email t)
          | otherwise           = Left InvalidEmail

instance FromJSON Email where
  parseJSON = withText "Email" $ t ->
    either (const (fail "invalid email")) pure (mkEmail t)

sendWelcome :: Email -> IO ()

Praktyczny sens: nie ma „pół-poprawnych” wartości w środku systemu. Źle sformatowane dane zatrzymujesz tam, gdzie powstały.

Phantom types do modelowania stanów i uprawnień

„Zatwierdź płatność” dostało obiekt, którego nie przeszło AML. Niech typ zabroni takiego wywołania.

{-# LANGUAGE DataKinds, KindSignatures #-}
data Draft
data Approved

data Payment s = Payment { amount :: Int }

approve :: Payment Draft -> IO (Payment Approved)
approve p = pure (Payment (amount p))

capture :: Payment Approved -> IO ()
capture = const (pure ())

-- compile error: wymagany Approved
-- capture = capture (Payment 100 :: Payment Draft)

Praktyczny sens: do funkcji „ostrych” nie podasz obiektu w złym stanie. Sekwencja kroków jest kodem, nie checklistą w wiki.

Tagowane identyfikatory, żeby nie pomylić encji

W jednej bazie jest users i orders; oba mają kolumnę id. Mylenie ich to klasyk.

{-# LANGUAGE GeneralizedNewtypeDeriving, DerivingStrategies #-}
import Data.UUID
import Data.Aeson

newtype Id a = Id UUID
  deriving stock (Eq, Ord, Show)
  deriving newtype (ToJSON, FromJSON)

data User
data Order

type UserId  = Id User
type OrderId = Id Order

loadOrder :: OrderId -> IO ()
notifyUser :: UserId  -> IO ()

Praktyczny sens: interfejsy API i zapytania SQL są samodokumentujące, a kategorie błędów znikają jeszcze w IDE.

Jednostki i kwoty: unikaj „gołych” liczb

Kurs walutowy pomnożono przez kwotę netto zamiast brutto. Jeden Double wygląda jak drugi.

import Data.Int (Int64)
import Data.Time (NominalDiffTime)

newtype Cents = Cents Int64 deriving (Eq, Ord, Show)
newtype USD   = USD Cents   deriving (Eq, Ord, Show)

addUSD :: USD -> USD -> USD
addUSD (USD (Cents a)) (USD (Cents b)) = USD (Cents (a + b))

-- konwersja tylko na krawędzi (np. z formularza)
parseUSD :: Double -> Maybe USD
parseUSD d | d >= 0    = Just . USD . Cents . round $ d * 100
           | otherwise = Nothing

-- czas jako typ, nie liczba sekund
timeoutBudget :: NominalDiffTime
timeoutBudget = 2.5

Praktyczny sens: mnożysz zgodne wielkości i nie gubisz centów przez błędy binarne. Konwersje z Double są świadome i odseparowane.

11. Efekty uporządkowane: ReaderT, ExceptT i bezpieczne IO

Po wdrożeniu alerty zasypały Slacka: część błędów „zniknęła”, bo ktoś połknął wyjątek w głębi IO. Efekty trzeba ułożyć tak, by błędy miały jeden tor, a zasoby były zwalniane.

Jedno AppM i wstrzykiwanie zależności przez ReaderT

Konfiguracja, logowanie, klient DB — zamiast globalnych zmiennych trzymaj je w Env i podawaj w monadzie aplikacyjnej.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad.Reader
import Control.Monad.Except

data Env = Env { dbPool :: Pool, logger :: Text -> IO () }
data AppErr = transient Text | business Text deriving Show

newtype AppM a = AppM { unAppM :: ReaderT Env (ExceptT AppErr IO) a }
  deriving newtype (Functor, Applicative, Monad, MonadIO, MonadReader Env, MonadError AppErr)

runAppM :: Env -> AppM a -> IO (Either AppErr a)
runAppM env (AppM m) = runExceptT (runReaderT m env)

Praktyczny sens: funkcje nie sięgają po „magiczne” IO; testujesz je, podając sztuczne Env.

Rozdziel biznes od awarii: ExceptT na reguły, wyjątki na katastrofy

„Brak środków” to nie wyjątek systemowy. „Nie mogę otworzyć socketu” — tak.

charge :: Amount -> AppM PaymentId
charge amt = do
  ok <- checkBalance amt
  if ok then Right <$> persistCharge amt
        else throwError (business "insufficient funds")

safeIO :: IO a -> AppM a
safeIO act = do
  r <- liftIO (tryAny act)
  either (throwError . transient . tshow) pure r

Praktyczny sens: komunikaty dla klienta i retry polityki działają przewidywalnie, bo klasy błędów są jawne.

Dlaczego Haskell i języki funkcyjne wracają do łask w projektach wysokiej niezawodności
Źródło: Pexels | Autor: Rafael Minguet Delgado

Zarządzanie zasobami z MonadUnliftIO zamiast „ręcznego” finally

W wątku poleciał wyjątek, połączenie do DB zostało „wypożyczone na zawsze”. Unikaj tego.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import UnliftIO (MonadUnliftIO, withRunInIO)
import UnliftIO.Exception (bracket)
import qualified Data.Pool as pool

-- dorzuć do AppM:
-- deriving newtype (MonadUnliftIO)

withDb :: MonadUnliftIO m => pool.Pool Conn -> (Conn -> m a) -> m a
withDb p use =
  withRunInIO $ run ->
    pool.withResource p (run . use)

-- dowolny zasób: open/close zawsze w parze
withFileText :: MonadUnliftIO m => FilePath -> (Handle -> m a) -> m a
withFileText path use =
  withRunInIO $ run -> bracket (openFile path ReadMode) hClose (run . use)

Praktyczny sens: znikają „wiszące” zasoby po wyjątkach i przerwaniach; interfejs jest czysty w AppM, a nie w surowym IO.

Idempotentne retry z budżetem czasu i klasyfikacją błędów

Gateway kartowy uniósł brwi i oddał 502. Jedno „spróbuj jeszcze raz” zrobiło podwójny capture. Brak klucza idempotencji.

import Control.Retry
import qualified Data.Text as T
import UnliftIO.Timeout (timeout)

newtype IdemKey = IdemKey T.Text deriving (Eq, Ord, Show)

data Transient = Net Text | Http5xx Int deriving Show

retryHttp :: RetryPolicyM m
retryHttp = limitRetries 4 <> exponentialBackoff 50_000

chargeIdem :: Amount -> IdemKey -> AppM PaymentId
chargeIdem amt key = do
  let act = callGateway amt key -- gwarantuj idempotencję po stronie zewnętrznej
  e <- timeout 2_000_000 $  -- 2s budżetu na całość
        recovering retryHttp
          [const $ Handler isTransient]  -- tylko błędy ulotne
          (const act)
  maybe (throwError (transient "timeout")) pure e

isTransient :: Monad m => SomeException -> m Bool
isTransient ex = pure (isNet ex || is5xx ex)

Praktyczny sens: ponawianie nie mnoży efektów ubocznych; wiesz, kiedy odpuścić (timeout) i które błędy kwalifikują się do retry.

Koordynacja z asynchronicznością bez wycieków

Importer CSV uruchomił trzy równoległe zadania, jedno padło, dwa pozostały bez nadzoru. Potem był już tylko throttling i długi wieczór.

import UnliftIO.Async

parallelImport :: [FilePath] -> AppM ()
parallelImport files = do
  -- failure w dowolnym dziecku anuluje resztę
  withRunInIO $ run -> withAsync (mapConcurrently_ (run . processOne) files) wait

processOne :: FilePath -> AppM ()
processOne fp = bracket (openResource fp) closeResource useResource

Praktyczny sens: brak „zombie” zadań i deterministyczne sprzątanie przy anulacjach.

12. STM i kolejki transakcyjne: mniej blokad, więcej gwarancji

Limit przepustowości miał trzymać 100 req/s, ale przy piku wątki zjadały się nawzajem na blokadach. Jeden MVar nie robi systemu odpornym.

Rate-limiter jako prosty licznik tokenów

Token bucket w STM jest krótki i pewny: albo rezerwujesz atomowo, albo czekasz.

import Control.Concurrent.STM

data Bucket = Bucket { tokens :: TVar Int, capacity :: Int }

newBucket :: Int -> IO Bucket
newBucket cap = atomically $ Bucket <$> newTVar cap <*> pure cap

acquire :: Bucket -> Int -> IO ()
acquire b n = atomically $ do
  t <- readTVar (tokens b)
  if t >= n then writeTVar (tokens b) (t - n)
            else retry  -- wstrzymaj transakcję do czasu zmian

refill :: Bucket -> Int -> IO ()
refill b n = atomically $ do
  t <- readTVar (tokens b)
  writeTVar (tokens b) (min (capacity b) (t + n))

Praktyczny sens: zero wyścigów; backpressure zamiast eksplozji wątków.

Priorytety i fallbacki z orElse

Dwóch klientów bije do jednego zasobu; premium ma wygrać, reszta ma czekać lub iść na kolejkę „zimną”.

Dlaczego Haskell i języki funkcyjne wracają do łask w projektach wysokiej niezawodności
Źródło: Pexels | Autor: Brett Jordan
import Control.Concurrent.STM.TQueue

dequeuePref :: TQueue Job -> TQueue Job -> IO Job
dequeuePref qPremium qStd = atomically $
  readTQueue qPremium `orElse` readTQueue qStd

Praktyczny sens: czytelne reguły arbitrażu bez ręcznego tasowania blokad.

13. Totalność zamiast pułapek: NonEmpty, Maybe i brak „partiali”

Raz w miesiącu ETL padał na pustej liście, bo ktoś zrobił head w „niemożliwym” miejscu. Niemożliwe zdarza się w piątki po 17.

NonEmpty i totalne API zamiast wyjątków w runtime

import qualified Data.List.NonEmpty as NE

avg :: NE.NonEmpty Int -> Double
avg xs = fromIntegral (sum xs) / fromIntegral (NE.length xs)

-- konwersja tylko tam, gdzie masz gwarancję
fromListNE :: [a] -> Maybe (NE.NonEmpty a)
fromListNE = NE.nonEmpty

Praktyczny sens: nie masz jak wywołać funkcji z pustą listą — kompilator pilnuje kontraktu.

Wycinaj „partial functions” i wymuszaj kompletność wzorców

-- w pliku .cabal lub ghci:
-- ghc-options: -Wall -Werror -Wincomplete-patterns -Wpartial-fields

safeHead :: [a] -> Maybe a
safeHead []    = Nothing
safeHead (x:_) = Just x

data State = New | Paid | Shipped

next :: State -> Maybe State
next New     = Just Paid
next Paid    = Just Shipped
next Shipped = Nothing

Praktyczny sens: brak „niespodzianek” produkcyjnych; braki w dopasowaniu wychodzą przy kompilacji.

Walidacja zakresów typem: porty, procenty, kwoty

newtype Port = Port Word16 deriving (Eq, Show)

mkPort :: Int -> Either Text Port
mkPort n | 0 <= n && n <= 65535 = Right (Port (fromIntegral n))
         | otherwise            = Left "port out of range"

Praktyczny sens: reguły domenowe nie wiszą w komentarzach, tylko w konstruktorach.

14. Testy własności: niezmienniki zamiast setek przykładów

Raz poprawiono błąd zaokrągleń, a tydzień później rozjechały się kwoty w JSON-ie na integracji mobilnej. Kilkadziesiąt unit testów z konkretnymi przykładami nie złapało rogu przypadków, który wygenerował użytkownik.

Roundtripy i niezmienniki domenowe

Najpierw pokryj warstwy graniczne: serializację i parsery. Własności mówią „dla każdego wejścia spełnionego przez generator”, a nie „dla tych trzech przykładów z Jira”.

Generatory i roundtripy w praktyce

Przy refaktorze serializacji kwot JSON zaczął „połykać” zera po przecinku. Nikt nie zauważył, bo testy miały dwa przykłady „19.99” i „0.01”. Własność roundtripowa gasi taki ogień zanim kod dotrze na staging.

Poprzedni artykułGeneza sztucznej inteligencji: od pionierskich algorytmów do modeli GPT
Jerzy Malinowski
Jerzy Malinowski to specjalista od bezpieczeństwa IT i ochrony danych, który od lat zajmuje się audytami, testami penetracyjnymi oraz reagowaniem na incydenty. Na Wymienialnik.pl tworzy analizy zagrożeń, praktyczne instrukcje zabezpieczania systemów oraz recenzje narzędzi security. Każdy materiał opiera na aktualnych standardach, raportach branżowych i własnych doświadczeniach z pracy z firmami różnej wielkości. Duży nacisk kładzie na odpowiedzialne podejście do publikowanych treści – unika sensacji, zamiast tego pokazuje realne ryzyka i konkretne kroki, które można wdrożyć od razu.