Elido
8 хв читанняІнженерія

Запуск міграції з Bitly: воркер, токен та 30-хвилинний бюджет

Як ми створили імпорт з Bitly в один клік для Elido - архітектура воркера, правила вирішення конфліктів та чотири обмеження, що гарантують безпеку goroutine.

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)

Перше джерело міграції для нашого розгортання інтеграцій Tier-3 вийшло сьогодні. Вставте Bitly Generic Access Token, оберіть групу, натисніть Start. За п'ять хвилин кожне посилання з'явиться на s.elido.me/<slug> (або на вашому власному домені) зі збереженим слагом Bitly.

Цей допис - технічний огляд: що є в коді, що ми свідомо залишили поза увагою і чому воркер наразі працює як внутрішній процес.

Діаграма конвеєра: API Bitly зліва надсилає посторінкові запити з автентифікацією токена до єдиного внутрішнього goroutine воркера імпорту, який вставляє посилання зі збереженим слагом у таблицю посилань Elido

Чому спочатку 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
)

Ці чотири константи виконують основну роботу. Це не просто налаштування - це контракт.

Чотири картки з константами воркера, що обмежують імпорт: 50 тис. MaxLinksPerImport, 30-хвилинний ImportRunBudget, 100 посилань на сторінку Bitly та ліміт журналу помилок у 1 000 записів

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 розгалужується на використання «як є» якщо слаг вільний, або на стратегії suffix, skip та fail при конфлікті імпортованого слага Bitly на цільовому домені

Пошук здійснюється одним індексованим читанням за (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, оскільки:

  1. Більшість завдань завершуються менш ніж за п'ять хвилин. Деплої рідко відбуваються в пікові години для імпорту.
  2. Рядок import_jobs фіксує last_progress_at. Планувальник кожні 5 хвилин перевіряє завдання: будь-яке завдання в статусі running без прогресу протягом останніх 30 хвилин переводиться в failed із чітким описом причини «воркер зупинився», щоб користувачі не гадали, що сталося.
  3. Повторний запуск є ідемпотентним для стратегій suffix та skip - уже імпортовані посилання виявляються та обробляються згідно зі стратегією. Дані не пошкоджуються.

Це компроміс. Для акаунтів з понад 10 000 посилань можливість відновлення є виправданою - ми записуємо курсор пагінації Bitly в import_jobs.source_filter і продовжуємо з того місця, де зупинилися минулого разу. Це завдання для наступної ітерації.

Що можна виміряти#

Випустили функцію - додайте інструментарій для вимірювання. Обробник створює структуровані логи zap для кожної події життєвого циклу завдання:

  • 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

Ми ще не будуємо графіки для цього в продакшні - перші запуски реальних користувачів покажуть, на що саме варто налаштувати сповіщення. Попереднє припущення: кількість 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 через те, що історія з міграцією була непевною, тепер вона цілком конкретна. Спробуйте - шлях від токена до останнього імпортованого посилання займає менше десяти хвилин для типових акаунтів.

Схожі статті у блозі#

Спробуйте Elido

Вставте URL - отримайте коротке посилання

Без реєстрації. Посилання живе 30 днів. Зареєструйтесь, щоб зберегти назавжди.

Безкоштовно, без реєстрації · 2 на день

Спробуйте Elido

URL-скорочувач із хостингом у ЄС: власні домени, глибока аналітика, відкритий API. Безкоштовний тариф - без кредитної картки.

Теги
bitly migration
url shortener
go worker
data migration
engineering
tier 3 integrations

Читати далі