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