アイテム上限と自動アーカイブ
1,000 行 / 5,000 行の天井、over_row_maximum エラー、そしてミラーがその十分手前で自己制限すべき理由
Slack List にはリストごとの厳格な容量上限があり、その天井を「そのうち埋めていけばよいもの」として扱うことが、外部の成長し続けるデータソースから供給されるリストで最もよくある設計ミスだ。
数字: アイテムとサブタスクで 1,000、Enterprise Grid では 5,000
Slack のヘルプ記事「Use lists in Slack」はこの上限をそのまま明記している。Pro と Business+ ではアイテム + サブタスクで 1,000、Enterprise Grid では 5,000。上位の有料プランへ移行して変わるのは容量であってアクセス可否ではない — Lists 自体はどの有料プランでもすでに利用できる(本セクション前半の概要ページを参照)。Enterprise Grid は天井を引き上げるだけだ。
サブタスク(parent_item_id を付けて作った行)は、トップレベルの行と同じ予算を消費する。 トップレベル 900 行 + サブタスク 150 行のリストは、標準プランの上限をすでに超えている — サブタスク用の別枠は存在しない。ソースデータが親子構造を持つなら、「本物のレコード」と考えている行だけでなく、合計行数で予算を組むこと。
over_row_maximum — 上限を示す唯一の文書化されたシグナル
slackLists.items.create には over_row_maximum エラーが文書化されている。これがリストが満杯であることを示す唯一の権威ある Slack 公式シグナルであり、リトライ・バックオフやアラートのロジックはこれで分岐させるべきだ — 自前の追跡から推測した行数ではなく。
未検証
この上限に関連する 2 つの問いは実証検証の対象としてフラグが立てられていたが、このプロジェクトの検証スパイクはスキップされた(テストトークンが用意できなかった)ため、どちらも元調査時点の未検証ステータスのままで、確定した事実には格上げされていない。
上限に達したときに最も古い行が自動アーカイブされるのか、そもそも。 これは報告されている内容であって、Slack が文書化したものでも、実地で観測されたものでもない。もしこれが誤りなら、上限到達時には何も黙ってアーカイブされず、単に
over_row_maximumで create が失敗するだけかもしれない — 本セクションが関心を寄せる設計にとって、意味のある違い(うるさい失敗か静かな失敗か)になる。アーカイブ済みの行が依然として上限を消費するのか、つまりアーカイブが空き容量を解放するのか、それともデフォルトの
items.listビューから隠すだけなのか。これは、長期運用するミラーにとって明示的なitems.deleteMultiple呼び出しが必須なのか、それとも行を自動アーカイブに任せるだけで足りるのかを左右する。
実ワークスペースで確認できるまでは、over_row_maximum をエラーハンドリングの土台にできる唯一の事実として扱い、自動アーカイブが信頼できる容量の逃し弁だとは仮定しないこと。
もし報告されている「古い行から自動アーカイブ」の挙動が実在するなら、それはここで起こり得る最も厄介な失敗モードだ。自分のデータベースがまだ指している行が、イベントもエラーもないまま黙ってリストから消える — 後になってその row_id に対する items.update が失敗するまで気づけない。この失敗は、些細な問題では済まなくなるまで一切うるさく知らせてくれない。
主要パターン: desired-state のクエリ自体に上限を組み込む
元調査の結論であり、本セクション全体が寄りかかっている設計はこうだ。成長し続けるテーブルの system of record をリストにしてはならない。 これを実現する最も堅牢な方法は、後付けの「確認して退避する」ステップではなく、desired state を生成するクエリそのものに上限を焼き込むことだ。
有界なアクティブ作業セットだけをミラーする — 例えば未アーカイブの行や現在関連のあるソース行だけで、全履歴は載せない。
desired-state のクエリ自体に上限を設ける: ビジネス上のタイムスタンプで新しい順に N 件だけ選ぶ(挿入順ではない)、同一タイムスタンプの行に対しては安定した id をタイブレークに使う。N は 1,000 の十分手前に保つこと — 元調査の推奨レンジは 300〜500 — こうしておけば、実際の天井付近でプラットフォームがどう振る舞うかに依存せずに成長の余地を確保できる。
ウィンドウから外れた行は特別扱いしない。 それらは単に、リストと突き合わせる desired-state 集合から現れなくなるだけであり、その結果、「desired だがリストにない」ものを create に変える同じ照合の仕組みが、そのまま通常の
desired_operation = deleteの作業に変えてくれる。同期ロジックの他の部分と整合を取り続けなければならない別枠の退避コードパスは存在しない — クエリの形そのものが容量ポリシーになる。各照合バッチ内では delete を create より先に順序付ける。 ウィンドウから外れた行を落とすのと新しい行を追加するのを同じバッチで行う場合、消費する前に容量を解放すべきだ — こうすることで、「ちょうど上限」と「上限を超えそう」の境界で無用な
over_row_maximumを作り出さずに済む。行の予算が厳しいならサブタスク(
parent_item_id)は一切使わない — 別枠なしで同じ上限を共有するため、親子構造にすると、自分で設けた上限に対して使える予算が実質的に半減する。
はっきり言っておく価値のある帰結が一つある。desired-state の上限が容量を強制する以上、自分が容量に達しているかどうかを判断するために items.info の list_limits / row_count を参照する必要は一切なくなる — 自分の有界な projection クエリそのものが容量ポリシーだ。items.info の list_limits は、依然としてヘルスチェックのシグナルとして定期的に読む価値がある(後述の「リストの充填状況を読む」を参照)。書き込み経路の判断からは完全に外れるだけだ。
バルククリーンアップのフォールバック: items.deleteMultiple
上記の主要パターンがあれば、明示的な一括削除パスはほとんど、あるいは一切必要ないはずだ — desired-state の projection が毎サイクル上限内に収めてくれる。items.deleteMultiple(Tier 2、20+/min)は、projection だけではカバーできないケースのフォールバックとして残しておく: 一度きりのバックフィルのクリーンアップ、あとから引き下げた上限、あるいはこのパターンを導入する前に溜まったバックログの一掃だ。
slackLists.items.deleteMultiple の Slack メソッドリファレンスには、リクエストボディが {list_id, ids} だと明記されている — パラメータは row_ids ではなく ids だ。delete 系のメソッド間には知っておく価値のある命名の非対称性がある: items.delete は単数の id を取り、deleteMultiple は複数形の ids 配列を取り、items.info は取得する 1 行に対して id を取る。トップレベルに row_id というパラメータを持つメソッドは一つもない — この名前が現れるのはネストした場所、つまり items.update の cells[] 内のエントリだけで、各セルはどの行を対象にするかを示すために独自の row_id(または row_id_to_create)を持つ。
deleteMultiple のまともなラッパーは、0 件の id 配列をノーオペとして扱い(空の ids 配列を往復させる理由はない)、1 件の配列は items.delete にルーティングする — それは正真正銘の単数削除だからだ。
// Bulk-cleanup fallback — the primary desired-state cap (above) should mean
// this rarely fires. Request body is {list_id, ids}, not row_ids; see
// https://docs.slack.dev/reference/methods/slackLists.items.deleteMultiple/
const MIRROR_ROW_CEILING = 400;
async function evictOldestIfOverCeiling(
botToken: string,
listId: string,
currentRowIds: readonly string[], // ordered oldest-first by your own db
): Promise<void> {
if (currentRowIds.length <= MIRROR_ROW_CEILING) return;
const toEvict = currentRowIds.slice(0, currentRowIds.length - MIRROR_ROW_CEILING);
// A 1-length array is a genuinely singular delete — route it to items.delete's
// `id` rather than paying for the deleteMultiple round trip.
const method = toEvict.length === 1 ? "slackLists.items.delete" : "slackLists.items.deleteMultiple";
const payload =
toEvict.length === 1 ? { list_id: listId, id: toEvict[0] } : { list_id: listId, ids: toEvict };
const res = await fetch(`https://slack.com/api/${method}`, {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify(payload),
});
// Slack's Web API returns HTTP 200 even on failure — {"ok": false, "error": "..."}.
// Only remove rows from your own mapping table AFTER confirming ok: true; removing
// them unconditionally would desync your mapping from Slack on a rejected call.
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) {
throw new Error(`${method} rejected: ${body.error}`);
}
// Remove the same row ids from your own mapping table in the same
// operation — do not let the two stores drift apart.
}リストの充填状況を読む
items.list は行数を一切返さない。容量に関する数字が表に出てくる唯一の場所は、items.info レスポンスの list_limits(row_count、row_count_limit、archived_row_count)だ — ミラー用の行を 1 つ潰さずにこれを読む方法は、リストの読み取りのセンチネル行パターンを参照。
desired-state の projection 上限を主要パターンとする以上(前述)、書き込みが安全かどうかを判断するために list_limits は不要だ — projection クエリが毎サイクル上限内に収めてくれる。それでも定期的にヘルスチェックとして読んでおくとよい: 自分の上限より低いはずなのに row_count が row_count_limit に近づいていくのは、何か他のものがそのリストに書き込んでいるか、自分の上限を見直すべきだというシグナルだ — over_row_maximum が発火してから事後的に反応するだけでは足りない。
関連ページ
リストの読み取り — 作業セットの行を消費せずに
list_limitsを読むためのセンチネル行パターン。イベントなし、冪等性なし — 自動アーカイブが(実在するなら)取り除いたものを見る唯一の方法である
archived: trueの照合パス。ワンウェイミラーパターン — 自己制限と明示的な退避が、完全な同期設計のどこに収まるか。