「グループ管理画面でメンバーを選んで更新すると『アプリケーションで問題が発生しました』と500になる」 「ほかのグループは普通に更新できるのに、特定のグループだけが毎回落ちる」 「数日前にデータベースのIDを直接書き換えたが、そのときは画面も正常に見えていた」
Pleasanter のレコードやマスタは、画面から見える「現行」のテーブルの裏に、更新のたびに積み上がる履歴テーブルを持っています。データベースのIDを手で付け替える作業で現行だけを直すと、そのときは何も起きず、次に画面から更新した瞬間に主キー衝突の500が出ます。
この記事では、症状からログを引く手順、履歴テーブルの版番号(Ver)がどう衝突するのか、履歴を消さずに直す SQL、そしてIDを手で変える作業の完了条件をまとめます。
- 画面の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回の処理で走ります。
- 現行の行をそのまま
_historyに退避する(このとき現行の Ver をそのまま使う) - 現行の 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を付け替える、といった作業では、完了条件に次を含めます。
- 書き換えた ID を持つテーブルすべてについて、対になる
_historyがあるか確認する - あるテーブルは、検査 SQL で「現行の Ver > 履歴の最大 Ver」を全件確認する
- NG があれば現行の Ver を進める(履歴は消さない)
- 画面から実際に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を揃える必要があるときは、この記事の検査を完了条件に入れてください。
