Перше джерело міграції для нашого розгортання інтеграцій 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 на день