Перше джерело міграції для нашого розгортання інтеграцій Tier-3 вийшло сьогодні. Вставте Bitly Generic Access Token, оберіть групу, натисніть Start. За п'ять хвилин кожне посилання з'явиться на s.elido.me/<slug> (або на вашому власному домені) зі збереженим слагом Bitly.
Цей допис - технічний огляд: що є в коді, що ми свідомо залишили поза увагою і чому воркер наразі працює як внутрішній процес.
Чому спочатку Bitly
У плані розгортання в черзі стоять п'ять провайдерів: Bitly, Rebrandly, Short.io, Dub.co, TinyURL. Bitly став першим, оскільки SEO та залучення аудиторії зосереджені на одному конкретному пошуковому запиті - «Bitly alternative». Усі інші джерела міграції отримають перевагу від використання спільної структури воркера, яку ми розробили для Bitly. Порядок визначено за зростанням вартості розробки; SEO став вирішальним фактором.
Інші чотири провайдери з'являться протягом наступних чотирьох тижнів, використовуючи ту саму таблицю import_jobs.
Модель даних
Уся функція базується на одній таблиці:
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 може бути порожнім (nullable) навмисно. TinyURL не має публічного API для безкоштовних акаунтів, тому для нього передбачено завантаження CSV - без токена. Завантаження CSV все одно створює рядок у тій же таблиці, щоб панель керування відображала єдиний інтерфейс прогресу імпорту для всіх п'яти джерел.
source_filter - це JSONB-контейнер для специфічних даних провайдера: {group_guid: "..."} для Bitly, {project_slug: "..."} для Dub, {domain_id: 123} для Short.io. Ми зможемо розділити це на окремі типізовані колонки, коли зрозуміємо, які саме дані варіюються; до того часу JSONB дозволяє зберігати схему пласкою.
error_log - це JSONB-масив об'єктів {source_id, source_slug, reason}, завдяки чому панель керування може відобразити повідомлення «12 з 4 302 посилань не вдалося мігрувати» без окремої таблиці чи join. Воркер обрізає лог на 1 000 записах - якщо їх більше, це свідчить про структурну проблему, і сама кількість уже є достатнім сигналом для дій.
Воркер
Одна goroutine на кожне запущене завдання. Воркер знаходиться в api-core (services/api-core/internal/imports/bitly.go) для версії v1 - менше рухомих частин, відсутність міжсервісної шини подій, а контекст кожного завдання обмежений 30-хвилинним таймаутом.
const (
MaxLinksPerImport = 50_000
ImportRunBudget = 30 * time.Minute
progressEvery = 50
errorLogCap = 1_000
bitlyPageSize = 100
)
Ці чотири константи виконують основну роботу. Це не просто налаштування - це контракт.
MaxLinksPerImport - це запобіжник, а не ліміт продукту. Більшість користувачів мають менше 5 000 посилань bitlinks. Якщо їх понад 50 тисяч, ми хочемо використовувати розділену на частини міграцію з явними контрольними точками, тому воркер видає помилку з інструкцією написати на [email protected]. Завтра це посилання вестиме на платний консьєрж-сервіс; сьогодні воно просто спрямовує до поштової скриньки.
ImportRunBudget - це ліміт часу для зручності деплою. Акаунт із 50к посилань при швидкості ~5 вставок/сек потребує приблизно трьох годин; ми краще зупинимося раніше і перезапустимо процес, ніж будемо деплоїти поверх тривалої goroutine. Якщо посилань понад 50к або час перевищує 30 хвилин, дивіться TODO про можливість відновлення в кінці файлу.
Пагінація
API Bitly працює передбачувано. GET /v4/groups/{guid}/bitlinks?size=100 повертає посилання та URL pagination.next. Порожній next означає завершення. Весь цикл виглядає так:
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)
}
Ми довіряємо курсору пагінації Bitly. Якщо вони двічі повернуть той самий next URL, ми зациклимося, але під час тестування такого не траплялося - а 30-хвилинний бюджет обмежує можливі збитки.
Вирішення конфліктів
Коли слаг Bitly конфліктує з посиланням Elido, яке вже існує на цільовому домені, воркер має зробити вибір. Користувач обирає стратегію під час запуску завдання:
- suffix (за замовчуванням): додавати суфікси
mylink-2,mylink-3, … аж до 50. Після 50 ми вважаємо це помилкою - це свідчить про патологічне нагромадження конфліктів, і користувачу слід спочатку впорядкувати джерело. - skip: залишити існуюче посилання Elido, записати рядок джерела в
error_log, порахувати як пропущене. - fail: зупинити все завдання при першому ж конфлікті. Для користувачів, яким потрібна сувора семантика 1:1.
Пошук здійснюється одним індексованим читанням за (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)
}
Це послідовний пошук, а не вставка з конфліктом. Ми платимо за додаткове читання кожного рядка, але отримуємо детермінований перебір суфіксів і набагато зрозуміліше повідомлення про помилку - альтернативою був би пошук порушення унікальності в pgx та вилучення імені обмеження (constraint name) з рядка помилки.
Що ми не мігруємо
Історія кліків. Bitly не надає дані про окремі кліки для експорту - лише агреговані лічильники для кожного посилання, і лише в тарифах Pro. Тому ми повідомляємо про це на кожній поверхні, яку бачить користувач: сторінка налаштувань у панелі керування, маркетинговий лендинг, інтерфейс прогресу імпорту та розділ FAQ на /migrate-from/bitly. Нові кліки потраплятимуть в аналітику Elido з моменту переходу.
Ми розглядали можливість отримання /v4/bitlinks/{id}/clicks/summary для кожного посилання, щоб заповнити метрику «імпортована кількість кліків». Відмовилися: це потроює кількість викликів API й дає лише одне приблизне число, яке неможливо використати для реального аналізу. Якщо вам потрібна історія кліків, вона вам все одно потрібна в GA4 або у вашому власному сховищі даних.
Дизайн QR-кодів та кампанії Bitly також ігноруються. Це специфічні для провайдера структури, які неможливо чітко перенести. Імпортовані з Bitly посилання отримують тег imported:bitly, щоб ви могли фільтрувати їх масово - більшість користувачів використовують це для призначення стандартного CTA-оверлея Elido або кампанії вже після імпорту.
Обробка токенів
Токен ніколи не зберігається на диску. HTTP-обробник приймає його в тілі запиту, додає до структури BitlyJobOptions і передає воркеру через запуск goroutine:
bgCtx := context.WithoutCancel(r.Context())
go h.worker.Run(bgCtx, job.ID, imports.BitlyJobOptions{
Token: req.Token,
GroupGUID: req.GroupGUID,
})
source_token_id залишається NULL. Таблиця service_tokens існує, і ми підключимо міграції до неї для інтеграцій Tier-2 з використанням токенів (Mailchimp, Brevo, Klaviyo, …), де збереження токена має сенс для регулярного використання. Для одноразових міграцій операційна вигода не виправдовує ризики зберігання - користувач вставляє токен один раз, воркер запускається, токен зникає.
context.WithoutCancel - це нова для мене функція. Контекст запиту в обробнику зазвичай використовується в програмах на Go для поширення сигналу скасування. Нам же потрібно протилежне - воркер має продовжувати роботу після завершення HTTP-запиту, який його запустив. WithoutCancel (Go 1.21+) зберігає значення контексту (логер, ID трасування, без дедлайнів), але прибирає сигнал скасування.
Можливість відновлення та проблема деплою
Воркер працює як внутрішній процес. Деплой під час імпорту зупиняє goroutine. Ми приймаємо це для версії v1, оскільки:
- Більшість завдань завершуються менш ніж за п'ять хвилин. Деплої рідко відбуваються в пікові години для імпорту.
- Рядок
import_jobsфіксуєlast_progress_at. Планувальник кожні 5 хвилин перевіряє завдання: будь-яке завдання в статусіrunningбез прогресу протягом останніх 30 хвилин переводиться вfailedіз чітким описом причини «воркер зупинився», щоб користувачі не гадали, що сталося. - Повторний запуск є ідемпотентним для стратегій suffix та skip - уже імпортовані посилання виявляються та обробляються згідно зі стратегією. Дані не пошкоджуються.
Це компроміс. Для акаунтів з понад 10 000 посилань можливість відновлення є виправданою - ми записуємо курсор пагінації Bitly в import_jobs.source_filter і продовжуємо з того місця, де зупинилися минулого разу. Це завдання для наступної ітерації.
Що можна виміряти
Випустили функцію - додайте інструментарій для вимірювання. Обробник створює структуровані логи zap для кожної події життєвого циклу завдання:
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
Ми ще не будуємо графіки для цього в продакшні - перші запуски реальних користувачів покажуть, на що саме варто налаштувати сповіщення. Попереднє припущення: кількість stuck-sweep > 0 у будь-якому 1-годинному вікні є сигналом для сповіщення, оскільки це означає, що воркер перестав працювати, і інтерфейс користувача застряг на статусі running довше, ніж це допустимо.
Що далі
Та сама база, ще чотири провайдери:
- Rebrandly -
GET /v1/links?limit=25з пагінацією. Slashtag → slug 1:1, коли слаг вільний. - Short.io -
GET /links?limit=150&domain_id=…. Пагінація на рівні домену; ми спочатку виводимо список доменів, щоб користувач міг обрати джерело. - Dub.co -
GET /api/links?projectSlug=…&limit=100. Папки + теги зберігаються; це найпростіша з чотирьох інтеграцій. - TinyURL - тільки завантаження CSV. Публічний TinyURL не має API; плани Pro дозволяють експорт у CSV. Ми приймаємо CSV безпосередньо і пропускаємо етап запитів до провайдера.
Кожен із них використовуватиме той самий рядок import_jobs і той самий інтерфейс опитування в панелі керування. Специфічний для провайдера воркер залишається в services/api-core/internal/imports/<vendor>.go.
Якщо ви відкладали порівняння з Bitly через те, що історія з міграцією була непевною, тепер вона цілком конкретна. Спробуйте - шлях від токена до останнього імпортованого посилання займає менше десяти хвилин для типових акаунтів.
Схожі статті у блозі
- Міграція з Bitly без розриву посилань: сценарій відмов
- Досягнення p95 < 15мс для редіректів із регіон ЄС, US East та Азійсько-Тихоокеанський регіон
- Чому ми використовуємо наше аналітичне сховище для аналітики кліків (а не Postgres)
- Міграція з Rebrandly: передача брендованого домену без втрати слагів
Спробуйте Elido
Вставте URL - отримайте коротке посилання
Без реєстрації. Посилання живе 30 днів. Зареєструйтесь, щоб зберегти назавжди.
Безкоштовно, без реєстрації · 2 на день