9 min de lectureIngénierie

Livraison de la migration Bitly : un worker, un token, un budget de 30 minutes

Comment nous avons construit les imports Bitly en un clic pour Elido - la conception du worker, les règles de résolution de conflits et les quatre plafonds qui maintiennent une goroutine in-process en sécurité.

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 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.

Schéma de pipeline montrant l'API Bitly à gauche alimentant des requêtes paginées authentifiées par token vers une seule goroutine de worker d'import in-process dans api-core, qui insère des liens avec slug préservé dans la table de liens Elido

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.

Quatre cartes étiquetées montrant les constantes du worker qui délimitent l'import : 50k MaxLinksPerImport, un budget ImportRunBudget de 30 minutes, 100 liens par page Bitly, et un plafond de journal d'erreurs de 1 000 entrées

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.
Flux de décision ou une recherche indexée unique sur domain_id et slug se divise en utilisation telle quelle si libre, ou dans les stratégies suffix, skip et fail quand un slug Bitly importé entre en collision sur le domaine cible

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 :

  1. 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.
  2. La ligne import_jobs enregistre last_progress_at. Un tick de scheduler toutes les 5 minutes flippe toute ligne running sans progrès dans les 30 dernières minutes vers failed avec une raison claire « worker stalled », pour que les utilisateurs ne soient pas laissés à se demander ce qui s'est passé.
  3. 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 GUID
  • import: bitly run complete - imported, skipped, failed, total
  • imports 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=25 paginé. 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

Essayer Elido

Raccourcisseur d'URL hébergé en UE : domaines personnalisés, analyses approfondies et API ouverte. Forfait gratuit - sans carte bancaire.

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

Lire la suite