Elido
9 min di letturaIngegneria

Rilasciamo la migrazione da Bitly: un worker, un token, un budget di 30 minuti

Come abbiamo costruito le importazioni one-click da Bitly per Elido - il design del worker, le regole di risoluzione dei conflitti e i quattro limiti che mantengono sicuro un goroutine in-process.

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)

La prima sorgente di migrazione per il nostro rollout delle integrazioni Tier-3 è stata rilasciata oggi. Incolla un Bitly Generic Access Token, scegli un gruppo, clicca Avvia. Cinque minuti dopo ogni link si trova su s.elido.me/<slug> (o sul tuo dominio personalizzato) con lo slug Bitly preservato.

Questo articolo è il write-up tecnico - cosa c'è nel codice, cosa è stato deliberatamente escluso e perché il worker è in-process per ora.

Diagramma della pipeline che mostra l'API Bitly a sinistra che alimenta richieste con paginazione e autenticazione tramite token in un singolo goroutine del worker di importazione in api-core, il quale inserisce i link con slug preservato nella tabella dei link di Elido

Perché Bitly prima#

Cinque vendor sono in coda nel piano di rollout: Bitly, Rebrandly, Short.io, Dub.co, TinyURL. Bitly è il primo perché la gravità SEO e di acquisizione si concentra su quella specifica query di ricerca - "alternativa a Bitly". Ogni altra sorgente di migrazione beneficia dell'impalcatura del worker che abbiamo messo in piedi per Bitly. L'ordine segue il costo di ingegneria in modo crescente; il SEO è il fattore di pareggio.

Gli altri quattro vendor arriveranno nelle prossime quattro settimane contro la stessa tabella import_jobs.

Modello dei dati#

L'intera funzionalità è una sola tabella:

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 è nullable di proposito. TinyURL non ha API pubblica per gli account gratuiti, quindi il suo percorso è un upload CSV - nessun token. Gli upload CSV ricevono comunque una riga nella stessa tabella in modo che la dashboard mostri un'unica UI "progresso importazione" per tutte e cinque le sorgenti.

source_filter è un bag JSONB per cose specifiche del vendor: {group_guid: "..."} per Bitly, {project_slug: "..."} per Dub, {domain_id: 123} per Short.io. Potremmo suddividerlo in colonne tipizzate una volta saputo cosa è effettivamente variante; fino ad allora il JSONB mantiene lo schema piatto.

error_log è un array JSONB di {source_id, source_slug, reason} in modo che la dashboard possa mostrare "12 di 4.302 link non hanno potuto essere migrati" senza una tabella separata o un join. Il worker tronca a 1.000 voci - oltre quella soglia c'è un problema strutturale e il solo conteggio è il segnale azionabile.

Il worker#

Un singolo goroutine per ogni job avviato. Il worker risiede in api-core (services/api-core/internal/imports/bitly.go) per la v1 - meno parti mobili, nessun event bus tra servizi e il contesto per-job è delimitato da un timeout di 30 minuti.

const (
    MaxLinksPerImport = 50_000
    ImportRunBudget   = 30 * time.Minute
    progressEvery     = 50
    errorLogCap       = 1_000
    bitlyPageSize     = 100
)

Queste quattro costanti fanno la maggior parte del lavoro. Non sono una manopola di configurazione - sono il contratto.

Quattro schede etichettate che mostrano le costanti del worker che delimitano l'importazione: 50k MaxLinksPerImport, un ImportRunBudget di 30 minuti, 100 link per pagina Bitly e un limite di 1.000 voci nel log degli errori

MaxLinksPerImport è un guardrail, non un limite di prodotto. La maggior parte degli utenti ha meno di 5.000 bitlink. Oltre i 50k vogliamo una migrazione a blocchi con checkpoint espliciti, quindi il worker fallisce definitivamente con un'istruzione di inviare un'email a [email protected]. Domani punterà a uno SKU concierge a pagamento; oggi instrada verso la casella postale.

ImportRunBudget è il budget per la facilità di deploy. Un account da 50k a ~5 inserimenti/sec richiederebbe circa tre ore; preferiamo fallire velocemente e riprovare piuttosto che fare un deploy sopra un goroutine a lunga esecuzione. Oltre i 50k o oltre i 30 minuti, vedi il TODO sulla riprendibilità in fondo al file.

Paginazione#

L'API di Bitly si comporta bene. GET /v4/groups/{guid}/bitlinks?size=100 restituisce link più un URL pagination.next. next vuoto significa fatto. L'intero ciclo è:

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)
}

Ci fidiamo del cursore di paginazione di Bitly. Se restituissero lo stesso URL next due volte entreremmo in loop, ma ciò non è mai avvenuto nei test - e il budget di 30 minuti limita il danno.

Risoluzione dei conflitti#

Quando uno slug Bitly collidem con un link Elido già esistente sul dominio di destinazione, il worker deve scegliere. L'utente sceglie la strategia quando avvia il job:

  • suffix (default): prova mylink-2, mylink-3, … fino a 50. Oltre 50 lo trattiamo come un errore - questo segnala una collisione patologica e dovrebbero prima ripulire la sorgente.
  • skip: lascia intatto il link Elido esistente, registra la riga sorgente in error_log, conta come saltato.
  • fail: interrompe l'intero job alla prima collisione. Per gli utenti che vogliono una semantica 1:1 rigorosa.
Flusso decisionale in cui una singola ricerca indicizzata su domain_id e slug si dirama in usa-cosi-com'e quando e libero, o nelle strategie suffix, skip e fail quando uno slug Bitly importato collidem sul dominio di destinazione

La ricerca è una singola lettura indicizzata su (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)
}

Si tratta di una ricerca sequenziale, non di un insert-with-conflict. Paghiamo una lettura extra per riga ma otteniamo un walk del suffisso deterministico e un messaggio di errore molto più comprensibile - l'alternativa è cercare una violazione di unicità in pgx e analizzare il nome del vincolo dalla stringa di errore.

Cosa non migriamo#

La cronologia dei clic. Bitly non espone i dati per singolo clic per l'esportazione - solo contatori aggregati per link, e solo sui piani Pro. Quindi lo indichiamo su ogni singola superficie che l'utente vede: la pagina della ricetta nella dashboard, il marketing landing, la UI del progresso di importazione e la sezione FAQ di /migrate-from/bitly. I nuovi clic atterrano nelle analisi Elido dal momento del cutover in avanti.

Abbiamo considerato di recuperare /v4/bitlinks/{id}/clicks/summary per link per inizializzare una metrica di "conteggio clic importato". Rifiutato: triplica le chiamate API e fornisce un singolo numero vago che non può guidare alcuna analisi reale. Se hai bisogno dei clic storici, hai bisogno di averli in GA4 o nel tuo warehouse comunque.

Anche i design QR e le campagne Bitly vengono scartati. Sono strutture specifiche del vendor che non si mappano in modo pulito. I link importati da Bitly portano un tag imported:bitly in modo da poterli filtrare in blocco - la maggior parte degli utenti lo usa per assegnare un overlay CTA Elido predefinito o una campagna a posteriori.

Gestione del token#

Il token non finisce mai su disco. Il gestore HTTP lo accetta nel corpo della richiesta, lo inserisce in una struct BitlyJobOptions e lo passa al worker tramite il lancio del goroutine:

bgCtx := context.WithoutCancel(r.Context())
go h.worker.Run(bgCtx, job.ID, imports.BitlyJobOptions{
    Token:     req.Token,
    GroupGUID: req.GroupGUID,
})

source_token_id rimane NULL. La tabella service_tokens esiste e la collegheremo alle migrazioni per le integrazioni paste-token Tier-2 (Mailchimp, Brevo, Klaviyo, …) dove il valore della persistenza è l'uso ricorrente. Per le migrazioni one-shot il beneficio operativo non giustifica la superficie di archiviazione - l'utente incolla il token una volta, il worker gira, il token sparisce.

context.WithoutCancel è la parte nuova per me. Il contesto della richiesta del gestore è normalmente il modo in cui i programmi Go propagano la cancellazione. Abbiamo bisogno dell'opposto - il worker dovrebbe sopravvivere alla richiesta HTTP che lo ha avviato. WithoutCancel (Go 1.21+) mantiene i valori del contesto (logger, trace ID, senza deadline) ma rimuove il segnale di cancellazione.

Riprendibilità e il problema del deploy#

Il worker è in-process. Un deploy a metà importazione uccide il goroutine. Lo accettiamo per la v1 perché:

  1. La maggior parte dei job finisce in meno di cinque minuti. I deploy sono poco frequenti nei momenti della giornata in cui si effettuano le importazioni.
  2. La riga import_jobs registra last_progress_at. Un tick dello scheduler ogni 5 minuti porta qualsiasi riga running senza progressi negli ultimi 30 minuti a failed con una chiara motivazione "worker stalled", quindi gli utenti non rimangono a chiedersi cosa sia successo.
  3. La riesecuzione è idempotente con le strategie suffix e skip - i link già importati vengono rilevati e risolti secondo la strategia. Nessuna corruzione dei dati.

Questo è il compromesso. Per account con oltre 10.000 link, la riprendibilità guadagna valore - registriamo il cursore di paginazione Bitly in import_jobs.source_filter e riprendiamo da dove si era interrotta l'ultima esecuzione. Questa è la prossima iterazione.

Cosa è misurabile#

Rilasci una funzionalità, la strumenti. Il gestore emette log zap strutturati per ogni evento del ciclo di vita del job:

  • import: starting bitly run - workspace, dominio di destinazione, strategia di conflitto, group GUID
  • import: bitly run complete - importati, saltati, falliti, totale
  • imports stuck-sweep flipped jobs to failed - conteggio

Non stiamo ancora graficando questi dati in produzione - il primo batch di esecuzioni di utenti reali ci dirà su cosa alertare. Ipotesi iniziale: stuck-sweep count > 0 in qualsiasi finestra di 1 ora è un segnale di paging, perché significa che un worker è morto e l'UI dell'utente è bloccata su running più a lungo di quanto dovrebbero tollerare.

Cosa c'è dopo#

La stessa impalcatura, quattro vendor in più:

  • Rebrandly - GET /v1/links?limit=25 paginato. Slashtag → slug 1:1 quando lo slug è libero.
  • Short.io - GET /links?limit=150&domain_id=…. Paginazione per dominio; elenchiamo prima i domini in modo che l'utente possa scegliere una sorgente.
  • Dub.co - GET /api/links?projectSlug=…&limit=100. Folder + tag preservati; questo è il più semplice dei quattro.
  • TinyURL - Solo upload CSV. Il TinyURL pubblico non ha API; i piani Pro esportano CSV. Accettiamo direttamente il CSV e saltiamo il roundtrip lato vendor.

Ognuno atterra dietro la stessa riga import_jobs e la stessa UI di polling nella dashboard. Il worker specifico del vendor rimane in services/api-core/internal/imports/<vendor>.go.

Se hai rimandato un confronto con Bitly perché la storia della migrazione era vaga, la storia della migrazione non è più vaga. Provalo - dal token all'ultimo link importato in meno di dieci minuti per account tipici.

Correlato sul blog#

Prova Elido

Incolla un URL, ottieni un link breve

Senza registrazione. Il link vive 30 giorni. Iscriviti per conservarlo.

Gratis, nessuna registrazione richiesta · 2 al giorno

Prova Elido

Accorciatore di URL ospitato nell'UE: domini personalizzati, analisi approfondite e API aperta. Piano gratuito - senza carta di credito.

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

Continua a leggere