Soft reservations z TTL w systemie B2B: jak zapobiec inventory blocking bez frustrowania użytkowników

Pierwszy raz złapałem ten problem na stagingu, gdy dwa równoległe testy automatyczne próbowały zamówić ten sam produkt. Oba dostały status 200, oba potwierdziły rezerwację, a w magazynie została ujemna ilość. Na stagingu to zabawny edge case. W produkcji, gdy restauracja planuje menu na weekend i dostaje potwierdzenie zamówienia 100 kg mięsa, które już jest zarezerwowane gdzie indziej, to problem biznesowy.

W systemach B2B dla gastronomii ten problem jest trudniejszy niż w typowym e-commerce. Konsument kupuje jedną sztukę, biznes zamawia 50 kilogramów. Konsument porzuca koszyk, tracisz potencjalną sprzedaż. Biznes porzuca koszyk z 20 pozycjami, blokuljesz połowę magazynu dla innych klientów. Przez godzinę. Albo dłużej, bo nikt nie pomyślał o timeout”cie.

Dlaczego "dodaj do koszyka" w B2B nie działa jak w sklepie internetowym

Klasyczny e-commerce ma prostszy problem. Amazon nie rezerwuje produktu gdy go dodajesz do koszyka. Pokazuje dostępność w momencie dodawania, ale faktyczna rezerwacja następuje dopiero przy checkout. Działa to, bo prawdopodobieństwo że akurat ten konkretny model butów się wyprzeda między dodaniem do koszyka a płatnością jest niskie. A jak się wyprzeda, dostajesz komunikat „niestety produkt niedostępny” i idziesz szukać dalej.

W B2B dla gastronomii ta logika się rozpada. Restauracja planuje menu na tydzień do przodu. Szef kuchni spędza pół godziny budując zamówienie – przegląda produkty, sprawdza dostępność, kalkuluje ilości pod przewidywaną liczbę gości. Dodaje 30 pozycji do koszyka. Idzie skonsultować się z menedżerem. Wraca po 15 minutach, klika „zamów”, i dostaje informację że 10 pozycji już nie ma, bo inna restauracja właśnie je zamówiła.

To nie jest „przykro nam, poszukaj czegoś innego”. To sabotaż planowania. Restauracja albo zmienia całe menu, albo zamawia u konkurencji. Obie opcje to utracone przychody.

Więc pojawiła się idea: rezerwujmy produkt już w momencie dodania do koszyka. Soft reservation – towar fizycznie nadal jest w magazynie, ale logicznie jest przypisany do konkretnego zamówienia. Brzmi sensownie, dopóki nie wdrożysz tego do produkcji.

Pierwszy tydzień po wdrożeniu: inventory blocking

Zaczęło się niewinnie. Menedżer magazynu napisał że „system pokazuje dziwne stany”. Mają fizycznie 200 kg kurczaka, system pokazuje 50 kg dostępnych. Gdzie reszta?

Okazało się że jest zarezerwowana. Przez 8 koszyków, które użytkownicy zaczęli wypełniać i porzucili. Jeden user dodał 30 kg do koszyka w piątek wieczorem i zamknął zakładkę. Koszyk nadal tam był w poniedziałek rano. Produkt zablokowany przez cały weekend.

Nie przewidzieliśmy tego w testach, bo testy działały w izolacji. Jeden użytkownik, jedna sesja, zakończona poprawnie. Nikt nie testował scenariusza „user dostaje telefon w trakcie zamawiania i nie wraca do systemu przez 3 dni”. W produkcji takie rzeczy zdarzają się codziennie.

Druga restauracja próbowała zamówić ten sam kurczak. Widziała że jest tylko 50 kg dostępnych, więc zamówiła 50 kg. Mogła zamówić więcej – te 150 kg w porzuconych koszykach nigdy nie zostanie zamówione, ale system tego nie wie.

Problem nie był w implementacji rezerwacji. Problem był w założeniu że użytkownik zawsze finalizuje albo świadomie anuluje koszyk. W rzeczywistości większość koszyków po prostu „umiera” – zakładka zamknięta, przeglądarka crashnęła, user dostał pilniejszą sprawę i zapomniał.

TTL: proste rozwiązanie z piekielnie trudnymi szczegółami

Time To Live dla rezerwacji wydawało się oczywiste. Rezerwacja dostaje timestamp expire, po upływie którego automatycznie się zwalnia. Redis ma wbudowaną komendę „EXPIRE”, PostgreSQL ma „expires_at” z background jobem który czyści przeterminowane rekordy. Technicznie trywialne.

Problem w tym, że musiałem ustalić konkretną liczbę minut. I każda wartość była zła z innego powodu.

5 minut: za krótko. Użytkownik buduje duże zamówienie, sprawdza dostępność w kilku kategoriach, dodaje produkty stopniowo. Po 5 minutach pierwsze pozycje już wygasają. User kończy koszyk, klika „zamów”, dostaje błąd że połowa produktów już nie jest zarezerwowana. Zaczyna od nowa, znowu timeout. Frustracja.

60 minut: za długo. User dodaje produkt, przegląda dalej, decyduje że jednak nie, zamyka zakładkę. Produkt zablokowany na godzinę. Przemnóż to przez 10 restauracji i 20 produktów dziennie – masz spory procent magazynu niepotrzebnie zamrożony.

Zrobiłem analizę logów. Mediana czasu od pierwszego dodania do koszyka do finalizacji zamówienia: 8 minut. 95 percentyl: 22 minuty. Ale 5% użytkowników potrzebowało więcej niż pół godziny. Często to byli ci najważniejsi klienci – duże restauracje z złożonymi zamówieniami.

Wybraliśmy 15 minut jako kompromis. Nie idealny, ale akceptowalny. Z jednym kluczowym dodatkiem: automatyczne przedłużanie dla aktywnych użytkowników.

Heartbeat pattern: przedłużanie rezerwacji dla aktywnych sesji

Użytkownik który faktycznie pracuje nad zamówieniem nie powinien tracić rezerwacji. Problem w tym, że backend nie wie czy user jest aktywny. Zakładka może być otwarta, ale user poszedł na przerwę. Albo system czeka na jego input, ale on rozwiązuje inny problem.

Zaimplementowaliśmy prosty heartbeat. Frontend wysyła ping do backend”u co 60 sekund, jeśli zakładka jest aktywna i widoczna (Page Visibility API). Ping zawiera „cart_id”. Backend sprawdza wszystkie rezerwacje dla tego koszyka i przedłuża „expires_at” o kolejne 15 minut.

typescript

// Frontend - prosty heartbeat

setInterval(() => {

if (document.visibilityState === "visible" && cartId) {

fetch("/api/cart/${cartId}/keepalive", { method: "POST" });

}

}, 60000);

"

Backend przy tym endpoincie:

"sql

UPDATE reservations

SET expires_at = NOW() + INTERVAL "15 minutes"

WHERE cart_id = $1

AND expires_at > NOW()

AND status = "reserved";

Wydaje się prosto, ale są niuanse. Co jeśli user ma otwarte dwie zakładki z tym samym koszykiem? Oba wysyłają heartbeat, oba przedłużają rezerwację. To akurat OK – chcemy przedłużać dopóki jakakolwiek zakładka jest aktywna.

Co jeśli network ma problem i heartbeat się nie udaje? Założenie było takie: jeśli user nie może wysłać heartbeat, prawdopodobnie nie może też sfinalizować zamówienia. Lepiej zwolnić rezerwację. User dostanie notification że jego koszyk wygasł, będzie musiał odświeżyć i spróbować ponownie. Frustrujące, ale rzadkie (network timeout), i lepsze niż alternatywa – trzymanie rezerwacji w nieskończoność przy martwej sesji.

Dodaliśmy też hard limit: maksymalny całkowity czas rezerwacji to 2 godziny, niezależnie od heartbeat. Po 2 godzinach rezerwacja się zwalnia, koniec. Założenie: jeśli ktoś potrzebuje 2 godzin na złożenie zamówienia, albo coś jest bardzo nie tak z UX, albo user nie jest faktycznie zainteresowany. W praktyce nigdy nie widziałem przypadku gdzie ktoś legitnie potrzebowałby więcej.

"Przez chwilę wydawało się, że największym problemem będzie race condition przy rezerwacji. Potem pojawili się użytkownicy, którzy potrafili zablokować pół magazynu po prostu zamykając kartę przeglądarki w niewłaściwym momencie."

Race condition przy wygasaniu: problem z checkout"em

Najbardziej wkurzający bug który wykryliśmy dopiero w produkcji: user aktywnie składa zamówienie, koszyk jest wypełniony, klika „zamów i zapłać”, system przechodzi przez walidację, i dokładnie w tym momencie wygasa TTL jednej z rezerwacji.

Sekwencja zdarzeń:

User widzi: „klikam zamów, dostaję błąd”. Dla niego to wygląda jak bug w systemie. Technicznie wszystko działało poprawnie – rezerwacja faktycznie wygasła. Ale UX jest katastrofalny.

Rozwiązanie wymagało zmiany flow. Checkout nie może polegać na istnieniu rezerwacji. Rezerwacja to soft lock – pomaga unikać konfliktów, ale nie gwarantuje alokacji.

Zmieniłem checkout na dwuetapowy:

Etap 1: Revalidation

– Sprawdź czy produkty są faktycznie dostępne (ignore rezerwacje)

– Jeśli tak, stwórz nowe krótkotrwałe hard locks (5 minut, nie przedłużane)

– Usuń stare soft reservations

Etap 2: Finalizacja

– Przetwórz płatność (jeśli wymagana)

– Stwórz zamówienie

– Zmień hard locks na confirmed allocation

– Wyślij potwierdzenie

Hard lock różni się od soft reservation tym, że nie może być automatycznie usunięty przez background job. Tylko explicit completion albo manual intervention. To znaczy, że jak system pada w trakcie checkout, hard lock zostaje i wymaga manualnego cleanup, ale to akceptowalne – rzadkie, i wolę wyciek kilku locks niż unpredictable failures przy checkout.

sql

-- Migracja z soft reservation do hard lock przy checkout

BEGIN;

-- Sprawdź dostępność (faktyczny stan, nie rezerwacje)

SELECT sku, SUM(quantity_physical - quantity_allocated) as available

FROM inventory

WHERE sku = ANY($1)

GROUP BY sku

FOR UPDATE; -- Pesymistyczny lock na czas transakcji

-- Jeśli available >= requested dla wszystkich SKU:

UPDATE inventory

SET quantity_allocated = quantity_allocated + $requested

WHERE sku = $sku;

INSERT INTO order_locks (order_id, sku, quantity, type, expires_at)

VALUES ($order_id, $sku, $quantity, "hard", NOW() + INTERVAL "5 minutes");

DELETE FROM reservations WHERE cart_id = $cart_id;

COMMIT;

To nie rozwiązuje race condition całkowicie – nadal możliwe że dwa checkout”y próbują alokować ten sam ostatni item – ale przenosi problem do miejsca gdzie można go obsłużyć deterministycznie: pesymistyczny lock na poziomie bazy danych w ramach transakcji.

Partial availability: 3 restauracje, 50 kg produktu

Edge case który pojawił się wcześniej niż się spodziewałem: 3 restauracje jednocześnie budują koszyki z tym samym produktem. Dostępne jest 50 kg. Każda dodaje 30 kg do koszyka.

Pierwsza dostaje rezerwację 30 kg. Zostaje 20 kg.

Druga próbuje zarezerwować 30 kg, dostępne tylko 20 kg – co system ma zrobić?

Opcja A: Odmów rezerwacji całkowicie. Komunikat: „Dostępne tylko 20 kg, usuń produkt albo zmień ilość”.

Opcja B: Zarezerwuj ile się da (20 kg), komunikuj że reszta (10 kg) będzie backorder.

Opcja C: Zarezerwuj 20 kg, pozwól user”owi zdecydować czy to wystarczy.

Zaczęliśmy od opcji A, bo była najprostsza technicznie. W praktyce użytkownicy byli sfrustrowani. Restauracja chce zamówić 30 kg, widzi że jest 50 kg dostępne (w momencie przeglądania), dodaje do koszyka, dostaje błąd że jest tylko 20 kg. „Ale przed chwilą było 50!”. System nie komunikuje że pozostałe 30 kg właśnie ktoś inny zarezerwował.

Przeszliśmy na opcję C z lepszą komunikacją:

Produkt: Filet z kurczaka

Zamawiana ilość: 30 kg

Dostępne teraz: 20 kg (pozostałe 30 kg zarezerwowane przez innych użytkowników)

Zarezerwowano dla Ciebie: 20 kg

Opcje:

[ ] Zarezerwuj 20 kg, reszta jako backorder

[ ] Zarezerwuj tylko tyle ile dostępne (20 kg), usuń resztę

[ ] Usuń produkt z koszyka i zamów gdzie indziej

To wymaga więcej kliknięć, ale daje user”owi kontrolę. I co ważniejsze – komunikuje co się faktycznie dzieje. Użytkownik nie czuje że system jest buggy, rozumie że to conflict z innym zamówieniem.

Implementacja partial reservation była ciekawa:

sql

-- Funkcja która próbuje zarezerwować tyle ile się da

CREATE FUNCTION reserve_partial(

p_cart_id UUID,

p_sku TEXT,

p_requested INT

) RETURNS TABLE(reserved INT, available INT) AS $$

DECLARE

v_available INT;

v_to_reserve INT;

BEGIN

-- Sprawdź ile faktycznie dostępne (pod lockiem)

SELECT inventory.quantity - COALESCE(SUM(reservations.quantity), 0)

INTO v_available

FROM inventory

LEFT JOIN reservations ON reservations.sku = inventory.sku

AND reservations.expires_at > NOW()

AND reservations.status = "reserved"

WHERE inventory.sku = p_sku

FOR UPDATE OF inventory;

-- Rezerwuj mniejszą z dwóch wartości

v_to_reserve := LEAST(p_requested, v_available);

IF v_to_reserve > 0 THEN

INSERT INTO reservations (cart_id, sku, quantity, expires_at)

VALUES (p_cart_id, p_sku, v_to_reserve, NOW() + INTERVAL "15 minutes");

END IF;

RETURN QUERY SELECT v_to_reserve, v_available;

END;

$$ LANGUAGE plpgsql;

Kluczowe jest „FOR UPDATE OF inventory” – to pesymistyczny lock który zapobiega race condition między sprawdzeniem dostępności a zapisaniem rezerwacji.

WebSocket i real-time updates: kiedy soft reservation się rozpada

Miałem plan żeby używać WebSocket do pushowania real-time updates dostępności. User ma otwarty koszyk, ktoś inny rezerwuje ten sam produkt, dostępność spada, user widzi update natychmiast. Ładnie wygląda w demo.

W praktyce to wprowadza więcej problemów niż rozwiązuje.

Problem 1: Jak często wysyłać update? Przy każdej zmianie rezerwacji? To oznacza że dodanie produktu do koszyka przez jednego user”a triggeruje broadcast do wszystkich innych użytkowników którzy przeglądają ten produkt. Przy 10 restauracjach i 500 produktach, to może być setki update”ów na minutę. WebSocket connection nie jest darmowa – każda wymaga utrzymywania state na serwerze.

Problem 2: Co user ma zrobić z tą informacją? Widzi że dostępność spadła z 50 kg do 20 kg. OK, i co teraz? Jeśli ma 30 kg w koszyku, to ma zmienić na 20 kg prewencyjnie? A może nic nie robić, bo jego rezerwacja jest nadal ważna? Real-time update bez jasnego action to tylko noise.

Problem 3: Race condition między update”em a akcją user”a. User widzi 50 kg dostępne, dodaje 30 kg do koszyka, w tym samym momencie przychodzi WebSocket update że jest tylko 20 kg. Frontend przetwarza update i zmienia wyświetlaną dostępność, ale request o rezerwację 30 kg jest już w locie. Co się stanie? Backend zarezerwuje 20 kg (partial), ale user myśli że zarezerwował 30 kg (bo tyle było w momencie klikania).

Ostatecznie zrezygnowaliśmy z real-time updates dostępności. Zamiast tego:

– Dostępność jest sprawdzana przy dodawaniu do koszyka (ten request i tak musi iść do backend”u)

– Jeśli partial reservation, user dostaje jasny komunikat z opcjami

– Countdown timer w UI pokazuje kiedy rezerwacja wygasa (ale to local timer, nie wymaga WebSocket)

Real-time rzeczywiście przydało się w jednym miejscu: powiadomienie gdy rezerwacja zaraz wygaśnie. 2 minuty przed expiration, backend wysyła notification (WebSocket lub poll) że „Twoja rezerwacja wygasa za 2 minuty”. User może kliknąć „przedłuż” (co wysyła heartbeat) albo zignorować. To jest actionable info, więc ma sens.

"Przez moment wyglądało to jak klasyczny race condition. Potem okazało się, że największym problemem nie były równoległe requesty, tylko fakt, że background job i checkout miały zupełnie inne zdanie na temat tego, czy ta rezerwacja jeszcze istnieje."

Background job dla cleanup: jak nie zabrać za dużo

Prosty cleanup job wygląda tak:

sql

DELETE FROM reservations

WHERE expires_at < NOW()

AND status = "reserved";

Uruchamiasz co minutę, usuwa przeterminowane rezerwacje, działa.

Dopóki nie uruchomisz tego w produkcji z tysiącami rezerwacji. Ten DELETE może być wolny – wymaga full table scan jeśli nie masz indeksu na „expires_at” i „status”, albo lock”uje dużą część tabeli jeśli używasz row-level locking.

Jeszcze gorsze: ten DELETE może usunąć rezerwację która jest właśnie w trakcie checkpoint. User klika „zamów”, backend zaczyna przetwarzać, cleanup job usuwa rezerwację, backend dostaje inconsistent state.

Pierwsza optymalizacja: index.

sql

CREATE INDEX idx_reservations_expiry

ON reservations(expires_at)

WHERE status = "reserved";

Partial index – tylko na wierszach gdzie „status = „reserved””, bo to jedyne które nas interesują przy cleanup. Inne statusy („completed”, „cancelled”) ignorujemy.

Druga optymalizacja: batch deletion z limitem.

sql

DELETE FROM reservations

WHERE reservation_id IN (

SELECT reservation_id

FROM reservations

WHERE expires_at < NOW() - INTERVAL "1 minute" -- Grace period

AND status = "reserved"

LIMIT 1000

);

Grace period (1 minuta) oznacza że usuwamy tylko rezerwacje które wygasły co najmniej minutę temu. Daje to buffer na checkout operations. Jeśli ktoś zaczął checkout w momencie expiration, ma minutę zanim rezerwacja zostanie usunięta. W tym czasie powinien zdążyć stworzyć hard lock (co zmienia status).

Limit 1000 zapobiega single query usuwającemu 100k wierszy i blokującemu bazę. Background job działa co minutę, więc może przetwarzać w batch”ach.

Trzecia optymalizacja: soft delete zamiast hard delete.

sql

UPDATE reservations

SET status = "expired"

WHERE reservation_id IN (

SELECT reservation_id

FROM reservations

WHERE expires_at < NOW() - INTERVAL "1 minute"

AND status = "reserved"

LIMIT 1000

);

Faktyczny DELETE następuje później, w osobnym job”ie który czyści expired records starsze niż X dni. Zalety: szybsze (UPDATE vs DELETE), możliwość audytu (widzimy co wygasło kiedy), możliwość odtworzenia rezerwacji jeśli user szybko wraca.

VIP users i priority allocation

Ten problem pojawił się rok po wdrożeniu. Duża restauracja (40% revenue) skarżyła się że często nie może zamówić popularnych produktów – są zarezerwowane przez mniejsze restauracje które składają zamówienia wcześniej.

Business chciał priorytetyzacji: VIP users powinni mieć gwarantowaną dostępność przynajmniej minimalnej ilości, nawet jeśli inni zarezerwowali wcześniej.

Technicznie skomplikowane. Nie możesz po prostu odebrać rezerwacji mniejszej restauracji – ta już planuje menu oparte na tej rezerwacji. Ale możesz wprowadzić reserved pools.

Koncept: każdy produkt ma:

– „quantity_physical” – ile faktycznie jest

– „quantity_vip_reserved” – ile zarezerwowane przez VIP

– „quantity_regular_reserved” – ile zarezerwowane przez zwykłych users

Dodatkowo, definicja minimum dla VIP: „vip_minimum_available”. Dopóki faktyczna dostępność („quantity_physical – quantity_vip_reserved”) jest >= „vip_minimum_available”, regular users mogą rezerwować normalnie. Gdy spadnie poniżej, tylko VIP mogą rezerwować.

Przykład:

– Produkt X: 100 kg fizycznie, „vip_minimum_available = 30”

– Regular user rezerwuje 50 kg -> OK, zostaje 50 kg

– Inny regular user próbuje 30 kg -> Denied, zostałoby 20 kg, poniżej VIP minimum

– VIP user rezerwuje 40 kg -> OK, ma priorytet

Implementacja wymaga zmiany logiki rezerwacji:

sql

CREATE FUNCTION reserve_with_priority(

p_cart_id UUID,

p_sku TEXT,

p_quantity INT,

p_is_vip BOOLEAN

) RETURNS BOOLEAN AS $$

DECLARE

v_available INT;

v_vip_minimum INT;

BEGIN

SELECT

quantity_physical - quantity_vip_reserved - quantity_regular_reserved,

vip_minimum_available

INTO v_available, v_vip_minimum

FROM inventory

WHERE sku = p_sku

FOR UPDATE;

-- VIP może zawsze rezerwować jeśli coś jest dostępne

IF p_is_vip AND v_available >= p_quantity THEN

UPDATE inventory SET quantity_vip_reserved = quantity_vip_reserved + p_quantity

WHERE sku = p_sku;

INSERT INTO reservations (...) VALUES (...);

RETURN TRUE;

END IF;

-- Regular user może rezerwować tylko jeśli nie naruszy VIP minimum

IF NOT p_is_vip AND (v_available - p_quantity) >= v_vip_minimum THEN

UPDATE inventory SET quantity_regular_reserved = quantity_regular_reserved + p_quantity

WHERE sku = p_sku;

INSERT INTO reservations (...) VALUES (...);

RETURN TRUE;

END IF;

RETURN FALSE;

END;

$$ LANGUAGE plpgsql;

To działa, ale wprowadza nową klasę problemów: co jeśli VIP minimum jest ustawione za wysoko i regular users w ogóle nie mogą niczego zamówić? Albo za nisko i VIP nie ma faktycznej korzyści?

Musieliśmy dodać monitoring i alerty:

– Ile razy regular user dostał reject przez VIP minimum (jeśli często, minimum jest za wysokie)

– Ile razy VIP user nie mógł zamówić bo wszystko było zarezerwowane przez innych VIP (minimum za niskie)

– Średnia dostępność po wykluczeniu VIP minimum (pokazuje ile faktycznie zostaje dla regular users)

I mechanizm dynamic adjustment: jeśli przez ostatnie 7 dni VIP users nie wykorzystali swojego minimum (ich rezerwacje były znacznie poniżej minimum), system automatycznie obniża „vip_minimum_available” o 10%. Odwrotnie, jeśli VIP users często nie mogli zamówić, podnosi minimum.

W praktyce to było więcej polityki niż technologii. Określenie kto jest VIP, jak bardzo priorytetyzować, co zrobić gdy VIP minimum koliduje z faktyczną dostępnością – to wszystko business decisions które wymagały iteracji.

Różne TTL dla różnych produktów

Jeden TTL (15 minut) dla wszystkich produktów to kolejne assumption które nie przetrwało kontaktu z rzeczywistością.

Produkty popularne (kurczak, wołowina) – users dodają do koszyka, decydują się szybko, konwersja wysoka. 15 minut to OK, może nawet za dużo.

Produkty niszowe (egzotyczne przyprawy, specjalne sosy) – users dodają „na zapas”, eksperymentują z menu, decyzja trwa długo. Konwersja niska, ale marża wysoka. 15 minut to za mało – user nie zdąży zdecydować, rezerwacja wygasa, frustration.

Zaimplementowaliśmy różne TTL per kategoria produktu:

– Świeże mięso i ryby: 10 minut (szybkie rotacje, krytyczna dostępność)

– Suche produkty i przyprawy: 30 minut (wolniejsza rotacja, mniejszy problem z blokowaniem)

– Wysokomarżowe/niszowe: 60 minut (dajemy więcej czasu na decyzję)

Plus dynamiczne adjustowanie based on metrics:

sql

-- Weekly job który analizuje conversion rate i adjustuje TTL

WITH conversion_stats AS (

SELECT

sku,

COUNT(*) FILTER (WHERE status = "completed") as completed,

COUNT(*) FILTER (WHERE status = "expired") as expired,

AVG(EXTRACT(EPOCH FROM (completed_at - created_at))/60) as avg_decision_time_minutes

FROM reservations

WHERE created_at > NOW() - INTERVAL "7 days"

GROUP BY sku

)

UPDATE products

SET reservation_ttl_minutes = CASE

WHEN cs.expired::float / NULLIF(cs.completed + cs.expired, 0) > 0.3 -- >30% expiration rate

THEN LEAST(reservation_ttl_minutes * 1.2, 60) -- Zwiększ TTL o 20%, max 60 min

WHEN cs.expired::float / NULLIF(cs.completed + cs.expired, 0) < 0.1 -- <10% expiration rate

THEN GREATEST(reservation_ttl_minutes * 0.8, 5) -- Zmniejsz TTL o 20%, min 5 min

ELSE reservation_ttl_minutes -- Bez zmian

END

FROM conversion_stats cs

WHERE products.sku = cs.sku;

Heurystyka prosta: jeśli dużo rezerwacji wygasa (>30%), TTL prawdopodobnie za krótki, wydłuż. Jeśli mało (<10%), można skrócić i zwolnić inventory szybciej.

To nie jest idealne – ignoruje sezonowość, eventy, promocje – ale działa jako baseline. Przy promocji produktu można manually override TTL dla tego SKU.

Monitoring: co faktycznie obserwować

Pierwsza wersja monitoringu była naiwna. Śledziłem:

– Liczba aktywnych rezerwacji

– Liczba expired rezerwacji

– Średni czas życia rezerwacji

To są metryki, ale nie pokazują problemów dopóki nie jest za późno.

Co faktycznie się przydało:

Inventory blocking rate: jaki procent fizycznego inventory jest zablokowany przez rezerwacje w danym momencie. Jeśli regularnie przekracza 70%, oznacza że albo TTL za długi, albo popyt przekracza supply i trzeba zwiększyć stany.

sql

SELECT

(SUM(quantity_vip_reserved + quantity_regular_reserved)::float /

SUM(quantity_physical)) * 100 as blocking_rate_percent

FROM inventory

WHERE quantity_physical > 0;

Alert gdy blocking_rate > 70% przez więcej niż 10 minut.

Reservation expiration at checkout: ile checkout operations failuje przez expired reservation. To jest najbardziej krytyczna metryka UX. Jeśli user dochodzi do checkout i dopiero wtedy dowiaduje się że rezerwacja wygasła, to bardzo zły experience.

Powinno być praktycznie zero. Jeśli jest > 1% checkout attempts, coś jest źle – albo TTL za krótki, albo checkout process za wolny, albo heartbeat nie działa.

Reservation extension rate: ile rezerwacji jest przedłużanych przez heartbeat. Jeśli bardzo wysoki (>50% rezerwacji przedłużane wielokrotnie), oznacza że base TTL jest za krótki i users faktycznie potrzebują więcej czasu. Albo że mają otwarte zakładki „na zapas” i nie finalizują zamówień.

VIP rejection rate: ile razy VIP user nie mógł zarezerwować przez brak dostępności. To nie może być zero (wtedy VIP minimum jest ustawione za wysoko i blokuje regular users), ale nie może być wysokie (wtedy VIP nie ma faktycznej korzyści).

Partial allocation rate: jak często rezerwacja jest partial (dostępne mniej niż requested). Wysoki rate oznacza że często jest contention o te same produkty – może warto zwiększyć stany albo wprowadzić inny mechanizm allocation (np. pre-order z gwarantowaną dostawą za 2 dni).

"W tym momencie przestaliśmy już rozwiązywać problem rezerwacji. Zaczęliśmy zarządzać skutkami wszystkich wcześniejszych rozwiązań problemu rezerwacji."

Co bym zrobił inaczej od początku

Po roku działania systemu w produkcji, wiem co działało, a co było over-engineered albo under-engineered.

Co działało:

– TTL z automatic cleanup – absolutny must-have, bez tego system by się zadusił

– Heartbeat dla active sessions – proste, skuteczne

– Partial reservation z user choice – dobre UX, users rozumieją co się dzieje

– Monitoring blocking rate i expiration at checkout – metryki które faktycznie pokazują problemy

Co było over-engineered:

– Real-time WebSocket updates dostępności – dodało complexity, nie pomogło UX

– Dynamiczny adjustment TTL per product – fajnie wygląda, ale manual override dla kategorii wystarczyłby

– Soft delete rezerwacji z późniejszym cleanup – lepiej od razu hard delete, audit log możemy trzymać osobno

Czego brakowało:

– Load testing realistic scenarios – testowaliśmy happy path, nie testowaliśmy 10 users jednocześnie walczących o ostatnie 20 kg

– Explicit handling checkout-in-progress – dużo bugów pochodziło z assumption że checkout jest atomowy, a nie jest

– Grace period dla VIP przy high demand – VIP minimum działa, ale powinien być mechanizm „jeśli wszyscy VIP chcą zamówić i przekraczają dostępność, jak alokować?”

I największa lekcja: nie projektujesz pod happy path, projektujesz pod konflikt. W systemie rezerwacji założenie nie jest „user dodaje produkt i go zamawia”, tylko „dziesięciu users próbuje zarezerwować ten sam produkt jednocześnie”. Jeśli system działa w tym scenariuszu, będzie działał zawsze. Jeśli testujesz tylko happy path, odkryjesz problemy w produkcji.

Praktyczne wnioski

Soft reservation z TTL brzmi jak prosty pattern – dodaj timestamp, ustaw timeout, cleanup co minutę, done. W praktyce każdy edge case to decyzja tradeoff między UX a inventory efficiency. Przedłużać rezerwację automatycznie czy wymagać explicit action? Długi TTL i zadowoleni users, czy krótki TTL i efektywna alokacja? Priorytet dla dużych klientów czy fairness dla wszystkich?

Nie ma uniwersalnej odpowiedzi. Są tylko świadome kompromisy i monitoring który mówi ci gdy kompromis przestał działać. Jeśli wdrażasz ten pattern, zacznij od prostej wersji – fixed TTL, bez extension, bez priority – i dodawaj complexity tylko gdy masz production data pokazujące że potrzebujesz. Bo na stagingu wszystko wygląda jak działa, dopóki nie wpuścisz prawdziwych użytkowników którzy robią rzeczy których nie przewidziałeś.

Podsumowanie

Soft reservations wyglądały na początku jak jeden z tych problemów, które da się zamknąć w kilku endpointach, jednej tabeli i prostym background jobie. Rezerwacja, TTL, cleanup, gotowe. Dopiero produkcja pokazała, że sam mechanizm rezerwacji jest najmniej interesującą częścią całego systemu. Prawdziwe problemy zaczynają się w momencie, gdy użytkownik dodaje produkt do koszyka, odbiera telefon, otwiera drugą kartę, wraca po dwudziestu minutach albo próbuje złożyć zamówienie dokładnie wtedy, gdy jakiś proces uznał jego rezerwację za martwą. Większość błędów nie wynikała z awarii systemu. Wynikała z tego, że system działał dokładnie tak, jak został zaprojektowany, tylko rzeczywistość miała zupełnie inne plany.

Przez jakiś czas wydawało się, że wystarczy znaleźć idealną wartość TTL. Potem okazało się, że nie istnieje coś takiego jak idealny TTL. Każda liczba jest kompromisem pomiędzy doświadczeniem użytkownika a efektywnym wykorzystaniem magazynu. Krótszy czas zwalnia zasoby szybciej, ale zwiększa ryzyko utraty koszyka. Dłuższy poprawia komfort składania zamówienia, ale zamienia porzucone koszyki w niewidzialne blokady magazynowe. Każde kolejne usprawnienie, heartbeat, grace period, partial reservation czy hard lock przy checkoutcie, usuwało jeden problem i jednocześnie ujawniało następny. Diagram architektury pozostawał niemal bez zmian. Liczba wyjątków rosła z każdym tygodniem działania systemu.

Po kilku miesiącach utrzymywania takiego rozwiązania trudno już patrzeć na dostępność produktu jak na prostą wartość przechowywaną w bazie danych. Dostępność staje się stanem przejściowym, zależnym od czasu, zachowania użytkowników, kolejek, opóźnień i decyzji biznesowych, które zmieniają się szybciej niż kod. Produkcja ma też irytującą tendencję do znajdowania dokładnie tych scenariuszy, których nie było na stagingu i których nikt nie wpisał do testów. System zwykle działa perfekcyjnie, dopóki wszystkie zdarzenia przychodzą w kolejności, którą przewidzieliśmy. Problem polega na tym, że prawdziwi użytkownicy bardzo rzadko czytają nasze diagramy przed kliknięciem przycisku „zamów”.

Zobacz powiązane case studies i analizy

Procesy, architektura i workflow powiązane z tematami poruszanymi w tym materiale – od integracji i realtime systems po automatyzacje operacyjne.

Inne

Pozostałe artykuły

Webhook Reliability Patterns: czego nauczyłem się debugując phantom calls w systemie VoIP
Distributed state w systemach realtime działa poprawnie tylko do momentu, w którym różne warstwy infrastruktury zaczynają posiadać sprzeczne informacje o tym samym połączeniu.
Confidence score OCR często wprowadza w błąd. Sprawdź, jak skutecznie walidować dane i ograniczyć błędy w produkcji.