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.
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.
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.
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é:
- 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.
- La riga
import_jobsregistralast_progress_at. Un tick dello scheduler ogni 5 minuti porta qualsiasi rigarunningsenza progressi negli ultimi 30 minuti afailedcon una chiara motivazione "worker stalled", quindi gli utenti non rimangono a chiedersi cosa sia successo. - 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 GUIDimport: bitly run complete- importati, saltati, falliti, totaleimports 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=25paginato. 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