Elido
1分で読了エンジニアリング

Rebrandly移行機能のリリース:ページあたり25件のページネーションと30分という予算

Elido向けのワンクリックRebrandlyインポート機能をいかにして構築したか—低速なページサイズ、ワークスペースフィルタのUX、そして意図的に移行しない対象について。

Marius Voß
DevRel · edge infra
パイプライン図:左側にRebrandly REST APIがあり、Elidoインポートワーカーを経由してlinksテーブルに流れる。サイドパネルには、ワーカーが保持する数値保証(上限5万件、予算30分、ページあたり25件、トークンはメモリ内のみ)が記載されている。

Tier-3展開の2番目の移行ソースを本日リリースしました。Rebrandly APIキーを貼り付け、必要に応じてワークスペースでフィルタリングし、「開始」をクリックします。6〜10分後には、すべてのスラッシュタグがElidoドメインに移行され、衝突がなかった箇所ではスラッグが保持されます。2週間前にリリースされたBitly移行機能が土台となっており、Rebrandlyはその2番目のベンダーとなります。

本稿はエンジニアリングの記録です。Rebrandly固有の点、Bitlyワーカーと同一に保った点、そしてRebrandlyのAPIにより異なるアプローチをとらざるを得なかった点について解説します。

Bitlyと共通の点#

この機能は当初から、1つのテーブルと1つのワーカーコントラクトで構成される設計でした。その設計は今回も維持されています。

CREATE TABLE import_jobs (
    id                  BIGSERIAL    PRIMARY KEY,
    workspace_id        BIGINT       NOT NULL,
    source_vendor       TEXT         NOT NULL,
    target_domain_id    BIGINT       NOT NULL,
    status              TEXT         NOT NULL DEFAULT 'queued',
    conflict_strategy   TEXT         NOT NULL DEFAULT 'suffix',
    source_filter       JSONB        NOT NULL DEFAULT '{}'::jsonb,
    -- counters + error_log + timestamps elided
);

source_vendorrebrandlyに切り替わります。source_filterは、ユーザーがフィルタリングを行う場合は{workspace_id: "..."}を、すべてのリンクを取得する場合は{}を保持します。それ以外(30分の予算、5万リンクの上限、suffix/skip/failの競合解決戦略、imported:rebrandlyタグなど)はすべてBitlyのパスと同一です。

ダッシュボードのランチャー(apps/web/src/app/dashboard/integrations/[id]/rebrandly-migration-launcher.tsx)は、構造的にはBitlyのものと同一ですが、グループドロップダウンが削除されています。Rebrandlyにはグループではなくワークスペースが存在します。また、Workspacesエンドポイントは認証なしのページネーションとなっており、一般的なユーザーは最大でも2つしかワークスペースを持っていないため、ドロップダウンではなくオプションのテキストフィルタとして公開しています。

RebrandlyのAPIにおける相違点#

以下の3点です。

ページサイズ。 Rebrandlyでは、1ページあたりのリンク数が最大25件に制限されています。Bitlyは100件です。そのため、Bitlyで4〜8分で完了する5,000リンクのアカウントは、Rebrandlyでは6〜10分かかります。ボトルネックはワーカーではなくベンダー側にあります。

ページネーション。 Rebrandlyは、前のページの最後のアイテムのIDを指定するlastクエリ文字列パラメータを使用します。Bitlyはpagination.next URLを返します。どちらもカーソルスタイルですが、Rebrandlyの方が少々冗長です。ループ全体は以下の6行です。

last := ""
for {
    page, err := w.fetchPage(ctx, opts.Token, opts.WorkspaceID, last)
    if err != nil { /* mark failed */ return }
    if len(page) == 0 { break }
    for _, link := range page {
        // ... resolve slug, insert, update counters ...
    }
    last = page[len(page)-1].ID
}

我々はカーソルを信頼します。もしRebrandlyが同じlastを2回返した場合は無限ループに陥りますが、30分の予算設定によって被害を抑えています。

ワークスペースのスコープ設定。 RebrandlyのAPIキーは、ユーザーが属するすべてのワークスペースの全リンクを参照します。5つのクライアントワークスペースを持つエージェンシーアカウントの場合、ほぼ間違いなく1つずつインポートしたいはずです。ランチャーでは、これをオプションのテキストフィールドとして公開しています。RebrandlyのURLバーからワークスペースIDを貼り付けるか、空のままにして「キーから見えるすべて」を対象とします。

移行しない対象#

クリック履歴。Rebrandlyのクリック別データはPremiumティア限定であり、リンクごとのクリックイベントではなく、集計カウンタとして表示されます。我々は、ダッシュボードのレシピページ、/migrate-from/rebrandlyランディングページ、インポート進捗UI、FAQセクションなど、ユーザーが目にするあらゆる場所でこの制限を明示しています。切り替えの瞬間以降の新しいクリックは、Elidoの分析に記録されます。

RebrandlyのUTMテンプレート。これはRebrandly上の表示用機能であり、エクスポート用のクリーンなAPIインターフェースが存在しません。Elidoのキャンペーンルールとして再構築してください。imported:rebrandlyタグが一括再割り当てのハンドルとなります。

QRコードのスタイル設定。デフォルトのElido QRコードはインポートされたすべてのリンクに対して生成されますが、カスタムデザインは再適用する必要があります。ほとんどのユーザーは、一括タグフィルタを使用して、後からデフォルトのElido CTAオーバーレイやキャンペーンを割り当てています。

トークンの取り扱い#

Bitlyと同一です。トークンはディスクに保存されません。

bgCtx := context.WithoutCancel(r.Context())
go h.rebrandly.Run(bgCtx, job.ID, imports.RebrandlyJobOptions{
    Token:       req.Token,
    WorkspaceID: req.WorkspaceID,
})

source_token_idはNULLのままです。ADR-0036のservice_tokensテーブルは、定期的な利用が永続化を正当化するTier-2のペーストトークン統合(Mailchimp、Brevo、Klaviyo)のためのものです。一度限りの移行については、メモリ内のみとするのが適切な運用トレードオフです。ユーザーがトークンを一度貼り付け、ワーカーが実行されれば、トークンは破棄されます。

context.WithoutCancel(Go 1.21以降)は、ロガー、トレースID、デッドラインなどのコンテキスト値を保持しつつ、キャンセルシグナルのみを削除するため、ワーカーはそれを開始したHTTPリクエストよりも長生きできます。これはBitlyワーカーと同じパターンであり、将来のすべての移行ベンダーでも使用されるパターンです。

競合解決#

Bitlyと同一の3つの戦略を用意しています。ジョブ開始時にユーザーが選択します。

  • suffix(デフォルト):mylink-2mylink-3、…と最大50の候補を試行します。50を超えると構造的な問題とみなし、エラーを表示します。
  • skip:既存のElidoリンクはそのままにし、ソース行をログに記録してスキップとしてカウントします。
  • fail:最初の競合が発生した時点でジョブ全体を中止します。厳密な1:1のセマンティクスが必要な場合に使用します。

スラッグの検索は、行ごとに1回のインデックス付き読み取りを行います。

func (w *RebrandlyWorker) 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)
    }
    // suffix/skip/fail branching identical to bitly.go
}

行ごとに読み取りが1回増えますが、決定論的なサフィックス探索と、より親切なエラーメッセージが得られます。別の方法として、pgxで一意制約違反をキャッチし、エラー文字列から制約名をパースすることもできますが、それは悪いトレードオフです。

測定可能な指標#

Bitlyと同じ構造化されたzapログを使用します。ワークスペース、ターゲットドメイン、競合解決戦略、オプションのワークスペースフィルタが記録されます。ジョブのライフサイクルイベント(開始、完了、stuck-sweepの反転)は既存のものであり、ダッシュボードは2秒ごとにポーリングエンドポイントを叩きます。

現時点では、移行ジョブのメトリクスを本番環境でグラフ化はしていません。Bitlyのコホートによって最初の実トラフィックのベースラインが得られました。ワーカーのメカニズムは同一であり、ベンダーによるページネーション形状の違いのみであるため、Rebrandlyのデータも直接比較可能です。最初の通知候補は、任意の1時間ウィンドウでstuck-sweepカウント > 0となった場合です。これはワーカーが停止し、ユーザーのUIがrunningのままになっていることを意味します。

レジューム機能とデプロイの問題#

Bitlyと同じトレードオフです。ワーカーはインプロセスで動作するため、インポート途中のデプロイによってゴルーチンが終了します。v1においてこれを許容している理由は以下の通りです。

  1. ほとんどのジョブは10分以内に完了します。インポートが行われる時間帯にデプロイを行うことは稀です。
  2. import_jobs.last_progress_atフィールドと、5分間隔のstuck-sweep cronにより、過去30分間進捗がないrunning状態の行は、明確な理由とともにfailedに変更されます。
  3. suffixおよびskip戦略においては再実行は冪等(べきとう)であり、すでにインポート済みのリンクは2回目のパスで検出され、戦略に従って解決されます。

10,000リンクを超えるアカウントについては、レジューム機能が重要になりますimport_jobs.source_filterにRebrandlyのlastカーソルを記録し、前回の終了位置から再開します。これは次回のイテレーションで行う予定であり、残りの4つの移行ソースも同様の変更で恩恵を受けることになります。

今後の展開#

同じ骨組みを使い、同じimport_jobsテーブルへさらに3つのベンダーを統合します。

  • Short.ioGET /links?limit=150&domain_id=…。ドメインごとのページネーションとなるため、ユーザーにはワークスペースではなくソースドメインを選択してもらう予定です。
  • Dub.coGET /api/links?projectSlug=…&limit=100。フォルダとタグが保持されるため、4つの中で最もクリーンな統合となるでしょう。
  • TinyURL — Pro/Bulk REST API。無料版TinyURLにはAPIが存在しないため、これまで通り手動での移行となります。

各ベンダーは、共通のダッシュボードポーリングUIと、imported:<vendor>タグパターンに従います。ベンダー固有のワーカーはservices/api-core/internal/imports/<vendor>.goに配置されます。

移行パスが文書化されていないためにRebrandlyとの比較を控えていた方は、これで文書化されました。ぜひお試しください。一般的なアカウントであれば、APIキーの入力から最後のリンクのインポートまで10分以内で完了します。

ブログの関連記事#

Elidoを試す

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

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

続きを読む