アルアカ - Arcadia Academia

Arcadia Academiaは「エンジニアリングを楽しむ」を合言葉に日本のデジタル競争力を高めることをミッションとするテックコミュニティです。

Pleasanterのグループ更新で500エラー(duplicate key PkHistory)が出る原因と直し方

Featured image of the post

「グループ管理画面でメンバーを選んで更新すると『アプリケーションで問題が発生しました』と500になる」 「ほかのグループは普通に更新できるのに、特定のグループだけが毎回落ちる」 「数日前にデータベースのIDを直接書き換えたが、そのときは画面も正常に見えていた」

Pleasanter のレコードやマスタは、画面から見える「現行」のテーブルの裏に、更新のたびに積み上がる履歴テーブルを持っています。データベースのIDを手で付け替える作業で現行だけを直すと、そのときは何も起きず、次に画面から更新した瞬間に主キー衝突の500が出ます。

この記事では、症状からログを引く手順、履歴テーブルの版番号(Ver)がどう衝突するのか、履歴を消さずに直す SQL、そしてIDを手で変える作業の完了条件をまとめます。

💡
30秒で要点
- 画面の500の正体は SysLogs にある。23505: duplicate key value violates unique constraint "PkHistory_..." なら履歴テーブルの主キー衝突
- Pleasanter は更新のたびに「現行行を履歴へ退避 → Ver を +1」する。履歴の主キーは (TenantId, ID, Ver)
- 不変条件は「現行の Ver = その ID の履歴の最大 Ver + 1」。IDを手で付け替えると、削除済みの旧IDが残した履歴と衝突する
- 直し方は履歴を消すのではなく、現行の Ver を「履歴の最大 Ver + 1」まで進める。切り戻しは Ver を戻すだけ
- IDを手で変える作業は、現行テーブルの整合だけでなく「次に更新できるか」を検査 SQL で確かめてから完了にする

目次を開く

症状: 特定のグループの更新だけが500になる

画面に出るのは「アプリケーションで問題が発生しました」だけで、原因は読み取れません。まず SysLogs テーブルを報告があった時刻で絞ります。

-- 報告時刻の直前から新しい順に見る
SELECT *
  FROM "SysLogs"
 WHERE "CreatedTime" >= '2026-09-05 10:00'
 ORDER BY "CreatedTime" DESC
 LIMIT 20;

該当行のエラー内容に、次の2行が並んでいれば本記事のケースです。

PostgresException: 23505: duplicate key value violates unique constraint "PkHistory_..."
Url: /groups/10/update

23505 は PostgreSQL の一意制約違反です。制約名が PkHistory_ で始まっているので、現行の Groups テーブルではなく、履歴テーブル側の主キーで衝突しています。URL からグループIDが 10 だと分かります。

原因: 履歴テーブルの Ver が現行と衝突している

Pleasanter は Groups / GroupMembers / Permissions / Sites / Results などの主要テーブルに、対になる _history テーブルと Ver 列を持っています。画面から更新するたびに、次の2つが1回の処理で走ります。

  1. 現行の行をそのまま _history に退避する(このとき現行の Ver をそのまま使う)
  2. 現行の Ver を +1 する

履歴テーブルの主キーは (TenantId, ID, Ver) の組です。正常なグループは「現行 Ver=10、履歴 Ver=1〜9」のように、現行の Ver が履歴の最大値より1つ大きい形で揃っています。

今回の環境で起きていたことを時系列にすると次のとおりです。

時点 出来事 Groups(現行) Groups_history
数か月前 GroupId=10 のグループが作られ、その後削除された 行なし (1, 10, 1) が残る
数日前 環境間でIDを揃えるため、GroupId を 27 から 10 に手で書き換えた (1, 10, Ver=1) 触らなかった
当日 画面からメンバーを更新した 退避 (1, 10, 1) を書こうとして衝突 (1, 10, 1) と重複

現行テーブルのIDだけを 27 から 10 に変え、「履歴は過去の記録だから触らない」と判断したのが原因です。削除済みの旧グループが同じIDで残していた履歴 Ver=1 と、書き換え後の現行 Ver=1 が同じ主キーになり、退避の INSERT が落ちます。

書き換えた直後は何も起きません。衝突は「次に更新したとき」に初めて出るので、作業当日の確認では見つかりません。

検査: 不変条件「現行の Ver = 履歴の最大 Ver + 1」を全グループで確かめる

直す前に、同じ形になっている行がほかにもないか全件で見ます。

-- 現在Ver vs 履歴の最大Ver(cur_ver > max_hist_ver が正常)
SELECT g."GroupId", g."Ver" AS cur_ver, MAX(h."Ver") AS max_hist_ver,
       CASE WHEN g."Ver" > COALESCE(MAX(h."Ver"),0) THEN 'OK' ELSE 'NG(衝突する)' END AS judge
  FROM "Groups" g LEFT JOIN "Groups_history" h
    ON h."TenantId"=g."TenantId" AND h."GroupId"=g."GroupId"
 WHERE g."TenantId"=1 GROUP BY g."GroupId", g."Ver" ORDER BY 1;

judge が NG の行が、次に更新すると500になるグループです。履歴が1件も無いグループは COALESCE で 0 になるので OK 側に出ます。

同じ検査は、IDを書き換えたほかのテーブルにも当てはめます。テーブル名と ID 列名を置き換えるだけです。

テーブル 履歴テーブル ID 列
Groups Groups_history GroupId
GroupMembers GroupMembers_history GroupMemberId
Permissions Permissions_history ReferenceId など複合
Sites Sites_history SiteId
Results Results_history ResultId

Permissions は主キーが複合なので、手元の版のテーブル定義で結合条件を確認してから流してください。

直し方: 履歴を消さず、現行の Ver を進める

衝突しているのは「現行の Ver が小さすぎる」ことなので、現行側の Ver を「履歴の最大 Ver + 1」に上げれば、次の退避は新しい主キーで書けます。履歴を消す必要はありません。

-- 対象の1件だけ。条件付きなので正常な行には当たらない
UPDATE "Groups" g
   SET "Ver" = (SELECT MAX(h."Ver") + 1
                  FROM "Groups_history" h
                 WHERE h."TenantId"=g."TenantId" AND h."GroupId"=g."GroupId")
 WHERE g."TenantId"=1 AND g."GroupId"=10
   AND g."Ver" <= (SELECT MAX(h."Ver")
                     FROM "Groups_history" h
                    WHERE h."TenantId"=g."TenantId" AND h."GroupId"=g."GroupId");

この方法を選ぶ理由は次の3点です。

  • 削除済みグループの履歴も残るので、過去の記録が消えない
  • 名前・メンバー・権限は一切変わらない。変わるのは版番号だけ
  • 切り戻しは Ver を元の値に戻す UPDATE 1本で済む

流したあとは、上の検査 SQL をもう一度実行して対象行が OK になったことを確認し、画面からメンバーを更新して500が消えたことを見ます。更新後に _history に新しい行が1つ増えていれば、退避が正しく動いています。

IDを手で付け替える作業の完了条件

今回の教訓は「現行テーブルの整合だけ検証しても、次に更新できるかは分からない」ことです。環境間でIDを揃える、テストデータのIDを付け替える、といった作業では、完了条件に次を含めます。

  1. 書き換えた ID を持つテーブルすべてについて、対になる _history があるか確認する
  2. あるテーブルは、検査 SQL で「現行の Ver > 履歴の最大 Ver」を全件確認する
  3. NG があれば現行の Ver を進める(履歴は消さない)
  4. 画面から実際に1回更新して、500が出ないことを確認してから完了にする

本番データベースへの UPDATE は、対象行数が想定どおり(今回なら1件)であることを WHERE 条件で保証し、実行前後の検査 SQL の結果を作業記録に残しておくと、後から説明できます。

まとめ

  • グループ更新の500は、SysLogs の 23505 ... "PkHistory_..." で履歴テーブルの主キー衝突だと分かる
  • Pleasanter は更新のたびに現行行を履歴へ退避して Ver を +1 する。履歴の主キーは (TenantId, ID, Ver)
  • IDを手で付け替えると、削除済みの旧IDが残した履歴の Ver と現行の Ver が重なり、次の更新で衝突する
  • 直し方は現行の Ver を「履歴の最大 Ver + 1」に進める UPDATE。履歴は消さない
  • IDを触る作業は、検査 SQL と「画面から1回更新」を完了条件に入れる

よくある質問(FAQ)

履歴テーブルの衝突している行を削除してはいけませんか

動きはしますが、削除済みグループの記録が消えます。監査や「あのとき誰がメンバーだったか」を追う用途で履歴を使うことがあるため、現行の Ver を進める方が安全です。切り戻しも Ver を戻すだけで済みます。

書き換えた直後は正常に見えたのはなぜですか

衝突が起きるのは「現行行を履歴に退避する」処理で、これは画面から更新したときにしか走らないからです。参照や一覧表示では履歴に書き込まないので、書き換え当日の確認では見つかりません。

グループ以外のテーブルでも同じことが起きますか

_history と Ver を持つテーブルならすべて同じ構造です。Sites や Results のIDを付け替えた場合も、検査 SQL のテーブル名と ID 列を置き換えて確認してください。Permissions のように主キーが複合のテーブルは、結合条件を手元の版の定義に合わせます。

そもそも ID を手で書き換えない方法はありますか

環境ごとにマスタの採番が違うこと自体が原因なので、コードや設定にIDを直書きせず、名前などのキーで引く作りにしておくのが根本の対策です。すでに直書きしてしまった環境でIDを揃える必要があるときは、この記事の検査を完了条件に入れてください。

プログラミング学習でお悩みですか?

現役エンジニアがあなたの学習をマンツーマンでサポートします。

  • 学習の進め方がわからない
  • ポートフォリオの作り方を知りたい
  • 現場で使える技術を学びたい
まずは30分の無料相談

相談は完全無料・オンラインで気軽に

あなたを爆速で成長させるメンタリングプログラムはこちら

メンタープログラムバナー

学習・開発のお悩みは現役エンジニアに相談

メンタープログラムの詳細を見る

エンジニアの基礎学習ゲーム

プログラミング学習支援

無料相談はこちら