Elido
8 Min. LesezeitEngineering

Die Bitly-Migration ausliefern: ein Worker, ein Token, ein 30-Minuten-Budget

Wie wir One-Click-Imports von Bitly für Elido gebaut haben - das Worker-Design, die Konfliktauflösungsregeln und die vier Limits, die eine In-Process-Goroutine sicher halten.

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)

Die erste Migrationsquelle für unseren Tier-3-Integrationsrollout ist heute live gegangen. Ein Bitly Generic Access Token einfügen, eine Gruppe wählen, Start klicken. Fünf Minuten später sitzt jeder Link auf s.elido.me/<slug> (oder Ihrer Custom Domain), wobei der Bitly-Slug erhalten bleibt.

Dieser Beitrag ist das Engineering-Write-up - was im Code steckt, was bewusst weggelassen wurde, und warum der Worker vorerst In-Process ist.

Pipeline-Diagramm: Bitly API links, seitenweise token-authentifizierte Anfragen fließen in eine einzelne In-Process-Import-Worker-Goroutine in api-core, die slug-erhaltende Links in die Elido-Links-Tabelle einfügt

Warum Bitly zuerst#

Fünf Anbieter stehen im Rollout-Plan in der Warteschlange: Bitly, Rebrandly, Short.io, Dub.co, TinyURL. Bitly ist zuerst dran, weil die SEO- und Acquisition-Gravitation auf genau dieser einen Suchanfrage liegt - „Bitly alternative". Jede andere Migrationsquelle profitiert vom Teilen der Worker-Scaffolding, die wir für Bitly aufgebaut haben. Die Reihenfolge ist aufsteigend nach Engineering-Kosten; SEO ist der Tie-Breaker.

Die vier anderen Anbieter landen in den nächsten vier Wochen gegen dieselbe import_jobs-Tabelle.

Datenmodell#

Das ganze Feature ist eine einzige Tabelle:

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 ist absichtlich nullable. TinyURL hat für Free-Accounts keine öffentliche API, daher ist sein Pfad ein CSV-Upload - kein Token. CSV-Uploads bekommen trotzdem eine Zeile in derselben Tabelle, damit das Dashboard eine einzige „Import-Progress"-UI für alle fünf Quellen ausspielt.

source_filter ist ein JSONB-Bag für anbieter-spezifische Dinge: {group_guid: "..."} für Bitly, {project_slug: "..."} für Dub, {domain_id: 123} für Short.io. Wir könnten es in getypte Spalten aufteilen, sobald wir wissen, was tatsächlich variant ist; bis dahin hält JSONB das Schema flach.

error_log ist ein JSONB-Array aus {source_id, source_slug, reason}, damit das Dashboard „12 von 4.302 Links konnten nicht migriert werden" rendern kann, ohne eine separate Tabelle oder einen Join. Der Worker schneidet bei 1.000 Einträgen ab - darüber hinaus haben Sie ein strukturelles Problem, und allein die Zählung ist das handlungsrelevante Signal.

Der Worker#

Eine einzelne Goroutine pro gestartetem Job. Der Worker lebt für v1 in api-core (services/api-core/internal/imports/bitly.go) - weniger bewegliche Teile, kein Inter-Service-Event-Bus, und der Pro-Job-Context ist durch ein 30-Minuten-Timeout begrenzt.

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

Diese vier Konstanten leisten die meiste Arbeit. Sie sind kein Config-Knopf - sie sind der Vertrag.

Vier beschriftete Karten mit den Worker-Konstanten, die den Import begrenzen: 50k MaxLinksPerImport, ein 30-Minuten-ImportRunBudget, 100 Links pro Bitly-Seite und ein Fehlerprotokoll-Limit von 1.000 Einträgen

MaxLinksPerImport ist ein Guardrail, kein Produkt-Limit. Die meisten Nutzer haben unter 5.000 Bitlinks. Über 50k wollen wir eine gechunkte Migration mit explizitem Checkpointing, also bricht der Worker hart ab mit der Anweisung, an [email protected] zu mailen. Morgen zeigt das auf eine kostenpflichtige Concierge-SKU; heute routet es in die Inbox.

ImportRunBudget ist das Deploy-Friendliness-Budget. Ein 50k-Account bei ~5 Inserts/Sek. erreicht etwa drei Stunden; wir wollen lieber schnell fehlschlagen und neu starten, als über eine langlaufende Goroutine zu deployen. Über 50k oder über 30 Minuten siehe das Resumability-TODO am Ende der Datei.

Pagination#

Bitlys API verhält sich brav. GET /v4/groups/{guid}/bitlinks?size=100 gibt Links plus eine pagination.next-URL zurück. Leeres next bedeutet fertig. Die ganze Schleife ist:

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

Wir vertrauen Bitlys Pagination-Cursor. Wenn sie dieselbe next-URL zweimal zurückgeben, loopen wir, aber das ist in Tests nie passiert - und das 30-Minuten-Budget begrenzt den Schaden.

Konfliktauflösung#

Wenn ein Bitly-Slug mit einem bereits existierenden Elido-Link auf der Ziel-Domain kollidiert, muss der Worker wählen. Der Nutzer wählt die Strategie beim Anstoßen des Jobs:

  • suffix (Default): mylink-2, mylink-3, … bis zu 50 durchgehen. Jenseits 50 behandeln wir das als Fehler - das signalisiert eine pathologische Kollision, und der Nutzer sollte zuerst die Quelle aufräumen.
  • skip: den bestehenden Elido-Link unangetastet lassen, die Quellzeile in error_log loggen, als skipped zählen.
  • fail: den ganzen Job beim ersten Konflikt abbrechen. Für Nutzer, die strikte 1:1-Semantik wollen.
Entscheidungsfluss: Ein einzelner indizierter Lookup auf domain_id und slug verzweigt zu 'direkt verwenden' wenn frei, oder zu den Strategien suffix, skip und fail wenn ein importierter Bitly-Slug auf der Ziel-Domain kollidiert

Die Suche ist ein einzelner indizierter Read auf (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)
}

Das ist sequentielles Lookup, kein Insert-with-Conflict. Wir zahlen einen zusätzlichen Read pro Zeile, bekommen aber einen deterministischen Suffix-Walk und eine deutlich freundlichere Fehlermeldung - die Alternative wäre, in pgx nach einer Uniqueness-Violation zu fischen und den Constraint-Namen aus dem Error-String zu parsen.

Was wir nicht migrieren#

Click-History. Bitly stellt keine Pro-Click-Daten zum Export bereit - nur aggregierte Counter pro Link, und nur in Pro-Plänen. Daher kommunizieren wir das auf jeder einzelnen Oberfläche, die der Nutzer sieht: der Dashboard-Recipe-Seite, dem Marketing-Landing, der Import-Progress-UI und im FAQ-Bereich von /migrate-from/bitly. Neue Klicks landen ab dem Cutover-Moment in den Elido-Analytics.

Wir haben überlegt, /v4/bitlinks/{id}/clicks/summary pro Link zu fetchen, um eine „imported click count"-Metrik zu seeden. Verworfen: Es verdreifacht die API-Aufrufe und liefert eine einzige unscharfe Zahl, die keine echte Analyse treiben kann. Wenn Sie historische Klicks brauchen, brauchen Sie sie ohnehin in GA4 oder Ihrem eigenen Warehouse.

QR-Designs und Bitly-Kampagnen werden ebenfalls fallengelassen. Sie sind anbieter-spezifische Strukturen, die nicht sauber mappen. Die Bitly-importierten Links tragen einen imported:bitly-Tag, damit Sie sie en bloc filtern können - die meisten Nutzer verwenden das, um nachträglich ein Default-Elido-CTA-Overlay oder eine Kampagne zuzuweisen.

Token-Handhabung#

Das Token landet nie auf der Platte. Der HTTP-Handler nimmt es im Request-Body entgegen, legt es in ein BitlyJobOptions-Struct und übergibt es dem Worker via Goroutine-Launch:

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

source_token_id bleibt NULL. Die service_tokens-Tabelle existiert, und wir werden Migrationen daran anschließen für die Tier-2-Paste-Token-Integrationen (Mailchimp, Brevo, Klaviyo, …), wo der Wert der Persistenz wiederkehrende Nutzung ist. Für One-Shot-Migrationen rechtfertigt der operative Vorteil die Storage-Oberfläche nicht - der Nutzer fügt das Token einmal ein, der Worker läuft, das Token ist weg.

context.WithoutCancel ist das für mich neue Stück. Der Request-Context des Handlers ist normalerweise, wie Go-Programme Cancellation propagieren. Wir brauchen das Gegenteil - der Worker soll den HTTP-Request überleben, der ihn gestartet hat. WithoutCancel (Go 1.21+) behält die Values des Contexts (Logger, Trace-IDs, deadline-los) bei, entfernt aber sein Cancellation-Signal.

Resumability und das Deploy-Problem#

Der Worker ist In-Process. Ein Deploy mitten im Import killt die Goroutine. Wir akzeptieren das für v1, weil:

  1. Die meisten Jobs in unter fünf Minuten abgeschlossen sind. Deploys sind zu Import-typischen Tageszeiten selten.
  2. Die import_jobs-Zeile last_progress_at aufzeichnet. Ein Scheduler-Tick alle 5 Minuten setzt jede running-Zeile ohne Fortschritt in den letzten 30 Minuten auf failed mit klarem „worker stalled"-Grund, sodass Nutzer nicht im Unklaren bleiben.
  3. Re-Running unter Suffix- und Skip-Strategien idempotent ist - bereits importierte Links werden erkannt und gemäß Strategie aufgelöst. Keine Datenkorruption.

Das ist der Trade. Für Accounts nördlich von 10.000 Links verdient Resumability ihr Geld - wir zeichnen den Bitly-Pagination-Cursor in import_jobs.source_filter auf und nehmen dort auf, wo der letzte Lauf aufgehört hat. Das ist die nächste Iteration.

Was messbar ist#

Ein Feature ausliefern, ein Feature instrumentieren. Der Handler emittiert strukturierte zap-Logs für jedes Job-Lifecycle-Event:

  • import: starting bitly run - Workspace, Ziel-Domain, Konfliktstrategie, Group-GUID
  • import: bitly run complete - imported, skipped, failed, total
  • imports stuck-sweep flipped jobs to failed - Anzahl

Wir graphen das in Produktion noch nicht - der erste Schwung echter Nutzer-Runs wird uns zeigen, worauf zu alertieren ist. Erste Vermutung: stuck-sweep count > 0 in einem beliebigen 1-Stunden-Fenster ist ein Paging-Signal, denn es bedeutet, dass ein Worker gestorben ist und die UI des Nutzers länger auf running festhängt, als er tolerieren sollte.

Was als Nächstes kommt#

Dieselbe Scaffolding, vier weitere Anbieter:

  • Rebrandly - GET /v1/links?limit=25 paginiert. Slashtag → Slug 1:1, wenn der Slug frei ist.
  • Short.io - GET /links?limit=150&domain_id=…. Pro-Domain-Pagination; wir listen zuerst Domains, damit der Nutzer eine Quelle auswählen kann.
  • Dub.co - GET /api/links?projectSlug=…&limit=100. Folder + Tags bleiben erhalten; das ist das einfachste der vier.
  • TinyURL - nur CSV-Upload. Public TinyURL hat keine API; Pro-Pläne exportieren CSV. Wir akzeptieren die CSV direkt und überspringen den Anbieter-seitigen Roundtrip.

Jedes landet hinter derselben import_jobs-Zeile und derselben Dashboard-Polling-UI. Der anbieter-spezifische Worker bleibt in services/api-core/internal/imports/<vendor>.go.

Wenn Sie wegen einer hand-wavy Migrations-Story bei einem Bitly-Vergleich gezögert haben, ist die Migrations-Story nicht mehr hand-wavy. Probieren Sie es aus - Token bis zum letzten importierten Link in unter zehn Minuten für typische Accounts.

Verwandt im Blog#

Elido testen

URL einfügen, kurzer Link in Sekunden

Kein Konto nötig. Link bleibt 30 Tage aktiv. Konto erstellen, um ihn dauerhaft zu behalten.

Kostenlos, keine Anmeldung erforderlich · 2 pro Tag

Elido testen

URL-Shortener mit EU-Hosting: eigene Domains, tiefe Analytik und eine offene API. Kostenloser Tarif - keine Kreditkarte nötig.

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

Weiterlesen