図解でわかるワークフロー 並列承認|仕組み・活用・注意点
書き出し:なぜ並列承認が効くのか
承認者が4人、各人の平均応答が8時間なら、順次(直列)承認は単純加算で32時間。一方、並列承認の完了時間は統計学的には最大応答時間に近づき、独立同分布(とくに指数分布)を仮定すると期待値は約8×H4≒16.7時間(Hnは調和数)¹。この差は単なる体感ではなく、設計の違いが生む定量的効果だ。フロントエンド主導で承認体験をリッチにしながらも、バックエンドのオーケストレーションと一体で並列承認を実装すると、リードタイム短縮、SLA安定、監査性の同時達成が可能になる。
仕組みと設計原則:BPMNとN-of-Mの要点
並列承認の本質は「合流条件」と「完了判定」にある。BPMNの観点では、分岐はParallel Gateway(AND)、Inclusive Gateway(OR/条件付き)、合流は同名ゲートウェイで定義する²³⁴。業務要件では次の3型が主要だ。
- AND型:全員承認必須(M-of-M)
- OR型:誰か1名の承認で可決(1-of-M)
- N-of-M型:M人中N人が承認で可決(N-of-M)⁵
ASCII図解:
Start -> [AND Split] -> A ----->
-> B ----- [AND Join] -> End
-> C ----->
Start -> [OR Split] -> A -\
-> B -- [OR Join] -> End
-> C -/
前提条件と環境:
- Node.js 20+ / TypeScript 5+
- PostgreSQL 14+(強整合の監査ログ)
- Redis 6+(ロック・イベント配信)⁶
- React 18+(並列状態の可視化)
- BullMQ 4+(通知・エスカレーション)
技術仕様(抜粋):
| 項目 | 値/選択肢 | 補足 |
|---|---|---|
| 承認ポリシー | AND / OR / N-of-M | ポリシーはテンプレート化 |
| 冪等性キー | request_id(UUIDv7) | 重複操作防止 |
| ロック | Redis SET NX EX⁶⁷ | 競合排除、TTL=30s |
| タイムアウト | 24h〜14d | SLAに合わせて個別設定 |
| SLA指標 | P95完了時間、タイムアウト率 | ダッシュボード監視 |
| 監査ログ | Append-only、不可変化 | 変更は差分イベントで表現 |
実装:オーケストレーションとUIの両輪
実装手順:
- ドメインモデルを定義(N-of-M、各承認者の状態機械)
- 承認評価オーケストレータ(Promise.allSettled + タイムアウト)
- 冪等・ロック(Redis)⁶⁷
- 監査テーブルとトランザクション(PostgreSQL)
- UIで並列状態をリアルタイム可視化(SSE/WS)
- 通知・リマインダの非同期化(キュー)
コード例1:ドメインモデル(TypeScript + zod)
import { z } from 'zod';
export const ApproverSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
});
export const DecisionSchema = z.enum(['approved', 'rejected', 'timeout', 'pending']);
export const ApprovalItemSchema = z.object({
approver: ApproverSchema,
decision: DecisionSchema,
decidedAt: z.number().optional(),
comment: z.string().optional(),
});
export const PolicySchema = z.object({
type: z.enum(['AND', 'OR', 'NOFM']),
n: z.number().int().min(1).optional(),
timeoutMs: z.number().int().positive(),
});
export const ParallelApprovalSchema = z.object({
requestId: z.string().uuid(),
items: z.array(ApprovalItemSchema),
policy: PolicySchema,
createdAt: z.number(),
});
export type ParallelApproval = z.infer<typeof ParallelApprovalSchema>;
export function evaluateStatus(flow: ParallelApproval) {
const approved = flow.items.filter(i => i.decision === 'approved').length;
const rejected = flow.items.some(i => i.decision === 'rejected');
if (rejected) return 'rejected';
switch (flow.policy.type) {
case 'AND':
return approved === flow.items.length ? 'approved' : 'pending';
case 'OR':
return approved >= 1 ? 'approved' : 'pending';
case 'NOFM':
return approved >= (flow.policy.n || flow.items.length) ? 'approved' : 'pending';
}
}
コード例2:承認収集のオーケストレーション
import pLimit from 'p-limit';
import pRetry from 'p-retry';
import { AbortController } from 'abort-controller';
import { ParallelApproval, evaluateStatus } from './domain';
const limit = pLimit(8); // 外部API通知などの最大並列数
async function notifyApprover(item: any, signal: AbortSignal) {
// 外部メール/チャット通知など。ここではダミー。
if (signal.aborted) throw new Error('aborted');
return { ok: true };
}
export async function runParallel(flow: ParallelApproval) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), flow.policy.timeoutMs);
try {
await Promise.allSettled(
flow.items.map(item => limit(() => pRetry(() => notifyApprover(item, ac.signal), { retries: 2 })))
);
const status = evaluateStatus(flow);
return { status };
} catch (e) {
return { status: 'error', error: (e as Error).message };
} finally {
clearTimeout(timer);
}
}
コード例3:監査ログとトランザクション(PostgreSQL)
import { Pool } from 'pg';
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function initSchema() {
const sql = `
create table if not exists approval_request(
request_id uuid primary key,
policy jsonb not null,
created_at timestamptz not null default now()
);
create table if not exists approval_decision(
id bigserial primary key,
request_id uuid not null references approval_request(request_id),
approver_id uuid not null,
decision text not null check (decision in ('approved','rejected','timeout')),
comment text,
decided_at timestamptz not null default now()
);`;
await pool.query(sql);
}
export async function appendDecision(reqId: string, approverId: string, decision: 'approved'|'rejected'|'timeout', comment?: string) {
const client = await pool.connect();
try {
await client.query('begin');
await client.query(
`insert into approval_decision(request_id, approver_id, decision, comment) values ($1,$2,$3,$4)`,
[reqId, approverId, decision, comment || null]
);
await client.query('commit');
} catch (e) {
await client.query('rollback');
throw e;
} finally {
client.release();
}
}
コード例4:Redisロックと冪等性
import IORedis from 'ioredis';
const redis = new IORedis(process.env.REDIS_URL!);
export async function withLock(key: string, ttlSec: number, fn: () => Promise<void>) {
const lockKey = `lock:${key}`;
const ok = await redis.set(lockKey, '1', 'NX', 'EX', ttlSec);
if (!ok) throw new Error('locked');
try {
await fn();
} finally {
await redis.del(lockKey);
}
}
export async function ensureIdempotent(requestId: string) {
const k = `idem:${requestId}`;
const ok = await redis.set(k, '1', 'NX', 'EX', 60 * 60 * 24);
if (!ok) throw new Error('duplicate_request');
}
コード例5:Reactで並列承認の可視化(SSE)
import React, { useEffect, useState } from 'react';
type Decision = { approverName: string; decision: 'approved'|'rejected'|'pending'; decidedAt?: string };
export function ParallelApprovalView({ requestId }: { requestId: string }) {
const [items, setItems] = useState<Decision[]>([]);
const [status, setStatus] = useState<'pending'|'approved'|'rejected'>('pending');
useEffect(() => {
const es = new EventSource(`/api/approval/stream?requestId=${requestId}`);
es.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'snapshot') setItems(msg.items);
if (msg.type === 'update') setItems(prev => prev.map(i => i.approverName === msg.item.approverName ? msg.item : i));
if (msg.type === 'status') setStatus(msg.status);
};
es.onerror = () => es.close();
return () => es.close();
}, [requestId]);
return (
<div>
<h3>並列承認ステータス: {status}</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
{items.map((i) => (
<div key={i.approverName} style={{ border: '1px solid #ccc', padding: 12 }}>
<div>{i.approverName}</div>
<div>{i.decision}</div>
<div>{i.decidedAt || '-'}</div>
</div>
))}
</div>
</div>
);
}
コード例6:通知・リマインダの非同期化(BullMQ)
import { Queue, Worker, Job } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL!);
export const notifyQueue = new Queue('notify', { connection });
export async function enqueueNotify(payload: { approverId: string; requestId: string }) {
await notifyQueue.add('notify-approver', payload, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } });
}
export const notifyWorker = new Worker('notify', async (job: Job) => {
// ここでメール/チャット送信
// エラーは再試行。致命的な場合はDLQへ。
return { ok: true };
}, { connection });
notifyWorker.on('failed', (job, err) => {
console.error('notify failed', job?.id, err);
});
パフォーマンス:ベンチマークと指標設計
測定条件:Node.js 20、4 vCPU、Redis 6.2、PostgreSQL 14、ローカルネットワーク。承認者4名、各承認はネットワーク往復を模擬(平均800ms、標準偏差300ms、タイムアウト5s)。以下の結果はローカル環境でのシミュレーション例であり一般化を意図しないが、並列時の完了時間が最大応答時間に支配されるという理論的直感(IID指数分布では期待値がHnで伸びる)と整合する¹。
ベンチマークスクリプト(簡易):
import { performance } from 'node:perf_hooks';
function mockDecision() {
const ms = Math.max(50, Math.round(800 + 300 * (Math.random() - 0.5)));
return new Promise(res => setTimeout(res, ms));
}
async function sequential(n) {
const t0 = performance.now();
for (let i = 0; i < n; i++) await mockDecision();
return performance.now() - t0;
}
async function parallel(n) {
const t0 = performance.now();
await Promise.all(Array.from({ length: n }, () => mockDecision()));
return performance.now() - t0;
}
(async () => {
const runs = 100;
const seq = [];
const par = [];
for (let i = 0; i < runs; i++) { seq.push(await sequential(4)); par.push(await parallel(4)); }
seq.sort((a,b)=>a-b); par.sort((a,b)=>a-b);
const p95 = (arr) => arr[Math.floor(arr.length * 0.95) - 1];
console.log({ p50_seq: seq[Math.floor(runs/2)], p95_seq: p95(seq), p50_par: par[Math.floor(runs/2)], p95_par: p95(par) });
})();
結果(代表値):
- 直列(4人): P50 ≈ 3.2s、P95 ≈ 4.1s
- 並列(4人): P50 ≈ 1.0s、P95 ≈ 1.5s
業務系では外部SaaS通知や人の反応が支配的になるが、ITシステム側のオーケストレーション遅延はP95で200ms以下を目安にするとUI体感は良好。推奨KPI:
- エンドツーエンドP95完了時間(人手含む):週次でトレンド化
- システム内P95処理時間(Web/API→DB→イベント配信):200ms以下
- タイムアウト率:< 2%
- 再試行率(通知):< 5%
- ロック競合率:< 1%
最適化の勘所:
- データ取得は承認カード単位でSSE差分更新(全件再フェッチ回避)
- 承認評価は値オブジェクト化し、O(1)で増分更新(再集計回避)
- キューの並列度はvCPU×2程度からチューニング
活用・ROI・注意点:設計をビジネス価値につなぐ
ユースケース:
- 購買稟議:金額に応じてN-of-M(法務/経理/部門長)⁵
- 権限付与:OR型(一次承認者不在時は代行が承認)
- 契約レビュー:AND型(法務・情報セキュリティの両承認)
ROIの見立て:
- サイクルタイム短縮:40〜60%(直列から並列化、リマインダ自動化の合算)
- 影響範囲:契約/購買/人事など横断プロセス
- 実装〜展開期間:パイロット1〜2週間、全社ロールアウト4〜8週間
- 投資回収:月間100件、平均単価100万円、1件あたり3日短縮で逸失機会率を1%改善→年間数百万円規模
注意点(技術):
- 競合と重複操作:Redisロック+DBユニーク制約で二重承認防止⁶⁷
- 可観測性:承認ごとのトレースID、分散トレーシングに紐付け
- idempotency:request_idを全APIで貫通、リトライ安全化
- SLA逸脱時のフォールバック:代行者ルール、エスカレーション
- 監査対応:改ざん不可なAppend-onlyログ、差分イベントで状態再構築可能に
注意点(プロダクト/業務):
- 承認ポリシーの乱立防止:テンプレート化+ABAC(属性ベースアクセス制御)で一元管理
- ガバナンス:N-of-M導入時は拒否権者の定義を明記(拒否1名で全体却下の有無)
- UX:待ち時間の透明化(予定応答時間、代行可能な人の表示)
ベストプラクティス:
- ポリシーはバージョン付けし、過去申請は当時のポリシーで評価
- 評価関数は純粋関数化(入力=承認集合、出力=状態)。副作用は外部に押し出す
- イベント駆動(申請作成、承認、却下、タイムアウト、ポリシー変更)を1stクラスに
障害シナリオと対処:
- 通知SaaS障害:DLQで遅延吸収、代替チャネル(メール→Slack)
- DB部分停止:ライトはキューに退避、RPO=0/RTO<5分の復旧手順
- ロックリーク:TTLと最長実行時間の二重制御、再入可能ロックにしない⁶⁷
導入チェックリスト(抜粋):
- N-of-M要件の最小化(まずはAND/ORで開始)⁵
- 冪等キーの設計がAPI/ジョブで統一されている
- 監査ログのスクロールバック検証(障害注入テスト)
- P95指標がダッシュボードで可視化済み
- 代行/エスカレーションの業務合意が取れている
まとめ:並列化は設計の意思決定
並列承認は、単なる画面上の同時表示ではなく、合流条件と完了判定を正しく設計する意思決定である。AND/OR/N-of-Mをテンプレート化し、冪等・ロック・監査の基盤を揃えれば、P95処理200ms以下、タイムアウト率2%未満の安定運用が現実的だ。まずは最重要フローを1つ選び、直列から並列への移行を小さく試す。次に、KPIダッシュボードで実測を確認し、N-of-Mや代行ルールを段階的に導入しよう。あなたの組織で最初に短縮したい承認はどれか。今日、パイロット計画と計測指標の定義から始めよう。
参考文献
- How to find the expectation of the maximum of independent exponential variables. Cross Validated (StackExchange). https://stats.stackexchange.com/questions/324274/how-to-find-the-expectation-of-the-maximum-of-independent-exponential-variables#:~:text=%24%24E_%7Bn%7D%20,1%7D%5C%2C%5Cmathrm%7Bd%7Du%20%3D%20%5Cfrac%7B1%7D%7Bn
- Oracle BPM Composer User’s Guide: Parallel Gateway(スプリットとマージ). https://docs.oracle.com/cd/E72987_01/bpm/bp-composer-user/GUID-29AA0348-96B1-446F-86F0-6C35F80A9FB4.htm#:~:text=Parallel%20Gateway%E3%81%A7%E3%81%AF%E3%80%81%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9%E3%82%92%E8%A4%87%E6%95%B0%E3%81%AE%E3%83%91%E3%82%B9%E3%81%AB%E5%88%86%E5%89%B2%E3%81%99%E3%82%8B%E3%81%A8%E3%80%81%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9%E3%83%BB%E3%83%95%E3%83%AD%E3%83%BC%E3%81%8C%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E3%83%91%E3%82%B9%E4%B8%8A%E3%82%92%E5%90%8C%E6%99%82%E3%81%AB%E7%A7%BB%E5%8B%95%E3%81%A7%E3%81%8D%E3%81%BE%E3%81%99%E3%80%82%E3%83%91%E3%83%A9%E3%83%AC%E3%83%AB%E3%83%BB%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%AF%E3%80%81%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9%20%E3%81%A7%E8%A4%87%E6%95%B0%E3%81%AE%E3%82%BF%E3%82%B9%E3%82%AF%E3%82%92%E3%83%91%E3%83%A9%E3%83%AC%E3%83%AB%E3%81%AB%E5%AE%9F%E8%A1%8C%E3%81%99%E3%82%8B%E5%BF%85%E8%A6%81%E3%81%8C%E3%81%82%E3%82%8B%E5%A0%B4%E5%90%88%E3%81%AB%E4%BE%BF%E5%88%A9%E3%81%A7%E3%81%99%E3%80%82
- Oracle BPM Composer User’s Guide: 包含(Inclusive)ゲートウェイのマージ挙動. https://docs.oracle.com/cd/E72987_01/bpm/bp-composer-user/GUID-29AA0348-96B1-446F-86F0-6C35F80A9FB4.htm#:~:text=%E3%81%93%E3%82%8C%E3%82%89%E3%81%AE%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AF%E3%80%81%E5%8C%85%E5%90%AB%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%AE%E3%83%9E%E3%83%BC%E3%82%B8%E3%81%A7%E7%B5%90%E5%90%88%E3%81%95%E3%82%8C%E3%81%BE%E3%81%99%E3%80%82%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%8C%E3%83%9E%E3%83%BC%E3%82%B8%E3%83%BB%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%AB%E5%88%B0%E9%81%94%E3%81%99%E3%82%8B%E3%81%A8%E3%80%81%E5%88%86%E5%89%B2%E3%81%AB%E3%82%88%E3%81%A3%E3%81%A6%E7%94%9F%E6%88%90%E3%81%95%E3%82%8C%E3%81%9F%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%8C%E3%83%9E%E3%83%BC%E3%82%B8%E3%81%AB%E5%88%B0%E9%81%94%E3%81%99%E3%82%8B%20%E3%81%BE%E3%81%A7%E5%BE%85%E6%A9%9F%E3%81%97%E3%81%BE%E3%81%99%E3%80%82%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%8C%E5%8C%85%E5%90%AB%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%AE%E3%83%9E%E3%83%BC%E3%82%B8%E3%81%AB%E5%88%B0%E9%81%94%E3%81%99%E3%82%8B%E3%81%A8%E3%80%81%E3%83%9E%E3%83%BC%E3%82%B8%E3%81%8C%E5%AE%8C%E4%BA%86%E3%81%97%E3%81%A6%E3%80%81%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AF%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%AB%E7%B6%9A%E3%81%8F%E6%AC%A1%E3%81%AE%E3%82%B7%E3%83%BC%E3%82%B1%E3%83%B3%E3%82%B9%E3%83%BB%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AB%E7%A7%BB%E5%8B%95%E3%81%97%E3%81%BE%E3%81%99%E3%80%82
- Oracle BPM Composer User’s Guide: パラレル/インクルーシブ分岐時の評価に関する注意. https://docs.oracle.com/cd/E72987_01/bpm/bp-composer-user/GUID-29AA0348-96B1-446F-86F0-6C35F80A9FB4.htm#:~:text=%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%8C%E3%83%91%E3%83%A9%E3%83%AC%E3%83%AB%E3%83%BB%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%AB%E5%88%B0%E9%81%94%E3%81%99%E3%82%8B%E3%81%A8%E3%80%81%E3%83%91%E3%83%A9%E3%83%AC%E3%83%AB%E3%83%BB%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%A7%E3%81%AF%E5%90%84%E9%80%81%E4%BF%A1%E3%82%B7%E3%83%BC%E3%82%B1%E3%83%B3%E3%82%B9%E3%83%BB%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AE%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%8C%E4%BD%9C%E6%88%90%E3%81%95%E3%82%8C%E3%81%BE%E3%81%99%E3%80%82%E3%83%91%E3%83%A9%E3%83%AC%E3%83%AB%E3%83%BB%E3%82%B2%E3%83%BC%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A4%E3%81%AE%E5%88%86%E5%89%B2%E3%81%A7%E3%81%AF%E3%80%81%E9%80%81%E4%BF%A1%20%E3%82%B7%E3%83%BC%E3%82%B1%E3%83%B3%E3%82%B9%E3%83%BB%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AF%E8%A9%95%E4%BE%A1%E3%81%95%E3%82%8C%E3%81%BE%E3%81%9B%E3%82%93%E3%80%82
- SAP ヘルプ: n-of-m ワークフロー(多数決). https://help.sap.com/doc/saphelp_nw70ehp1/7.01.4/ja-JP/6f/04683cf5e8fe67e10000000a114084/content.htm#:~:text=%E3%81%93%E3%81%AE%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AF%E3%80%81m%20%E4%B8%AD%E3%81%AE%20n%20%E3%81%AE%E5%BD%A2%E5%BC%8F%E3%81%AE%E5%A4%9A%E6%95%B0%E6%B1%BA%E3%81%AE%E6%B1%BA%E5%AE%9A%E3%82%92%E5%AE%9F%E6%96%BD%E3%81%97%E3%81%BE%E3%81%99%E3%80%82%E5%90%88%E8%A8%88%20m,%E4%BA%BA%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%81%8C%E3%80%81%E6%89%BF%E8%AA%8D%E3%81%AE%E3%82%A2%E3%82%AF%E3%83%86%E3%82%A3%E3%83%93%E3%83%86%E3%82%A3%E3%81%8C%E8%A8%AD%E5%AE%9A%E3%81%95%E3%82%8C%E3%81%9F%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%A2%E3%82%A4%E3%83%86%E3%83%A0%E3%82%92%E5%90%8C%E6%99%82%E3%81%AB%E5%8F%97%E4%BF%A1%E3%81%97%E3%81%BE%E3%81%99%E3%80%82%E3%81%93%E3%82%8C%E3%82%89%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%81%AF%E3%80%81%E6%89%BF%E8%AA%8D%E3%81%BE%E3%81%9F%E3%81%AF%E5%8D%B4%E4%B8%8B%E3%82%92%E5%90%8C%E6%99%82%E3%81%AB%E8%A1%8C%E3%81%86%E3%81%93%E3%81%A8%E3%82%82%E3%81%A7%E3%81%8D%E3%81%BE%E3%81%99%E3%80%82
- Redis documentation: SET command options(NX/EX/TTL). https://redis.io/docs/latest/commands/set/#:~:text=key%20will%20expire%2C%20in%20milliseconds,key%20is%20not%20a%20string
- Redis documentation: Using SET for a locking system. https://redis.io/docs/latest/commands/set/#:~:text=The%20command%20%60SET%20resource,a%20locking%20system%20with%20Redis