Elido
9 min de leituraEngenharia

Lançar a migração do Bitly: um worker, um token, um orçamento de 30 minutos

Como construímos importações do Bitly com um clique para o Elido - o design do worker, as regras de resolução de conflitos, e os quatro limites que mantêm uma goroutine em processo segura.

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)

A primeira fonte de migração do nosso rollout de integração Tier-3 foi lançada hoje. Cole um Bitly Generic Access Token, escolha um grupo, clique em Start. Cinco minutos depois, cada hiperligação está em s.elido.me/<slug> (ou o seu domínio personalizado) com o slug do Bitly preservado.

Este artigo é o relatório técnico - o que está no código, o que foi deliberadamente deixado de fora, e por que o worker está em processo por agora.

Diagrama de pipeline mostrando a API do Bitly a alimentar pedidos paginados autenticados por token para uma unica goroutine de worker de importacao em processo no api-core, que insere hiperligacoes com slug preservado na tabela de hiperligacoes do Elido

Por que o Bitly primeiro#

Cinco fornecedores estão na fila do plano de rollout: Bitly, Rebrandly, Short.io, Dub.co, TinyURL. O Bitly é primeiro porque a gravidade de SEO e aquisição está nessa consulta de pesquisa específica - "alternativa ao Bitly". Todas as outras fontes de migração beneficiam de partilhar o scaffolding de worker que colocámos em prática para o Bitly. A ordem é por custo de engenharia ascendente; o SEO é o desempate.

Os outros quatro fornecedores serão lançados nas próximas quatro semanas contra a mesma tabela import_jobs.

Modelo de dados#

A funcionalidade inteira é uma 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 é nullable propositalmente. O TinyURL não tem API pública para contas gratuitas, pelo que o seu caminho é um carregamento CSV - sem token. Os carregamentos CSV ainda têm uma linha na mesma tabela para que o painel apresente uma única UI de "progresso de importação" para todas as cinco fontes.

source_filter é uma bag JSONB para coisas específicas do fornecedor: {group_guid: "..."} para o Bitly, {project_slug: "..."} para o Dub, {domain_id: 123} para o Short.io. Poderíamos dividi-lo em colunas tipadas uma vez que soubéssemos o que é realmente variante; até lá, o JSONB mantém o esquema plano.

error_log é um array JSONB de {source_id, source_slug, reason} para que o painel possa apresentar "12 de 4.302 hiperligações não puderam ser migradas" sem uma tabela separada ou um join. O worker trunca em 1.000 entradas - além disso, tem um problema estrutural e a contagem por si só é o sinal acionável.

O worker#

Uma única goroutine por trabalho iniciado. O worker vive em api-core (services/api-core/internal/imports/bitly.go) para a v1 - menos partes móveis, sem bus de eventos entre serviços, e o contexto por trabalho é limitado por um timeout de 30 minutos.

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

Estas quatro constantes fazem a maior parte do trabalho. Não são um parâmetro de configuração - são o contrato.

Quatro cartoes rotulados mostrando as constantes do worker que limitam a importacao: 50k MaxLinksPerImport, um ImportRunBudget de 30 minutos, 100 hiperligacoes por pagina do Bitly e um limite de 1.000 entradas no log de erros

MaxLinksPerImport é uma proteção, não um limite de produto. A maioria dos utilizadores tem menos de 5.000 bitlinks. Acima de 50k queremos uma migração por blocos com checkpointing explícito, pelo que o worker falha definitivamente com uma instrução para enviar email para [email protected]. Amanhã aponta para um SKU de concierge pago; hoje encaminha para a caixa de entrada.

ImportRunBudget é o orçamento de facilidade de deploy. Uma conta de 50k a ~5 inserções/seg atinge cerca de três horas; preferimos falhar rapidamente e voltar a executar do que fazer deploy sobre uma goroutine de longa duração. Acima de 50k ou acima de 30 minutos, veja o TODO de resumibilidade no fundo do ficheiro.

Paginação#

A API do Bitly é bem comportada. GET /v4/groups/{guid}/bitlinks?size=100 devolve hiperligações mais um URL pagination.next. next vazio significa concluído. O loop inteiro é:

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

Confiamos no cursor de paginação do Bitly. Se devolverem o mesmo URL next duas vezes entraremos em loop, mas isso nunca aconteceu nos testes - e o orçamento de 30 minutos limita os danos.

Resolução de conflitos#

Quando um slug do Bitly colide com uma hiperligação do Elido que já existe no domínio alvo, o worker tem de escolher. O utilizador escolhe a estratégia quando inicia o trabalho:

  • suffix (padrão): percorre mylink-2, mylink-3, … até 50. Acima de 50 tratamos como erro - isso sinaliza uma colisão patológica e devem limpar a fonte primeiro.
  • skip: deixa a hiperligação do Elido existente intacta, regista a linha de origem em error_log, conta como ignorado.
  • fail: aborta o trabalho inteiro na primeira colisão. Para utilizadores que querem semântica estrita de 1:1.
Fluxo de decisao onde uma pesquisa indexada em domain_id e slug ramifica em usar como esta quando livre, ou nas estrategias suffix, skip e fail quando um slug do Bitly importado colide no dominio alvo

A pesquisa é uma única leitura indexada em (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)
}

Esta é uma pesquisa sequencial, não uma inserção com conflito. Pagamos uma leitura extra por linha mas obtemos uma percorrida de sufixos determinística e uma mensagem de erro muito mais amigável - a alternativa seria pescar uma violação de unicidade no pgx e analisar o nome da restrição a partir da string de erro.

O que não migramos#

Histórico de cliques. O Bitly não expõe dados por clique para exportação - apenas contadores agregados por hiperligação, e apenas em planos Pro. Portanto, apresentamos isto em todas as superfícies que o utilizador vê: a página de receita do painel, o landing de marketing, a UI de progresso de importação e a secção FAQ de /migrate-from/bitly. Os novos cliques ficam na analytics do Elido a partir do momento do cutover.

Considerámos obter /v4/bitlinks/{id}/clicks/summary por hiperligação para alimentar uma métrica de "contagem de cliques importados". Rejeitado: triplica as chamadas de API e dá um único número vago que não pode impulsionar qualquer análise real. Se precisar de cliques históricos, precisa deles no GA4 ou no seu próprio warehouse de qualquer forma.

Os designs de QR e as campanhas do Bitly também são descartados. São estruturas específicas do fornecedor que não mapeiam de forma limpa. As hiperligações importadas do Bitly têm uma tag imported:bitly para que possa filtrá-las em massa - a maioria dos utilizadores usa isso para atribuir uma sobreposição de CTA padrão do Elido ou campanha posteriormente.

Tratamento do token#

O token nunca fica em disco. O handler HTTP aceita-o no corpo do pedido, coloca-o numa struct BitlyJobOptions, e passa-o ao worker via o lançamento da goroutine:

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

source_token_id fica NULL. A tabela service_tokens existe e ligaremos as migrações a ela para as integrações de token por paste de Tier-2 (Mailchimp, Brevo, Klaviyo, …) onde o valor da persistência é o uso recorrente. Para migrações de uso único, o benefício operacional não justifica a superfície de armazenamento - o utilizador cola o token uma vez, o worker executa, o token desaparece.

context.WithoutCancel é a peça que era nova para mim. O contexto do pedido do handler é normalmente a forma como os programas Go propagam o cancelamento. Precisamos do oposto - o worker deve sobreviver ao pedido HTTP que o iniciou. WithoutCancel (Go 1.21+) mantém os valores do contexto (logger, IDs de trace, sem deadline) mas remove o sinal de cancelamento.

Resumibilidade e o problema do deploy#

O worker está em processo. Um deploy a meio da importação mata a goroutine. Aceitamos isso para a v1 porque:

  1. A maioria dos trabalhos termina em menos de cinco minutos. Os deploys são pouco frequentes nas horas de importação.
  2. A linha import_jobs regista last_progress_at. Um tick de scheduler a cada 5 minutos muda qualquer linha running sem progresso nos últimos 30 minutos para failed com uma razão clara de "worker parou", para que os utilizadores não fiquem sem perceber o que aconteceu.
  3. A reexecução é idempotente sob as estratégias suffix e skip - as hiperligações já importadas são detetadas e resolvidas de acordo com a estratégia. Sem corrupção de dados.

Esse é o trade-off. Para contas acima de 10.000 hiperligações, a resumibilidade paga-se - registamos o cursor de paginação do Bitly em import_jobs.source_filter e continuamos onde a última execução ficou. Essa é a próxima iteração.

O que é mensurável#

Lance uma funcionalidade, instrumente uma funcionalidade. O handler emite logs zap estruturados para cada evento do ciclo de vida do trabalho:

  • import: starting bitly run - workspace, domínio alvo, estratégia de conflito, GUID do grupo
  • import: bitly run complete - importados, ignorados, falhados, total
  • imports stuck-sweep flipped jobs to failed - contagem

Ainda não estamos a representar graficamente isto em produção - o primeiro lote de execuções com utilizadores reais dir-nos-á o que alertar. Estimativa inicial: contagem de stuck-sweep > 0 em qualquer janela de 1 hora é um sinal de paging, porque significa que um worker morreu e a UI do utilizador está bloqueada em running mais tempo do que deveriam tolerar.

O que vem a seguir#

Mesmo scaffolding, mais quatro fornecedores:

  • Rebrandly - GET /v1/links?limit=25 paginado. Slashtag → slug 1:1 quando o slug está livre.
  • Short.io - GET /links?limit=150&domain_id=…. Paginação por domínio; listamos os domínios primeiro para que o utilizador possa escolher uma fonte.
  • Dub.co - GET /api/links?projectSlug=…&limit=100. Pastas + tags preservadas; este é o mais fácil dos quatro.
  • TinyURL - Apenas carregamento CSV. O TinyURL público não tem API; os planos Pro exportam CSV. Aceitamos o CSV diretamente e saltamos a viagem ao lado do fornecedor.

Cada um fica atrás da mesma linha import_jobs e da mesma UI de polling do painel. O worker específico do fornecedor fica em services/api-core/internal/imports/<vendor>.go.

Se tem estado a adiar uma comparação com o Bitly porque a história de migração era vaga, a história de migração já não é vaga. Experimente - do token até à última hiperligação importada em menos de dez minutos para contas típicas.

Relacionados no blog#

Experimente Elido

Cole uma URL, obtenha um link curto

Sem cadastro. O link vive 30 dias. Cadastre-se para mantê-lo para sempre.

Grátis, sem necessidade de registo · 2 por dia

Experimente o Elido

Encurtador de URL hospedado na UE: domínios personalizados, análises profundas e API aberta. Plano gratuito - sem cartão de crédito.

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

Continuar lendo