データウェアハウスにエクスポートする
アナリストは、Sursaのデータを社内の他のデータと並べて扱いたいことがよくあります。どのカードが誰によって検証されたか、チームごとにナレッジがどう増えているか、どの質問に回答がないままか、などです。このガイドでは、Sursaのデータをデータウェアハウスにコピーし、そのコピーを最新に保つ定期ジョブを作ります。
このジョブは、APIの3つの部分を組み合わせます。
| 部分 | 得られるもの |
|---|---|
updated_sinceを指定したカードのリストと、全体を読み込む短いリスト | 前回の実行以降に変更されたオブジェクトの現在の状態。 |
| イベントフィード | 30日分の削除と、起きたことの履歴。 |
| カーソル | 抜けも重複もない完全なページ。 |
cards:readとusers:readのスコープに加えて、イベントフィードのためのwebhooks:manageを持つキーが必要です。ジョブにはadminロールの専用のキーを用意してください。ユーザーの完全なリストとイベントフィードは、このロールのキーでしか読み取れません。より狭いロールとデータプロファイルのキーでは、特定のグループが参照できるものだけがエクスポートされます。そのキーはユーザーの簡易リスト(brief=true)を読み取り、イベントフィードは読み取れないので、削除は後述の毎週の完全なロードで取り込みます。
テーブル
オブジェクトごとに1つのテーブルと、イベント用に1つのテーブルを置く、シンプルな構成の例です。
| テーブル | キー | データの取得元 |
|---|---|---|
sursa_cards | id | GET /v1/cards |
sursa_collections | id | GET /v1/collections |
sursa_users | id | GET /v1/users |
sursa_groups | id | GET /v1/groups |
sursa_events | id | GET /v1/webhook-events |
各オブジェクトは受け取ったままJSONの列に保存し、よく検索する列(updated_at、collection_id、status、owner_id)を加えます。APIの後のバージョンでは新しいフィールドが加わります。JSONの列があれば、ジョブを変更せずにそれらを取り込めます。
初回ロード
updated_sinceなしですべてのリストを最後まで読み込み、すべてのオブジェクトを書き込みます。ロードを開始した時刻を記録してください。次の実行はそこから続きます。
増分ロード
実行のたびに(たとえば15分ごとに):
- この実行の開始時刻を記録します。
- カードを、前回の実行の開始時刻から1分の余裕を引いた値を
updated_sinceに指定して読み込み、次のページはすべてカーソルと同じupdated_sinceを付けて要求します(最初のページの絞り込み条件がないカーソルには400 api.cursor_invalidが返ります)。コレクション、ユーザー、グループは全体を読み込みます。コレクションとユーザーにはupdated_atがなく、グループのupdated_atはメンバーが変わっても変わりません。各オブジェクトをidで作成または置換します。 - 前回保存したカーソルからイベントフィードを読み込みます。各イベントを
sursa_eventsに追加し、削除を反映します。card.deletedイベントはカードをsursa_cardsから削除するか、履歴を残す場合は削除済みとしてマークします。 - 開始時刻とフィードの新しいカーソルを保存します。データウェアハウスが対応していれば、データと同じトランザクションで保存してください。
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分ごとのジョブなら、どのクォータにも遠く及びません。