1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare D1で1日10件の投稿受付と翌日3時の処理対象を実装する

1
Posted at

140文字の要望でホームページが変わるAfter 6 Chaosを作っています。

投稿機能には、サイト全体で1日10件、同じ接続元からは60秒に1件という制限を設けました。さらに、受け付けた投稿を改修するのは「受付日の翌日03:00 JST以降」です。

この三つを実装すると、日付境界、同時送信、保存失敗時の扱いがつながってきます。ここでは、Cloudflare WorkersとD1を使った実装の要点を紹介します。コードは2026年10月30日の正式リリース時点の受付処理をもとにしています。

最初に受付ルールを決める

項目 ルール
本文 1文字以上140文字以内
日次上限 サイト全体で10件
1日の境界 日本時間00:00
連投間隔 同じ接続元は60秒
件数に含めるもの 保存に成功した投稿
改修の対象時刻 受付日の翌日03:00 JST
採用の保証 受付時点ではしない

ここでいう1日は、直近24時間ではなく日本時間の暦日です。連投制限は、最後に保存できた時刻から数えます。拒否された送信で待ち時間を延ばさないようにします。

文字数は書記素単位で数える

JavaScriptの string.length はUTF-16のコード単位数です。入力画面の文字数には、結合された絵文字などを一つとして扱える Intl.Segmenter を使っています。

const segmenter = new Intl.Segmenter('ja', {
  granularity: 'grapheme',
});

export function countCharacters(value) {
  return [...segmenter.segment(value)].length;
}

export function validateSubmission(value) {
  if (typeof value !== 'string' || !value.trim()) {
    throw new Error('1文字以上入力してください。');
  }
  if (countCharacters(value) > 140) {
    throw new Error('140文字以内で入力してください。');
  }
  return value;
}

クライアントの表示とサーバーの検証で、同じ関数を使います。trim() は空白だけの入力を弾くために使い、保存する原文は加工していません。文字数に加えて、APIではリクエスト本文のバイト数にも上限を設けています。

日本時間の当日範囲をUTCで求める

DBには、サーバーが生成したUTCのISO文字列を保存します。比較する境界も同じ形式にそろえます。

const hour = 3_600_000;

export function dayWindow(now = Date.now()) {
  const day = new Date(now + 9 * hour)
    .toISOString()
    .slice(0, 10);

  const start = Date.parse(day + 'T00:00:00+09:00');

  return {
    day,
    startAt: new Date(start).toISOString(),
    resetAt: new Date(start + 24 * hour).toISOString(),
  };
}

export function nextRevisionAt(createdAt) {
  const time = typeof createdAt === 'number'
    ? createdAt
    : Date.parse(createdAt);

  return new Date(
    Date.parse(dayWindow(time).resetAt) + 3 * hour,
  ).toISOString();
}

日本時間の受付日の終わりを求め、そこへ3時間足しています。この実装はUTC+9の日本時間を対象としています。夏時間のある地域へそのまま流用する場合は、タイムゾーンの扱いを変更する必要があります。

日時の例を日本時間で並べると、次のようになります。

日本時間23時59分の投稿は翌日3時、0時と2時30分の投稿はさらに翌日の3時から改修対象になる。

図1:同じ深夜帯でも、受付日が変わると改修対象の日が変わります。

投稿の日時 改修対象になる日時
10月29日23:59 10月30日03:00
10月30日00:00 10月31日03:00
10月30日02:30 10月31日03:00

深夜2時半の投稿でも、30分後の改修に入るわけではありません。「受付日の翌日」という条件を、画面の案内とAPIで共通にしています。

件数の確認と保存を一つのSQLにする

画面で送信ボタンを無効にするだけでは、APIへの送信を制限できません。また、件数をSELECTしてから別の処理でINSERTすると、その間に別の投稿が入る可能性があります。

そこで、保存できる条件をINSERT文の中に置きます。

テーブルの要点は次の二つです。

CREATE TABLE submissions (
  id TEXT PRIMARY KEY NOT NULL,
  content TEXT NOT NULL,
  created_at TEXT NOT NULL
);

CREATE TABLE submission_throttles (
  client_key TEXT PRIMARY KEY NOT NULL,
  last_at INTEGER NOT NULL
);

投稿保存に使うSQLは次の形です。

INSERT INTO submissions (id, content, created_at)
SELECT ?, ?, ?
WHERE (
  SELECT COUNT(*)
  FROM submissions
  WHERE created_at >= ? AND created_at < ?
) < ?
AND NOT EXISTS (
  SELECT 1
  FROM submission_throttles
  WHERE client_key = ? AND last_at > ?
)
RETURNING id;

パラメーターは順に、投稿ID、原文、現在日時、当日の開始、翌日の開始、上限10、接続元キー、現在時刻から60秒引いた値です。値はprepared statementの bind() へ渡します。

連投条件を last_at > now - 60_000 にしているため、59秒後は拒否し、60秒ちょうどなら受け付けます。

条件を満たさない場合は、INSERTされず、RETURNINGの結果が空になります。SQLの実行自体は成功しても、投稿を受け付けたとは限りません。結果行の有無を判定に使います。

投稿保存と連投記録を同じbatchに入れる

投稿だけ保存されて連投記録に失敗したり、その逆になったりしないよう、D1の batch() でまとめて実行します。

連投時刻の更新にも条件を付けます。

INSERT INTO submission_throttles (client_key, last_at)
SELECT ?, ?
WHERE EXISTS (
  SELECT 1 FROM submissions WHERE id = ?
)
ON CONFLICT(client_key)
DO UPDATE SET last_at = excluded.last_at;

このとき参照する投稿IDは、今回の送信のために生成したものです。最初のINSERTでその投稿が保存された場合だけ、連投時刻を更新します。

全体の順序は次のとおりです。

  1. 条件付きINSERTで投稿を保存する。

  2. 今回の投稿が存在する場合だけ連投時刻を更新する。

  3. 当日の件数と、接続元の最終受付時刻を取得する。

  4. 古くなった連投記録を削除する。

CloudflareのD1 Databaseドキュメントでは、batch内の文を順に実行し、途中の文が失敗した場合は全体を中止またはロールバックする仕様が説明されています。

ここでは、エラーによるロールバックに加えて、「受付条件に合わず0行だった」という正常終了も扱っています。後続の更新に存在条件を付けることで、拒否した送信が待機時間を延ばすことを防いでいます。

接続元は、Cloudflare側で付与されるIP情報をハッシュ化したキーで扱い、取得できない場合はCookieで補助します。これは連投を抑えるための識別であり、厳密な一人一枠の仕組みではありません。同じ回線を使う人同士が待機制限を共有することもあります。

未処理と今処理できる投稿を分ける

受付時刻の条件を満たしていても、すでに改修した投稿は対象に含めません。まず、投稿IDと改修履歴を照合して未処理の pending を作り、その中から時刻条件を満たす ready を求めます。

export function readyPosts(posts, now = Date.now()) {
  return posts.filter(
    post => Date.parse(nextRevisionAt(post.createdAt)) <= now,
  );
}

pending があることと、今の実行で処理すべき投稿があることは別です。定期改修はreadyを使います。

現在の予約は毎日03:05 JSTですが、処理対象の判定はサーバー側のこの関数で行います。改修が遅れた場合も、期限を迎えた未処理投稿はreadyに残ります。

APIの結果を画面にそのまま伝える

投稿APIは、保存できた場合は201、上限または連投制限は429、DBなどの保存エラーは503を返します。入力不正は400です。

429では Retry-After と再試行可能な時刻を返し、画面で「今日の枠が満員」と「あと何秒待つ必要がある」を分けて表示します。

満員の場合は入力欄を案内の漫画に差し替えます。日付が変わると再び入力できるよう、画面に持っているquotaの有効期限も確認します。前日の「残り0件」を持ち続けて、翌日も送信できない状態にしないためです。

保存に失敗した場合は、入力した文章を残します。逆に、保存成功後の履歴再取得だけが失敗した場合は「投稿は保存しました」と知らせます。保存失敗と誤認させて、同じ投稿を送り直してもらわないための区別です。

境界と失敗時のテストを書く

受付処理では、次のようなケースをテストしています。

ケース 期待する結果
日本時間23:59:59と00:00:00 受付日が切り替わる
翌日02:59:59と03:00:00 readyへの追加が境界で切り替わる
10件保存後の11件目 保存せずdailyで拒否
同じ接続元の59秒後と60秒後 前者を拒否し後者を受付
無効な本文 件数も連投記録も増やさない
連投記録の保存でDBエラー 投稿保存もロールバック
複数の受付呼び出し 上限と同一接続元の制限を維持

テストではNode.jsのSQLiteを使ったD1互換の小さなアダプターで、batchをトランザクションとして実行しています。複数の呼び出しをまとめるケースもありますが、このアダプターは同期的にSQLを実行するため、本番D1への並列負荷試験とは区別しています。

2026年10月30日のソースで、受付以外も含む49件のテストが通過しました。受付に関する境界を関数として切り出したことで、実際の深夜0時や3時を待たずに確認できます。

この実装で中心になったのは、保存してよい条件をDBへ置くことと、保存された事実に合わせて後続処理を進めることでした。画面の受付案内、連投時刻、日次件数、翌日の改修対象を、同じ投稿の保存結果と時刻ルールから決めています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?