Skip to content

Upsert 処理 ​

第5版作成 最終更新 (日本時間)
確認バージョン1.5.7.01.5.8.1

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() の処理は次のとおりです。

  1. Keys と JSON を検証する(ValidateJsonKeys)
  2. Keys の値から View に完全一致フィルタを組み立てる(AddColumnFilterHash + ExactMatch)
  3. new IssueModel(..., view: view, issueId: 0, ...) で対象を検索する
  4. 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 の行ロックではなく、アプリケーション層でのセッションベースの排他制御です。さらに、次の条件を満たすときだけ有効になります。

csharp
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 の分岐による)。

エラーステータスコード
Overlap400(Bad Request)
InvalidUpsertKey400(Bad Request)
Duplicated500(Internal Server Error)。ApiResponses.Error(...) の既定分岐に入るため

Keys に指定できる項目 ​

Keys の検証は ValidateJsonKeys() と CheckKeyExists() で実装されています。

前提条件 ​

  • Keys は配列で必須(空は不可)
  • 各キー名が ss.ColumnDefinitionHash に存在すること
    • ColumnDefinitionHash は SiteSettings.cs でテーブル定義から生成されます(SiteSettings.cs)

値の持ち方 ​

CheckKeyExists() は列の種類ごとに、リクエスト JSON の中で値を見る場所が違います。

列の種類値を見る場所キーにできるか
標準列(Title、Body、Status など)ルート直下の同名プロパティ○
拡張列 ClassClassHash.{列名}○(空文字は不可)
拡張列 NumNumHash.{列名}○
拡張列 DateDateHash.{列名}○(範囲外の日時は Upsert 本体で不可。後述)
拡張列 DescriptionDescriptionHash.{列名}○(空文字は不可)
拡張列 CheckCheckHash.{列名}○
拡張列 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 へ完全一致フィルタを作ります。

主な変換は次のとおりです。

型検索値
bit0 / 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 は呼び出し側で用意する想定の関数です)。

csharp
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 する場合は、分散ロックを使うか、キー列にユニーク制約を付けて「片方が失敗する」形にしてください。既存レコードへの同時更新は後から書いた方が残ります。

関連ページ ​

変更履歴

第5版記事の確認版を繰り返す表現を整理する
第4版サイト設定の変更履歴・拡張 SQL の外部 DB 接続・サイト名の解決・API ラッパー・ApiVersion の解説と、関連する改修・設計メモを追加
第3版「内部実装を読む」を 1.5.8.1 のソースで検証して修正
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「内部実装を読む」に SiteSettings・Upsert・Pleasanter Setup を追加