Live Activity の古いボタンが、次の勤務を変更できないようにした
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 を sessionId、generationId、eventId、操作種別へ固定した。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 として止まる。
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 を現在状態との照合で狭める。保証できない境界を明示すると、どこで安全側に倒すべきかが決めやすくなる。