nano-cms ドキュメントは株式会社ケイズ・ソフトウェアが運営しています。
nano-cms技術ドキュメント
← ドキュメント一覧へ

予約を 100 件積んでも、順序も競合も迷わない ― 予約変更を時系列で積み上げる設計

予約プレビューのキャッシュ設計では、未来の状態を「どう速く出すか」を書きました。ここではその一段下 ―― そもそも「公開当日の状態」とは何で、なぜ予約を何件積み上げても順序や競合に迷わないのかを、設計判断として書きます。

素朴な発想は「枝を分けてマージ」、そこに 3 つの迷いがある

お知らせやバナーを「この日時に出す」と予約していく。これらから「公開当日のサイトの姿」を作る、と素朴に考えると、予約ごとに作業の枝(ブランチ)を分け、それを 1 本ずつマージして仮想の「未来の main」を組み上げる ―― という発想になります。これは動きますが、必ず 3 つのことに迷います。

  • 順序 ― どの予約を先に重ねるのか。順番をどこかに入力・保存させると、「保存した順序」と「実際の公開予定」がやがてずれていく。
  • 競合 ― 2 つの予約が同じ箇所を書き換えていたら、どちらを採るのか。マージは競合で止まる。
  • 未定 ― そもそも「いつ公開するか」が決まっていない予約があると、「いつの姿」を作ればいいのかが決まらない。

nano-cms はこの 3 つを、賢いマージアルゴリズムで解いたのではありません。3 つとも「状態として持たない」ことで、迷いの元を消しました。順序を保存しない・「競合」という状態を持たない・「未定」という状態を持たない ―― 順に見ていきます。

図1: 未来の状態の作り方。素朴な枝マージ(順序・競合・組合せに迷う)と、scheduledAt 昇順で時系列に畳み込む方式(基準時刻で状態が一意に決まる)の対比。
図1: 「公開当日の状態」の作り方 ― 枝を分けてマージ(順序・競合・組合せに迷う)か、時刻で時系列に畳み込む(基準時刻で一意に決まる)か。

順序を保存しない ― 時刻が唯一の正本

予約には必ず「公開予定日時」が付きます。なら、その時刻の昇順こそが、唯一の順序です。nano-cms は予約のチェーンを、公開予定時刻の早い順に並べて作ります。「どれをベースにするか」を選ばせる UI はありません。順序を人にもカラムにも入力させないのです。

効いているのは、この順序をどこにも保存しないことです。「この予約はあの予約の次」という並びをカラムに持つと、予約の時刻が変わるたびに並びを直す同期処理が要り、そこは取り違え(typo)やテスト用モックとの食い違いの温床になります。nano-cms は順序カラムを持たず、必要なときに公開予定時刻から並びを毎回その場で求めます。保存していない情報は、ずれようがありません。並び順を直す処理が、そもそも要らない。

補足:新しいワークスペースは、何をベースにする?

常に現在の main(=いま公開されている状態)です。「どの予約をベースにするか」は選びません ―― その選択肢自体を無くしてあります。重ね順は、そのとき各予約に付いている公開予定時刻の昇順から、描画のたびに求めます。先行する予約があれば main にそれらを時刻順で畳み込み、最後に自分の編集を乗せます(先行が無ければ main だけが土台)。

なぜ「保存せず毎回求める」のか ―― 公開予定時刻は、ワークスペースを作ったあとでも変えられるからです。時刻を変えれば重ね順も変わる。もし順序をどこかに保存していたら、変えるたびに直さねばならず、いつか必ずずれます。毎回その場で時刻から求めるので、予定をいつ組み替えても、常に正しい順で畳まれます。

これは「あとで掃除する」ではなく「最初から散らからない」という、このシステムが繰り返し採っている判断と同じ形です(→ なぜ 10 年運用しても重くならないのか)。

「競合」という状態を持たない

順序が時刻で一意に決まると、競合の扱いも単純になります。2 つの予約が同じ行を書き換えていたら、公開予定が後の予約を採る(後勝ち)。時刻という全順序があるので、「どちらが後か」は常に一意に決まり、例外がありません。

だから nano-cms には「競合中」という特別な状態がありません。多くの仕組みが「競合したら止めて人間に解決させる」状態を持つのに対し、ここでは競合は状態ではなく、その場で後勝ちに片付く一手順です。承認済みの予約は、すべてそのままチェーンに並びます。競合を git の標準オプションで後勝ちに倒す“やり方”と、その性能(競合があっても遅くならない)は 予約プレビューのキャッシュ設計に書きました。

ただし、後勝ちで機械的に決めると壊れかねない構造的な衝突(たとえば改名と削除のぶつかり)だけは、安全のために止めて人手に委ねます。日常的な競合 ―― 同じ行の取り合い ―― は、すべて時刻順の後勝ちで進みます。

「未定」という状態を持たない

3 つめ。予約には公開予定時刻が必ずあり、「公開予定が未定」という状態を作れません。即時公開なら「いま」を、予約公開なら未来の日時を、作成時に必ず決めます。

これが効くのは、プレビューの基準時刻が常に確定しているからです。「公開当日の姿」を出すには、「いつの姿か」という基準時刻が要ります。未定を許すと「時刻が決まらないから、とりあえず現在時刻で代用する」という曖昧な逃げ道ができ、プレビューが「だいたいこんな感じ」になってしまう。基準時刻が必ずある設計なら代用が要らず、プレビューは公開当日の実際の見え方とそのまま一致します。「だいたい合っている」を許さない、ということです。

3 系統を、ひとつの時刻で畳む

「公開当日の姿」は、性質の違う 3 系統を重ねて作ります。

  • テンプレート・CSS・JS(サイトの骨格ファイル)― git のブランチに入る。
  • 記事(お知らせ・ニュースの本文)― DB に版として入る。
  • 共通パーツ(ヘッダー等の使い回し断片)― 同じく DB に版として入る。

3 系統は編集対象も保存場所も独立で、互いに直接は触りません。それでも「同じ瞬間の姿」にそろうのは、1 つの基準時刻を、そのまま 3 系統すべてに同じ値で配るからです。記事と共通パーツは「その時刻までに公開される版」を DB から、ファイルは「その時刻までの予約を畳み込んだ中身」を返す。基準が 1 つなので、3 系統がばらばらの瞬間を指すことがありません。なお記事と共通パーツはそもそも git を使わず、時刻で版を絞る DB のクエリだけで畳めます(畳み込みで git が要るのはファイルだけ、という切り分けは キャッシュ設計側の主題です)。

図2: 基準時刻(previewAt)を 3 系統(テンプレ=git / 記事=DB / 共通パーツ=DB)すべてに同じ値で配り、公開当日の姿に合成する。
図2: ひとつの基準時刻を 3 系統に配る ― テンプレは git、記事と共通パーツは DB クエリ。ばらばらの瞬間を指さない。

同じ畳み込みが、プレビューも preprod も、公開後の正体も

この「時刻で畳む」は 1 つの計算です。だから、用途が違っても結果が食い違いません。

  • ページ単体のプレビュー ― その 1 ページを、基準時刻で畳んで動的に描く。
  • サイト丸ごとの事前確認 ― 同じ畳み込みを全ページに広げ、静的サイト一式として書き出す(→ 事前確認サイト)。
  • 公開後の実体 ― 予約時刻が来れば、その畳み込み結果がそのまま公開される。

プレビューと事前確認サイトは同じ畳み込み結果を見る 2 つの窓(動的に描くか、静的に書き出すか)にすぎません。データは 1 本、計算は 1 つ。だから「プレビューでは大丈夫だったのに、公開したら違った」が原理的に起きません。

図3: 時刻で畳んだ 1 つの結果を、ページ単体プレビュー(動的描画)・サイト丸ごとの事前確認(静的書き出し)・公開後の実体、の 3 つの窓から見る。
図3: ひとつの畳み込みを 3 つの窓から見る ― データは 1 本、計算は 1 つ。preview / 事前確認サイト / 公開後で結果が一致する。

設計のポイント ― 3 つの「持たない」

  • 順序を保存しない:公開予定時刻の昇順が唯一の正本。並び順カラムも、それを直す同期処理も持たない。
  • 「競合」という状態を持たない:時刻の全順序があるから、同じ行は後勝ちで一意に解決。「競合中」という特別な状態が無い。
  • 「未定」という状態を持たない:公開予定時刻は必須。基準時刻が常に確定するので、プレビューは公開当日と一致する(“だいたい”を許さない)。

予約を 100 件積んでも順序や競合に迷わないのは、巧妙なマージアルゴリズムのおかげではありません。迷いの元になる「状態」を、そもそも持たないようにした結果です。速さの話は 予約プレビューのキャッシュ設計、機能としての使い方は 予約公開しても「当日の見え方」が事前に分かる事前確認サイト にあります。なお、このドキュメントサイト自体も @nano-cms/core で動いており、各ページも予約公開で「当日の見え方」を確認してから出しています。