Box のファイルコメントでアップロードした人をメンションし、処理の進み具合を知らせる — 開始・完了・失敗

Box フォルダを監視する処理基盤で、アップロードされたファイルに開始・完了・失敗のコメントを付け、ファイルの modified_by からアップロードした人をメンションしました。

白いカードの中央に、丸みのある小文字で box と書かれた鮮やかな青のBoxのロゴタイプ
目次

はじめに

Box のフォルダにファイルがアップロードされるたびに起動し、そのファイルを処理するパイプラインを作りました。ところが、ファイルを置くたびに、処理が進んでいるのかどうか私にはわかりませんでした。成功しても失敗しても、アップロードした人には通知が届かず、しばらくしてからファイルの処理が終わったかを確かめに戻るしかありませんでした。

アップロードした人が自分のファイルの処理状況をすぐに把握できるよう、パイプラインから進み具合をリアルタイムで知らせたいと考えました。ただ、専用の UI は作りたくありませんでしたし、Slack で通知するのも避けたいと思いました。利用者が増えると、通知の数もそれに合わせて膨らむからです。

解決策は、アップロードされたファイルに、アップロードした人を @メンションするコメントを付けることでした。コメントを付けるのは、処理を始めたとき、結果へのリンクを添えて終わったとき、そして最終的に失敗したときです。Box はメンションされた相手にメールと受信トレイで通知するので、待っている本人は Box を開かなくても進み具合を受け取れます。

アップロードした人に届く Box の手段

別の UI を作らずに、アップロードした人が処理の進み具合を確認できる方法をいくつか挙げました。

手段アップロードした人が知る方法副作用
ファイルをロックするファイル名の横に鍵アイコンが出る。通知なし「編集できない」という意味に見え、ジョブが途中で落ちるとロックが残る
説明欄に書き込む詳細パネルに表示される。通知なし人が自分で書き込む欄を上書きしてしまう
[processing] … に名前を変えるファイル一覧の名前が変わる。通知なしFILE.RENAMED が発火し、パイプラインを起動する Box の Webhook がもう一度動く
Box Skills のステータスカードプレビューのサイドバーに表示される。通知なしファイルのプレビューを開いているときしか見えない
@メンション付きのコメントコメント欄に表示され、メールと Box の受信トレイに通知が届くコメントのメール通知をオンにしているコラボレータにもメールが届く

アップロードした人をメンションして、ファイルにコメントする

パイプラインは 2 つの部品でできています。

  1. Box の Webhook:ファイルがアップロードされるたびに、キューへメッセージを 1 件積みます。
  2. ワーカー:キューからメッセージを取り出してファイルを処理し、結果のファイルを Box に書き戻します。

処理の進み具合は、ワーカーがファイルに付ける 3 つのコメントで伝えます。ワーカーは Box の POST /comments エンドポイントでコメントを付けます。メンションは tagged_message に入れます。Box はこのフィールドから @[user_id:name] を探し、その相手に通知を送ります。

ワーカーがアップロードされたファイルにコメントする箇所4 つの手順が横に並ぶ。監視している Box フォルダにファイルが置かれ、Box の Webhook が発火し、キューがメッセージを保持し、ワーカーがファイルを処理する。ワーカーからの矢印はアップロードされたファイルのコメント欄につながり、そこに 3 つのコメントが並ぶ。開始はロックを取り、対応形式で、処理済みでない版と分かった後に付く。完了は結果のファイルを置いた後に、そのリンク付きで付く。失敗は最後の配信のときだけ付く。どのコメントもアップロードした人をメンションし、コメント欄からの矢印は modified_by と書かれたアップロードした人につながる。その人にはメンションごとにメールが届き、Box の受信トレイにも通知が届く。ファイルを置く監視しているフォルダへBox WebhookFILE.UPLOADEDキューイベントごとに1件ワーカー処理して結果を書き戻すアップロードしたファイルのコメント欄開始@アップロードした人ロックを取り、対応形式で、未処理の版と分かった後完了@アップロードした人結果のファイルを置いた後、結果へのリンク付き失敗@アップロードした人最後の配信のときだけ、再試行が残っていないアップロードした人(modified_by)メンションごとにメールと Box の受信トレイに通知
ワーカーは元のファイルに 3 つの時点でコメントします。失敗のコメントは、キューの再試行が残っていない最後の配信でだけ付けます。
  1. 開始。ワーカーがファイルのロックを取り、対応している形式で、処理を終えた版ではないと分かった時点で付けます。
  2. 完了。結果のファイルをアップロードした後に、そのリンクを添えて付けます。
  3. 失敗。最後の配信も失敗したときにだけ付けます。キューは同じメッセージを最大 3 回配信し、それより前の失敗は次の配信で成功するかもしれません。そのため、最後の配信も失敗して初めて、ワーカーがファイルに失敗のコメントを付けます。

Box の日本語 UI で、1 つのファイルのアクティビティパネルにアプリからのコメントが 3 件並んでいる。どれもアップロードした人への @メンションで始まり、続く本文は「Processing has started. I will comment here again when it finishes.」「Processing finished. Output:」と Box のファイルリンク、「Processing failed. The operators have been notified automatically.」。下部にコメント入力欄がある

アップロードした人がファイルのアクティビティパネルで見る 3 件のコメント。コメントした人とメンション先の名前はぼかしています。

コメントを付けるのは、Box API をラップするコネクタの post_comment() で、数行しかありません。mention は必須の引数です。通知する相手のいないコメントは進捗の報告にならないからです。

box/client.py
_MENTION_NAME_DELIMITERS = str.maketrans("", "", "[]:")
def post_comment(self, file_id: str, text: str, *, mention: Person) -> None:
item = CreateCommentItem(id=file_id, type=CreateCommentItemTypeField.FILE)
name = mention.name.translate(_MENTION_NAME_DELIMITERS).strip() or mention.id
tagged = f"@[{mention.id}:{name}] {text}"
self._call(lambda: self._client.comments.create_comment(None, item, tagged_message=tagged))

表示名から [、]、: を取り除いているのは、この 3 文字がメンション構文そのものの区切り文字で、Box がエスケープ方法を公開していないからです。表示名が Sato [QA]: Tokyo の場合で比べます。

除去前
@[12345:Sato [QA]: Tokyo]
除去後
@[12345:Sato QA Tokyo]

除去前の文字列では、構文上 QA の後ろの ] でメンションが終わってしまいます。名前はメンションのラベルにすぎず、Box は通知の宛先を user_id で決めるので、誰に届くかは変わりません。最後の行の None については、後述の落とし穴 1 で説明します。

コメントの投稿に失敗しても、ジョブは失敗させません。エラーを外に投げるとメッセージがキューに戻り、完了コメントの投稿に失敗した場合は、結果をすでにアップロードしたファイルをもう一度処理してしまいます。そのため、失敗は警告としてログに残すだけにしています。ログに含めるのは例外の型だけで、アップロードした人の名前やコメントの本文は残しません。

メンションする相手は created_by ではなく modified_by

待っているのは、ファイルの modified_by、つまり現在の版をアップロードしたユーザーです。ワーカーはファイル名と親フォルダを得るためにもともと GET /files/{id} を呼んでいるので、アップロードした人の取得は fields に 1 項目足すだけで済み、リクエストは増えません。

box/client.py
file = self._call(lambda: self._client.files.get_file_by_id(
file_id, fields=["name", "parent", "size", "version_number", "modified_by"]
))

どちらのフィールドがアップロードした人を指すのかは、2 つのアカウントから版をアップロードして確かめました。最初の版を私がアップロードした時点では、どちらのフィールドも私を指していました。アプリのサービスアカウントが新しい版をアップロードすると、modified_by はサービスアカウントに変わりましたが、created_by は私のままでした。

私がもう一度アップロードすると、modified_by は私に戻りました。created_by が記録するのは最初にファイルを置いた人なので、同僚がファイルを差し替えた後は、間違った人をメンションしてしまいます。

アプリ自身のアカウントがアップロードした版には、コメントは不要です。そこでワーカーは、modified_by が GET /users/me の返すアカウント(つまりアプリ自身のアカウント)と一致するときはコメントを付けません。

コメントのメール通知をオフにしてもメンションは届く

アップロードした人がフォルダのコメントのメール通知をオフにしても、メンションは届きます。フォルダの通知設定で「Adds a comment」のチェックを外し、コメントのたびに届くメールをオフにしました。その後アプリがメンション付きのコメントを付けると、メールと Box の受信トレイの通知はどちらも届きました。

Box の日本語 UI の通知メール。Box のロゴの下に、コメントした人が office-features.docx のコメントであなたをメンションしました、とある。枠の中にコメント「Processing has started. I will comment here again when it finishes.」が引用され、その下に青い返信ボタンがある

メンションに対して Box が送るメール。コメントを引用し、ファイルへのリンクを含みます。

Box の日本語 UI で、受信トレイのアイコンの下に受信トレイのパネルが開いている。未読のカードに、コメントした人が office-features.docx であなたにメンションしました、とあり、「Processing failed. The operators have been notified automatically.」が引用されている

メンションは Box の受信トレイにも未読として届きます。この画像は失敗のコメントのメンションです。

そのため、共有フォルダをコメントのメールであふれさせずに済みます。他人のファイルの進み具合を受け取りたくないコラボレータは、そのフォルダのコメントのメール通知をオフにすれば済みます。それでも、自分がアップロードしたファイルについてはメンションで知らせが届きます。

落とし穴 1: message と tagged_message を両方送ると Box が 400 を返す

Box の Python SDK の create_comment() は message と tagged_message を一緒に送りますが、Box はこの組み合わせを受け付けません。SDK の box_sdk_gen は message を必須の位置引数にしていて、リクエストボディを両方のキーで組み立てます。

box_sdk_gen/managers/comments.py
request_body: Dict = {
'message': message,
'tagged_message': tagged_message,
'item': item,
}

コメントの本文を message に、メンションを加えた同じ本文を tagged_message に渡すのは、シグネチャが求めているとおりに見えます。ところが実際の Box に対しては、どの呼び出しも次のエラーを返しました。

Message: 400 Bad Request; Request ID: …
Status code: 400
Code: bad_request

Box 自身のガイドにも、メンションを含む文字列は「message ではなく tagged_message として渡す」と書かれています(拙訳)。ただ SDK は message を必須にしているので、None を渡しました。SDK の serialize() は値が None のキーを落とすので、実際に送られるリクエストボディには tagged_message だけが残ります。

message に文字列を渡す(Box が 400 を返す)
# SDK の引数
create_comment(text, item, tagged_message=tagged)
# 送られるリクエストボディ
{
"message": "Processing has started.",
"tagged_message": "@[12345:Sato] Processing has started.",
"item": { "id": "…", "type": "file" }
}
message に None を渡す(コメントが作成される)
# SDK の引数
create_comment(None, item, tagged_message=tagged)
# 送られるリクエストボディ
{
"tagged_message": "@[12345:Sato] Processing has started.",
"item": { "id": "…", "type": "file" }
}

落とし穴 2: 開始の通知を最初の配信でだけ出すと、ロックの競合で抜け落ちる

開始のコメントを最初の配信のときだけ付けるようにすると、その配信がファイルのロックを取れなかったときに開始のコメントが抜け落ちます。ワーカーは DynamoDB のジョブレコードでファイルごとのロックを持ち、2 つのジョブが同じファイルを同時に処理しないようにしています。ロックがすでに取られていると、そのジョブはキューに戻り、後の配信を待ちます。

Box は同じファイルについて FILE.MOVED と FILE.UPLOADED を同時に送ることがあるので、ロックを取れないのは珍しいことではありません。キューは配信の回数を ApproximateReceiveCount で数えていますが、この回数が 1 のときだけ通知する判定はここで破綻します。

1 回目の配信はロックを取れず、何も通知しません。2 回目の配信はロックを取ってファイルを処理しますが、回数が 2 なので、やはり何も通知しません。アップロードした人には、開始のコメントがないまま完了のコメントだけが届きます。

2 回の配信の時系列と、開始コメントの判定ごとに付くコメント1 つのメッセージが 2 回配信される時系列を、2 つのレーンで示す。配信 1 回目はロックを取れずキューに戻り、2 回目はロックを取ってファイルを処理する。配信回数が 1 のときに付ける判定のレーンでは、1 回目はロックが無いので何も付けず、2 回目は回数が 2 なので開始を飛ばして完了だけを付ける。アップロードした人のコメント欄には完了だけが届き、開始は届かない。版を記録する判定のレーンでは、1 回目は何も記録せず、2 回目が start_notified を記録して開始、続いて完了を付ける。アップロードした人には両方が届く。時間 →配信 1 回目ロックを取れず、キューに戻る配信 2 回目ロックを取り、処理して結果を置くアップロードした人への通知判定:回数が 1判定:版の記録ロックが無く、何も付けない回数が 2、開始を飛ばす✓完了を付けるロックが無く、何も記録しないstart_notified を記録✓開始を付ける✓完了を付ける開始が届かない完了開始完了
1 回目の配信はロックを取れず、2 回目の配信が処理を行います。回数で判定するとどちらの配信でも何も通知せず、版の記録で判定すると 2 回目の配信で開始を通知します。

再実行でも同じ判定が破綻します。ワーカーの処理中に新しい版が届くと、ワーカーは同じ配信の中でもう一度処理しますが、回数がすでに 2 以上なら、その新しい版の開始は通知されません。

解決策は、開始を通知したファイルの版を、DynamoDB でロックを持っているのと同じレコードに記録することです。フィールド名は start_notified です。ワーカーはこのフィールドを条件付き更新で書き込み、start_notified がまだ現在の版でないときだけ書き込みが成功します。そのため、同じ版について成功する書き込みは 1 回だけで、開始のコメントも 1 回だけ付きます。

state.py
def claim_start_notice(self, key: str, *, source_version: str | None) -> bool:
try:
self._table.update_item(
Key={"pk": key},
UpdateExpression="SET start_notified = :ver",
ConditionExpression=(
"attribute_not_exists(start_notified) OR start_notified <> :ver"
),
ExpressionAttributeValues={":ver": source_version or ""},
)
except ClientError as exc:
if exc.response["Error"]["Code"] != "ConditionalCheckFailedException":
raise
return False
return True
条件付き更新で開始のコメントを付けるかが決まる流れフローチャート。ワーカーがロックを取った後、start_notified がすでに現在の版かを確かめる。そうでなければ現在の版の書き込みが成功し、開始のコメントを付ける。そうであれば書き込みは拒否され、コメントは付けない。どちらの場合もその後ファイルを処理する。処理が完了すると start_notified を消し、失敗したときは start_notified を残す。いいえはいワーカーがロックを取るstart_notified は現在の版?現在の版を書き込む✓ 開始を付ける書き込みが拒否される○ コメントしないファイルを処理する完了start_notified を消す失敗start_notified を残す
条件付き更新で、その配信が開始のコメントを付けるかが決まります。完了すると start_notified を消し、失敗したときは残します。

ジョブが完了すると start_notified を消し、失敗したときは残します。そのため、次のようになります。

  • 1 回目の配信でロックを取れなかった場合。start_notified はまだ書き込まれていないので、2 回目の配信が開始を通知します。
  • 失敗の後の再試行。start_notified はすでにこの版なので、再試行では通知しません。
  • 新しい版のための再実行。完了時に start_notified を消しているので、再実行で通知します。
  • 最終的な失敗の後に届いた新しい版。start_notified はまだ古い版なので、新しい版の開始を通知します。

まとめ

今では Box が、アップロードした人に、処理が始まったこと、結果へのリンク付きで終わったこと、あるいは処理に失敗したことを知らせます。ファイルのコメント欄にはその履歴も残ります。仕組みはすべて Box 自体の機能を利用したもので、ワーカーは処理状態に応じて 3 種類のコメントを付け、それぞれでアップロードした人をメンションします。

この作業で学んだことの多くは Box についてでした。メンションはコメントのメール通知とは別に届くので、コラボレータは騒がしいフォルダの通知を切っても、自分のファイルの知らせは受け取れます。modified_by は現在の版をアップロードした人を指しますが、created_by は最初にアップロードした人を指したままです。

SDK の create_comment() では message に None を渡すと、tagged_message だけが送られ、Box が受け付けます。開始の通知はファイルの版ごとに start_notified へ記録するので、どの配信がロックを取っても、各版の開始は 1 回だけ通知されます。

参考リンク

この記事をシェア