プログラミング サーバー 公開日 2026.09.12 更新日 2026.09.12

「最終実施日」が信用できなくなる設計|実施と通知を同じ列に書かない

運用タスクの一覧に「最終実施日 3日前」と出ていても、実施した日とは限りません。通知を送る処理が同じ列を更新していると、一度も実行していなくても最近やったように見えます。SQLiteで動く形で再現し、事実を1行ずつ残す設計への直し方と、手元のデータが信用できるか調べるクエリまでまとめます。

先に要点

  • 運用タスクの一覧に「最終実施日 3日前」と出ていても、その日付が「実施した日」とは限りません。通知を送った処理が同じ列を更新していると、一度も実行していなくても最近やったように見えます
  • 原因は運用ではなく設計です。「やった」と「やれと言った」を同じ1列に書くと、後から区別できなくなります。どちらも「日付を新しくする」という同じ操作に見えるためです。
  • この記事では実際に動く形で再現します。リマインドを3回送っただけで、画面上の最終実施日は 2026-09-09 になり、実施回数は0回のままでした。
  • 直し方は起きた事実を1行ずつ残し、状態はそこから計算することです。全部を作り直す必要はなく、記録を足すところから始められます。

定期的にやるべき作業を一覧で管理していると、たいてい「最終実施日」のような列が付きます。ダッシュボードに「3日前に実施」と出ていれば、ふつうは実施済みだと判断します。

ところがその列を更新しているのが、実施処理とは別のものだった場合、この判断は成り立ちません。しかも画面上は正常に見えるので、突き合わせるまで誰も気づきません。この記事では、その状態を再現したうえで、設計としてどう直すかを書きます。

動かして確かめる

追加のライブラリなしで動く形にしました。SQLitePHP だけです。

まず、よくある形の設計を作ります。タスク1件につき1行、最終実施日を1列で持ちます。

$db->exec('CREATE TABLE task_a (id INTEGER PRIMARY KEY, name TEXT, last_done_at TEXT)');
$db->exec("INSERT INTO task_a VALUES (1, 'バックアップ復元テスト', '2026-06-01')");

// リマインドを送る処理。ここで last_done_at を触ってしまうのが事故のもと
$remind = function (string $today) use ($db) {
    $db->prepare('UPDATE task_a SET last_done_at = ? WHERE id = 1')->execute([$today]);
};

foreach (['2026-09-03', '2026-09-06', '2026-09-09'] as $d) {
    $remind($d);   // 3回リマインドしただけ。作業は一度もしていない
}

実行した結果です。

画面の表示 : バックアップ復元テスト / 最終実施日 2026-09-09
実際の実施 : 0 回(一度も実行していない)

一度も実行していないのに、3日前に実施したように見えます。

この処理を書いた人に悪意はありません。「通知したのだから、この行は最近触った」という感覚で日付を更新しただけです。列の名前が last_done_at であっても、更新する側から見れば単に日付を新しくする操作にしか見えません。

なぜ気づけないのか

画面が正常に見える

値が入っていて、日付も新しい。表示としては何も壊れていません。異常として現れるのは「実施記録が1行も無い」ことですが、記録が無いこと自体は画面に出ません。

更新する側が増えていく

最初は実施処理だけが更新していた列に、通知、自動再試行、手動の修正と書き手が増えます。増えるたびに列の意味が薄まりますが、列名は変わりません。

気づく機会が定期的に来ない

日付が古くなれば誰かが気づきます。しかしこの不具合は日付を新しく保つ方向に働くので、放っておくと永久に指摘されません。

言い換えると、この設計は「サボっている」ことを隠す方向に間違えます。検知したい状態を、まさにその検知から外してしまう形です。

「やった」と「やれと言った」は別の事実

直し方の考え方は1つです。1つの列に状態をまとめて持つのをやめ、起きた事実を1行ずつ残します

$db->exec('CREATE TABLE task_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id INTEGER NOT NULL,
    event_type TEXT NOT NULL,   -- reminded / started / succeeded / failed
    occurred_at TEXT NOT NULL,
    actor TEXT NOT NULL
)');

同じようにリマインドを3回記録すると、今度はこう出ます。

記録されている事実:
  reminded   3件  最新 2026-09-09
最終実施日 = なし(未実施)

リマインドは3件あるが、実施は0件。これが本当の状態です。最終実施日は列に書かれた値ではなく、記録から計算します。

SELECT MAX(occurred_at) FROM task_events WHERE event_type = 'succeeded';

実際に1回だけ成功させると、両方が正しく数えられます。

最終実施日   = 2026-09-11
リマインド数 = 3 回
なぜ計算する方が安全なのか

列に書き込む方式では、書き手が増えるたびに意味を壊す機会が増えます。記録から計算する方式では、書き手が増えても行が増えるだけで、既存の意味は変わりません。間違った行が混ざっても、種類で絞れば影響を切り分けられます。

2つの方式の比較

1つの列に持つ 事実を1行ずつ残す
読み出しの速さ 速い 集計が要る(必要なら別途キャッシュ
書き手が増えたとき 意味が壊れる 行が増えるだけ
「実施していない」の検知 できない できる
誰がやったかの追跡 残らない 残る
失敗した実行の扱い 成功と区別できない 種類で区別できる
実装の手間 小さい 中くらい

読み出しが重くなるのは事実です。ただし運用タスクの一覧は1日に数回しか見ない画面であることがほとんどなので、集計の速さが問題になる場面は多くありません。速さが要るなら、計算した結果を別の列に持たせて常に記録から作り直す形にします。書き込みの入口を1つに絞れるので、列を直接触られる設計とは別物になります。

手元のデータが信用できるか調べる

今あるデータについて、次の3つを並べて出せば判断できます。

SELECT t.name,
       (SELECT MAX(occurred_at) FROM task_events
         WHERE task_id = t.id AND event_type = 'succeeded') AS really_done,
       (SELECT MAX(occurred_at) FROM task_events
         WHERE task_id = t.id AND event_type = 'reminded')  AS last_reminded,
       t.last_done_at AS shown_on_screen
FROM task_a t

実際の出力がこれです。

name             バックアップ復元テスト
really_done      2026-09-11
last_reminded    2026-09-09
shown_on_screen  2026-09-09

画面の値がリマインドの日付と一致し、実際の実施日とは違っています。この一致こそが、列が実施を追えていない証拠です。

判断の基準はこうなります。

  • 画面の値が通知の日付と毎回一致するなら、その列は実施を記録していません
  • 実施の記録がどこにも無いなら、まず記録を足すのが先です。列の意味を直すのはその後
  • 両方あって食い違うなら、どちらが正しいかではなく、どちらを正としてこれから運用するかを決めます
過去の値は直さない

記録が無い期間について、後から実施日を推測して埋めるのはやめた方がよいです。推測で埋めた値は、次に見た人には本物と区別がつきません。分からない期間は分からないままにして、いつから記録が信用できるかを添える方が実務では役に立ちます。

全部を作り直す必要はない

この手の設計の話は「イベントで持つべき」という結論になりがちですが、既存のテーブルを全部組み替えるのは現実的ではありません。段階を踏めます

  1. 記録用のテーブルを足すだけにする。既存の列はそのまま残す
  2. 実施処理と通知処理の両方から、種類つきで記録を書く
  3. しばらく並行させて、既存の列と記録から計算した値が一致するかを見る
  4. 一致しない原因が説明できたら、画面の参照先を記録側へ切り替える
  5. 既存の列は消さずに読み取り専用にするか、最後に落とす

3番目が重要です。切り替える前に、ずれの理由を説明できる状態にします。ここを飛ばすと、今度は新しい方が信用されません。

同じ形で起きる別の例

この「意図を完了として記録してしまう」形は、あちこちにあります。

  • 送信済みフラグ:送信処理を呼んだ時点で立てていると、実際に届かなくても送信済みになります。応答を確認してから立てるか、送信の試行と結果を別々に記録します
  • 承認の記録:承認画面を開いた時刻と、承認した時刻を同じ列に入れてしまう
  • デプロイ日時デプロイを開始した時刻で更新していると、途中で失敗しても最新のデプロイが成功したように見えます
  • 既読の管理:一覧に表示しただけで既読にすると、本文を読んでいなくても既読になります

共通するのは、「始めた」「頼んだ」「表示した」と「終わった」を同じ場所に書いていることです。区別が要るかどうかは、「一度もやっていない状態を検知したいか」で決められます。検知したいなら分けます。

関連する考え方として、同じ操作を何度実行しても結果が変わらないようにする冪等性や、誰が何をしたかを残す監査ログの話とも地続きです。本番の変更を追える状態にする話は変更管理とはにまとめています。

よくある質問

列を1つ増やして、実施日と通知日を分ければ済みませんか

当面はそれで解決します。ただし列を分ける方式は種類が増えるたびに列が増えます。失敗した実行、手動での実施、自動再試行と増えていくと、また同じ問題に戻ります。種類が2つで増える見込みが無いなら列を分ける、増えそうなら記録として持つ、という判断でよいと思います。

記録が増え続けるのが心配です

運用タスクの記録は、1件あたり年に数十行程度にしかなりません。件数が問題になるのは、利用者の操作を1つずつ記録するような用途です。心配なら、古い記録を月ごとに集計して別テーブルへ移し、明細は一定期間で削除します。先に容量を心配して記録を残さない判断をするのが、いちばん損です。

どの種類を記録すればよいですか

最低限は「開始」と「終了」で、終了には成功と失敗の区別を付けます。そのうえで、通知や督促のように「まだ終わっていない」ことを示す出来事を、終了と同じ場所に書かないことだけ守れば十分です。種類を細かくしすぎると、今度は種類の使い分けが人によってずれます。

監査のために必要という話ですか

監査でも役に立ちますが、目的はもっと手前にあります。「やっていないことに気づけるようにする」のが目的です。定期作業の価値は実施そのものではなく、実施されていないときに分かることにあります。そこが機能していない一覧は、あっても判断材料になりません。

まとめ

「最終実施日」のような列は、書き手が1つのうちは正しく、書き手が増えた瞬間に意味を失います

  • 通知処理が同じ列を更新していると、一度も実施していなくても最近やったように見える
  • 画面は正常に見えるので、突き合わせるまで気づけない
  • しかもこの不具合は日付を新しく保つ方向に間違えるので、放置すると永久に指摘されない
  • 直し方は、起きた事実を種類つきで1行ずつ残し、状態はそこから計算すること
  • 既存の設計を全部作り直す必要はない。記録を足して並行させ、ずれの理由を説明できてから切り替える

判断の基準は1つです。「一度もやっていない状態を検知したいかどうか」。検知したいなら、「やった」と「やれと言った」は別の場所に書きます。

参考リンク

あとで見返すならここで保存

読み終わったあとに残しておきたい記事は、お気に入りからまとめて辿れます。