Zudo Slack Wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

イベントなし、冪等性なし

Lists への受信経路はポーリングだけで、upsert は存在せず、行 ID のマッピングは呼び出し側が所有する

Worker から Slack Lists と連携するとき、すべてを規定する構造的な欠落が 2 つある。リストが変更されたことを通知してもらう手段がないことと、行に冪等に書き込む手段がないことだ。どちらも後から埋まりそうな成熟度のギャップではない — いずれも、ドキュメント化されていない片隅ではなく Slack 自身が公開している面に対して確認された「不在」である。

プッシュシグナルは存在しない — 受信経路はポーリングだけ

Slack Events API のカタログ全体をリスト関連イベントについて調べたが、該当するものは 1 つもなかった。slackLists.items.create / items.update / items.delete はいずれも list_item_createdlist_item_updated といったイベントを一切発火しない。Socket Mode を足しても何も変わらない — 同じイベントカタログを別のトランスポートで配送するだけだからだ。slackLists.items.list — ただのポーリング — が、Lists が提供する唯一の信頼できる受信シグナルになる。

これの実務上の要点はこうだ。人間がボードの列間でカードをドラッグしても、購読できるものは何も出てこない。 Worker が「前回のティック以降に何か変わったか」を知る必要があるなら、正直な答えは items.list をもう一度呼んで差分を取ることからしか得られない。

未検証

Workflow Builder には「リストアイテムが更新されたとき」というトリガーがあり、原理上は変更時にチャンネルへメッセージを投稿してプッシュ的な抜け道になり得る。しかし、それがアイテムの作成時にも発火するかは未記載で、別途「作成された」トリガーも存在せず、プログラム的な正しさをワークフロー自動化の細かな発火ルールに依存させるのは、動く場合でも脆い。このプロジェクトの検証スパイク(作成と更新のどちらで発火するかを実証的に決着させられたはずのもの)はスキップされた — このトリガーは通知の便利機能としてはあり得るが、同期の正しさが依存する仕組みとしては決して扱わないこと。重要なものはすべて items.list のポーリングで拾う。

upsert は存在しない — 行 ID のマッピングは呼び出し側が所有する

slackLists.items.create の引数一式は list_id に加えてオプションの duplicated_item_idparent_item_idinitial_fields だけだ。duplicated_item_id は既存の行を複製するもので、重複排除キーではないし、それに類するものも存在しない。このメソッドのエラー表には duplicate_itemalready_existsconflict もない。同一の items.create を 2 回呼べば、行が 2 つできる。黙って、毎回。

items.create は新しい行の ID を item.idRec… 始まりの文字列)として返す。Slack の行と自分側のソースレコードを結ぶ関係は、その ID がすべてだ — find-or-create もなければ、呼び出し側が指定する外部キーもなく、「ソースレコード #482 に対応する行はもう存在するか」と尋ねる手段もない。そのマッピングを追跡しているのは自分だけであり、次のことをしなければならない。

  1. 返ってきた row_id を、create と同じステップで、他の作業より先に自分のデータベースへ永続化する。 これは失敗ウィンドウを狭めるだけで、閉じはしない。Slack API 呼び出しと自分のデータベース書き込みをまたぐトランザクションは存在しないので、「Slack に行ができた」と「ID を自分の DB に保存した」の間でクラッシュしたり曖昧なタイムアウトが起きたりすれば、Slack 側の行が孤立し得る。即時永続化を完全な解決策として扱わないこと — 下記の照合パスと組み合わせて初めて孤立行が実際に捕捉できる。すべての Slack 行のテキスト列に自分側の主キーをミラーしておけば、照合スイープが想定外の行を元のソースレコードへ突き合わせられるようになり、次の実行が何も知らずに重複を作ってしまう事態を防げる。

  2. ソースレコードがすでに row_id を保持しているかどうかで create と update を分岐する。 ソーステーブルに追加可能な nullable 列(とインデックス)が 1 つあれば十分だ。NULL は「未同期、items.create を呼ぶ」を意味し、値が入っていれば「items.update を呼ぶ」を意味する。

  3. セルへの書き込みは、非推奨の key / 汎用 value 形式ではなく、必ず column_id と型付きの値キーで指定する — Slack は前者を削除予定だとすでに告知している。

// The nullable column IS the idempotency key. NULL means never synced;
// populated means items.update, never items.create, for this record.
interface SourceRow {
  id: number;
  slackRowId: string | null; // set atomically with the create call, never after
}

cron 再実行による重複の罠

重複は実行をまたいだリスクにとどまらない — 単一の実行の内側でも起こり、その仕組みは「cron が 2 回発火した」よりも微妙だ。Slack SDK はネットワークレベルの失敗(タイムアウト、接続リセット)を再試行するデフォルトのリトライポリシーを備えている。items.create が自分側ではタイムアウトしたのに Slack 側では実際には成功していた場合、SDK のデフォルトリトライは同じ create をもう一度送る — サーバー側から見れば完全な create が 2 回で、実在するソース行 2 件と見分けがつかない。

独立したガードが 2 つ、1 つではない

SDK のデフォルトを信頼せず、create 呼び出しには retryConfig を明示的に設定して、クライアント側で観測されたタイムアウトが黙って 2 行目にならないようにする。それとは別に、cron ハンドラ自体が次のティックと重ならないよう、実行ロックかリースでガードする — あるティックがまだ同期の途中で次が発火すると、両方が同じ未永続のソースレコードに対して行を作り得る。これらは別の失敗モード(リトライレベル対スケジューラレベル)であり、どちらのガードも他方の代わりにはならない。

照合スイープ — 正しさの最後の砦

プッシュシグナルがない以上、低頻度(毎時または毎日)の全件照合パスは任意ではなく必須として扱う — 自分の書き込み以外で何が変わったかを知る手段はこれしかない。

  1. items.list を最後までページングし(リストの読み取りを参照)、返ってきた row_id の集合を自分のデータベースに保存されたマッピングと突き合わせる。

  2. 同じパスを archived: true でもう一度実行する。 自動アーカイブが黙って取り除いたものを見る方法はこれしかない — 成長するソーステーブルにとってなぜそれが重要かはアイテム上限と自動アーカイブを参照。

  3. items.list はすべての行に updated_by を返す — bot 以外が最後に触った行のフラグ付けに使う。同期が排他的に所有しているつもりの行を人間が編集したというシグナルになる。

  4. 自分のデータベースに対応する row_id がない行は、「未同期」と記録済みのソースレコードだけでなく、ミラーした主キーのテキスト列とも突き合わせる。 これこそが上記の「create してからクラッシュ」のギャップを実際に塞ぐ手順だ — 失敗した実行が残した孤立行はちょうどこの形をしており、ここで突き合わせればソースレコードに再接続できる。放置すれば、次の同期ティックが同じデータに対して 2 つ目の行を自由に作ってしまう。

1,000 行あたりおよそ 10 回のページネーション呼び出し(Tier 2、20+/min)なので、リストが有界な作業セットに収まっているなら照合パスは毎 cron ティックで回せるほど安い — いずれにせよその上限を設けたい理由はアイテム上限と自動アーカイブを参照。

関連ページ

Revision History

作成更新