本文へスキップ
Sursa Developers

データウェアハウスにエクスポートする

アナリストは、Sursaのデータを社内の他のデータと並べて扱いたいことがよくあります。どのカードが誰によって検証されたか、チームごとにナレッジがどう増えているか、どの質問に回答がないままか、などです。このガイドでは、Sursaのデータをデータウェアハウスにコピーし、そのコピーを最新に保つ定期ジョブを作ります。

このジョブは、APIの3つの部分を組み合わせます。

部分得られるもの
updated_sinceを指定したカードのリストと、全体を読み込む短いリスト前回の実行以降に変更されたオブジェクトの現在の状態。
イベントフィード30日分の削除と、起きたことの履歴。
カーソル抜けも重複もない完全なページ。

cards:readとusers:readのスコープに加えて、イベントフィードのためのwebhooks:manageを持つキーが必要です。ジョブにはadminロールの専用のキーを用意してください。ユーザーの完全なリストとイベントフィードは、このロールのキーでしか読み取れません。より狭いロールとデータプロファイルのキーでは、特定のグループが参照できるものだけがエクスポートされます。そのキーはユーザーの簡易リスト(brief=true)を読み取り、イベントフィードは読み取れないので、削除は後述の毎週の完全なロードで取り込みます。

テーブル

オブジェクトごとに1つのテーブルと、イベント用に1つのテーブルを置く、シンプルな構成の例です。

テーブルキーデータの取得元
sursa_cardsidGET /v1/cards
sursa_collectionsidGET /v1/collections
sursa_usersidGET /v1/users
sursa_groupsidGET /v1/groups
sursa_eventsidGET /v1/webhook-events

各オブジェクトは受け取ったままJSONの列に保存し、よく検索する列(updated_at、collection_id、status、owner_id)を加えます。APIの後のバージョンでは新しいフィールドが加わります。JSONの列があれば、ジョブを変更せずにそれらを取り込めます。

初回ロード

updated_sinceなしですべてのリストを最後まで読み込み、すべてのオブジェクトを書き込みます。ロードを開始した時刻を記録してください。次の実行はそこから続きます。

増分ロード

実行のたびに(たとえば15分ごとに):

  1. この実行の開始時刻を記録します。
  2. カードを、前回の実行の開始時刻から1分の余裕を引いた値をupdated_sinceに指定して読み込み、次のページはすべてカーソルと同じupdated_sinceを付けて要求します(最初のページの絞り込み条件がないカーソルには400 api.cursor_invalidが返ります)。コレクション、ユーザー、グループは全体を読み込みます。コレクションとユーザーにはupdated_atがなく、グループのupdated_atはメンバーが変わっても変わりません。各オブジェクトをidで作成または置換します。
  3. 前回保存したカーソルからイベントフィードを読み込みます。各イベントをsursa_eventsに追加し、削除を反映します。card.deletedイベントはカードをsursa_cardsから削除するか、履歴を残す場合は削除済みとしてマークします。
  4. 開始時刻とフィードの新しいカーソルを保存します。データウェアハウスが対応していれば、データと同じトランザクションで保存してください。
import json
from datetime import datetime, timedelta, timezone

LISTS = {"cards": "/v1/cards", "collections": "/v1/collections", "users": "/v1/users", "groups": "/v1/groups"}


def read_list(path, since):
    """Every object of a list, changed since `since` (or all of them). The next page keeps the filters of the first."""
    params = {"limit": 200}
    if since:
        params["updated_since"] = (since - timedelta(minutes=1)).isoformat()
    while params:
        response = session.get(BASE + path, params=params, timeout=120)
        response.raise_for_status()
        body = response.json()
        items = body["data"] if isinstance(body, dict) and "data" in body else body
        yield from items
        if isinstance(body, dict) and "data" in body:
            params = {**params, "cursor": body["next_cursor"]} if body.get("has_more") else None
        else:
            cursor = response.headers.get("X-Next-Cursor")
            params = {**params, "cursor": cursor} if cursor else None


def read_events(cursor):
    """New events of the feed after `cursor` (or from the start of the 30 days), and the cursor to save."""
    events = []
    params = {"limit": 200, **({"cursor": cursor} if cursor else {})}
    while True:
        page = api("GET", "/v1/webhook-events?" + requests.compat.urlencode(params))
        events += page["data"]
        params["cursor"] = page["next_cursor"]
        if not page["has_more"]:
            return events, page["next_cursor"]


def run(state, warehouse):
    started_at = datetime.now(timezone.utc)
    since = state.get("since")
    for table, path in LISTS.items():
        for obj in read_list(path, since if table == "cards" else None):
            warehouse.upsert(f"sursa_{table}", obj["id"], json.dumps(obj))

    events, feed_cursor = read_events(state.get("feed_cursor"))
    for event in events:
        warehouse.insert_once("sursa_events", event["id"], json.dumps(event))
        if event["type"] == "card.deleted":
            warehouse.delete("sursa_cards", event["object"]["id"])

    warehouse.save_state({"since": started_at, "feed_cursor": feed_cursor})

フィードのカーソルは、最後のページも含め、すべてのページで返されます。これを保存すれば、次の実行は保存した最後のイベントの直後から始まるので、イベントを二度読んだり飛ばしたりすることはありません。

コピーを正確に保つ

  • 30日を超えて実行しなかった場合。フィードはイベントを30日間保持します。ジョブがそれより長く実行されなかった場合は、完全なロードをもう一度実行し、そのロードで返されなかったオブジェクトをすべてテーブルから削除してください。
  • 毎週の完全なロード。フィードが正常でも、週に1回の完全なロードをテーブルと比較すれば、コピーが完全かどうかを手軽に確認できます。
  • キーで参照できないものはエクスポートされない。ジョブには、キーで参照できるものがそのまま見えます。カードがキーのアクセス範囲から外れた場合(たとえば制限付きのコレクションに移動した場合)、完全なロードではそのカードは返されません。削除されたものとして扱ってください。
  • 質問。質問ログには専用のエクスポートがあります。GET /v1/questions/export.csvは、期間内の質問を評価と出典とともに1つのCSVファイルで返します。askスコープが必要です。

上限を守る

大きな組織の完全なロードでは、多くのリクエストを送信します。リストは1つずつlimit=200で読み込み、RateLimitヘッダーに注意してください。変更だけを読み込む15分ごとのジョブなら、どのクォータにも遠く及びません。