6本のアプリを個人で並行運用していると、リリース作業は「ひとつのボタン」では終わりません。AdMob のメディエーション設定を更新し、App Store Connect のメタデータを差し替え、Remote Config のフラグを上げ、最後に告知用の短い投稿を下書きする。2014年から手で回してきたこの一連の流れを、Antigravity のエージェントに束ねて任せ始めたのが今年の春でした。
最初の数回は気持ちよく回りました。問題が起きたのは、ある木曜の深夜です。AdMob 設定の反映は成功し、Remote Config のフラグも上がったのに、App Store Connect のメタデータ更新が API のレート制限で 4 本目のアプリの途中で止まりました。エージェントは素直に「失敗しました」と報告して停止しました。誠実な挙動です。けれど私の手元には、広告設定だけが新しく、ストア表記は半分だけ新しく、フラグはもう新機能を有効にしている という、どこにも存在してはいけない中間状態が残りました。
ここでエージェントに「もう一度やって」と言うのは危険です。冪等でない操作を二度実行すれば、すでに成功した AdMob 設定をもう一度書き換え、二重に課金イベントを発火させかねません。やり直し(リトライ)では直らない壊れ方があるのです。必要なのは、成功してしまった副作用を意図的に取り消す仕組み ―― 分散システムでいう補償トランザクション(Saga パターン)でした。
なぜ「リトライ」では足りないのか
エージェントの信頼性設計というと、まずリトライと冪等性キーが思い浮かびます。私も以前、冪等性キーで重複実行を抑える記事を書きました。けれどあれは「同じ操作を二度送っても一度しか効かない」ための仕組みであって、「複数の異なる操作のうち一部だけ成功した状態を畳む」ための仕組みではありません。
多段の副作用には、3つの状態があります。
全段成功 ―― 何もしなくてよい
全段失敗(最初の一歩も踏み出していない) ―― リトライで足りる
部分成功 ―― ここが本題。前に進めて完成させるか、後ろに戻して無かったことにするか、判断が要る
リトライが守ってくれるのは 2 だけです。3 の部分成功は、放置すると「半分だけ新しい世界」が本番に固着します。エージェントは止まったことを報告できても、止まった地点より前で起きた副作用までは勝手に片付けません。そこを設計で埋める必要があります。
補償アクションが満たすべき4条件
Saga の核は、各ステップに「順方向の操作(do)」と「巻き戻しの操作(undo=補償)」を対で持たせることです。補償アクションは普通の関数より制約が厳しく、私は次の4条件を満たさないものは補償として認めない運用にしています。
冪等であること ―― 補償自体が途中で失敗して再実行されても、二重に取り消さない。「フラグを false にする」は冪等ですが、「カウンタを 1 減らす」は冪等ではありません
順方向の前提に依存しないこと ―― 「do が完全に終わっている」と仮定しない。do が途中まで進んで失敗した地点からでも安全に呼べる
可観測な完了条件を持つこと ―― 補償が「効いた」ことを、推測ではなく確認できる(フラグの再取得、メタデータの再読み込みなど)
それ自体が新たな部分失敗を生まないこと ―― 補償が複数の副作用を持つなら、その補償もまた Saga である
4つ目が現場では一番きつい制約です。「App Store メタデータを元に戻す」補償が、実は「メタデータ更新+スクリーンショット差し戻し」の2段だったりするからです。
最小の Saga オーケストレータ
まず、ステップ定義と実行器の骨格です。各ステップは do と compensate を持ち、do が成功したものだけを逆順に補償します。
type StepResult < T > = { ok : true ; value : T } | { ok : false ; error : Error };
interface SagaStep < Ctx > {
name : string ;
// 順方向。戻り値は後続ステップとロールバック判断のためにcontextへ格納する
do : ( ctx : Ctx ) => Promise < unknown >;
// 巻き戻し。do が成功した場合のみ、逆順に呼ばれる
compensate : ( ctx : Ctx ) => Promise < void >;
// この地点を越えたら後退できない(後述のpoint of no return)
pivot ?: boolean ;
}
interface SagaReport {
status : "completed" | "compensated" | "stuck" ;
completedSteps : string [];
failedStep ?: string ;
compensationFailures : string [];
}
async function runSaga < Ctx >( steps : SagaStep < Ctx >[], ctx : Ctx ) : Promise < SagaReport > {
const done : SagaStep < Ctx >[] = [];
let crossedPivot = false ;
for ( const step of steps) {
try {
await step. do (ctx);
done. push (step);
if (step.pivot) crossedPivot = true ;
} catch (error) {
// 部分失敗が確定した瞬間
if (crossedPivot) {
// 後退できない地点を越えている → 前進回復に切り替える(後述)
return {
status: "stuck" ,
completedSteps: done. map (( s ) => s.name),
failedStep: step.name,
compensationFailures: [],
};
}
return await compensate (done, ctx, step.name);
}
}
return { status: "completed" , completedSteps: done. map (( s ) => s.name), compensationFailures: [] };
}
補償の実行は、成功したステップを逆順 に取り消します。ここで一つでも補償が失敗したら、握りつぶさずに記録して次へ進むのが要点です。途中で止めると、今度は「巻き戻しの途中」というさらに厄介な中間状態が生まれます。
async function compensate < Ctx >(
done : SagaStep < Ctx >[],
ctx : Ctx ,
failedStep : string ,
) : Promise < SagaReport > {
const compensationFailures : string [] = [];
// 逆順 = 最後に成功したものから取り消す
for ( const step of [ ... done]. reverse ()) {
try {
await step. compensate (ctx);
} catch (e) {
// 補償の失敗は止めない。記録して人間に上げる対象にする
compensationFailures. push (step.name);
}
}
return {
status: "compensated" ,
completedSteps: done. map (( s ) => s.name),
failedStep,
compensationFailures,
};
}
compensationFailures が空でなければ、それは自動回復が及ばなかった領域です。エージェントに任せきりにせず、ここだけは人間(私)の確認キューに送ります。全部を自動化しないことが、むしろ自動化を続けられる条件でした。
リリース作業を Saga として書く
冒頭の事故を、この骨格に載せ替えます。順序が重要です。取り消しにくい操作ほど後ろに置き、取り消しやすい操作を先に置く 。こうすると、後ろで失敗したときに前を畳むコストが小さくなります。
const releaseSaga : SagaStep < ReleaseCtx >[] = [
{
name: "admob-mediation" ,
do : async ( ctx ) => {
ctx.admobSnapshot = await admob. getMediationConfig (ctx.appId); // 巻き戻し用に現状を保存
await admob. updateMediationConfig (ctx.appId, ctx.newMediation);
},
compensate : async ( ctx ) => {
if (ctx.admobSnapshot) await admob. updateMediationConfig (ctx.appId, ctx.admobSnapshot);
},
},
{
name: "appstore-metadata" ,
do : async ( ctx ) => {
ctx.metaSnapshot = await appstore. getMetadata (ctx.appId);
await appstore. updateMetadata (ctx.appId, ctx.newMetadata);
},
compensate : async ( ctx ) => {
if (ctx.metaSnapshot) await appstore. updateMetadata (ctx.appId, ctx.metaSnapshot);
},
},
{
name: "remote-config-flag" ,
// フラグ点灯はユーザーに新機能が見え始める = 実質的な不可逆点
pivot: true ,
do : async ( ctx ) => {
await remoteConfig. setFlag (ctx.appId, "new_feature" , true );
},
compensate : async ( ctx ) => {
await remoteConfig. setFlag (ctx.appId, "new_feature" , false ); // 冪等
},
},
];
この設計の肝は、各 do の最初で現状のスナップショットを context に退避してから書き換える 点です。補償は「あるべき過去」を外部に問い合わせるのではなく、自分が保存した直前の値に戻すだけにします。外部 API は補償のタイミングで別の値を返すかもしれず、それを信用すると巻き戻しが汚染されるからです。
後退できない地点(pivot)と前進回復
すべての操作が取り消せるわけではありません。ユーザーへの通知送信、課金の確定、フラグ点灯による新機能の露出 ―― これらは「無かったこと」にできません。Saga ではこの一線を pivot(後退不能点、point of no return) と呼びます。
私の運用ルールはシンプルです。pivot は Saga の最後の方に集める。そして pivot を越えた後に失敗したら、補償(後退)ではなく前進回復(forward recovery)に切り替える。 上のコードで crossedPivot を見て status: "stuck" を返していたのはこのためです。stuck は「後ろに戻せないので、前に進めて完成させるしかない」状態を意味します。
前進回復は、残ったステップだけを冪等にリトライして完成へ持っていきます。だからこそ pivot 以降のステップは、すべて冪等で・スナップショット不要・単独でリトライ可能になるよう設計しておきます。pivot を前に置いてしまうと、その後ろのほぼ全工程が前進回復の対象になり、設計負担が跳ね上がります。「取り消しやすいものを先に」という順序づけは、見た目以上に効きます。
補償不能ステップは前倒しで弾く
実運用で一番効いたのは、コードの工夫ではなく順序の工夫でした。「やってしまうと取り消せない」かつ「失敗しやすい」操作は、Saga の本体に入れる前に、別の軽い検証ステップとして前倒しで通す のです。
たとえば App Store のメタデータ更新は、文字数制限や禁止語のバリデーションで弾かれることがあります。これを本体の3段目で踏むと、AdMob と Remote Config を巻き戻す羽目になります。そこで、本体実行の前に「メタデータの dry-run 検証だけ」を走らせ、ここで弾かれたら Saga 本体を一度も起動しません。
私の手元の数字で言うと、この前倒し検証を入れる前は、Saga 全体の失敗のうち約 60% がメタデータ起因で、しかもその大半が本体の途中まで進んでからの失敗でした。前倒し検証を入れてからは、本体起動後の部分失敗が体感で月に数回まで減りました。部分失敗を「畳む」前に「起こさない」方が、補償ロジックを磨くよりずっと安く効きます。
部分失敗を速く見つける
補償が正しく組めても、部分失敗の検知が遅ければ中間状態が長く本番に残ります。最初の頃、私はエージェントの完了報告を待ってからログを見ていたので、部分失敗に気づくまで平均で 90 秒ほどかかっていました。深夜だと、その 90 秒の間にユーザーが新旧混在の画面を踏みます。
今は各 do の成否を即座に構造化ログへ流し、compensate が走った瞬間にアラートを上げています。検知から補償着手までを平均 8 秒前後まで縮められました。秒数そのものより大事なのは、「補償が走った」という事実を、エージェントの自己申告ではなく外部の観測点で掴む ことです。エージェントが途中でクラッシュすると自己申告は届きませんが、ステップごとの構造化ログは残ります。
// do/compensate を計測ラッパで包み、観測点を一本化する
function observed < Ctx >( step : SagaStep < Ctx >) : SagaStep < Ctx > {
return {
... step,
do : async ( ctx ) => {
const t = Date. now ();
try {
const r = await step. do (ctx);
log. info ({ saga: "release" , step: step.name, phase: "do" , ms: Date. now () - t });
return r;
} catch (e) {
log. error ({ saga: "release" , step: step.name, phase: "do" , failed: true });
throw e;
}
},
compensate : async ( ctx ) => {
log. warn ({ saga: "release" , step: step.name, phase: "compensate" , started: true });
await step. compensate (ctx);
log. warn ({ saga: "release" , step: step.name, phase: "compensate" , done: true });
},
};
}
どこまで自動化し、どこで手を止めるか
最後に、これは技術というより姿勢の話です。私は当初、補償まで含めて全自動で回そうとしました。けれど compensationFailures が出たケース ―― つまり巻き戻しすら失敗したケース ―― だけは、必ず私の確認キューに上げて手で締めるようにしています。年に数えるほどしか起きませんが、起きたときに自動の自動で押し切ると、傷をさらに広げます。
宮大工だった祖父たちは、組み上げた後に必ず手で表面を確かめていました。機械で削った後の最後のひと撫でを省かなかった。エージェントに多段の副作用を任せるときも、最後の確認だけは自分の手に残す ―― その線引きがあるからこそ、安心して大半を任せられるのだと感じています。
次に同じ設計を入れるなら、まず手元の多段作業をひとつ選び、各ステップに「これを取り消す関数を1つ書けるか」を自問してみてください。書けない操作が見つかったら、それが pivot です。pivot を後ろに寄せるところから始めれば、補償ロジック全体がぐっと軽くなります。実装の参考になれば幸いです。