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.
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.
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.
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:
- 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.
- La fila
import_jobsregistralast_progress_at. Un tick del scheduler cada 5 minutos voltea cualquier filarunningsin progreso en los últimos 30 minutos afailedcon una razón clara "worker stalled", para que los usuarios no se queden preguntándose qué pasó. - 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 GUIDimport: bitly run complete- importados, omitidos, fallidos, totalimports 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=25paginado. 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