Upsert 処理
API の Upsert は「キーに一致するレコードがあれば更新、なければ新規作成」という機能です。このページでは、その実装がどうなっているか、ロックがどこで掛かるか、Keys に指定できる項目と落とし穴をまとめます。
結論: Upsert は DB の単一文 MERGE ではなく、検索(Get)と更新/作成(Update or Insert)の 2 段階 で実装されています。単体 Upsert には排他制御がないため、同じキーで並列実行すると重複作成などの衝突が起こり得ます。「Upsert だから重複しない」とは限らないので、キー設計(論理)とユニーク制約(物理)をセットで考える 必要があります。
INFO
ソースへのリンクは Implem/Implem.Pleasanter のコミット 46782220c8ce20abb6123d86fa7c07d381f7c960 固定のパーマリンクです(TableExclusive の有効条件と General.json のみコミット 870a56a)。
実行フロー(Get → Update or Insert)
/api/items/{id}/Upsert は ItemsController.Upsert() から ItemModel.UpsertByApi() に入り、IssueUtilities.UpsertByApi()(または ResultUtilities.UpsertByApi())に分岐します。
IssueUtilities.UpsertByApi() の処理は次のとおりです。
Keysと JSON を検証する(ValidateJsonKeys)Keysの値からViewに完全一致フィルタを組み立てる(AddColumnFilterHash+ExactMatch)new IssueModel(..., view: view, issueId: 0, ...)で対象を検索するAccessStatusを見て分岐する
図を読み込み中…
ロック:単体 Upsert と BulkUpsert の違い
単体 Upsert(UpsertByApi) | BulkUpsert(BulkUpsertByApi) | |
|---|---|---|
| 排他制御 | なし(TableExclusive を使わない) | TableExclusive を使う |
| ロック取得失敗時 | - | 429 を返す |
| ロックの種類 | - | アプリケーション層のセッションベース排他(DB の行ロックではない) |
| 有効条件 | - | BlockSiteTaskWhileRunning == true かつ Parameters.AllowBlockSiteTaskWhileRunning() が true(既定は無効) |
単体 Upsert
単体の UpsertByApi() には TableExclusive がありません。まず検索して NotFound を判定し、その後で CreateByApi() を実行するという順です。そのため同じ Keys で並列実行されると、両方が NotFound を見て同時に Create に進む可能性があります(典型的なレース条件)。記録テーブル(ResultUtilities.UpsertByApi())も同じ構造で、確認したソースでも単体 Upsert に排他制御はなく、BulkUpsert だけが TableExclusive を使います(ResultUtilities.cs#L4896-L4976、ResultUtilities.cs#L5239)。
BulkUpsert
BulkUpsertByApi() には TableExclusive があり、TryLock() に失敗すると 429 を返します。
ただしこのロックは DB の行ロックではなく、アプリケーション層でのセッションベースの排他制御です。さらに、次の条件を満たすときだけ有効になります。
TableExclusiveはBlockSiteTaskWhileRunning == trueかつParameters.AllowBlockSiteTaskWhileRunning()がtrueのときだけ有効(SessionExclusive.cs)General.jsonのBlockSiteTaskWhileRunningの既定値はfalse(General.json)- 確認したソースでも条件と既定値は同じです(SessionExclusive.cs#L92-L101、General.json#L106)
public TableExclusive(Context context, long? siteId = null, [CallerMemberName] string callerMethodName = "")
: base(
context: context,
enabled: Parameters.General.BlockSiteTaskWhileRunning == true
&& Parameters.AllowBlockSiteTaskWhileRunning(),
key: $"TableExclusive_SiteId={siteId ?? context.SiteId}",
siteId: siteId,
comment: callerMethodName)
{
}WARNING
1.5.7.0 で Parameters.AllowBlockSiteTaskWhileRunning() の判定が追加されました。この中でライセンスチェック(TrialLicense または License)が行われるため、General.json で BlockSiteTaskWhileRunning を true にしていても、対象ライセンスがない環境では BulkUpsert の排他制御が効きません。ライセンス判定の仕組みは ライセンス判定の実装 を参照してください。
レース条件で衝突は起こるか
起こり得ます。 単体 Upsert は「検索」と「作成/更新」を一体化した原子的な処理ではないためです。同じキーで並列にリクエストすると、次の流れが発生します。
図を読み込み中…
キー列にユニーク制約がない設計だと重複行が入り得ます。ユニーク制約がある場合は片方が重複エラーになります。どちらにせよ「衝突が起きる前提」で設計する必要があります。
単体 Upsert で問題になりやすいエラーのステータスコードは次のとおりです(ApiResponses.StatusCode と ApiResponses.Error の分岐による)。
| エラー | ステータスコード |
|---|---|
Overlap | 400(Bad Request) |
InvalidUpsertKey | 400(Bad Request) |
Duplicated | 500(Internal Server Error)。ApiResponses.Error(...) の既定分岐に入るため |
Keys に指定できる項目
Keys の検証は ValidateJsonKeys() と CheckKeyExists() で実装されています。
前提条件
Keysは配列で必須(空は不可)- 各キー名が
ss.ColumnDefinitionHashに存在することColumnDefinitionHashはSiteSettings.csでテーブル定義から生成されます(SiteSettings.cs)
値の持ち方
CheckKeyExists() は列の種類ごとに、リクエスト JSON の中で値を見る場所が違います。
| 列の種類 | 値を見る場所 | キーにできるか |
|---|---|---|
標準列(Title、Body、Status など) | ルート直下の同名プロパティ | ○ |
| 拡張列 Class | ClassHash.{列名} | ○(空文字は不可) |
| 拡張列 Num | NumHash.{列名} | ○ |
| 拡張列 Date | DateHash.{列名} | ○(範囲外の日時は Upsert 本体で不可。後述) |
| 拡張列 Description | DescriptionHash.{列名} | ○(空文字は不可) |
| 拡張列 Check | CheckHash.{列名} | ○ |
| 拡張列 Attachments | - | ×(常に false) |
どの種類でも、値が JSON の null のときはキーとして無効です。確認したソースでも同じ判定です(IssueUtilities.cs#L5202-L5297)。Data 配列を使う場合は、配列の全要素でキーの値がそろっている必要があります(IssueUtilities.cs#L5323-L5334)。
文字列型キーの注意
Class と Description は、値が存在しても空文字だとキーとして無効と判定され、InvalidUpsertKey エラーになります(null でなくても空文字なら不可)。
datetime キーの注意
キー検証を通過しても、Upsert 本体の InRange() チェックに落ちると InvalidUpsertKey になります。デフォルト設定では MinTime=1900/1/1、MaxTime=2100/1/1 が定義されており、この範囲外は無効値として扱われます。このチェックは DateHash の拡張列だけでなく、StartTime や CompletionTime などの標準の datetime 列にも掛かります(1.5.8.1 のソースでは IssueUtilities.cs#L5155-L5160、General.json#L94-L95)。
検索条件の作られ方
Upsert では Keys ごとに View へ完全一致フィルタを作ります。
主な変換は次のとおりです。
| 型 | 検索値 |
|---|---|
bit | 0 / 1 |
int / bigint / nvarchar | 単一選択列は ["値"] 形式、通常列は文字列 |
decimal | ["値,値"](レンジ形式) |
datetime | ["yyyy/MM/dd HH:mm:ss.fff,yyyy/MM/dd HH:mm:ss.fff"](開始と終了を同じ値にした完全一致用レンジ) |
このフィルタで複数件ヒットすると AccessStatus.Overlap になり、Upsert は更新に進まず競合扱いで終了します。
Keys の設計指針
実装から逆算すると、Keys は次を満たす設計が安全です。
- 実データで一意になる列(または列の組み合わせ)を使う
- できれば DB 側にもユニーク制約を付ける
- 空文字になり得る文字列列はキーにしない
Attachments系はキーにしない- 並列実行が多い場合は「衝突前提」でリトライ/再取得の方針を持つ
呼び出し側でできる対策
プリザンター側には、検索と作成・更新をまとめて守る仕組み(行ロック、楽観的ロック、DB の MERGE 文など)はありません。同じ Keys の Upsert が並列に走らないようにするのは呼び出し側の役目です。
| 対策 | 内容 | 効く範囲 |
|---|---|---|
| 逐次化 | 同じキーの Upsert をキューに積み、1 つのワーカーが順に送る | 設計次第(キューを共有すれば複数プロセスでも可) |
| キー単位の排他 | キー(サイト ID と Keys の値)ごとにセマフォを取ってから送る | 同じプロセス内だけ |
| リトライ | 重複エラーなどで失敗したら、間隔を伸ばしながら再送する | どこでも(ただし重複作成は防げない) |
| 分散ロック | Redis などの共有ロックをキーごとに取る | 複数プロセス・複数サーバー |
同じ API キーで複数の処理を並列に動かす C# クライアントなら、キー単位の SemaphoreSlim とリトライを組み合わせる形になります(UpsertAsync と IsConflictError は呼び出し側で用意する想定の関数です)。
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new();
public async Task<TResponse> UpsertWithLockAsync(
long siteId, string keyValue, TRequest request, CancellationToken ct = default)
{
var semaphore = Locks.GetOrAdd($"{siteId}:{keyValue}", _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
try
{
for (var i = 0; ; i++)
{
var response = await UpsertAsync(siteId, request, ct);
if (response.IsSuccess || !IsConflictError(response) || i >= 2) return response;
await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, i)), ct);
}
}
finally
{
semaphore.Release();
}
}WARNING
SemaphoreSlim によるロックは同じプロセスの中でしか効きません。複数のプロセスやサーバーから同じキーで Upsert する場合は、分散ロックを使うか、キー列にユニーク制約を付けて「片方が失敗する」形にしてください。既存レコードへの同時更新は後から書いた方が残ります。