ブログ一覧

D1 にトランザクションが無い中で EmDash が整合性を保つ仕組み

自分のサイトのデータベースに溜まったリビジョン(本文の版の履歴)を数えたら、公開にも下書きにも紐づかない行が246件ありました。Cloudflare D1(サーバーレスの SQLite)には、トランザクション(複数の SQL 文をまとめて全部成功か全部失敗にする仕組み)が無く、更新の途中で Worker(Cloudflare 上でサイトを動かす実行単位)が打ち切られて残った孤児だろうと思い込みました。

ソースを読むと、自分のサイトで使っている Astro 製の CMS「EmDash」の設計が分かりました。D1 にトランザクションが無いことを EmDash は隠さず、守るべき1行だけを条件付きの1文で更新し、残りのリビジョンは多めに作って後で捨てる形で整合性を保っていました。EmDash は TypeScript のクエリビルダーである kysely を、kysely-d1 というアダプタ経由で D1 に繋いでいます。

D1 にトランザクションは無く、EmDash はそれをソースに書いている

EmDash(0.34.0、2026年9月12日時点)の `transaction.ts` 冒頭には、次のコメントがあります。

D1 (via kysely-d1) does not support transactions. On workerd, the error from beginTransaction() crosses request contexts and can hang the worker.

「D1(kysely-d1 経由)はトランザクションに対応しておらず、workerd 上では beginTransaction() のエラーがリクエストの境界を越えて Worker をハングさせることがある」という趣旨です。同じファイルの `withTransaction` は、次のように初回だけ実際にトランザクションを試します。

// First call: probe
try {
	const result = await db.transaction().execute(fn);
	transactionsSupported = true;
	return result;
} catch (error) {
	if (error instanceof Error && TRANSACTIONS_NOT_SUPPORTED_RE.test(error.message)) {
		transactionsSupported = false;
		return fn(db);
	}
	throw error;
}

失敗のメッセージが「サポートされていない」ことを示していれば、以後はトランザクション無しで同じ処理を素通しします。ただしこの関数を使っているのはメニュー、バイライン、タクソノミー、メディアの使用量、プラグインの保存の5か所だけで、記事本文を保存するコンテンツのリポジトリは使っていません。

kysely-d1 自体もトランザクション未対応で、複数文を安全にまとめる手段は batch() だけになる

kysely-d1 の 作者本人が起票した Issue #2 には、次のように書かれています。

Transactions are not currently supported in D1. Once they are, this package needs support added for them.

「D1 では現状トランザクションに対応しておらず、対応され次第このパッケージにもサポートを追加する必要がある」という内容です。これに Cloudflare の開発者が、D1 への batch() 呼び出しはすべて暗黙にトランザクションで包まれると補足しています。D1 の公式ドキュメントも、原子性(途中の状態が外から見えないこと)を持てるのは batch() だけだと書いています。

Batched statements are SQL transactions. If a statement in the sequence fails, then an error is returned for that specific statement, and it aborts or rolls back the entire sequence.

まとめて実行した文のどれかが失敗すれば、シーケンス全体が中断されロールバックされるという意味です。複数文の原子性を持てる手段は batch() だけで、EmDash のコンテンツ更新はこの batch() を使わず、文を1つずつ順に実行します。

守るべきなのはコンテンツの行1つで、条件付きの1文で更新する

保存を1回実行すると、書き込みは次の2段階に分かれます。

  1. リビジョンの行を1つ作り、本文の JSON スナップショットを保存する
  2. コンテンツの行が持つ下書き・公開リビジョンの ID を更新する

この2段階目の更新は楽観ロック(読んだときの版のまま変わっていないかを条件に付け直す方式)です。UPDATE 文は `version = version + 1` を伴います

assignments.push(sql`version = version + 1`);

const result = await sql`
	UPDATE ${sql.ref(tableName)}
	SET ${sql.join(assignments, sql`, `)}
	WHERE id = ${id}
	AND deleted_at IS NULL
	AND version = ${expected.version}
	AND ${nullableColumnMatch("live_revision_id", expected.liveRevisionId)}
	AND ${nullableColumnMatch("draft_revision_id", expected.draftRevisionId)}
	// …
`.execute(this.db);

WHERE 句には、読み込んだときの version と、下書き・公開それぞれのリビジョン ID の3つが条件として入っています。どれかがずれていれば0行更新になり、他の保存と競合したと分かります。EmDash はこの場合、直前に作ったリビジョンを削除し、最新の状態を読み直してから最大32回まで更新をやり直します。UPDATE 文自体が例外を投げた場合も、同じように作ったリビジョンを削除してから例外を投げ直します。

D1 は単一ライター(同時に書き込めるのが1つだけという性質)です。transaction.ts のコメントは、この性質によって個々の文の中は原子的だと続けて述べています。だからこそ、外から見えて困る途中状態(コンテンツの行が半分だけ新しい状態)は、1文にまとめた UPDATE の中に閉じ込められていて起こりません。この楽観ロックを API 側から見た形は、EmDash の記事を API で一括更新するで扱った _rev の仕組みと同じです。

一方で、「リビジョンは作ったがコンテンツの行の更新は届いていない」という途中状態は起こり得ます。EmDash はこれを不整合としてではなく、余分なリビジョンが1行残っている状態として扱います。

リビジョンは多めに作って後で捨てる。孤児と履歴を区別しない

リビジョンは、コレクションの設定で有効になる版の履歴です。ソースのコメントは「各リビジョンはある時点での本文の JSON スナップショットを保存する。コレクションが supports: ["revisions"] を持つときに使われる」と説明しています。

リビジョンの行を作った直後、コンテンツの行を更新する前に Worker が止まると(CPU 上限による打ち切りなど)、後始末は呼ばれません。この区間を捕捉して後始末を呼ぶ例外処理は、ソースに書かれていません。後始末自体、つまり作ったリビジョンを削除する処理も、失敗すればログに書くだけで呼び出し元には伝わりません。

その代わり EmDash は、リビジョンを作るたびに掃除待ちのキューへコレクションと記事 ID を登録します

await this.db
	.insertInto("_emdash_revision_prune_queue")
	.values({
		collection: input.collection,
		entry_id: input.entryId,
		revision_id: id,
	})
	.onConflict((conflict) =>
		conflict.columns(["collection", "entry_id"]).doUpdateSet({ revision_id: id }),
	)
	.execute();

同じコレクション・記事の組で既にキューに行があれば、onConflict で新しいリビジョン ID に上書きします。Cron の定期処理がこのキューを1回10件ずつ処理し、記事ごとに最新50版を残して、公開にも下書きにも指されていないリビジョンを削除します。

消す条件は「公開にも下書きにも指されていない」ことだけで、途中で打ち切られて残った行と、版が進んで単純に古くなった行を区別する列はありません。

const result = await sql`
	DELETE FROM revisions
	WHERE id = ${revisionId}
	AND collection = ${collection}
	AND entry_id = ${entryId}
	AND NOT EXISTS (
		SELECT 1 FROM ${sql.ref(tableName)} AS content
		WHERE content.live_revision_id = revisions.id
		OR content.draft_revision_id = revisions.id
	)
`.execute(this.db);

NOT EXISTS の条件は liverevisionid と draftrevisionid のどちらとも一致しないことだけを見ており、区別しない設計です。打ち切りで生まれた余分なリビジョンは、通常の版の履歴と同じ経路で消えます。

本番の D1 を数えると、いま消える対象は無かった

自分のサイトの本番 D1 を読み取り専用で数えると、コンテンツは182件(記事153、固定ページ6、制作実績23)に対してリビジョンは397件でした。このうち公開にも下書きにも指されていないものは246件です。1件あたり平均2.2版で、50版に届いているものは無く、いまの掃除で消える対象はありません。

246件のうちどれだけが打ち切りに由来するかは、ソースからもクエリからも分かりません。指されていないリビジョンを、打ち切りで残ったものと版が進んで古くなったものに分ける列がそもそも無いからです。だから「打ち切りで孤児が残った」とは言い切れず、言えるのは残り得る経路がソース上にあることと、残ったとしても履歴と同じ経路で消える設計になっていることまでです。

原子性が要るのは1文だけ、と決めてある

  • 原子性を要求しているのは、コンテンツの行の version と2つのリビジョン ID を同時に進める1文だけ
  • それ以外(リビジョンの作成、後始末、掃除)は、失敗しても余分な行が残る以上のことにならない設計

D1 で複数の文にまたがる書き込みをするとき、原子性を持てる手段は batch() だけです。EmDash は batch() を使わず、壊れて困るものを1文に集め、残りは後で消せる形にするという置き方を採っています。

参考リンク

  • transaction.ts — EmDash のトランザクション処理の実装とコメント
  • kysely-d1 Issue #2 — D1 のトランザクション未対応についてのやり取り
  • D1 Worker API — batch() の原子性についての公式ドキュメント

この記事をシェア