Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

楽観同時実行制御を行うサンプルを追加する #1237

Closed
tsuna-can-se opened this issue May 15, 2024 · 13 comments · Fixed by #2137
Closed

楽観同時実行制御を行うサンプルを追加する #1237

tsuna-can-se opened this issue May 15, 2024 · 13 comments · Fixed by #2137
Assignees
Labels
target: Dressca サンプルアプリケーションDresscaに関係がある
Milestone

Comments

@tsuna-can-se
Copy link
Contributor

概要

現状「.NETアプリケーションの処理方式」のトランザクション管理方針の中で、楽観同時実行制御を使うと書いている。
https://maris.alesinfiny.org/app-architecture/overview/dotnet-application-processing-system/transaction-management-policy/#preventing-update-conflicts

しかし、現状のDresscaのサンプルには楽観同時実行制御を行っている箇所がない(そのようなユースケースが存在しないので)。
サンプルとして実装が欲しいので、どこかに楽観同時実行制御を行うサンプルを入れたい。

無理にやるなら買い物かごとかに導入できるかもしれない。
例えばBasketにRowVersion(行バージョン)とLastUpdated(最終更新日時)のカラムを追加すれば、別画面等で買い物かごを同時更新するような場合に制御をかけることができる(更新競合した場合は画面をリロードが良いか?)。

複数画面で同時操作したときの業務的な仕様を先に決めてから、どのような制御を行うか設計する。

完了条件

  • 楽観同時実行制御を行うサンプルがDresscaに組み込まれていること
@tsuna-can-se tsuna-can-se added this to the v0.10 milestone May 15, 2024
@tsuna-can-se tsuna-can-se added サンプルAP target: Dressca サンプルアプリケーションDresscaに関係がある labels May 15, 2024
@tsuna-can-se tsuna-can-se modified the milestones: v0.10, v1.0 Jul 4, 2024
@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 4, 2024

ケースの洗い出し

リソースの競合が発生しそうなケースと対応する場合の方針のネタ出しを行う。

下記の記載があるので、これに該当するケースがベスト。
https://learn.microsoft.com/ja-jp/ef/core/saving/concurrency?tabs=data-annotations#optimistic-concurrency

同じデータが同時に変更されると、不整合やデータ破損が発生する場合があります。たとえば、2 つのクライアントが同じ行の中の、何かしらの方法で関連付けられている別々の列を変更する場合などです。

コンカレンシーの競合の解決について、
ユーザに通知する以外に、マージのパターンがある。

  1. 現在の値
  2. 元の値
  3. データベース値
    一旦はユーザに通知(フロントエンド側で何か処理をする)のケースを考える。

コンカレンシートークンの設定

  1. [Timestamp] 属性を使用してプロパティを SQL Server の rowversion 列にマップ
    • これは SQL Server の機能
  2. [ConcurrencyCheck]属性を使用
    • SQL Server以外のケース、あるいは細かい制御をやりたいケース
      この点に関しては一旦1があればよさそう。

商品情報の更新と注文

CatalogItems、CatalogBrands、CatalogCategoriesあたりのテーブルの更新
①商品Aをカートに入れる
②商品Aの情報が更新される
③商品Aを注文する
のときの挙動を定める必要がある

Good/Bad

  • ユーザが商品情報を更新するのはありえないので、管理者用の管理画面等を作る必要がある
  • ここで起きる衝突は更新/更新よりも更新(管理者)/参照(ユーザ)のケースが多そうなので、やりたいこと(更新/更新)と少し違う?
  • 管理者Aが更新、管理者Bが更新で被るパターンはありうる
    • 商品情報を手動更新と商品情報を日中バッチで更新等が被るのはありそう
    • CatalogItemsの別々の列が更新されるパターンが書きやすい

在庫の更新と注文

テーブル構造は要検討
①BuyerId AでBasketに入れる
②BuyerId BでBasketに入れる
③BuyerId Aで注文(在庫-1)
④BuyerId Bで注文(在庫-1)
をしたときに在庫を更新する処理③と④が競合する

Good/Bad

  • 更新/更新 のパターン、ただし単純なパターンだと同じ列(在庫数)が更新されそう
  • ユーザー/ユーザー、ユーザー/管理者、管理者/管理者のどのペアでも競合のシナリオが書きやすい
  • 管理者が在庫を増減するケースをやるためには商品の管理画面が必要
  • 追加で在庫0は注文不可とか売切の表示とかの機能をあわせて入れたくなるはず
  • テストしているDBの在庫が切れたりしたときに補充が面倒

カートの更新

Baskets
別タブで開いた場合は同じBuyerIdなので競合する
別ブラウザで開いた場合は別のBuyerIdが振られるのでカートの中身は同期しない

現状の挙動で、別タブで開き、
①タブAで商品削除
②タブBで商品数更新
を実行すると400 (Bad Request)でエラーになる。

Good/Bad

  • 改修箇所が少ない
  • ECサイトの機能として見た際に、商品情報の更新や在庫周りの機能に比べると優先度が低いので、サンプルとしての訴求力に欠ける

選外

支払い情報の更新

①決済方法Aで決済を試みる(決済のステータスを変更しようとする)
②うまくいかないので決済方法Bで決済を試みる(決済のステータスを変更しようとする)
みたいなパターンはありえるはずで、
かつうまく制御しないとクリティカルな影響が出るが、
このチケットでやるにはややこしすぎる気がするので見送り

レビューやコメントの更新

実際の機能としてはありそうだが、
サンプルで表現したい内容としては商品情報の更新と一緒なので見送り

プロモーションの更新

ポイントやキャンペーンなど
実際の機能としてはありそうだが、
サンプルで表現したい内容としては商品情報の更新と一緒なので見送り

ユーザ情報更新

カートの情報とやっていることが同じで、
こちらよりもカートの情報更新のほうがケースとしてありえそうなので見送り

@KentaHizume
Copy link
Contributor

競合する操作 コンカレンシートークンの設定 競合の解決
・更新
・削除
・[Timestamp] 属性を使用してプロパティを SQL Server の rowversion 列にマップ
・[ConcurrencyCheck]属性を使用
・ユーザに通知
・現在の値
・元の値
・データベース値

@KentaHizume
Copy link
Contributor

ASP.NETの例だが、管理画面から複数人が同じ商品の情報を更新するパターン

https://learn.microsoft.com/ja-jp/aspnet/web-forms/overview/data-access/editing-inserting-and-deleting-data/implementing-optimistic-concurrency-cs

楽観同時実行制御が向いているパターン
(悲観ロックするほどでもないが、単純に後勝ちでもまずいくらいの温度感の業務として)
マスタデータのメンテナンス業務が挙げられている

https://atmarkit.itmedia.co.jp/fdotnet/entwebapp/entwebapp11/entwebapp11_03.html

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 5, 2024

2024/7/8時点でのテーブル構造は下図の通り

image

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 5, 2024

No ケース フロー 競合した場合の処理 競合リソース テーブル変更 バックエンド フロントエンド 規模感 メモ
1 商品の削除・数量変更によるカートの更新の競合 ①タブAでカートを表示 ②タブBでカートを表示 ③タブAで商品1の個数を更新する ④タブBで商品1の個数を更新する★ ②の参照(ロックなし)時点と④の更新(ロック)時点でデータの更新日時が異なるのでDbUpdateConcurrencyExceptionが発生 ユーザに競合を通知し、画面をリロードして表示されるカートの情報を更新する。 Basket ・BasketにTimeStamp列を追加 ・BasketRepository.UpdateAsync()またはRemoveAsync()でDbUpdateConcurrencyExceptionがthrowされる ・APIコール ・APIからエラーを受け取る ・画面に通知 ・画面をリロード  
2 商品情報の変更による商品の更新の競合 ①タブAで商品情報を表示 ②タブBで商品情報を表示 ③タブAで商品Pの商品情報を更新する ④タブBで商品Pの商品情報を更新する★ ②の参照(ロックなし)時点と④の更新(ロック)時点でデータの更新日時が異なるのでDbUpdateConcurrencyExceptionが発生 ユーザに競合を通知し、画面をリロードして表示される商品の情報を更新する。 CatalogItems ・CatalogItemsにTimeStamp列を追加 ・CatalogRepositoryに更新、削除の処理が生えていないのでそこから、Controller、AppServiceにも手を入れる必要がある ・APIコール ・APIからエラーを受け取る ・画面に通知 ・画面をリロード 管理画面の作成 ・カスタマーが商品情報を更新するのは変なので、管理画面が必要
3 注文による在庫の更新の競合 ①BuyerId Aでカートに入れる ②BuyerId Bでカートに入れる ③BuyerId Aで注文する(在庫-1) ④BuyerId Bで注文する(在庫-1)★ ②の参照(ロックなし)時点と④の更新(ロック)時点でデータの更新日時が異なるのでDbUpdateConcurrencyExceptionが発生 ・在庫が負になる(注文不可の運)な場合 ユーザに通知し、注文処理を行わずにカート画面に遷移する。 ・在庫が負にならない(注文可能)な場合 ユーザに通知せず、注文処理を行う。在庫数の整合性を確認する。 ・CatalogItems ・Stocks(新規) ・CatalogItemsに在庫数のカラム追加 あるいは ・在庫情報の新規テーブルを作成 そのうえでTimeStamp列を追加 同上 在庫のテーブルを増やすのであれば一式新規作成から ・APIコール ・APIからエラーを受け取る ・画面に通知 ・画面をリロード 管理画面の作成 ・注文と在庫は集約が別 ・フロー自体はカスタマーだけで実行可能だが、在庫数をメンテナンスするのに管理画面があったほうがよいはず

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 5, 2024

DbUpdateConcurrencyException 出力の確認

BasketItemsにVersionカラムを追加し、デバッグ実行で
⇒Basketが集約ルートなのでBasketが管理用のカラムを持つべき

Basketにカラム追加でも同じ動作だったので問題なし

ShoppingApplicationService.SetBasketItemsQuantitiesAsync()について

  1. DBからエンティティ取得
  2. (手動)該当行にUPDATEをかける
  3. DB更新 ★

をやると下記の通りDbUpdateConcurrencyExceptionを出力する

Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.

WHERE句の条件にVersionが入るようになっている

bug: 2024/07/05 13:38:47.549 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executing DbCommand [Parameters=[@p1='?' (DbType = Int64), @p0='?' (DbType = Int32), @p2='?' (Size = 8) (DbType = Binary), @p4='?' (DbType = Int64), @p3='?' (Size = 64)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      UPDATE [BasketItems] SET [Quantity] = @p0
      OUTPUT INSERTED.[Version]
      WHERE [Id] = @p1 AND [Version] = @p2;
      UPDATE [Baskets] SET [BuyerId] = @p3
      OUTPUT 1
      WHERE [Id] = @p4;

下記のパターンでもUpdatedDateTimeが更新されていると同じようにDbUpdateConcurrencyExceptionを出力する

    [ConcurrencyCheck]
    public DateTimeOffset UpdatedDateTime { get; set; } = DateTimeOffset.Now;

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 8, 2024

シナリオ検討

コンシューマーからのアクション(管理画面不要)で楽観同時実行制御が適したシナリオがないか検討する

プロフィール情報

ユーザーの登録情報操作により、ユーザーのプロフィール情報(住所氏名)が更新される。
Basketsに比べると、マスタ系の情報が対象になっているので排他制御する意味が多少出てきているが、
アクターが1人しか想定されないので競合するような操作が起きることがなさそう。

erDiagram
    Buyers ||--o{ Orders : has
    Buyers {
        bigint Id PK
        varchar FullName
        varchar PostalCode
        varchar Todofuken
        varchar Shikuchoson
        varchar AzanaAndOthers
        date LastUpdatedTime
    }
    Orders {
        bigint Id PK
        bigint BuyerId FK
    }
Loading

決済情報

ユーザーの決済により、決済のステータス(完了、決済中、未決済)が更新される。
アクターは1人しか想定されないが、競合するとクリティカルな処理なので排他制御する価値はある。
問題点として、
(1)クリティカルなのであれば悲観排他制御にしたほうがよいのではないか
(2)自然な画面構成にするためにフロント側をそれなりに考えて改修する必要がある

erDiagram
    Orders ||--o{ Payments : has
    Orders {
        bigint Id PK
        varchar BuyerId
    }
    Payments {
        bigint Id PK
        bigint OrderId
        decimal PaymentAmount  "決済額"
        varchar Status "ステータス"
        date LastUpdatedTime "最終更新日時"
    }
Loading

在庫情報

ユーザーの注文により、在庫の数量が更新される。
問題点として、
(1)クリティカルなのであれば悲観排他制御にしたほうがよいのではないか
(2)バックエンド側の処理をそれなりに考えて改修する必要がある

  • 注文⇒在庫減算のフロー
    競合した場合にややこしい
  • 在庫が0でなければそのまま注文を通す
  • 0になったらユーザに売り切れ通知で注文を通さない
    (3)売り切れになると開発環境のメンテナンスが面倒なので、在庫補充等の管理機能が欲しくなる
erDiagram
    CatalogItems ||--o{ Stocks : has
    CatalogItems {
        bigint Id PK
    }
    Stocks {
        bigint Id PK
        bigint CatalogItemId FK
        int Quantity "数量"
        date LastUpdatedTime "最終更新日時"
    }
Loading

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 8, 2024

管理画面を作成する場合、フロントエンドでは

  • 管理画面用のフォルダを切る

  • 既存のDressca用のフォルダを切り、移動

  • 共通ソースのフォルダを切り、適宜移動
    が必要

  • ViteのMonorepo構成 #377

現在は下記の状態

<project-name>
├─ cypress/ ------------------ cypress による E2E テストに関するファイルを格納します。
├─ public/ ------------------- メディアファイルや favicon など静的な資産を格納します。
├─ src/
│  ├─ assets/ ---------------- コードや動的ファイルが必要とするCSSや画像などのアセットを格納します。
│  ├─ components/ ------------ 単体で自己完結している再利用性の高い vue コンポーネントなどを格納します。
│  ├─ config/ ---------------- 設定ファイルを格納します。
│  ├─ generated/ ------------- 自動生成されたファイルを格納します。
│  ├─ router/ ---------------- ルーティング定義を格納します。
│ ├─ services/ -------------- サービスに関するファイルを格納します。
│  ├─ stores/ ---------------- store に関するファイルを格納します。
│  ├─ views/ ----------------- ルーティングで指定される vue ファイルを格納します。またページ固有の挙動などもここに含めます。
│  ├─ App.vue
│  └─ main.ts
├─ index.html
└─ package.json

@KentaHizume
Copy link
Contributor

フロントエンドのフォルダ構成

dressca-frontend
├─ apps
│  ├─ customer ---------------- 現在のDresscaアプリ
│  │ ├─ src/
│  │ ├─ App.vue
│  │ ├─ main.ts
│  │ ├─ vite.config.ts -------- アプリごとにサーバーを分離
│  │ ├─ tsconfig.app.json
│  │ └─ package.json
│  └─ admin ------------------- Dressca管理用アプリ
│    ├─ src/
│    ├─ App.vue
│    ├─ main.ts
│    ├─ vite.config.ts -------- アプリごとにサーバーを分離
│    ├─ tsconfig.app.json
│    └─ package.json
├─ packages ------------------- 各アプリで共有するコンポーネント、ユーティリティスクリプト
│  └─ common ------------------ component/scriptに分割したほうがよいかも
│    ├─ src/
│    ├─ vite.config.ts -------- サーバーは不要だが、ビルドの設定がいる?
│    ├─ main.ts        -------- エントリーポイント
│    ├─ tsconfig.app.json
│    └─ package.json
├─ 共通の設定ファイル ----------- ルートに配置し、各ワークスペースで継承する
├─ tsconfig.json -------------- Project Reference を構成
└─ package.json --------------- ワークスペースを管理

共通の設定ファイル

--- 各ワークスペースに置く必要なし
├─ .gitignore
├─  .editorconfig
├─  .vscode/
│    ├─ extensions.json
│    ├─ settings.json
--- 各ワークスペースにも必要
├─ .eslintrc.cjs
├─ .prettierrc.json
└─ .stylelintrc.js

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 9, 2024

タスク

ワークスペース化

  • 各ワークスペースの作成
  • 既存のフロントエンドDresscaをapps配下のワークスペースに移植
    バックとの連携、CIについても修正要
  • 各設定ファイルのルートへの共通化
    ルートフォルダに移管していく作業が必要
    ルートに置くべきパッケージの見極めが必要
  • 共有するコンポーネント等を共有ワークスペースに移植
    これが重め、何を共有すべきかの見極めと既存のアプリの参照をいじるので全体的な無影響確認が必要

管理用アプリの追加

  • バックエンドアプリの実装
    新規のWeb API Viteプロジェクトを追加する、並行して先に進めていても問題ないはず
  • バックエンド、フロントエンドの連携設定
    並行して問題ないはず、Visual Studioからの起動、openapi-generator周りの設定
  • フロントエンドアプリの実装
    共有パッケージの移行を先にやるほうがベター

@KentaHizume
Copy link
Contributor

KentaHizume commented Jul 9, 2024

課題

既存のアプリはViteのプロキシ経由でSwaggerUI(/swagger)を開けるが、新規作成したアプリだと開けない
バックエンドでリッスンしているポートを直接指定すれば開ける

バックとの連携がうまくいっていないため要調査
[vite] http proxy error: Must provide a proper URL as target
Error: Must provide a proper URL as target

@KentaHizume
Copy link
Contributor

KentaHizume commented Aug 15, 2024

DbUpdateConcurrencyExceptionをcatchする場所

https://maris.alesinfiny.org/app-architecture/overview/dotnet-application-processing-system/transaction-management-policy/#batch-transaction
更新対象データの不整合により発生した例外は、システム例外として集約例外ハンドラーに処理されます。

ApplicationServiceでtry-catchし、DbUpdateConcurrencyExceptionを業務例外に詰め直してスローすることも考えられるが、
ControllerでDbUpdateConcurrencyExceptionをcatchするほうがよさそう。
ApplicationServiceがレポジトリの実装であるEntity Frameworkの例外クラスに関する知識を持つのがあまりよくないように思う。

            try
            {
                await this.managementService.UpdateCatalogItemAsync(command);
            }
            catch (DbUpdateConcurrencyException ex)
            {
                this.logger.LogWarning(Events., ex, ex.Message);
                return this.Conflict();
            }

@KentaHizume
Copy link
Contributor

KentaHizume commented Aug 19, 2024

操作とエラーハンドリング定義

更新

ステータスコード 挙動
204 NoContent アイテムの情報を初期化(OnMountedと同じ処理)
401 Unauthorized ログイン画面に遷移
404 NotFound アイテム一覧画面に遷移
409 Conflict 変更前のアイテム情報を初期化、変更後アイテムのRowVersionのみ初期化
500 Error画面に遷移

削除

ステータスコード 挙動
204 NoContent アイテム一覧画面に遷移
401 Unauthorized ログイン画面に遷移
404 NotFound アイテム一覧画面に遷移
500 Error画面に遷移

追加

ステータスコード 挙動
201 Created アイテム一覧画面に遷移
401 Unauthorized ログイン画面に遷移
500 Error画面に遷移

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
target: Dressca サンプルアプリケーションDresscaに関係がある
Projects
None yet
2 participants