Первый источник миграции для развертывания интеграций Tier-3 запущен сегодня. Вставьте Bitly Generic Access Token, выберите группу, нажмите «Начать». Пять минут спустя каждая ссылка будет доступна по адресу s.elido.me/<slug> (или на вашем кастомном домене) с сохранением Bitly slug.
Этот пост - технический разбор: что под капотом, что намеренно оставлено за бортом и почему воркер пока работает внутри процесса.
Почему 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
)
Эти четыре константы выполняют основную работу. Это не ручки настройки, а условия контракта.
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):
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, потому что:
- Большинство задач завершается менее чем за пять минут. Деплои происходят нечасто в часы, когда обычно запускают импорт.
- Запись в
import_jobsфиксируетlast_progress_at. Тик планировщика каждые 5 минут переводит любые строки со статусомrunningбез прогресса за последние 30 минут в статусfailedс четкой причиной «worker stalled» (воркер завис), чтобы пользователи не гадали, что произошло. - Повторный запуск идемпотентен при стратегиях 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 в любом часовом окне - это повод для вызова инженера, так как это означает, что воркер упал, а интерфейс пользователя застрял в статусе 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 в день