2分で読了エンジニアリング

Bitlyの移行をリリースする:ワーカー、トークン、30分のバジェット

ElidoにワンクリックのBitlyインポートをどのようにビルドしたかについてのEngineering記録 - ワーカーの設計、コンフリクト解決のルール、インプロセスのgoroutineを安全に保つ4つの制限。

Marius Voß
DevRel · edge infra
パイプライン図:左側のBitly APIがElido import workerを通ってlinksテーブルへと流れ、サイドパネルにはワーカーが保持する4つの数値保証(50k cap、30 min budget、100/page、token never persisted)がリストされている

Tier-3統合ロールアウトの最初の移行ソースが本日リリースされました。Bitly Generic Access Tokenを貼り付け、グループを選択し、開始をクリックします。5分後には、すべてのリンクがBitlyのスラグを保持したまま s.elido.me/<slug>(またはカスタムドメイン)に存在します。

この記事はエンジニアリングの記録です - コードに何があるか、意図的に省いたもの、そしてなぜワーカーが今のところインプロセスなのか。

左側のBitly APIがページ化されたトークン認証リクエストをapi-core内の単一インプロセスのインポートワーカーgoroutineに送り込み、スラグを保持したままElidoのlinksテーブルにインサートするパイプライン図

なぜBitlyが最初なのか

ロールアウト計画に5つのベンダーが並んでいます:Bitly、Rebrandly、Short.io、Dub.co、TinyURL。Bitlyが最初なのは、SEOと獲得の重力がその特定の検索クエリ - 「Bitlyの代替」 - に集中しているからです。他のすべての移行ソースは、Bitlyのために用意したワーカーの足場を共有することで恩恵を受けます。順番はエンジニアリングコストの昇順で、SEOがタイブレーカーです。

残り4つのベンダーは、同じ import_jobs テーブルに対して今後4週間でリリースされます。

データモデル

機能全体が1つのテーブルです:

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アップロードも同じテーブルに行が得られるため、ダッシュボードはすべての5つのソースに対して単一の「インポート進捗」UIを表示できます。

source_filter はベンダー固有のもののためのJSONBバッグです:Bitlyには {group_guid: "..."}、Dubには {project_slug: "..."}、Short.ioには {domain_id: 123}。実際に何が可変かがわかった時点で型付きカラムに分割できます。それまではJSONBでスキーマをフラットに保ちます。

error_log は {source_id, source_slug, reason} のJSONB配列で、ダッシュボードが別のテーブルや結合なしで「4,302リンク中12件を移行できませんでした」とレンダリングできます。ワーカーは1,000エントリで切り捨てます - それ以上は構造的な問題であり、カウント単体が行動可能なシグナルです。

ワーカー

キックオフされた各ジョブに対して1つのgoroutine。ワーカーはv1ではapi-core(services/api-core/internal/imports/bitly.go)に存在します - 可動部品が少なく、サービス間のイベントバスがなく、ジョブごとのコンテキストは30分のタイムアウトによって制限されます。

const (
    MaxLinksPerImport = 50_000
    ImportRunBudget   = 30 * time.Minute
    progressEvery     = 50
    errorLogCap       = 1_000
    bitlyPageSize     = 100
)

これら4つの定数がほとんどの仕事をします。設定ノブではありません - これらはコントラクトです。

インポートを制限するワーカー定数を示す4つのラベル付きカード:50k MaxLinksPerImport、30分のImportRunBudget、Bitly 1ページあたり100リンク、1,000エントリのエラーログ上限

MaxLinksPerImport はガードレールであり、製品上の制限ではありません。ほとんどのユーザーのbitlinkは5,000件以下です。50k超の場合は明示的なチェックポイントを持つチャンク化された移行が必要なため、ワーカーは [email protected] にメールするよう指示してハードフェイルします。今後は有料のコンシェルジュSKUに向けますが、今日は受信トレイにルーティングします。

ImportRunBudget はデプロイフレンドリー性のバジェットです。50kのアカウントでは毎秒約5インサートで約3時間かかります。長く実行されているgoroutineの上にデプロイするよりも、早く失敗して再実行する方を選びます。50kまたは30分を超えたら、ファイルの一番下の再開可能性のTODOを参照してください。

ページネーション

BitlyのAPIは動作が良好です。GET /v4/groups/{guid}/bitlinks?size=100 はリンクと pagination.next URLを返します。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を2回返した場合はループしますが、テストではそれは一度も起きていません - そして30分のバジェットがダメージを制限します。

コンフリクト解決

Bitlyのスラグが対象ドメインに既に存在するElidoのリンクと衝突する場合、ワーカーが選択する必要があります。ユーザーはジョブをキックオフする際に戦略を選択します:

  • suffix(デフォルト):mylink-2、mylink-3、… 最大50まで歩きます。50を超えると、病的なコンフリクションを示すためエラーとして扱います - ソースを先にクリーンアップする必要があります。
  • skip:既存のElidoリンクをそのままにし、ソース行を error_log に記録し、スキップとしてカウントします。
  • fail:最初のコンフリクト時にジョブ全体を中断します。厳密な1対1のセマンティクスを望むユーザー向けです。
インポートされたBitlyスラグが対象ドメインで衝突しない場合はそのまま使用し、衝突する場合はsuffix、skip、failの各戦略に分岐する、domain_idと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)
}

これはコンフリクト付きインサートではなく、逐次ルックアップです。行ごとに余分な読み取りが発生しますが、決定論的なサフィックスウォークとはるかにフレンドリーなエラーメッセージが得られます - 代替案はpgxの一意性違反を釣り出してエラー文字列から制約名を解析することです。

移行しないもの

クリック履歴。Bitlyはエクスポート用のクリックごとのデータを公開していません - リンクごとの集計カウンターのみで、Proプランのみ対応しています。そのため、ユーザーが見るすべての画面にこれを表示しています:ダッシュボードのレシピページ、マーケティングのランディング、インポート進捗UI、そして /migrate-from/bitly のFAQセクション。新しいクリックはカットオーバーの時点からElidoアナリティクスに記録されます。

各リンクに対して /v4/bitlinks/{id}/clicks/summary を取得して「インポートされたクリック数」メトリクスをシードすることを検討しました。却下しました:API呼び出しが3倍になり、実際の分析を行えない単一の曖昧な数値が得られるだけです。履歴クリックが必要な場合は、GA4または独自のウェアハウスにある必要があります。

QRデザインとBitlyキャンペーンも削除されます。これらはきれいにマッピングできないベンダー固有の構造です。Bitlyからインポートされたリンクには imported:bitly タグが付くため、一括でフィルタリングできます - ほとんどのユーザーはこれを使用して、後からデフォルトのElido CTAオーバーレイまたはキャンペーンを割り当てます。

トークンの取り扱い

トークンはディスクに保存されません。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. ほとんどのジョブは5分以内に完了します。デプロイはインポートが多い時間帯には少ない。
  2. import_jobs 行が last_progress_at を記録します。5分ごとのスケジューラーティックが、最後の30分間で進行がない running 行を failed に、「ワーカーが停止した」という明確な理由とともにフリップします。ユーザーは何が起きたか分からないまま待ち続けません。
  3. 再実行はsuffixとskip戦略の下でべき等です - 既にインポートされたリンクは戦略ごとに検出されて解決されます。データの破損はありません。

これがトレードオフです。10,000件を超えるアカウントでは、再開可能性が価値を持ちます - Bitlyのページネーションカーソルを import_jobs.source_filter に記録し、最後の実行が停止したところから続きます。それが次のイテレーションです。

測定可能なもの

機能をリリースしたら、機能をインストルメントします。ハンドラーはすべてのジョブライフサイクルイベントに対して構造化されたzapログを出力します:

  • import: starting bitly run - ワークスペース、対象ドメイン、コンフリクト戦略、グループGUID
  • import: bitly run complete - インポート済み、スキップ済み、失敗、合計
  • imports stuck-sweep flipped jobs to failed - カウント

本番でこれらをグラフ化していません - 最初のリアルユーザーの実行バッチが何にアラートを出すべきかを教えてくれます。初期の推測:任意の1時間ウィンドウでstuck-sweepカウント > 0はページングシグナルです。ワーカーが死んだことを意味し、ユーザーのUIが許容すべき以上に長く running で停止しています。

次は何か

同じ足場で、4つのベンダーが続きます:

  • Rebrandly - GET /v1/links?limit=25 でページネーション。スラッシュタグ → スラグは、スラグが空いている場合は1:1。
  • Short.io - GET /links?limit=150&domain_id=…。ドメインごとのページネーション。ユーザーがソースを選べるよう最初にドメインをリストします。
  • Dub.co - GET /api/links?projectSlug=…&limit=100。フォルダーとタグが保持されます。4つの中で最も簡単です。
  • TinyURL - CSVアップロードのみ。パブリックTinyURLにはAPIがなく、Proプランのみがエクスポートのために使えます。CSVを直接受け入れ、ベンダー側のラウンドトリップをスキップします。

それぞれが同じ import_jobs 行と同じダッシュボードポーリングUIの背後に存在します。ベンダー固有のワーカーは services/api-core/internal/imports/<vendor>.go に留まります。

移行ストーリーが曖昧だったためにBitlyのComparisonを保留していた場合、移行ストーリーはもう曖昧ではありません。試してみてください - 一般的なアカウントではトークンから最後にインポートされたリンクまで10分以内です。

ブログの関連記事

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
bitly migration
url shortener
go worker
data migration
engineering
tier 3 integrations

続きを読む