クラウド Mac で iOS 定期予定の夏時間境界を検証する

クラウド Mac で iOS 定期予定の夏時間境界を検証する

「毎日、現地時間の 02:30 に同期する」処理をバックグラウンドタスクに任せると、通常の日には問題なく動いても、夏時間への切り替えや終了時に実行が抜けたり、重複したりすることがあります。まずクラウド Mac 上で、次回実行時刻の計算を純粋関数として切り出し、固定した日付で検証してから、通知やバックグラウンドスケジューリングに組み込みます。ここで検証するのはカレンダー計算の契約であり、OS が実際にアプリを起動した時刻をアルゴリズムの計算結果として扱うわけではありません。

壁時計の時刻と経過時間を区別する

「二十四時間ごと」と「毎日 02:30」は別のルールです。前者は経過秒数に従って進み、後者は特定のタイムゾーンの暦に従って進みます。一日が常に二十四時間とは限らないため、どちらも同じ秒数加算のロジックで実装することはできません。

毎日のルールには、時、分、タイムゾーン識別子、ポリシーのバージョンを保存します。次回実行の絶対時刻を保存しておくと検索には便利ですが、それを元のルールの代わりにはできません。ユーザーがタイムゾーンやポリシーを変更した場合は、再計算が必要です。

Date が表すのは絶対時刻であり、タイムゾーン自体は持ちません。「毎日何時」という指定はカレンダー上のルールなので、明示的な Calendar、TimeZone と組み合わせて解釈する必要があります。

さらに、どのタイムゾーンを基準にするかを決めます。作成時のタイムゾーンに固定するのか、それともユーザーの現在地に追従するのか、という選択です。クラウドマシンの現在のタイムゾーンに、製品としての判断を委ねてはいけません。また、固定のオフセットだけを保存するのも不十分です。オフセットでは、将来の夏時間の切り替えを表現できません。

存在しない時刻と重複する時刻の扱いを製品仕様にする

春に時計を進める際には、02:30 が存在しないことがあります。その日をスキップする、同日の次の有効な時刻まで繰り下げる、分の値を保ったまま繰り下げる、という選択肢があります。この三つは、それぞれ異なる結果になります。

Calendar の .strict は完全一致を要求します。毎日のルールで指定時刻が存在しなければ、後日を引き続き検索します。.nextTime は後続の有効な時刻を選びますが、元の分の値が必ず保たれるとは限りません。分の値を保つ必要がある場合は、.nextTimePreservingSmallerComponents を別途検証してください。

秋に時計を戻す際には、02:30 が二度現れることがあります。.first または .last で、どちらを選ぶかを明示します。両方で実行する仕様なら、二つの候補時刻と、それぞれのイベント識別子を設計する必要があります。一度の一致検索だけで実装が完了したことにはできません。

固定日付の Swift 受け入れテストスクリプトを実行する

以下を recurrence.swift として保存します。この例ではグレゴリオ暦と明示したタイムゾーンを使います。テスト日付は境界条件を検証するためのフィクスチャであり、計測を行う日付ではありません。マシンの時刻を変更したり、シミュレータを起動したりする必要はありません。

import Foundation

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Berlin")!

let formatter = ISO8601DateFormatter()

func instant(_ value: String) -> Date {
    guard let date = formatter.date(from: value) else {
        fatalError("Invalid fixture date")
    }
    return date
}

func next(
    after cursor: Date,
    policy: Calendar.MatchingPolicy,
    repeated: Calendar.RepeatedTimePolicy
) -> Date {
    guard let date = calendar.nextDate(
        after: cursor,
        matching: DateComponents(hour: 2, minute: 30, second: 0),
        matchingPolicy: policy,
        repeatedTimePolicy: repeated,
        direction: .forward
    ) else {
        fatalError("No matching occurrence")
    }
    precondition(date > cursor)
    return date
}

let spring = instant("2027-03-28T00:00:00+01:00")

let skipped = next(
    after: spring,
    policy: .strict,
    repeated: .first
)
precondition(skipped == instant("2027-03-29T02:30:00+01:00"))

let shifted = next(
    after: spring,
    policy: .nextTime,
    repeated: .first
)
precondition(shifted == instant("2027-03-28T03:00:00+02:00"))

let autumn = instant("2027-10-31T00:00:00+02:00")

let first = next(
    after: autumn,
    policy: .strict,
    repeated: .first
)
let last = next(
    after: autumn,
    policy: .strict,
    repeated: .last
)
precondition(first == instant("2027-10-31T02:30:00+02:00"))
precondition(last == instant("2027-10-31T02:30:00+01:00"))
precondition(last.timeIntervalSince(first) == 3600)

選択済みの開発ツールチェーンで実行します。

xcrun swift recurrence.swift

成功するとスクリプトは何も出力せずに終了し、アサーションに失敗すると異常終了します。ログが生成されたかどうかだけで判断せず、失敗をパイプラインに伝播させてください。ツールチェーンや OS を更新したらフィクスチャを再実行し、OS のバージョン、ツールチェーンのバージョン、タイムゾーン識別子も記録します。

このスクリプトで検証するのは、02:30 に固定したルールだけです。プロジェクトに組み込む際は時と分をパラメータ化し、通常の日、月末、年末、うるう日のテストケースを追加します。各ケースには期待する絶対時刻を明記し、整形済みの文字列だけを比較するテストにしないでください。

カーソルの更新と未実行分の補完で重複が起きないことを確認する

複数の候補時刻を計算する場合は、前回の戻り値を次の検索のカーソルにし、結果が常に前の値より後になることを確認します。一秒引いてから再検索してはいけません。境界にちょうど一致したとき、同じイベントが再び返される可能性があります。まとめて検索する場合は回数の上限も設け、異常なルールによってタスクが実行リソースを長時間占有しないようにします。

未実行分を補完する処理には、固定した期間を使います。開始時点は含めず、終了時点は含めます。まず終了時点を固定してから候補を列挙し、ループ内で現在時刻を何度も取得しないでください。そうしないと、長時間の処理では補完対象の期間が広がり続け、受け入れテストの結果も再現できなくなります。

一日に一度だけ実行する場合は、「スケジュール識別子+ルールのバージョン+ルールのタイムゾーンにおける現地日付」で重複排除キーを作ります。完了記録は永続化してください。業務処理に外部への副作用がある場合は、そのキーを業務ロジック層にも渡す必要があります。メモリ上で成功を記録するだけでは不十分です。

処理の途中で中断するケースもテストします。業務処理を完了させた後、完了記録がまだ永続化されていない状態を再現します。再試行は業務側の冪等性ルールで止められなければなりません。スケジューラの重複排除を、トランザクションの保証と見なしてはいけません。

カレンダー計算の正しさとシステムによる実行を分けて検証する

純粋関数の検証が通ったら、シミュレータでルールの編集、表示、永続化したデータの再読み込みをテストします。バックグラウンドで実行できるタイミングはシステムが決めるため、秒単位での起動は保証できません。「実行予定時刻」と「実際の実行時刻」を別々のフィールドで表示すれば、障害調査で遅延をカレンダー計算の誤りと取り違えずに済みます。

最後に四点を確認します。テストがデフォルトのタイムゾーンを参照していないこと、毎日のルールを固定秒数の加算で進めていないこと、重複時刻のポリシーを明示的に渡していること、未実行分の補完に固定した期間と永続化された識別情報を使っていることです。夏時間の境界をまたぐ回帰テストでは、システム時計を変更して偶然うまく動くことを期待するのではなく、計算結果と実行記録を比較してください。

よくある質問

毎日の実行時刻は、前回に二十四時間を足せば求められますか?

経過時間を固定する要件なら可能です。現地の同じ時刻を保つ場合は、明示したタイムゾーンの Calendar で時分秒を照合し、夏時間によるずれを避けます。

夏時間終了時に重複する時刻では二回実行すべきですか?

最初、二回目、両方のどれを採用するか製品仕様で決めます。一日一回なら UTC のタイムスタンプだけでなく、予定識別子、規則の版、現地日付で重複を防ぎます。

専有の物理 Mac でプロジェクトを実行

クラウド Mac でビルドやテストを行い、リモートデスクトップから操作できます。プロジェクトに合った構成と利用期間を選択してください。現在の利用状況はコンソールでリアルタイムに確認できます。

次は、実行環境を確認しましょう

まず構成を比較し、接続方法を選びましょう。SSH コマンドラインはビルドに、VNC デスクトップは操作やデバッグに便利です。