8 min czytaniaInżynieria

Wysyłamy migrację z Bitly: worker, token i budżet 30 minut

Jak zbudowaliśmy jednokliniowe importy z Bitly dla Elido - projekt workera, reguły rozwiązywania konfliktów oraz cztery limity, które utrzymują goroutine w bezpiecznych granicach.

Marius Voß
DevRel · edge infra
Pipeline diagram: Bitly API on the left flowing through Elido import worker into the links table, with side panel listing the four numeric guarantees the worker holds (50k cap, 30 min budget, 100/page, token never persisted)

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.

Diagram potoku pokazujacy API Bitly po lewej stronie zasilajace stronicowane zadania uwierzytelnione tokenem do pojedynczej goroutine workera importu in-process w api-core, ktora wstawia linki z zachowanymi slugami do tabeli linkow Elido

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.

Cztery opisane karty pokazujace stałe workera ograniczajace import: 50k MaxLinksPerImport, budzet 30 minut ImportRunBudget, 100 linkow na strone Bitly oraz limit 1000 wpisow dziennika bledow

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.
Schemat decyzyjny, w ktorym jedno indeksowane wyszukiwanie na domain_id i slug rozgałezia sie na uzyj-bez-zmian gdy slug jest wolny lub na strategie suffix, skip i fail gdy importowany slug z Bitly koliduje na docelowej domenie

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ż:

  1. Większość zadań kończy się w mniej niż pięć minut. Deploye są rzadkie w godzinach, kiedy użytkownicy importują.
  2. Wiersz import_jobs zapisuje last_progress_at. Harmonogram co 5 minut zmienia każdy wiersz running bez postępu przez ostatnie 30 minut na failed z wyraźnym powodem „worker stalled", żeby użytkownicy nie zastanawiali się, co się stało.
  3. 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 grupy
  • import: bitly run complete - zaimportowane, pominięte, nieudane, łącznie
  • imports 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=25 z 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

Wypróbuj Elido

Skracarka URL hostowana w UE: własne domeny, głęboka analityka i otwarte API. Darmowy plan - bez karty kredytowej.

Tagi
bitly migration
url shortener
go worker
data migration
engineering
tier 3 integrations

Czytaj dalej