8 мин чтенияИнженерия

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

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

Marius Voß
DevRel · edge infra
Диаграмма конвейера: Bitly API слева проходит через Elido import worker в таблицу links, со боковой панелью, перечисляющей четыре числовых гарантии воркера (50k cap, 30 min budget, 100/page, token never persisted)

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

Этот пост - технический разбор: что под капотом, что намеренно оставлено за бортом и почему воркер пока работает внутри процесса.

Схема пайплайна: Bitly API слева подает постраничные запросы с токен-аутентификацией в единую внутрипроцессную goroutine импорта в api-core, которая вставляет ссылки с сохранением slug в таблицу ссылок 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 намеренно может принимать значение null. У 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 битлинков. При превышении 50к мы хотим использовать сегментированную миграцию с явными чекпоинтами, поэтому воркер выдает критическую ошибку с инструкцией написать на [email protected]. Завтра это будет указывать на платный тариф concierge; сегодня - просто в почтовый ящик.

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. Если они дважды вернут один и тот же URL next, мы зациклимся, но при тестировании такого не случалось - а 30-минутный бюджет ограничивает возможный ущерб.

Разрешение конфликтов

Когда slug из Bitly конфликтует с уже существующей ссылкой Elido на целевом домене, воркер должен сделать выбор. Пользователь выбирает стратегию при запуске задачи:

  • suffix (по умолчанию): перебор mylink-2, mylink-3, … до 50. После 50 мы считаем это ошибкой - это сигнализирует о патологическом конфликте, и пользователю следует сначала навести порядок в источнике.
  • skip: оставить существующую ссылку Elido в покое, записать исходную строку в error_log, посчитать как пропущенную.
  • fail: прервать всю задачу при первом же конфликте. Для пользователей, которым нужна строгая семантика 1:1.
Дерево решений: одиночный индексированный поиск по domain_id и slug разветвляется на «использовать как есть» при свободном slug или на стратегии suffix, skip и fail при коллизии импортируемого Bitly slug на целевом домене

Поиск выполняется одним индексированным чтением по (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)
}

Это последовательный поиск, а не вставка с обработкой конфликта (insert-with-conflict). Мы платим за одно дополнительное чтение на строку, но получаем детерминированный перебор суффиксов и гораздо более понятное сообщение об ошибке - альтернативой было бы вылавливание нарушения уникальности в pgx и парсинг имени ограничения из строки ошибки.

Что мы не мигрируем

Историю кликов. 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 с четкой причиной «worker stalled» (воркер завис), чтобы пользователи не гадали, что произошло.
  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 в любом часовом окне - это повод для вызова инженера, так как это означает, что воркер упал, а интерфейс пользователя застрял в статусе running дольше допустимого.

Что дальше

Та же база, еще четыре вендора:

  • Rebrandly - пагинация через GET /v1/links?limit=25. Slashtag → slug 1:1, если slug свободен.
  • Short.io - GET /links?limit=150&domain_id=…. Пагинация по доменам; сначала мы выводим список доменов, чтобы пользователь мог выбрать источник.
  • Dub.co - GET /api/links?projectSlug=…&limit=100. Папки и теги сохраняются; это самая простая задача из четырех.
  • TinyURL - только загрузка CSV. У бесплатного TinyURL нет API; тарифы Pro позволяют экспортировать CSV. Мы принимаем CSV напрямую и пропускаем запросы к API вендора.

Каждый из них будет использовать ту же запись в 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

Читать дальше