Architecture2026.08.26 · 5 min read

Live Activity の古いボタンが、次の勤務を変更できないようにした

NCP認証基盤・クラウドインフラ

Live Activity や Live Update のボタンは、画面上の一時的な callback として扱うには寿命が長い。表示を更新したあとや、アプリの process が終了したあとに、古い tap の処理だけが遅れて届くことがある。勤怠アプリでは、その操作を次の勤務へ適用すると過去の入力ではなく現在のデータを変えてしまう。そこでボタンを「操作」だけでなく、「どの表示から発生した操作か」へ束縛した。

ボタンは、表示が消えたあとにも届く

当初の iOS 側は、Live Activity の App Intent が送る即時 event を React Native の listener で受けていた。しかし即時通知は永続ストレージではない。listener 登録前や cold start の途中で届けば、あとから回収する正本がない。Android 側でも、Receiver が到着時点で処理済みにしてしまうと、Headless JS や SQLite の失敗後に同じ操作を取り戻せない。

逆に、未処理 event を保存するだけでも足りなかった。退勤して次の勤務が始まったあとや、通知設定を切り替えて表示を作り直したあとに、以前のボタンが遅れて届く可能性が残る。同じ「休憩開始」という種類でも、現在の表示が発行した操作でなければ実行してはいけない。

古い tap は、再生せずに失効させる

Android では event を sessionIdgenerationIdeventId、操作種別へ固定した。session は勤務ごとに替わり、generation は同じ勤務中に表示を作り直すたびに替わる。通知を閉じたときは、その session と generation が現在の表示に一致する場合だけ抑止し、古い表示から遅れてきた dismiss も拒否する。

iOS は generation を別フィールドにせず、session と「現在表示中の各ボタンの event ID」の membership を enqueue、acquire、ack、retry の全段で確認する。表示更新で event ID が替われば、同じ勤務中に残った古い retryable event も取得できない。両 OS で表現は違うが、判定している事実は同じだ。現在の表示が発行した操作かどうかである。

表示更新と tap が競合したときは、古い操作を推測して再生するより、その tap を失効させるほうを選んだ。操作が一度欠ける不便より、次の勤務を誤って変更する損害のほうが大きい。

event は表示公開前に永続化する。iOS では App Group の ledger へ保存してから即時 event を送り、mount 直後と foreground 復帰時にも drain する。Android では通知へ付ける event を永続化し、通知公開に成功してから active membership を切り替える。通知置換に失敗した場合は、画面に残っている旧ボタンまで先に無効化しない。

lease が、古い実行の完了を拒否する

現在の event を見つけた処理は、すぐ完了扱いにせず、期限と token を持つ lease を取得する。lease は 60 秒で切れる。process kill や timeout で明示的に戻せなくても、期限後には新しい token で再取得できる。一方、先に動いていた処理があとから戻ってきても、古い token では新しい lease を ack も retry もできない。

さらに所有権の確認を SQLite 変更の直前まで遅らせた。foreground の操作、Headless JS、設定変更、データ復元は process 全体の直列 lane を通し、その中で session と表示世代と lease をもう一度見る。event を取得した瞬間には正しくても、待っている間に退勤や復元が完了していれば、データへ触れずに stale として止まる。

01
persist pending eventボタンを表示する前に、操作種別と表示の所属を永続化する。
02
acquire lease現在の表示に所属する event だけを、期限と token 付きで取得する。
03
guard before SQLiteデータ変更の直前に session・表示世代・lease の所有権を再確認する。
04
punch, then acknowledge勤怠の変更を先に commit し、成功した event をあとから完了にする。
ロック画面 action を現在の表示へ束縛して処理する順序

exactly-once を名乗らず、gap を縮める

勤怠の正本は SQLite、action ledger は App Group または SharedPreferences にある。保存先が違うので、打刻と ack を1つの transaction にはできない。ここで ack を先にすると、その直後に process が終了した場合に「完了済みだが打刻はない」という回復不能な欠落ができる。そのため SQLite の commit を先、ack を後にした。

この順序にも gap は残る。SQLite の commit 後、成功応答や ack の前に処理が止まると、event は再取得される。再実行で core が同じ遷移を拒否したら最新 Snapshot を読み、休憩開始なら現在が休憩中、再開なら勤務中、退勤なら勤務外かを照合する。期待した状態なら前回の commit が効いたと判断して handled へ収束させ、一致しなければ retryable に戻す。

これは完全な exactly-once ではない。完全にするには SQLite 側にも idempotency key を保存し、action と同じ transaction に入れる必要がある。今回は既存 schema を変えない制約があったため採用せず、欠落を作らない順序と、core の遷移拒否、Snapshot 照合で重複可能な区間を狭めた。

まとめ

ロック画面のボタンは UI 部品ではなく、遅延し、再配送され、process の寿命を越える入力である。永続 queue を足すだけでは安全にならない。どの勤務 session、どの表示世代、どの event が発行し、どの lease が処理権を持つかまで照合して、現在から外れた tap を失効させる必要がある。

複数の保存先をまたぐ以上、exactly-once と言い切らないことも設計の一部だった。回復不能な欠落を避ける順序を選び、残る gap を現在状態との照合で狭める。保証できない境界を明示すると、どこで安全側に倒すべきかが決めやすくなる。

iOSAndroidLive ActivityLive Update