我讓工作流在 Box 檔案上留言 @提及上傳者,並回報處理進度 — 開始、完成與失敗

我讓工作流監看 Box 資料夾。每個檔案上傳後,工作流會在處理開始、完成和失敗時於該檔案上留言,並依 modified_by 欄位提及上傳者。

白色卡片中央的 Box 字標,以圓潤的小寫字母寫成鮮藍色的 box
本頁目錄

引言

我做了一個工作流,每當有人把檔案上傳到 Box 資料夾時就會被啟動並處理該檔案。但每次我把檔案放進去時,都看不出它有沒有在處理。無論成功或失敗,上傳者都收不到通知,只能過一段時間再回來確認檔案是否處理好了。

我希望工作流能夠即時回饋處理狀況,讓上傳者了解自己的檔案處理到哪裡,但我不想另外建立 UI,也不想在 Slack 上通知,因為使用者一多,通知的數量也會跟著膨脹。

我的解決方法是在上傳的檔案上留言,並 @提及上傳者。留言的時機是開始處理時、處理完成並附上結果連結時,以及最終確定失敗時。Box 會用電子郵件和收件匣通知被提及的人,所以等待中的上傳者不必打開 Box,也能收到進度。

Box 有哪些方式能讓上傳者看到進度?

我列出了幾個不必另外建立 UI,就能讓上傳檔案的人看到處理進度的方式:

方式上傳者如何得知副作用
鎖定檔案檔案名旁邊會出現鎖頭圖示,不會通知看起來像「你不能編輯」,而且工作中途當掉時鎖會留下來
寫入描述欄顯示在詳細資料面板,不會通知會覆寫掉使用者自己填寫的內容
改名為 [processing] …檔案清單中的名稱改變,不會通知會觸發 FILE.RENAMED,讓啟動工作流的 Box Webhook 再執行一次
Box Skills 狀態卡片顯示在預覽的側邊欄,不會通知只有打開檔案預覽時才看得到
附 @提及的留言顯示在留言區,並以電子郵件和 Box 收件匣通知開啟留言電子郵件通知的協作者也會收到郵件通知

在檔案上留言,並提及上傳者

這個工作流由兩個元件組成:

  1. Box Webhook:每次有檔案上傳,就把一則訊息放進佇列。
  2. worker:從佇列取出訊息、處理檔案,並把結果檔案寫回 Box。

處理進度則由 worker 在檔案上發出的三則留言回報。worker 透過 Box 的 POST /comments 端點留言。@提及要放在 tagged_message 裡。Box 會在這個欄位中尋找 @[user_id:name],並通知對應的使用者。

Worker 在上傳檔案上留言的時機四個步驟橫向排列:檔案上傳到監看中的 Box 資料夾,觸發 Box 的 Webhook,佇列保存訊息,worker 處理檔案。從 worker 出發的箭頭指向上傳檔案的留言區,裡面有三則留言。開始,在取得檔案的鎖、確認格式有支援,且當前版本尚未處理完成之後留下。完成,在結果檔案上傳之後留下,並附上結果的連結。失敗,只在最後一次傳遞時留下。每則留言都提及上傳者,留言區再以箭頭指向標示為 modified_by 的上傳者,每則提及都會寄電子郵件給上傳者,並在 Box 收件匣通知上傳者。上傳檔案放進監看的資料夾Box WebhookFILE.UPLOADED佇列每個事件一則訊息worker處理並寫回結果上傳檔案的留言區開始@上傳者已取得檔案的鎖當前版本尚未處理完成完成@上傳者結果檔案已上傳附上結果檔案的連結失敗@上傳者最後一次傳遞也失敗不會再重新傳遞上傳者(modified_by)每則提及都以電子郵件和 Box 收件匣通知
worker 會在三個時間點於原始檔案上留言。等到佇列不會再重新傳遞,最後一次傳遞才會留下失敗留言。
  1. 開始:worker 先取得檔案的鎖,再確認是否支援該檔案的格式,並確認該檔案的當前版本尚未處理完成後,留言開始處理。
  2. 完成:處理完成後,worker 會在原本的檔案上留言,並附上結果檔案的連結。
  3. 失敗:worker 只在最後一次傳遞也失敗時才會留言。佇列最多會傳遞同一則訊息三次,較早的失敗仍可能在下一次傳遞時成功,所以只有最後一次傳遞也失敗時,worker 才會在該檔案上留言告知處理失敗。

Box 日文介面中,一個檔案的「活動」面板裡有三則來自應用程式的留言。每則都以 @提及上傳者開頭,內容依序是「Processing has started. I will comment here again when it finishes.」、「Processing finished. Output:」加上一個 Box 檔案連結,以及「Processing failed. The operators have been notified automatically.」。面板底部是留言輸入框

上傳者在檔案的「活動」面板中看到的三則留言。留言者和被提及者的名稱已經模糊處理。

負責發出留言的是包裝 Box API 的 connector 裡的 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))

顯示名稱會去掉 [、] 和 :,因為這三個字元就是提及語法本身的分隔符號,而 Box 沒有公開跳脫它們的方式。以顯示名稱 Sato [QA]: Tokyo 為例:

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

去掉前的字串在語法上會在 QA 後面的 ] 就結束提及。名稱只是提及的標籤。Box 是依 user_id 決定通知要送給誰,所以去掉這些字元也不會改變收件人。最後一行為什麼要傳 None,會在後面的陷阱 1 說明。

留言發送失敗時,不會讓工作跟著失敗。如果讓錯誤往外拋,工作就會回到佇列。這樣一來,如果完成留言發送失敗,已經上傳過結果的檔案就會被重新處理一次。所以失敗時只記錄一則警告。警告裡只有例外的型別,不會有上傳者的名稱或留言內容。

要 @提及誰:用 modified_by,而不是 created_by

等待處理結果的是檔案的 modified_by,也就是上傳目前這個版本的使用者。worker 本來就會呼叫 GET /files/{id} 取得檔名和上層資料夾,所以要取得上傳者,只需在 fields 裡多加一項,不必多送一次請求:

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

為了確認哪個欄位會跟著上傳者變動,我先透過自己的帳戶上傳了第一個版本。第一個版本的 modified_by 和 created_by 都指向了我。透過應用程式的服務帳戶上傳第二個版本之後,modified_by 變成了服務帳戶,created_by 仍然是我。

我再用自己的帳戶上傳第三個版本後,modified_by 又變回了我。由此可知,created_by 記錄的是最早上傳這個檔案的人。如果用 created_by 當作通知對象,一旦最早上傳的人和更新版本的人不同,就會提及錯誤的人。

由應用程式自己的帳戶上傳的版本不需要留言。因此當 modified_by 與 GET /users/me 回傳的帳戶(也就是應用程式自己的帳戶)相同時,worker 會略過留言。

即使關閉留言電子郵件通知,被 @提及時仍然收到郵件通知

上傳者即使關掉了資料夾的留言電子郵件通知,仍然會收到提及的通知。我在資料夾的通知設定中取消勾選「Adds a comment」,關閉留言時的寄信通知。之後應用程式發出一則提及時,電子郵件和 Box 的收件匣兩邊的通知都還是送達了。

Box 日文介面的通知電子郵件。Box 標誌下方寫著留言者在 office-features.docx 的留言中提及了你,框內引用了留言「Processing has started. I will comment here again when it finishes.」,下方是一個藍色的回覆按鈕

Box 針對提及寄出的電子郵件,內容引用了留言並附上檔案連結。

Box 日文介面中,收件匣圖示下方展開了收件匣面板,裡面有一張未讀卡片,寫著留言者在 office-features.docx 提及了你,並引用了「Processing failed. The operators have been notified automatically.」

提及也會以未讀狀態出現在 Box 收件匣裡,這裡顯示的是失敗留言的提及。

因此,這些留言不會讓共用資料夾的成員被信件淹沒。不想收到別人檔案進度的協作者,只要關掉那個資料夾的留言電子郵件通知即可。對於自己上傳的檔案,仍然會透過 @提及收到通知。

陷阱 1:同時送出 message 和 tagged_message 時,Box 會回傳 400

Box Python SDK(box_sdk_gen)的 create_comment() 會同時送出 message 和 tagged_message,而 Box 會拒絕這樣的組合。這個 SDK 把 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 自己的指南也寫明,有 @提及時要改用 tagged_message 而非平常使用的 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:如果只在第一次工作傳遞時通知處理開始,搶不到鎖時通知會被漏掉

如果只在第一次傳遞時發出開始留言,那麼當那次傳遞拿不到檔案的鎖時,開始留言就會漏掉。worker 在 DynamoDB 的工作紀錄裡為每個檔案保存一把鎖,避免兩個工作同時處理同一個檔案。如果工作發現鎖已經被其他工作取走,就會回到佇列,等待之後的傳遞。

Box 可能在同一時間針對同一個檔案送出 FILE.MOVED 和 FILE.UPLOADED,所以拿不到鎖是很常見的情況。佇列會在 ApproximateReceiveCount 中計算傳遞次數。如果只在次數為 1 時才通知,遇到這種情況通知就會被跳過。

第 1 次傳遞拿不到鎖,什麼都沒通知。第 2 次傳遞拿到鎖並處理了檔案,但次數是 2,所以同樣什麼都沒通知。最後上傳者只收到了完成留言,前面的開始留言卻被跳過了。

兩次傳遞的時間軸,以及兩種開始留言判定各自留下的留言一則訊息傳遞兩次的時間軸,分成兩條泳道。第 1 次傳遞拿不到鎖,回到佇列;第 2 次傳遞拿到鎖,處理檔案並上傳結果。以傳遞次數為 1 判定的泳道中,第 1 次因為沒有鎖而不留言,第 2 次因為次數是 2 而跳過開始,只留下完成。上傳者的留言區只有完成,缺少開始。以版本紀錄判定的泳道中,第 1 次什麼都不記錄,第 2 次記下 start_notified,先留下開始,再留下完成,上傳者兩則都會收到。時間 →第 1 次傳遞拿不到鎖,回到佇列第 2 次傳遞拿到鎖,處理檔案並上傳結果上傳者收到的 @提及通知判定:次數為 1判定:版本紀錄沒有鎖,不留言次數是 2,跳過開始✓留下完成沒有鎖,不記錄記下 start_notified✓留下開始✓留下完成缺少開始完成開始完成
第 1 次傳遞拿不到鎖,由第 2 次傳遞完成處理。以次數判定時兩次都不會通知,以版本紀錄判定時會在第 2 次傳遞通知開始。

重新執行也會讓同樣的判定失效。worker 忙碌時如果有新版本進來,worker 會在同一次傳遞裡再處理一次。這時次數如果已經是 2 以上,這個新版本的開始就永遠不會被通知。

解決方法是把已經通知過開始的檔案版本,記在 DynamoDB 裡那筆保存鎖的紀錄上,欄位名稱是 start_notified。worker 用條件式更新寫入這個欄位:只有在 start_notified 還不是目前這個版本時,寫入才會成功。所以同一個版本只會有一次寫入成功,開始留言也只會發一次:

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
條件式更新如何決定要不要留下開始留言流程圖。worker 拿到鎖之後,會檢查 start_notified 是否已經是目前版本。如果不是,寫入目前版本會成功,worker 留下開始留言;如果是,寫入會被拒絕,不留言。兩種情況之後都會處理檔案。處理完成時會清除 start_notified,失敗時則會保留 start_notified。否是worker拿到鎖start_notified 已經是目前版本?寫入目前版本✓ 留下開始留言寫入被拒○ 不留言處理檔案完成清除 start_notified失敗保留 start_notified
條件式更新決定這次傳遞要不要留下開始留言。完成時會清除 start_notified,失敗時則會保留。

工作完成時會移除 start_notified,失敗時則會保留,因此:

  • 第 1 次傳遞拿不到鎖:start_notified 還沒有寫入,所以第 2 次傳遞會通知開始。
  • 失敗後重新傳遞:start_notified 已經是這個版本,所以重新傳遞時不會通知。
  • 為了新版本重新執行:完成時已經清除 start_notified,所以重新執行時會通知。
  • 最終失敗後才進來的新版本:start_notified 還是舊版本,所以會通知新版本的開始。

總結

現在 Box 會告訴上傳者:檔案開始處理了、處理完成並附上結果連結,或是告知處理失敗。檔案的留言區也會留下這些紀錄。我完全是利用了 Box 本身的機制,現在 worker 會針對處理狀態留下三種留言,並在每一則裡 @提及上傳者。

這次做下來,我學到的大多是關於 Box 的事。提及的通知和留言的電子郵件通知是分開的,所以協作者即使關掉某個資料夾的留言郵件通知,也不會漏掉自己檔案的進度。modified_by 指的是目前版本的上傳者,而 created_by 則是指向第一個上傳的人。

SDK 的 create_comment() 要把 message 傳成 None,請求才只會帶 tagged_message,Box 也才會接受。開始通知則依檔案版本記在 start_notified,這樣不論哪一次傳遞拿到鎖,每個版本都只會通知一次開始。

參考連結

分享這篇文章