Elido
9 min de lecturaIngeniería

Lanzando la migración desde Bitly: un worker, un token, un presupuesto de 30 minutos

Cómo construimos importaciones de Bitly de un solo clic para Elido - el diseño del worker, las reglas de resolución de conflictos y los cuatro límites que mantienen segura una goroutine en proceso.

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 primera fuente de migración para nuestro despliegue de integración Tier-3 se lanzó hoy. Pega un Bitly Generic Access Token, elige un grupo, haz clic en Start. Cinco minutos después cada enlace está en s.elido.me/<slug> (o tu dominio personalizado) con el slug de Bitly preservado.

Esta publicación es la nota de ingeniería - qué hay en el código, qué se deja deliberadamente fuera y por qué el worker es in-process por ahora.

Diagrama del pipeline que muestra la API de Bitly a la izquierda alimentando solicitudes paginadas con autenticacion por token a una sola goroutine del worker de importacion en api-core, que inserta enlaces con el slug preservado en la tabla de enlaces de Elido

Por qué Bitly primero#

Cinco proveedores están en cola en el plan de despliegue: Bitly, Rebrandly, Short.io, Dub.co, TinyURL. Bitly es el primero porque la gravedad de SEO y adquisición está en esa consulta de búsqueda específica - "Bitly alternative". Cada otra fuente de migración se beneficia de compartir el andamiaje del worker que pusimos en marcha para Bitly. El orden es por coste de ingeniería ascendente; el SEO es el desempate.

Los otros cuatro proveedores aterrizarán en las próximas cuatro semanas contra la misma tabla import_jobs.

Modelo de datos#

Toda la función es una tabla:

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 es nullable a propósito. TinyURL no tiene una API pública para cuentas gratuitas, por lo que su ruta es una subida CSV - sin token. Las subidas CSV todavía obtienen una fila en la misma tabla para que el dashboard muestre una única UI de "progreso de importación" para las cinco fuentes.

source_filter es una bolsa JSONB para cosas específicas del proveedor: {group_guid: "..."} para Bitly, {project_slug: "..."} para Dub, {domain_id: 123} para Short.io. Podríamos dividirlo en columnas tipadas una vez que sepamos qué es realmente variante; hasta entonces JSONB mantiene el esquema plano.

error_log es un array JSONB de {source_id, source_slug, reason} para que el dashboard pueda renderizar "12 de 4.302 enlaces no pudieron ser migrados" sin una tabla separada o un join. El worker trunca en 1.000 entradas - más allá de eso tienes un problema estructural y solo el recuento es la señal accionable.

El worker#

Una sola goroutine por trabajo iniciado. El worker vive en api-core (services/api-core/internal/imports/bitly.go) para v1 - menos piezas móviles, sin bus de eventos inter-servicios, y el contexto por trabajo está limitado por un timeout de 30 minutos.

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

Estas cuatro constantes hacen la mayor parte del trabajo. No son un knob de configuración - son el contrato.

Cuatro tarjetas etiquetadas que muestran las constantes del worker que delimitan la importacion: 50k MaxLinksPerImport, un ImportRunBudget de 30 minutos, 100 enlaces por pagina de Bitly y un limite de 1.000 entradas en el registro de errores

MaxLinksPerImport es una barandilla, no un límite de producto. La mayoría de los usuarios tienen menos de 5.000 bitlinks. Por encima de 50k queremos una migración por chunks con checkpointing explícito, así que el worker falla duro con una instrucción para enviar email a [email protected]. Mañana apunta a un SKU de concierge pagado; hoy se enruta a la bandeja de entrada.

ImportRunBudget es el presupuesto de deploy-friendliness. Una cuenta de 50k a ~5 inserts/seg llega a aproximadamente tres horas; preferimos fallar rápido y re-ejecutar que hacer deploy sobre una goroutine de larga duración. Por encima de 50k o por encima de 30 minutos, ver el TODO de reanudabilidad al final del archivo.

Paginación#

La API de Bitly se comporta bien. GET /v4/groups/{guid}/bitlinks?size=100 devuelve enlaces más una URL pagination.next. next vacío significa terminado. Todo el bucle es:

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 en el cursor de paginación de Bitly. Si devuelven la misma URL next dos veces haremos un bucle, pero eso nunca ha pasado en las pruebas - y el presupuesto de 30 minutos limita el daño.

Resolución de conflictos#

Cuando un slug de Bitly colisiona con un enlace de Elido que ya existe en el dominio objetivo, el worker tiene que elegir. El usuario elige la estrategia cuando inicia el trabajo:

  • suffix (por defecto): camina mylink-2, mylink-3, … hasta 50. Más allá de 50 lo tratamos como un error - eso señala una colisión patológica y deberían limpiar la fuente primero.
  • skip: deja el enlace de Elido existente en paz, registra la fila fuente en error_log, cuenta como omitido.
  • fail: aborta todo el trabajo en el primer conflicto. Para usuarios que quieren semántica estricta 1:1.
Flujo de decision donde una busqueda indexada sobre domain_id y slug bifurca en 'usar tal cual' cuando el slug esta libre, o en las estrategias suffix, skip y fail cuando un slug importado de Bitly colisiona en el dominio objetivo

La búsqueda es una sola lectura indexada en (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)
}

Esto es búsqueda secuencial, no insert-with-conflict. Pagamos una lectura extra por fila pero obtenemos un recorrido determinista de sufijos y un mensaje de error mucho más amigable - la alternativa es pescar una violación de unicidad en pgx y analizar el nombre de la restricción de la cadena de error.

Lo que no migramos#

Historial de clics. Bitly no expone datos por clic para exportación - solo contadores agregados por enlace, y solo en planes Pro. Por eso lo mostramos en cada superficie que el usuario ve: la página de receta del dashboard, el landing de marketing, la UI de progreso de importación y la sección FAQ de /migrate-from/bitly. Los nuevos clics aterrizan en la analítica de Elido desde el momento del corte hacia adelante.

Consideramos obtener /v4/bitlinks/{id}/clicks/summary por enlace para sembrar una métrica de "recuento de clics importados". Rechazado: triplica las llamadas a la API y da un único número difuso que no puede impulsar ningún análisis real. Si necesitas clics históricos, los necesitas en GA4 o tu propio warehouse de todos modos.

Los diseños QR y las campañas de Bitly también se descartan. Son estructuras específicas del proveedor que no mapean limpiamente. Los enlaces importados de Bitly llevan una etiqueta imported:bitly para que puedas filtrarlos en masa - la mayoría de los usuarios usan eso para asignar un overlay CTA predeterminado de Elido o una campaña post-hoc.

Manejo del token#

El token nunca aterriza en disco. El handler HTTP lo acepta en el body de la solicitud, lo deja caer en una estructura BitlyJobOptions y se lo entrega al worker a través del lanzamiento de la goroutine:

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

source_token_id permanece NULL. La tabla service_tokens existe y cablearemos migraciones a ella para las integraciones de paste-token Tier-2 (Mailchimp, Brevo, Klaviyo, …) donde el valor de la persistencia es el uso recurrente. Para migraciones de un solo disparo el beneficio operativo no justifica la superficie de almacenamiento - el usuario pega el token una vez, el worker se ejecuta, el token se ha ido.

context.WithoutCancel es la pieza nueva para mí. El contexto de solicitud del handler es normalmente cómo los programas Go propagan la cancelación. Necesitamos lo opuesto - el worker debe sobrevivir a la solicitud HTTP que lo inició. WithoutCancel (Go 1.21+) mantiene los valores del contexto (logger, IDs de traza, sin deadline) pero elimina su señal de cancelación.

Reanudabilidad y el problema del deploy#

El worker es in-process. Un deploy a mitad de la importación mata la goroutine. Aceptamos eso para v1 porque:

  1. La mayoría de los trabajos terminan en menos de cinco minutos. Los deploys son infrecuentes en las horas más cargadas de importación del día.
  2. La fila import_jobs registra last_progress_at. Un tick del scheduler cada 5 minutos voltea cualquier fila running sin progreso en los últimos 30 minutos a failed con una razón clara "worker stalled", para que los usuarios no se queden preguntándose qué pasó.
  3. Re-ejecutar es idempotente bajo estrategias suffix y skip - los enlaces ya importados se detectan y resuelven según la estrategia. Sin corrupción de datos.

Ese es el intercambio. Para cuentas al norte de 10.000 enlaces, la reanudabilidad se gana su salario - registramos el cursor de paginación de Bitly en import_jobs.source_filter y retomamos donde la última ejecución se quedó. Esa es la siguiente iteración.

Lo que es medible#

Lanza una feature, instrumenta una feature. El handler emite logs estructurados zap para cada evento del ciclo de vida del trabajo:

  • import: starting bitly run - workspace, dominio objetivo, estrategia de conflicto, group GUID
  • import: bitly run complete - importados, omitidos, fallidos, total
  • imports stuck-sweep flipped jobs to failed - conteo

Aún no estamos graficando esto en producción - el primer lote de ejecuciones de usuarios reales nos dirá sobre qué alertar. Suposición inicial: conteo de stuck-sweep > 0 en cualquier ventana de 1 hora es una señal de paginación, porque significa que un worker murió y la UI del usuario está atascada en running más tiempo del que deberían tolerar.

Qué sigue#

El mismo andamiaje, cuatro proveedores más:

  • Rebrandly - GET /v1/links?limit=25 paginado. Slashtag → slug 1:1 cuando el slug está libre.
  • Short.io - GET /links?limit=150&domain_id=…. Paginación por dominio; listamos los dominios primero para que el usuario pueda elegir una fuente.
  • Dub.co - GET /api/links?projectSlug=…&limit=100. Carpetas + etiquetas preservadas; este es el más fácil de los cuatro.
  • TinyURL - solo subida CSV. TinyURL público no tiene API; los planes Pro exportan CSV. Aceptamos el CSV directamente y nos saltamos el viaje al lado del proveedor.

Cada uno aterriza detrás de la misma fila import_jobs y la misma UI de polling del dashboard. El worker específico del proveedor permanece en services/api-core/internal/imports/<vendor>.go.

Si has estado posponiendo una comparación con Bitly porque la historia de migración era vaga, la historia de migración ya no es vaga. Pruébalo - token al último enlace importado en menos de diez minutos para cuentas típicas.

Relacionados en el blog#

Prueba Elido

Pega una URL, obtén un enlace corto

Sin registro. El enlace vive 30 días. Crea una cuenta para conservarlo.

Gratis, sin registro · 2 por día

Prueba Elido

Acortador de URL alojado en la UE: dominios personalizados, análisis profundo y API abierta. Plan gratuito - sin tarjeta de crédito.

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

Seguir leyendo