Siedem godzin zaplanowanego przestoju brzmi jak sporo. Pięć z nich zajęły skrypty przenoszące dane i sprawdzanie ich wyników. Samo wdrożenie kodu było krótkim fragmentem okna, a tego akurat spodziewałem się najmniej.
3DPCC zaczynał jako kalkulator kosztów druku 3D. Przebudowa do systemu zarządzania produkcją wymagała nowej architektury i przeniesienia żywych danych użytkowników do innej struktury. Stary dashboard stał na Netlify i część operacji wykonywał bezpośrednio w Supabase. Nowa wersja to front na Vercel, backend w Node.js na Railway i ten sam projekt Supabase, który dalej odpowiada za konta użytkowników, bazę PostgreSQL, pliki i funkcje subskrypcyjne. Zamiast podmieniać projekt produkcyjny na stagingowy, rozbudowałem istniejący addytywnie. Konta Auth, identyfikatory użytkowników, aktywne sesje, pliki w Storage i powiązania ze Stripe zostały dzięki temu tam, gdzie były.
Poniżej plan tej migracji, przebieg okna i to, co z niego wyniosłem.
Dlaczego przestój był zaplanowany
Stary frontend zapisywał dane wprost do Supabase, więc wystawienie strony maintenance niczego realnie nie zamrażało. Blokada musiała objąć interfejs, bezpośrednie zapisy ról anon i authenticated oraz funkcje uprzywilejowane. Bez okna istniało ryzyko, że ktoś dopisze rekord do starej struktury dokładnie w chwili, gdy ją przepisuję. Przerwa dała spójny stan i możliwość sprawdzenia każdego kroku, zanim ruszył następny.
Backup, który musiał się udać
Projekt przez większość czasu działał na planie Free, bez zarządzanych kopii zapasowych, więc napisałem własny skrypt robiący logiczny dump. Przed startem weryfikuje on identyfikator projektu produkcyjnego, odrzuca staging i katalogi wewnątrz repozytorium, a connection string przyjmuje przez maskowany prompt zamiast argumentów wiersza poleceń. Wynikiem jest zestaw roles.sql, schema.sql i data.sql z manifestem SHA-256. Bajtów plików Storage w tej kopii nie ma, zostają w tym samym projekcie i wymagają osobnego kopiowania.
Dzień przed migracją przeszliśmy na plan Pro, co dodało zarządzane kopie Supabase. W samym oknie zrobiłem jeszcze jeden finalny dump, już po zamrożeniu zapisów, żeby punkt przywracania zawierał stan spójny. Niepowodzenie tego backupu przerywało całą operację, zanim wykonał się jakikolwiek DDL.
Sam dump niewiele znaczy, dopóki nie sprawdzisz, że da się z niego wrócić. Skrypt próby odtworzenia weryfikuje sumy kontrolne, podnosi jednorazowego lokalnego Postgresa na losowych portach, odtwarza bazę w jednej transakcji z ON_ERROR_STOP, sprawdza klucze obce, niewalidowane constrainty i rekordy osierocone, a na koniec usuwa kontenery i wolumen. Dwie takie próby, z 30 sierpnia i 23 września, odbyły się jeszcze na planie Free i trwały około 54 i 40 sekund, odtwarzając 22 tabele publiczne oraz metadane Storage. Wiedziałem więc z pomiaru, a nie z przeczucia, że backup nie będzie głównym składnikiem okna.
Zamrożenie zapisów i jego granice
Zapisy zablokowałem odwracalnymi triggerami na poziomie zapytań, założonymi na wszystkich tabelach schematu public. Odmawiają one operacji INSERT, UPDATE, DELETE i TRUNCATE rolom anon, authenticated i service_role oraz procesom działającym pod innymi rolami. Połączenia postgres i supabase_admin zostawiłem otwarte dla kontrolowanej migracji, co oznaczało, że wszystkie deploye backendu z produkcyjnym DATABASE_URL musiały zostać zatrzymane jeszcze przed zamrożeniem. Skrypt freeze jest idempotentny, więc po migracjach uruchomiłem go ponownie i objął nowe tabele, przez co liczba chronionych relacji wzrosła z 22 do 120. Komenda status działa fail-closed i wykrywa rozbieżność między liczbą tabel a liczbą guardów, a unfreeze zdejmuje wszystko w jednej transakcji i nie akceptuje częściowego sukcesu.
Blokada miała granicę, której nie dało się przeskoczyć. Schematy auth i storage należą do innych właścicieli Supabase, więc trigger dałoby się tam założyć, ale nie dałoby się go bezpiecznie zdjąć. Świadomie ich nie ruszałem. Zamiast tego wyłączyłem rejestrację w Auth, wstrzymałem crony Supabase i zabroniłem bezpośrednich uploadów do Storage. Webhooki Stripe na czas okna były zatrzymane, a ich zdarzenia musiały pozostać odtwarzalne i idempotentne, żeby żadne nie zginęło i żadne nie zostało przetworzone dwa razy.
Sekwencja w oknie wyglądała tak: zamrożenie zapisów, finalny backup, migracje jednym jobem, idempotentne backfille z walidacjami, deploy backendu. Z grubsza zgadzała się z planem.
Audyt, który kazał mi wycofać dwie rzeczy
Niezależny audyt migracji podtrzymał decyzję no-go i wyłapał dwa moje błędne założenia.
Pierwsze dotyczyło duplikatów SKU. Planowałem dopisywać do nich suffixy automatem, ale short_name filamentów jest u wielu użytkowników etykietą materiału, a nie unikalnym SKU. Automat zmieniłby więc znaczenie danych. Zastąpiłem go migracjami z mapą zmian i transakcyjnym skryptem naprawczym, który rusza po backfillach, poprawia wyłącznie poprawne rekordy bez linku z zajętym SKU, zapisuje stare i nowe SKU, a jeśli po naprawie cokolwiek nadal jest bez linku, przerywa się. Drugi przebieg musi dać zero zmian.
Drugie założenie było wygodniejsze dla mnie niż dla użytkowników: chciałem przerzucić uzupełnianie brakujących kursów walut na nich. Zamiast tego batch NBP pobiera raz najnowszą publikację, odrzuca uruchomienie, jeśli brakuje kursu dla którejkolwiek waluty, zapisuje snapshoty przeliczenia z audytem systemowym i nie dotyka ceny w walucie zakupu. Warunkiem końcowym jest remainingConvertible=0.
Pięć godzin na dane
Około 70% okna zajęło uruchamianie skryptów przenoszących i mapujących dane wraz z weryfikacją wyników. Kolejność była sztywna, a schemat zawsze ten sam: podgląd, zapis, powtórka dla dowodu idempotencji. Objęło to domyślne SKU, backfille magazynowe, naprawę konfliktów SKU, snapshoty kursów NBP dla drukarek, import katalogu produktów i wariantów oraz parametry cenowe. Powiązania materiałów z nowym magazynem miały pełne pokrycie, a powtórne przebiegi nie wprowadzały żadnych zmian.
Jedno miejsce zostawiłem nierozwiązane celowo. Część żywic nie ma zapisanej pojemności opakowania, więc ich ilości nie da się ustalić jednoznacznie, a zgadywanie tutaj oznaczałoby wpisanie użytkownikowi do magazynu liczby wziętej z sufitu. Aplikacja oznacza takie rekordy na liście, po onboardingu pokazuje komunikat, a edytor wymaga podania rzeczywistej pojemności. Pozostałe dwie godziny okna rozłożyły się między backup, zamrożenie, migrację schematu, wdrożenia, ustawianie środowisk i testy.
Test na dwóch prawdziwych kontach z celowo zepsutymi rekordami
Przed końcowym cutoverem utworzyłem na dwóch istniejących kontach Pro rekordy z prefiksem MIG25-TEST-, odwzorowujące przypadki, które najłatwiej psują migrację: żywicę bez pojemności opakowania, dwa filamenty o identycznym SKU, drukarkę w walucie innej niż waluta organizacji z żywotnością równą 0, dwie pozycje hardware o tym samym SKU oraz zwykły filament ze starą wyceną. Po migracji sprawdzałem, czy edytor żywicy wymaga rzeczywistej pojemności, czy zduplikowane SKU zostały rozdzielone z zachowaniem nazw, kolorów i stanów, czy snapshot FX nie ruszył ceny zakupu i czy stara wycena nadal jest czytelna. Trzymałem się przy tym zasady, że nie wpisuję sztucznych wartości po to, żeby zamknąć komunikat, i nie obchodzę walidacji produkcyjnego interfejsu.
CI/CD i uruchomienie środowisk
Po migracji trzeba było postawić nowe środowiska produkcyjne i upewnić się, że staging i produkcja mają właściwe zmienne. To był etap, który sprawił w całym oknie najwięcej kłopotu.
Złożyła się na to liczba drobnych ustawień do ogarnięcia naraz: CORS, zmienne środowiskowe, domeny i webhooki, każde osobno dla stagingu i produkcji. Najwięcej uwagi kosztowało pilnowanie, w którym środowisku akurat pracuję i czy dana wartość trafiła tam, gdzie miała trafić.
Synchronizacja staging z main w obu repozytoriach szła przez zwykłe pull requesty z wymaganym zielonym CI, bez force-push, a skróty commitów zapisałem jako zamrożone wersje. Workflow ma trzy niezależne bramki: statyczną, czyli typy, lint, format, testy jednostkowe i build, testy integracyjne na efemerycznym lokalnym Supabase oraz build produkcyjnego kontenera ze sprawdzeniem liveness, użytkownika non-root i zamykania po SIGTERM. Repozytorium nie wdraża się samo. Railway buduje Dockerfile z gałęzi main i uruchamia kontener bezpośrednio przez node dist/server.js, żeby SIGTERM docierał do aplikacji, a healthcheck na /ready sprawdza bazę i Redis. Frontend na Vercel to preset Vite z katalogiem dist, API dostało własną domenę api-prod.3dpcc.com, a app.3dpcc.com przełącza CNAME dodany dopiero na końcu okna.
Żaden proces asynchroniczny nie działa w procesie API. Zamiast tego są trzy osobne usługi Cron na Railway z tego samego obrazu, uruchamiane co 5 minut: wygaszanie rezerwacji, outbox zdarzeń z powiadomieniami i wysyłka e-maili transakcyjnych. Lease oraz FOR UPDATE SKIP LOCKED pozwalają kolejnym uruchomieniom bezpiecznie się nakładać.
Testy przed otwarciem ruchu
Kolejność testów wynikała z tego, że odczyt można sprawdzić bez ryzyka, a zapis już nie. Najpierw, pod nadal aktywną blokadą, przeszedłem logowanie starą i nową sesją, dashboard, stare materiały, drukarkę z FX, starą wycenę, produkt, zdjęcie i PDF, plany Pro i Free oraz brak dostępu do cudzych danych. Dopiero po pozytywnym wyniku zdjąłem blokadę i przetestowałem pełny przepływ zapisu: nowa wycena, rezerwacja, akceptacja, zużycie, zamówienie. Do tego rejestracja, subskrypcje, faktury i webhooki, spójność danych i brak błędów.
Warto tu zapamiętać jedną rzecz o tym etapie. Od zdjęcia blokady do przełączenia DNS każdy, kto miał ważną sesję albo otwartą starą kartę, mógł zapisywać dane, bo prywatny Netlify i chroniony Vercel nie blokują bezpośrednich żądań do Supabase. Płatności subskrypcyjne przeszły wcześniej pełny test w Stripe Sandbox, więc na produkcji nie robiłem płatności prawdziwą kartą, ale otworzyłem produkcyjny Checkout i potwierdziłem konfigurację Managed Payments oraz automatycznego podatku.
TTL rekordu app obniżyłem do 60 sekund jeszcze przed oknem, a przełączenie DNS na Vercel było ostatnim krokiem włączania ruchu. Stary Netlify został kilka dni w trybie Private jako zapas i przez ten czas obserwowałem, czy nowa wersja zachowuje się poprawnie.
Rollback, z którego nie musiałem skorzystać
Poza przewidzianymi ostrzeżeniami o niepełnych danych w kilku rekordach magazynu, na które miałem osobny ekran, nic się nie wysypało. Rollback i tak był przygotowany. Wszystkie migracje były typu expand-only, więc wariantem preferowanym był powrót do poprzednich deploymentów, a odtworzenie bazy zostawiłem wyłącznie na wypadek uszkodzonych danych, bo oznacza utratę wszystkiego zapisanego po finalnym backupie. Automatyczny rollback wyzwalały między innymi niezgodne liczności lub rekordy osierocone, nierozliczony blocker backfillu, błąd izolacji tenantów, brak możliwości logowania, utrzymujące się błędy 5xx i niedostępny odczyt krytycznych plików Storage. Gdyby decyzja no-go zapadła przed otwarciem ruchu, plan zakładał utrzymanie blokady i zatrzymanie usług Railway, a przy nieprzełączonym DNS klienci niczego by nie zauważyli.
Następnym razem oszacuję to inaczej
Największą część okna zajęły skrypty danych i weryfikacja ich wyników. Przy kolejnej takiej operacji będę liczył przestój według faktycznego wolumenu danych i liczby potrzebnych napraw, a nie według czasu publikacji aplikacji.
Podsumowanie
Całość zamknęła się w siedmiu godzinach, czyli w założonym przedziale sześciu do ośmiu. Plan, który dało się przetestować przed migracją, zachowanie produkcyjnego projektu Supabase zamiast jego podmiany, odwracalna blokada zapisów i migracje expand-only sprawiły, że najgorszy scenariusz przez cały czas pozostawał odwracalny. Reszta to spokojne przechodzenie kolejnych kroków i sprawdzanie wyniku po każdym z nich.
3DPCC działa dziś na nowej architekturze. Jeśli liczysz koszty druku 3D albo prowadzisz pracownię i chcesz zobaczyć, jak system wygląda po tej przebudowie, zajrzyj na 3dpcc.com.


