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.
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.
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_logloggen, als skipped zählen. - fail: den ganzen Job beim ersten Konflikt abbrechen. Für Nutzer, die strikte 1:1-Semantik wollen.
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:
- Die meisten Jobs in unter fünf Minuten abgeschlossen sind. Deploys sind zu Import-typischen Tageszeiten selten.
- Die
import_jobs-Zeilelast_progress_ataufzeichnet. Ein Scheduler-Tick alle 5 Minuten setzt jederunning-Zeile ohne Fortschritt in den letzten 30 Minuten auffailedmit klarem „worker stalled"-Grund, sodass Nutzer nicht im Unklaren bleiben. - 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-GUIDimport: bitly run complete- imported, skipped, failed, totalimports 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=25paginiert. 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