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, выберите группу, нажмите «Начать». Пять минут спустя каждая ссылка будет доступна по адресу 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

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