La première source de migration pour notre rollout d'intégration Tier-3 a livré aujourd'hui. Collez un Bitly Generic Access Token, choisissez un groupe, cliquez sur Start. Cinq minutes plus tard, chaque lien se trouve sur s.elido.me/<slug> (ou votre domaine personnalisé) avec le slug Bitly préservé.
Ce billet est l'écriture ingénierie - ce qui est dans le code, ce qui est délibérément laissé de côté, et pourquoi le worker est in-process pour l'instant.
Pourquoi Bitly en premier#
Cinq fournisseurs sont en file dans le plan de rollout : Bitly, Rebrandly, Short.io, Dub.co, TinyURL. Bitly est le premier parce que la gravité SEO et acquisition est sur cette requête de recherche spécifique - « Bitly alternative ». Chaque autre source de migration bénéficie du partage du scaffolding worker que nous mettons en place pour Bitly. L'ordre est le coût d'ingénierie croissant ; le SEO est le tie-breaker.
Les quatre autres fournisseurs atterriront dans les quatre prochaines semaines contre la même table import_jobs.
Modèle de données#
Toute la fonctionnalité est une seule table :
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 est nullable à dessein. TinyURL n'a pas d'API publique pour les comptes gratuits, donc son chemin est un upload CSV - pas de token. Les uploads CSV obtiennent quand même une ligne dans la même table pour que le dashboard expose une seule UI « import progress » pour les cinq sources.
source_filter est un sac JSONB pour les choses spécifiques au fournisseur : {group_guid: "..."} pour Bitly, {project_slug: "..."} pour Dub, {domain_id: 123} pour Short.io. Nous pourrions le diviser en colonnes typées une fois que nous saurons ce qui est réellement variable ; jusque-là, JSONB garde le schéma plat.
error_log est un tableau JSONB de {source_id, source_slug, reason} pour que le dashboard puisse afficher « 12 des 4 302 liens n'ont pas pu être migrés » sans table séparée ou jointure. Le worker tronque à 1 000 entrées - au-delà, vous avez un problème structurel et le compte seul est le signal exploitable.
Le worker#
Une seule goroutine par job lancé. Le worker vit dans api-core (services/api-core/internal/imports/bitly.go) pour v1 - moins de pièces mobiles, pas de bus d'événements inter-services, et le contexte par job est borné par un timeout de 30 minutes.
const (
MaxLinksPerImport = 50_000
ImportRunBudget = 30 * time.Minute
progressEvery = 50
errorLogCap = 1_000
bitlyPageSize = 100
)
Ces quatre constantes font la majeure partie du travail. Ce ne sont pas des boutons de config - c'est le contrat.
MaxLinksPerImport est un garde-fou, pas une limite produit. La plupart des utilisateurs ont moins de 5 000 bitlinks. Au-dessus de 50k, nous voulons une migration chunkée avec checkpointing explicite, donc le worker échoue dur avec une instruction d'emailer [email protected]. Demain ça pointe sur un SKU concierge payant ; aujourd'hui ça route vers la boîte de réception.
ImportRunBudget est le budget de friendly-deploy. Un compte de 50k à ~5 inserts/sec atteint environ trois heures ; nous préférons échouer vite et relancer plutôt que de déployer-au-dessus d'une goroutine de longue durée. Au-dessus de 50k ou au-dessus de 30 minutes, voir le TODO de reprise en bas du fichier.
Pagination#
L'API de Bitly est bien comportée. GET /v4/groups/{guid}/bitlinks?size=100 retourne des liens plus une URL pagination.next. next vide signifie terminé. Toute la boucle est :
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)
}
Nous faisons confiance au curseur de pagination de Bitly. S'ils retournent la même URL next deux fois, nous bouclons, mais ça n'est jamais arrivé dans les tests - et le budget de 30 minutes plafonne les dégâts.
Résolution de conflits#
Quand un slug Bitly entre en collision avec un lien Elido qui existe déjà sur le domaine cible, le worker doit choisir. L'utilisateur choisit la stratégie au moment où il lance le job :
- suffix (défaut) : parcourt
mylink-2,mylink-3, … jusqu'à 50. Au-delà de 50, nous traitons cela comme une erreur - ça signale une collision pathologique et ils devraient nettoyer la source en premier. - skip : laisse le lien Elido existant tranquille, journalise la ligne source dans
error_log, compte comme skipped. - fail : abandonne tout le job à la première collision. Pour les utilisateurs qui veulent une sémantique stricte 1:1.
La recherche est une seule lecture indexée sur (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)
}
C'est une recherche séquentielle, pas un insert-with-conflict. Nous payons une lecture supplémentaire par ligne mais obtenons une marche de suffixe déterministe et un message d'erreur beaucoup plus amical - l'alternative est de pêcher une violation d'unicité dans pgx et de parser le nom de la contrainte hors de la chaîne d'erreur.
Ce que nous ne migrons pas#
L'historique de clics. Bitly n'expose pas les données par clic pour export - seulement des compteurs agrégés par lien, et seulement sur les plans Pro. Nous le faisons donc apparaître sur chaque surface que voit l'utilisateur : la page recette du dashboard, la landing marketing, l'UI de progression d'import et la section FAQ de /migrate-from/bitly. Les nouveaux clics atterrissent dans les analytics Elido à partir du moment du cutover.
Nous avons considéré récupérer /v4/bitlinks/{id}/clicks/summary par lien pour seeder une métrique « compteur de clics importés ». Rejeté : ça triple les appels API et donne un seul chiffre flou qui ne peut piloter aucune analyse réelle. Si vous avez besoin de clics historiques, vous en avez besoin dans GA4 ou votre propre entrepôt de toute façon.
Les designs QR et les campagnes Bitly sont aussi abandonnés. Ce sont des structures spécifiques au fournisseur qui ne se mappent pas proprement. Les liens importés depuis Bitly portent un tag imported:bitly pour que vous puissiez les filtrer en bulk - la plupart des utilisateurs s'en servent pour assigner un CTA overlay Elido par défaut ou une campagne post-hoc.
Gestion du token#
Le token n'atterrit jamais sur disque. Le handler HTTP l'accepte dans le body de la requête, le drop dans une struct BitlyJobOptions, et le passe au worker via le lancement 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 reste NULL. La table service_tokens existe et nous câblerons les migrations dans elle pour les intégrations Tier-2 paste-token (Mailchimp, Brevo, Klaviyo, …) où la valeur de la persistence est l'usage récurrent. Pour les migrations one-shot, le bénéfice opérationnel ne justifie pas la surface de stockage - l'utilisateur colle le token une fois, le worker tourne, le token disparaît.
context.WithoutCancel est la pièce nouvelle pour moi. Le contexte de requête du handler est normalement la façon dont les programmes Go propagent l'annulation. Nous avons besoin de l'opposé - le worker doit survivre à la requête HTTP qui l'a lancé. WithoutCancel (Go 1.21+) garde les valeurs du contexte (logger, trace IDs, deadline-less) mais retire son signal d'annulation.
Reprise et le problème du déploiement#
Le worker est in-process. Un déploiement au milieu de l'import tue la goroutine. Nous acceptons cela pour v1 parce que :
- La plupart des jobs finissent en moins de cinq minutes. Les déploiements sont peu fréquents aux moments d'import-y de la journée.
- La ligne
import_jobsenregistrelast_progress_at. Un tick de scheduler toutes les 5 minutes flippe toute lignerunningsans progrès dans les 30 dernières minutes versfailedavec une raison claire « worker stalled », pour que les utilisateurs ne soient pas laissés à se demander ce qui s'est passé. - Relancer est idempotent sous les stratégies suffix et skip - les liens déjà importés sont détectés et résolus selon la stratégie. Pas de corruption de données.
C'est le trade. Pour les comptes au nord de 10 000 liens, la reprise gagne sa pierre - nous enregistrons le curseur de pagination Bitly dans import_jobs.source_filter et reprenons où la dernière exécution s'est arrêtée. C'est la prochaine itération.
Ce qui est mesurable#
Livrer une fonctionnalité, instrumenter une fonctionnalité. Le handler émet des logs zap structurés pour chaque événement du cycle de vie du job :
import: starting bitly run- workspace, target domain, conflict strategy, group GUIDimport: bitly run complete- imported, skipped, failed, totalimports stuck-sweep flipped jobs to failed- count
Nous ne les graphons pas encore en production - le premier batch d'exécutions réelles nous dira sur quoi alerter. Hypothèse initiale : un compte stuck-sweep > 0 dans n'importe quelle fenêtre d'1 heure est un signal de pagination, parce que ça signifie qu'un worker est mort et que l'UI de l'utilisateur est bloquée sur running plus longtemps qu'elle ne devrait tolérer.
Quoi de neuf ensuite#
Même scaffolding, quatre fournisseurs de plus :
- Rebrandly -
GET /v1/links?limit=25paginé. Slashtag → slug 1:1 quand le slug est libre. - Short.io -
GET /links?limit=150&domain_id=…. Pagination par domaine ; nous listons les domaines d'abord pour que l'utilisateur puisse choisir une source. - Dub.co -
GET /api/links?projectSlug=…&limit=100. Folders + tags préservés ; c'est le plus facile des quatre. - TinyURL - upload CSV uniquement. TinyURL public n'a pas d'API ; les plans Pro exportent CSV. Nous acceptons le CSV directement et sautons le round-trip côté fournisseur.
Chacun atterrit derrière la même ligne import_jobs et la même UI de polling dashboard. Le worker spécifique au fournisseur reste dans services/api-core/internal/imports/<vendor>.go.
Si vous reteniez une comparaison Bitly parce que l'histoire de migration était floue, l'histoire de migration n'est plus floue. Essayez - token au dernier lien importé en moins de dix minutes pour les comptes typiques.
Pour aller plus loin sur le blog#
Essayer Elido
Collez une URL, obtenez un lien court
Sans inscription. Lien actif 30 jours. Inscrivez-vous pour le garder pour toujours.
Gratuit, sans inscription · 2 par jour