Pierwszy źródłowy migracji w ramach naszego wdrożenia integracji Tier-3 wyszedł dziś na produkcję. Wklej Bitly Generic Access Token, wybierz grupę, kliknij Start. Pięć minut później każdy link ląduje na s.elido.me/<slug> (lub Twojej własnej domenie) z zachowanym slugiem z Bitly.
Ten wpis to inżynierski opis - co znajdziesz w kodzie, co celowo pominęliśmy i dlaczego worker działa in-process na razie.
Dlaczego Bitly jako pierwsze
W planie wdrożenia stoi pięciu dostawców: Bitly, Rebrandly, Short.io, Dub.co, TinyURL. Bitly jest pierwszy, bo SEO i siła przyciągania przejęć skupia się właśnie na tym jednym zapytaniu - „alternatywa dla Bitly". Każde kolejne źródło migracji korzysta z rusztowania workera, które zbudowaliśmy pod Bitly. Kolejność wyznacza rosnący koszt implementacji; remisy rozstrzyga SEO.
Czterech pozostałych dostawców pojawi się w ciągu następnych czterech tygodni - wszystko na tej samej tabeli import_jobs.
Model danych
Cała funkcja to jedna tabela:
CREATE TABLE import_jobs (
id BIGSERIAL PRIMARY KEY,
workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
source_vendor TEXT NOT NULL,
source_token_id BIGINT REFERENCES service_tokens(id) ON DELETE SET NULL,
target_domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'queued',
conflict_strategy TEXT NOT NULL DEFAULT 'suffix',
source_filter JSONB NOT NULL DEFAULT '{}'::jsonb,
total_items INT NOT NULL DEFAULT 0,
imported_items INT NOT NULL DEFAULT 0,
skipped_items INT NOT NULL DEFAULT 0,
failed_items INT NOT NULL DEFAULT 0,
error_log JSONB NOT NULL DEFAULT '[]'::jsonb,
-- timestamps + check constraints elided
);
source_token_id jest celowo nullable. TinyURL nie udostępnia publicznego API dla kont darmowych, więc tam ścieżka to upload CSV - bez tokenu. Uploady CSV też dostają wiersz w tej samej tabeli, dzięki czemu dashboard wyświetla jeden spójny interfejs „postępu importu" dla wszystkich pięciu źródeł.
source_filter to worek JSONB na dane specyficzne dla dostawcy: {group_guid: "..."} dla Bitly, {project_slug: "..."} dla Dub, {domain_id: 123} dla Short.io. Moglibyśmy to rozbić na typowane kolumny, kiedy już wiemy, co faktycznie się różni; na razie JSONB utrzymuje schemat płaski.
error_log to tablica JSONB zawierająca wpisy {source_id, source_slug, reason}, dzięki czemu dashboard może wyświetlić „12 z 4 302 linków nie mogło zostać zmigrowanych" bez osobnej tabeli czy joina. Worker przycina log do 1 000 wpisów - jeśli dojdziesz do tej liczby, masz problem strukturalny i sam licznik jest już wystarczającym sygnałem do działania.
Worker
Jedna goroutine na każde uruchomione zadanie. Worker mieszka w api-core (services/api-core/internal/imports/bitly.go) w wersji v1 - mniej ruchomych części, brak magistrali zdarzeń między serwisami, a kontekst każdego zadania jest ograniczony timeoutem 30 minut.
const (
MaxLinksPerImport = 50_000
ImportRunBudget = 30 * time.Minute
progressEvery = 50
errorLogCap = 1_000
bitlyPageSize = 100
)
Te cztery stałe wykonują większość roboty. To nie są konfigurowane parametry - to kontrakt.
MaxLinksPerImport to zabezpieczenie, a nie limit produktowy. Większość użytkowników ma poniżej 5 000 bitlinków. Powyżej 50k chcemy migracji podzielonej na części z jawnym checkpointingiem, więc worker kończy z błędem i instrukcją, żeby napisać na [email protected]. Jutro to wskaże na płatny SKU concierge; dziś trafia do skrzynki.
ImportRunBudget to budżet na przyjazność wobec deployów. Konto z 50k linkami przy ~5 insertach/s potrzebuje mniej więcej trzech godzin; wolimy szybko zawieść i ponowić, niż deployować na działającą długo goroutine. Powyżej 50k lub 30 minut - patrz TODO dotyczące wznawiania na dole pliku.
Paginacja
API Bitly zachowuje się wzorowo. GET /v4/groups/{guid}/bitlinks?size=100 zwraca linki plus URL pagination.next. Puste next oznacza koniec. Cała pętla wygląda tak:
page := fmt.Sprintf("%s/v4/groups/%s/bitlinks?size=%d",
BitlyAPIBase, url.PathEscape(opts.GroupGUID), bitlyPageSize)
for page != "" {
resp, err := w.fetchPage(ctx, opts.Token, page)
if err != nil { /* mark failed */ return }
for _, link := range resp.Links {
// ... resolve slug, insert, update counters ...
}
page = strings.TrimSpace(resp.Pagination.Next)
}
Ufamy kursorowi paginacji Bitly. Gdyby dwa razy zwrócili ten sam URL next, pętla by się zapętliła - ale to nigdy nie wydarzyło się podczas testów, a 30-minutowy budżet ogranicza potencjalne straty.
Rozwiązywanie konfliktów
Kiedy slug z Bitly koliduje z linkiem Elido, który już istnieje na docelowej domenie, worker musi wybrać. Użytkownik wybiera strategię przy uruchamianiu zadania:
- suffix (domyślna): próbuje kolejno
mylink-2,mylink-3, … do 50. Po przekroczeniu 50 traktujemy to jako błąd - to sygnał patologicznej kolizji i najpierw trzeba posprzątać źródło. - skip: pozostawia istniejący link Elido bez zmian, loguje wiersz źródłowy do
error_log, liczy jako pominięty. - fail: przerywa całe zadanie przy pierwszym konflikcie. Dla użytkowników, którzy chcą ścisłej semantyki 1:1.
Wyszukiwanie to jeden indeksowany odczyt na (domain_id, slug):
func (w *BitlyWorker) resolveSlug(ctx context.Context, domainID int64, desired, strategy string) (string, error) {
if _, err := w.links.GetByDomainSlug(ctx, domainID, desired); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return desired, nil
}
return "", fmt.Errorf("slug lookup: %w", err)
}
switch strategy {
case "skip": return "", nil
case "fail": return "", fmt.Errorf("slug %q already exists", desired)
case "suffix":
for i := 2; i <= maxSuffix; i++ {
candidate := fmt.Sprintf("%s-%d", desired, i)
if _, err := w.links.GetByDomainSlug(ctx, domainID, candidate); err != nil {
if errors.Is(err, pgx.ErrNoRows) { return candidate, nil }
return "", err
}
}
return "", fmt.Errorf("more than %d collisions, giving up", maxSuffix)
}
return "", fmt.Errorf("unknown conflict_strategy %q", strategy)
}
To jest sekwencyjne wyszukiwanie, nie insert-with-conflict. Płacimy jednym dodatkowym odczytem na wiersz, ale zyskujemy deterministyczny przebieg sufiksów i znacznie czytelniejszy komunikat błędu - alternatywą byłoby łowienie naruszenia unikalności w pgx i parsowanie nazwy ograniczenia z łańcucha błędu.
Czego nie migrujemy
Historia kliknięć. Bitly nie udostępnia danych o pojedynczych kliknięciach do eksportu - tylko zagregowane liczniki na link, i tylko na planach Pro. Dlatego informujemy o tym na każdej powierzchni, którą widzi użytkownik: na stronie przepisu w dashboardzie, na stronie landingowej, w UI postępu importu oraz w sekcji FAQ /migrate-from/bitly. Nowe kliknięcia trafiają do analityki Elido od momentu przełączenia.
Rozważaliśmy pobieranie /v4/bitlinks/{id}/clicks/summary na każdy link, żeby zainicjować metrykę „zaimportowana liczba kliknięć". Odrzuciliśmy: potrojona liczba wywołań API daje jedną rozmytą liczbę, która nie napędza żadnej prawdziwej analizy. Jeśli potrzebujesz historycznych kliknięć, i tak potrzebujesz ich w GA4 lub własnym hurtowni danych.
Pomijamy też projekty QR i kampanie Bitly. To struktury specyficzne dla dostawcy, które nie mapują się czysto. Zaimportowane z Bitly linki niosą tag imported:bitly, dzięki czemu możesz filtrować je hurtowo - większość użytkowników używa tego do przypisania domyślnej nakładki CTA Elido lub kampanii po fakcie.
Obsługa tokenu
Token nigdy nie trafia na dysk. Handler HTTP przyjmuje go w treści żądania, wrzuca do struktury BitlyJobOptions i przekazuje workerowi przy uruchomieniu goroutine:
bgCtx := context.WithoutCancel(r.Context())
go h.worker.Run(bgCtx, job.ID, imports.BitlyJobOptions{
Token: req.Token,
GroupGUID: req.GroupGUID,
})
source_token_id pozostaje NULL. Tabela service_tokens istnieje i podepniemy do niej migracje dla integracji Tier-2 z tokenami wklejanymi (Mailchimp, Brevo, Klaviyo, …), gdzie wartość trwałości leży w wielokrotnym użyciu. Dla jednorazowych migracji korzyść operacyjna nie uzasadnia powierzchni storage - użytkownik wkleja token raz, worker działa, token znika.
context.WithoutCancel to dla mnie nowość. Kontekst żądania handlera to normalnie sposób, w jaki programy Go propagują anulowanie. Potrzebujemy odwrotności - worker powinien przeżyć żądanie HTTP, które go uruchomiło. WithoutCancel (Go 1.21+) zachowuje wartości kontekstu (logger, trace IDs, bez deadline) ale usuwa sygnał anulowania.
Wznawianie i problem deployów
Worker działa in-process. Deploy w trakcie importu zabija goroutine. Akceptujemy to w wersji v1, ponieważ:
- Większość zadań kończy się w mniej niż pięć minut. Deploye są rzadkie w godzinach, kiedy użytkownicy importują.
- Wiersz
import_jobszapisujelast_progress_at. Harmonogram co 5 minut zmienia każdy wierszrunningbez postępu przez ostatnie 30 minut nafailedz wyraźnym powodem „worker stalled", żeby użytkownicy nie zastanawiali się, co się stało. - Ponowne uruchomienie jest idempotentne przy strategiach suffix i skip - już zaimportowane linki są wykrywane i rozwiązywane zgodnie ze strategią. Brak korupcji danych.
Taki jest kompromis. Dla kont powyżej 10 000 linków wznawialność się opłaca - zapisujemy kursor paginacji Bitly w import_jobs.source_filter i kontynuujemy od miejsca, gdzie ostatnio skończyliśmy. To kolejna iteracja.
Co jest mierzalne
Wysyłamy funkcję, instrumentujemy funkcję. Handler emituje ustrukturyzowane logi zap dla każdego zdarzenia cyklu życia zadania:
import: starting bitly run- workspace, docelowa domena, strategia konfliktów, GUID grupyimport: bitly run complete- zaimportowane, pominięte, nieudane, łącznieimports stuck-sweep flipped jobs to failed- liczba
Na razie nie tworzymy wykresów z tych danych w produkcji - pierwsza partia uruchomień prawdziwych użytkowników powie nam, na co ustawiać alerty. Wstępne przypuszczenie: stuck-sweep count > 0 w dowolnym oknie 1-godzinnym to sygnał do pageowania, bo oznacza, że worker umarł i UI użytkownika utknęło na running dłużej niż powinno.
Co dalej
To samo rusztowanie, czterech kolejnych dostawców:
- Rebrandly -
GET /v1/links?limit=25z paginacją. Slashtag → slug 1:1 gdy slug jest wolny. - Short.io -
GET /links?limit=150&domain_id=…. Paginacja per-domena; najpierw listujemy domeny, żeby użytkownik mógł wybrać źródło. - Dub.co -
GET /api/links?projectSlug=…&limit=100. Foldery i tagi zachowane; to najłatwiejszy z czterech. - TinyURL - tylko upload CSV. Publiczny TinyURL nie ma API; plany Pro eksportują CSV. Przyjmujemy CSV bezpośrednio i pomijamy rundę po stronie dostawcy.
Każdy ląduje za tym samym wierszem import_jobs i tym samym interfejsem dashboardu z pollowaniem. Worker specyficzny dla dostawcy zostaje w services/api-core/internal/imports/<vendor>.go.
Jeśli wstrzymywałeś się z porównaniem Bitly, bo historia migracji była niejasna - historia migracji nie jest już niejasna. Wypróbuj - od tokenu do ostatniego zaimportowanego linka w mniej niż dziesięć minut dla typowych kont.
Powiązane na blogu
Wypróbuj Elido
Wklej URL, otrzymaj krótki link
Bez rejestracji. Link działa 30 dni. Zarejestruj się, aby zachować go na zawsze.
Za darmo, bez rejestracji · 2 dziennie