Let it crash - dlaczego systemy w Elixirze naprawiają się same i co z tego ma Twoja firma

Sobota, 3:00 w nocy. Serwer produkcyjny. Jeden użytkownik wysyła zamówienie z emoji w polu "uwagi do zamówienia". Parser nie obsługuje tego znaku. Wyjątek. Crash.

W systemie napisanym w Javie, Pythonie czy Node.js: cały serwer leży. 200 użytkowników traci połączenie. Tomek dostaje telefon. Jedzie do biura. Restartuje. Szuka buga. Naprawia. Wdraża. Wraca do domu o 7:00.

W systemie napisanym w Elixirze: proces obsługujący tego jednego użytkownika umiera. Supervisor restartuje go w milisekundach. Użytkownik widzi komunikat "Coś poszło nie tak, spróbuj ponownie". Klika jeszcze raz (tym razem bez emoji). Zamówienie przechodzi. 199 pozostałych użytkowników nie zauważa niczego. Tomek śpi.

Rano Tomek przychodzi do biura, otwiera logi, widzi: "03:14:22 - crash w OrderController, argument: emoji w polu uwagi. Proces zrestartowany w 2ms. Wpływ na użytkowników: 1." Naprawia buga. Wdraża. Koniec historii.

Filozofia "Let it crash"

W tradycyjnym programowaniu uczymy się: obsłuż każdy błąd. Każde wywołanie API opakowuj w try-catch. Każdy dostęp do pliku sprawdzaj if-else. Każde zapytanie do bazy obsłuż error handling. Kod jest w 40% logiką biznesową, w 60% obsługą błędów.

I mimo to systemy padają. Bo zawsze jest błąd, którego programista nie przewidział. Emoji w polu uwagi. NULL w polu, które "na pewno nie jest NULL". Timeout na API, które "zawsze odpowiada w 100ms". Baza danych, która "nigdy nie odrzuca połączenia".

Erlang (a za nim Elixir) ma radykalnie inną filozofię: nie próbuj obsłużyć każdego błędu. Pozwól procesowi umrzeć. Niech ktoś inny go naprawi.

To brzmi jak szaleństwo. Ale to jest najzdrowsza architektura oprogramowania, jaką wymyślono.

Analogia: elektrownia a bezpiecznik

Twój dom ma bezpieczniki. Gdy zwarcie w jednym gniazdku powoduje przepięcie, bezpiecznik wyłącza ten jeden obwód. Lodówka dalej działa. Światło w salonie dalej świeci. Telewizor dalej gra.

Nie projektujesz instalacji elektrycznej tak, żeby gniazdko w łazience "obsługiwało" zwarcie i dalej działało. Projektujesz tak, żeby zwarcie w łazience nie wpływało na resztę domu. Bezpiecznik izoluje awarię i wyłącza uszkodzony obwód.

W BEAM:

  • Gniazdko = proces (lekki wątek BEAM)
  • Bezpiecznik = supervisor
  • Obwód = drzewo nadzoru (supervisor tree)
  • Dom = cały system

Zwarcie (crash) w jednym procesie nie kładzie systemu. Supervisor restartuje ten proces. Reszta systemu działa nieprzerwanie.

Procesy BEAM - nie mylić z procesami OS

Proces w BEAM to nie jest proces systemu operacyjnego. To lekka abstrakcja, zarządzana przez maszynę wirtualną:

CechaProces OS (Java thread)Proces BEAM
Rozmiar1-8 MB RAM0.5-2 KB RAM
Czas tworzeniaMilisekundyMikrosekundy
Limit na maszynęTysiąceMiliony
PamięćWspółdzielonaIzolowana
Crash jednegoMoże zabić cały systemDotyczy tylko tego procesu
Garbage collectionZatrzymuje cały system (stop-the-world)Per-proces, nie wpływa na inne

Kluczowa różnica: izolacja pamięci. Każdy proces BEAM ma własną pamięć. Nie ma współdzielonych zmiennych, nie ma muteksów, nie ma wyścigów danych. Jeśli proces umrze, jego pamięć jest zwolniona. Żaden inny proces nie jest dotknięty. Zero efektów ubocznych.

W Javie/C#: wyjątek w jednym wątku może uszkodzić współdzielony stan i powodować kaskadowe awarie w innych wątkach. Debugowanie takiego problemu to tygodnie.

W BEAM: crash procesu A nie ma fizycznej możliwości uszkodzenia stanu procesu B. Procesy komunikują się wyłącznie przez wiadomości (message passing). Każda wiadomość jest kopią - nie referencją.

Supervisor - nadzorca, który nigdy nie śpi

Supervisor to specjalny proces BEAM, którego jedynym zadaniem jest pilnowanie innych procesów. Nie wykonuje logiki biznesowej. Nie obsługuje żądań. Obserwuje swoje "dzieci" i reaguje, gdy któreś umrze.

Strategie restartu

Supervisor ma trzy strategie reagowania na crash dziecka:

:one_for_one - umarło jedno dziecko? Restartuj tylko to jedno. Reszta działa dalej.

Supervisor
├── Proces A  ← crash!
├── Proces B  ← działa dalej
└── Proces C  ← działa dalej

Po restarcie:
Supervisor
├── Proces A  ← nowy, świeży, działa
├── Proces B  ← nie ruszony
└── Proces C  ← nie ruszony

Idealny scenariusz: niezależne procesy. Crash handlera jednego zamówienia nie wpływa na handlery innych zamówień.

:one_for_all - umarło jedno dziecko? Restartuj wszystkie. Bo są od siebie zależne.

Supervisor
├── Baza danych (connection pool)  ← crash!
├── Cache                          ← restartowany
└── Worker                         ← restartowany

Jeśli connection pool umarł, cache i worker bez niego
nie mają sensu. Restartujemy cały zestaw.

:rest_for_one - umarło jedno dziecko? Restartuj to dziecko i wszystkie uruchomione po nim. Bo późniejsze zależą od wcześniejszych.

Supervisor
├── Config loader     ← nie ruszony
├── Database pool     ← crash!
├── Cache (zależy od DB)   ← restartowany
└── Worker (zależy od cache) ← restartowany

Config loader nie zależy od reszty - nie ruszany.
Ale cache i worker zależą od DB - restartowane.

Drzewo supervisorów

Supervisory mogą nadzorować inne supervisory. Tworzą drzewo - hierarchiczną strukturę, w której każdy poziom izoluje awarie:

                    Application Supervisor
                    /          |          \
              Web Supervisor   Business   Background
              /     |    \    Supervisor   Supervisor
         Endpoint  PubSub  Telemetry   /    \      |     \
                                   Orders  Stock  Oban   Mailer
                                    /  \
                              Order1  Order2  ...

Crash w Order1 → supervisor Orders restartuje ten jeden proces. Crash w Orders supervisor → supervisor Business restartuje go (i wszystkie zamówienia). Crash w Business supervisor → Application Supervisor restartuje tę gałąź.

Każdy poziom izoluje awarię. Crash jednego zamówienia nie wpływa na web serwer, nie wpływa na Oban, nie wpływa na mailer. Struktura jest jak drzewo - gałąź może odpaść, ale pień stoi.

Jak to wygląda w kodzie Elixira

Definiowanie supervisora

defmodule MojSystem.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Baza danych - musi wystartować pierwsza
      MojSystem.Repo,

      # PubSub - do komunikacji między procesami
      {Phoenix.PubSub, name: MojSystem.PubSub},

      # Oban - kolejka zadań
      {Oban, Application.fetch_env!(:moj_system, Oban)},

      # Web serwer - na końcu, bo zależy od reszty
      MojSystemWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: MojSystem.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

To jest cały kod uruchamiający system. Supervisor pilnuje: bazy danych, PubSub, kolejki Oban, web serwera. Jeśli którykolwiek crashuje - jest restartowany. Automatycznie. Bez interwencji. Bez Tomka.

Dynamiczny supervisor - proces na żądanie

W systemie ERP każdy użytkownik to osobny proces. Supervisor tworzy je dynamicznie:

defmodule MojSystem.SessionSupervisor do
  use DynamicSupervisor

  def start_link(init_arg) do
    DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  def start_session(user_id) do
    spec = {MojSystem.UserSession, user_id: user_id}
    DynamicSupervisor.start_child(__MODULE__, spec)
  end

  def init(_init_arg) do
    DynamicSupervisor.init(strategy: :one_for_one)
  end
end

200 użytkowników = 200 procesów. Crash sesji użytkownika #47 (bo wysłał emoji w polu uwagi) nie wpływa na pozostałe 199 sesji. Supervisor restartuje sesję #47. Użytkownik widzi "spróbuj ponownie". Koniec.

Circuit breaker na poziomie procesu

Supervisor ma wbudowany mechanizm circuit breakera: jeśli proces crashuje zbyt często, supervisor przestaje go restartować. Domyślnie: 3 crashe w 5 sekund = supervisor sam się wyłącza (i jego supervisor decyduje, co dalej).

# Jeśli API kuriera crashuje 5 razy w 30 sekund
# - supervisor przestaje restartować i eskaluje
opts = [
  strategy: :one_for_one,
  max_restarts: 5,
  max_seconds: 30
]

To chroni przed "restart stormem" - sytuacją, w której błędny proces jest restartowany w nieskończoność i zjada zasoby. System sam rozpoznaje, że restart nie pomaga, i eskaluje problem.

Realne scenariusze w systemach biznesowych

Scenariusz 1: API kuriera nie odpowiada

System ERP wysyła etykietę do InPost API. API nie odpowiada (timeout 5 sekund). Crash.

W tradycyjnym systemie: Wyjątek leci w górę. Jeśli nie jest złapany - cały request pada. Użytkownik widzi błąd 500. Jeśli jest złapany - programista musi obsłużyć 15 scenariuszy: timeout, connection refused, 503, 429, invalid JSON, certificate expired...

W Elixirze:

defmodule MojSystem.Workers.WyslijDoKuriera do
  use Oban.Worker,
    queue: :kurier,
    max_attempts: 10

  def perform(%{args: %{"zamowienie_id" => id}}) do
    zamowienie = Zamowienia.get!(id)
    InPostAPI.create_shipment(zamowienie)  # Może crashnąć - i OK!
  end
end

Worker crashuje na timeout? Oban łapie crash, loguje, ponawia za 16 sekund. Crashuje znowu? Ponawia za 81 sekund. I tak dalej. Programista nie pisze ani jednej linijki error handlingu. Nie pisze try-catch. Nie obsługuje 15 scenariuszy. Pozwala procesowi umrzeć i ufać, że supervisor (w tym przypadku Oban) go naprawi.

A użytkownik? Użytkownik widzi status zamówienia: "Etykieta w trakcie generowania. Przewidywany czas: do 15 minut." Nie widzi "Błąd 500. Skontaktuj się z administratorem."

Scenariusz 2: Jeden użytkownik psuje dane

Użytkownik wkleja 50 MB tekstu w pole "opis produktu". Proces obsługujący tego użytkownika zjada pamięć. Crash (out of memory na poziomie procesu BEAM, nie na poziomie OS).

W tradycyjnym systemie: Jeden wątek zjada pamięć → garbage collector włącza tryb agresywny → cały system zwalnia (stop-the-world GC) → albo OOM Killer zabija cały proces serwera.

W BEAM: Proces tego jednego użytkownika zjada za dużo pamięci. BEAM ubija ten jeden proces. Pamięć jest zwolniona natychmiast (GC per-process). Supervisor restartuje proces z czystą pamięcią. Użytkownik dostaje komunikat o błędzie. 199 pozostałych użytkowników nie zauważa absolutnie nic - ich garbage collector nie był nawet wywoływany.

Scenariusz 3: Baza danych restart

PostgreSQL restartuje się (aktualizacja, crash, failover na replikę). Wszystkie połączenia z bazą są zerwane.

W tradycyjnym systemie: Connection pool traci połączenia. Każdy request dostaje "connection refused". Serwer zwraca 500. Trzeba ręcznie zrestartować aplikację, żeby connection pool odbudował połączenia. Albo (lepiej) aplikacja ma retry logic na connection poolu - ale to dodatkowy kod do napisania i utrzymania.

W BEAM:

Connection pool (Ecto) - supervisor z :one_for_one
├── Connection 1  ← crash (PostgreSQL restart)
├── Connection 2  ← crash
├── Connection 3  ← crash
└── Connection 4  ← crash

Supervisor restartuje każde połączenie.
Nowe połączenia łączą się z bazą (która już wstała).
Czas niedostępności: sekundy.
Interwencja ręczna: zero.

Ecto (ORM Elixira) connection pool jest zbudowany na supervisorach. Każde połączenie to osobny proces. Gdy PostgreSQL restartuje się, połączenia umierają i są restartowane automatycznie. Programista nie pisze ani jednej linijki kodu obsługi tego scenariusza. To jest wbudowane w architekturę.

Scenariusz 4: Wyciek pamięci w jednym module

Moduł generowania raportów PDF ma wyciek pamięci. Przy każdym raporcie gubi 50 KB. Po 1000 raportów - 50 MB. W tradycyjnym systemie proces rośnie, aż padnie.

W BEAM: Możesz skonfigurować supervisor, żeby restartował proces generatora co N raportów (albo co N minut). Restart procesu zwalnia całą pamięć tego procesu. Wyciek jest "naprawiany" automatycznie, zanim stanie się problemem.

# GenServer, który restartuje się co 1000 zadań
def handle_info(:check_health, state) do
  if state.reports_generated > 1000 do
    # Celowy crash - supervisor nas zrestartuje z czystą pamięcią
    {:stop, :scheduled_restart, state}
  else
    Process.send_after(self(), :check_health, :timer.minutes(5))
    {:noreply, state}
  end
end

To nie jest hack. To jest zamierzone użycie architektury BEAM. Procesy są tanie w tworzeniu. Restart jest szybki. Wyciek pamięci w jednym procesie jest izolowany i naprawiany automatycznie.

Scenariusz 5: Kaskada awarii (której nie ma)

W mikroserwisach: Serwis A wywołuje Serwis B, który wywołuje Serwis C. C pada. B czeka na C i sam pada (timeout). A czeka na B i sam pada. Kaskada. Cały system leży, bo jeden serwis na końcu łańcucha padł. Potrzebujesz circuit breakera (Hystrix), retry policy (Polly), service mesh (Istio). Tygodnie konfiguracji.

W BEAM: procesy komunikują się przez wiadomości asynchronicznie. Proces A wysyła wiadomość do B i nie czeka. Jeśli B padnie, A dostaje notyfikację (monitor/link) i podejmuje decyzję: ponów, zignoruj, użyj wartości domyślnej. Kaskada jest architektonicznie niemożliwa, bo procesy nie blokują się nawzajem.

# Proces A nie czeka na odpowiedź - działa dalej
Task.Supervisor.async_nolink(MojSystem.TaskSupervisor, fn ->
  ExternalAPI.call(data)  # Może crashnąć - i OK
end)

# Albo z timeoutem - odpowiedź albo wartość domyślna
case Task.yield(task, 5_000) || Task.shutdown(task) do
  {:ok, result} -> result
  nil -> {:error, :timeout}  # Nie crash - kontrolowana degradacja
end

Defensive programming vs let it crash

Defensive programming (tradycyjne podejście)

# Python - defensive programming
def process_order(order_data):
    try:
        if order_data is None:
            logger.error("order_data is None")
            return {"error": "invalid input"}

        if "items" not in order_data:
            logger.error("missing items")
            return {"error": "missing items"}

        if not isinstance(order_data["items"], list):
            logger.error("items is not a list")
            return {"error": "invalid items"}

        if len(order_data["items"]) == 0:
            logger.error("empty items")
            return {"error": "no items"}

        total = 0
        for item in order_data["items"]:
            try:
                price = float(item.get("price", 0))
                qty = int(item.get("quantity", 0))
                if price < 0:
                    logger.error(f"negative price: {price}")
                    return {"error": "invalid price"}
                if qty < 0:
                    logger.error(f"negative quantity: {qty}")
                    return {"error": "invalid quantity"}
                total += price * qty
            except (ValueError, TypeError) as e:
                logger.error(f"calculation error: {e}")
                return {"error": "calculation failed"}

        try:
            db.save_order(order_data, total)
        except DatabaseError as e:
            logger.error(f"database error: {e}")
            return {"error": "could not save"}

        try:
            send_confirmation_email(order_data)
        except EmailError as e:
            logger.warning(f"email failed: {e}")
            # Nie zwracamy błędu - zamówienie jest zapisane

        return {"ok": True, "total": total}

    except Exception as e:
        logger.error(f"unexpected error: {e}")
        return {"error": "internal error"}

40 linii error handlingu. 10 linii logiki biznesowej. I nadal może paść na czymś nieprzewidzianym (stąd except Exception na końcu, które łapie wszystko i nic nie mówi).

Let it crash (Elixir)

def process_order(order_data) do
  total =
    order_data.items
    |> Enum.map(& &1.price * &1.quantity)
    |> Enum.sum()

  {:ok, order} = Repo.insert(%Order{items: order_data.items, total: total})

  WyslijPotwierdzenie.new(%{order_id: order.id})
  |> Oban.insert()

  {:ok, total}
end

10 linii. Zero try-catch. Zero error handlingu.

Co się stanie, jeśli order_data jest niepoprawne? Crash. Supervisor restartuje. Log z dokładnym stack trace mówi co konkretnie było nie tak: "KeyError: key :items not found in %{}" - jasne, czytelne, naprawialne.

Co się stanie, jeśli baza nie zapisze? Crash. Supervisor restartuje. Oban ponowi.

Co się stanie, jeśli coś nieprzewidzianego? Crash. Supervisor restartuje. Log powie co.

A użytkownik? Użytkownik widzi "Spróbuj ponownie" i próbuje ponownie. Jego proces jest świeży, czysty, gotowy do pracy.

Walidacja danych wejściowych oczywiście istnieje w Elixirze - to robią Changesets w Ecto, zanim dane trafią do logiki. Ale walidacja to nie error handling. Walidacja mówi użytkownikowi "popraw dane". Error handling mówi programiście "coś poszło nie tak w kodzie". W Elixirze te dwie rzeczy nie są mieszane.

Co to oznacza dla Twojej firmy

Mniej nocnych telefonów

System, który naprawia się sam, nie budzi Tomka o 3:00. Crash procesu → restart → log. Rano Tomek czyta logi, naprawia przyczynę, wdraża poprawkę. Spokojnie, bez presji, bez zmęczenia.

Mniej kodu do utrzymania

40% mniej kodu (brak defensywnego error handlingu) = 40% mniej kodu do czytania, testowania, utrzymywania. Nowy programista rozumie moduł szybciej, bo widzi logikę biznesową, nie gąszcz try-catchów.

Izolacja awarii = izolacja wpływu na biznes

Crash jednego zamówienia nie blokuje 199 innych. Crash generatora PDF nie blokuje wysyłki emaili. Crash integracji z InPost nie blokuje generowania faktur. Każdy problem jest izolowany do swojego procesu i swojego supervisora.

Degradacja zamiast katastrofy

API kuriera nie działa? System dalej przyjmuje zamówienia, generuje faktury, aktualizuje stany magazynowe. Etykiety kurierskie czekają w kolejce Oban i zostaną wygenerowane, gdy API wróci. Firma dalej pracuje - z jednym obniżonym priorytetem, nie z pełnym przestojem.

To jest zasadnicza różnica między "system padł" a "jedna funkcja tymczasowo niedostępna". W tradycyjnej architekturze często jedno oznacza drugie. W BEAM - nigdy.

Prostsze debugowanie

Crash w BEAM to nie tajemnica do rozwiązania. Log zawiera:

  • Dokładny moduł i linię kodu
  • Pełny stack trace
  • Stan procesu w momencie crashu
  • Wiadomość, która spowodowała crash
[error] GenServer #PID<0.1234.0> terminating
** (KeyError) key :items not found in: %{user: "jan@firma.pl"}
    (moj_system) lib/moj_system/orders.ex:15: MojSystem.Orders.process/1
    (moj_system) lib/moj_system_web/live/order_live.ex:42: ...
Last message: {:process_order, %{user: "jan@firma.pl"}}
State: %{user_id: 789, step: :checkout}

Tomek czyta ten log i wie: użytkownik jan@firma.pl próbował złożyć zamówienie bez items w danych. Linia 15 w orders.ex. Naprawa: dodaj walidację w formularzu. 5 minut.

Podsumowanie

AspektTradycyjny systemBEAM / Elixir
Crash jednego żądaniaMoże zabić cały serwerZabija jeden proces (~2 KB RAM)
Restart po crashuRęczny (Tomek, SSH, 3:00)Automatyczny (supervisor, 2ms)
Izolacja pamięciBrak (shared state)Pełna (per-process heap)
Error handling40% kodu to try-catchLet it crash + supervisor
GC pauseStop-the-world (cały system)Per-process (niewidoczna)
Kaskada awariiCzęsta (microservices)Architektonicznie niemożliwa
Nocne telefonyRegularneRzadkie do zerowych
Degradacja usługSystem działa albo niePoszczególne funkcje degradują niezależnie
Debugowanie"Coś padło, nie wiem co"Dokładny log: moduł, linia, stan, wiadomość

Chcesz system, który naprawia się sam i nie budzi Twojego IT o 3:00? Porozmawiajmy - pokażemy, jak architektura BEAM eliminuje kategorię problemów, z którymi inne technologie walczą latami.