home 候補リストの「現在の滞在時間」が 1,065,210,042 分 と表示されたのが、この記事を書く直接のきっかけです。分単位なので、年に直すと約 2,025 年。アプリを起動した iPhone のオーナーは、弥生時代からずっと自宅にいたことになる(卑弥呼より昔の話なので、さすがに長すぎる)。
Date == Date.distantFuture は「等しいか / 等しくないか」のはずですよね。そう思って書いていた箇所が、Codable round-trip と CoreLocation の実機配信を経由した瞬間にあっさり裏切ってきた、という話です。Date.distantFuture をストレージに通すと Date.distantFuture で返ってくる、と素朴に信じた結果、stay 値が西暦 4001 年付近の引き算結果として現れた(つまり 2,025 年滞在)。
自社で出している PollenShield は、CoreLocation の Visit Monitoring と Region Monitoring で「いつ家にいて」「いつ家を出たか」を OS 側に検知させ、家を出る5分前に1通知だけ鳴らす iOS アプリです(App Store 配信中)。CoreLocation 周りで「閾値設計」と「審査文書」の話は前に書いた通りで、今回はその裏で踏んだ Swift の sentinel 値設計のミスの記録です。具体的には、CLVisit.departureDate の Date.distantFuture を == で見ていた箇所が原因で、デバッグ画面に 1,065,210,042 分という数字が出るところまで行きました。
この記事は、PollenShield の v2 (Visit/Region Monitoring ベース) の修正で踏んだ 3 つの根本原因のうち、== Date.distantFuture の厳密等価比較が壊れた話を中心に書きます。closed-wins upsert と record(visit:) の固定順序の話は、その地続きとして補助的に絡めます。読者像は「Swift / iOS の Visit Monitoring を実機で運用したことがある人」「sentinel 値で if 文を書いている人」です。
この記事で扱うこと
CLVisit.departureDate == Date.distantFutureで「滞在中か」を判定したら何が起きたか- ストレージ往復を経た sentinel 値が「ほぼ distantFuture だが等しくない」値になる現象
- 厳密等価をやめて「10 年以上先なら sentinel」と扱う閾値ベース判定への置換
- ついでに必要だった、closed-wins upsert と
record(visit:)の固定順序設計
「設定ゼロ UX」の話と App Store 申請の話は別記事で書ききったので、ここでは触れません。Date.distantFuture を sentinel として扱うときに、どこで足を滑らせるかだけに絞ります。
始まりは「滞在中の home 候補が 2,025 年いる」というデバッグ画面
PollenShield には、開発ビルド限定の「学習状態デバッグ」画面があります。Visit Monitoring が拾った滞在履歴と、そこから計算した home 候補のクラスタが見られる画面で、自分の通勤を 1〜2 週間ドッグフードしながら毎日眺めるのに使っています。
ある朝、その画面の Home Candidates セクションの 1 行がこうなっていました。
nights: 5 stay: 1065210042 min p90: 3m n=5
5 夜分のサンプルが集まっていて、クラスタの 90% タイル半径は 3m と十分絞れている。問題は stay。1,065,210,042 分 = 約 2,025 年(家を出ない人類の限界を軽く超えている)。明らかにどこかで Date.distantFuture 近傍の値が秒として加算され、それが分換算で出ている。これが Issue の出発点でした。
同じデバッグ画面の Visit リストには、別の不整合も並んでいました。
- 同一
arrivalDateの visit が closed と open のペアで並ぶ(同じ到着時刻・近接座標) - 複数の「滞在中」visit が同時に残存する(07:44 出発 → 07:58 帰宅のはずなのに、両方が「滞在中」のまま)
- 上記の
stay異常値
数字 1 件だけならログの読み間違いで済むのですが、3 つ揃って出ていたので、これは設計レベルでミスっているなと観念しました(観念したところで直さないといけないのは結局自分なんですが)。
根本原因は 3 つ独立していた
Apple の docs と CoreLocation の運用記事を読み直しながら、原因を 3 つに分けて並べました。記事のコアはここの R3 ですが、まず全景。
| ID | 何が原因か | 何が壊れていたか |
|---|---|---|
| R1 | record(visit:) が単純 append で upsert していなかった | 同一 arrivalDate の visit が 2 行に増殖 |
| R2 | 新しい visit が来ても、先行する open を close していなかった | 「滞在中」visit が 2 つ以上同時に残る |
| R3 | == Date.distantFuture の厳密等価で「滞在中」を判定していた | sentinel 近傍値が滑り込み、stay が 2,025 年分になる |
R1 と R2 が「同じ到着時刻で 2 行」「滞在中が複数」を作っていて、R3 が「2,025 年滞在」を作っていた、という分担です。3 つは独立していて、片方を直しても他方は残ります。
このあとは R3 を中心に書きます。R1/R2 は最後に簡単に触れます。
R3: なぜ == Date.distantFuture を信じてはいけないか
CLVisit は 滞在の到着時と離脱時の 2 段階で配信される API です。Apple のドキュメントにこう書いてあります。
The departure date is equal to
[NSDate distantFuture]if the device hasn't yet left the location.
つまり「まだ離脱していない visit」は、departureDate に Date.distantFuture を入れた状態で渡される。離脱が確定すると、同じ arrivalDate で departureDate が実日時に書き換わった visit が、もう一度配信される。
最初に書いていたのは、ほぼ docs そのままの判定でした。
// 最初の素朴な実装(後で壊れる)
struct VisitEvent {
let arrivalDate: Date
let departureDate: Date
var isOpen: Bool {
departureDate == Date.distantFuture
}
}
これで 1〜2 日くらいは何の問題もなく動きます。Visit Monitoring が拾った値をその場で isOpen 判定する分には、Date.distantFuture は == で素直に拾えるからです(ここで安心するな、というのが今回の教訓なんですが)。
問題が出るのは、この値を 一度ストレージに書いて、あとから読み直したときでした。じゃあ Codable で round-trip した Date.distantFuture は本当に同値で返ってくるのか、と問われると、実機で見ると正直どうなの?という挙動です。PollenShield では VisitEvent を Codable で UserDefaults に永続化しています(home 学習は数日〜1 週間スパンで動くため、メモリ保持だけでは足りない)。値型としてはこういう感じ。
struct VisitEvent: Codable, Equatable {
let lat: Double
let lng: Double
let arrivalDate: Date
let departureDate: Date
let horizontalAccuracy: Double
}
Swift の Date は内部的に timeIntervalSinceReferenceDate を Double で持っていて、Date.distantFuture はその値が 63113904000.0(≒ 西暦 4001 年)。Codable で encode → decode しただけなら同値が保たれるはずなんですが、実機で動かしていると、デバッグ画面の tIsRD=... 表示にこういう値が時々混ざっていました。
arrival=2026-04-23 19:11:42 departure=2026-04-23 19:11:43 (tIsRD=63113903999.999...)
Date.distantFuture ではなく、その 1 秒未満の近傍。Date.distantFuture.addingTimeInterval(-1) のような値が、現実に届くケースがある。docs の [NSDate distantFuture] という記述は「ぴったりこの値を入れる」と読みたくなるんですが、実機は「だいたいこの値」を返してくる、わけです(だいたい、で書いてくれていれば誰も == を書かなかった)。これは Apple のドキュメントでも一応認められていて、CoreLocation の運用記事でも「visit の dates は invalid data を含み得るので、信頼しすぎないこと」と書かれています。
== で見ていると、この ほぼ distantFuture が false 側に流れ込む。すると判定はこうなります。
departureDate | == Date.distantFuture の結果 | アプリの解釈 |
|---|---|---|
Date.distantFuture ぴったり | true | 滞在中(正しい) |
Date.distantFuture - 1s | false | 「離脱済み」 |
| 2026-04-24 07:44 | false | 離脱済み(正しい) |
問題は 2 行目です。「離脱済み」と解釈されたまま stayDurationSeconds = departureDate.timeIntervalSince(arrivalDate) を計算すると、arrivalDate は普通の現在時刻、departureDate は西暦 4001 年付近(≒ 約 2,000 年先)の値なので、引き算の結果が 63,912,602,520 秒程度になります。これを秒→分換算すると約 1,065,210,042 分。デバッグ画面に出ていた数字とぴったり一致しました(つまり原因はここで確定)。
つまり == Date.distantFuture で sentinel を判定するのは、「ストレージ往復や型変換の過程で 1 ナノ秒の誤差も入らない」という強すぎる前提に乗っている設計でした。docs どおりに書いていても、実機では成立しない。Swift の Date は == で見れば true か false の二択しか返さないんですが、その二択を信じた瞬間に 西暦 4001 年が滑り込んでくるわけです。
直し方は「等価」をやめて「閾値」に切り替えること
設計判断はシンプルで、ひとことで言うと 10 年以上先の departure は全部 sentinel として扱う。実ユースケースで「10 年連続で 1 件の visit に滞在し続ける」状況は存在しないので、Date.distantFuture ぴったりも、その近傍値も、CoreLocation がたまに渡してくる「妙に遠い未来」も、全部まとめて open として吸収できる。
struct VisitEvent: Codable, Equatable {
let lat: Double
let lng: Double
let arrivalDate: Date
let departureDate: Date
let horizontalAccuracy: Double
/// 滞在継続中(CLVisit.departureDate が Date.distantFuture 付近)かどうか。
/// `==` ではなく「10 年以上先なら sentinel」とみなす閾値判定にすることで、
/// distantFuture 近傍値や遠未来値を全てまとめて吸収する。
var isOpen: Bool {
departureDate.timeIntervalSinceNow > 10 * 365 * 86400
}
/// 滞在時間(秒)。isOpen のときは 0 を返す。
var stayDurationSeconds: TimeInterval {
guard departureDate > arrivalDate else { return 0 }
if isOpen { return 0 }
return departureDate.timeIntervalSince(arrivalDate)
}
}
ポイントを並べます。
isOpenは==を使わない。departureDate.timeIntervalSinceNow > 10 * 365 * 86400で「10 年以上先かどうか」を見るだけ。Date.distantFutureもDate.distantFuture - 1sも、そろってtrue側に落ちる。stayDurationSecondsをisOpenベースに書き換える。isOpenがtrueなら問答無用で 0 を返す。これで「sentinel 近傍値が引き算されて 2,025 年分の秒数を返す」事故は構造的に起こらなくなる。- 10 年以上先の
10 * 365 * 86400は意図的にざっくり。閏年補正もしていません。境界値の数日ズレは sentinel 判定にとって無関係なので、「実用上 10 年」を意味するマジックナンバーで十分。
書いてみると当たり前の話に見えるんですが、Date.distantFuture == something という Apple の docs サンプルどおりの構文に最初に引っ張られると、なかなかこちら側に来ません(自分は来なかった)。「等価で見るな、幅で見ろ」と言葉にしてしまえば 1 行ですが、最初は == で書いて、実機で 2,025 年が出てから初めて辿り着くたぐいの結論なのよね、というのが正直なところ。Swift で sentinel を厳密等価で見るパターンを書いている人は、ストレージ往復や Codable round-trip が挟まる経路を必ず疑った方がいいと思います。
テストで境界値を 11 件並べる
修正したあと、isOpen の境界が壊れていないことを保証するために、VisitEventTests を新規で 11 件書きました。distantFuture ぴったり / distantFuture - 1s / 11 年先 / 9 年先 / 通常の closed / stay の 0 返し、あたりを並べて、isOpen の閾値が動いたら即落ちる状態にしてあります。
代表的な 4 件を貼ります。
final class VisitEventTests: XCTestCase {
/// departureDate が Date.distantFuture なら open
func test_isOpen_distantFutureExact() {
let event = makeEvent(arrival: Date(), departure: .distantFuture)
XCTAssertTrue(event.isOpen)
}
/// distantFuture の直前 (1 秒前) でも open 扱い
func test_isOpen_distantFutureMinusOneSecond() {
let near = Date.distantFuture.addingTimeInterval(-1)
let event = makeEvent(arrival: Date(), departure: near)
XCTAssertTrue(event.isOpen, "distantFuture - 1s は実質 sentinel として open 扱い")
}
/// 11 年先(>10 年閾値)は open
func test_isOpen_elevenYearsFuture() {
let eleven = Date().addingTimeInterval(11 * 365 * 86400)
let event = makeEvent(arrival: Date(), departure: eleven)
XCTAssertTrue(event.isOpen, "10 年以上先は sentinel と判定")
}
/// distantFuture departure では stay は 0
func test_stayDurationSeconds_isZero_whenOpen() {
let event = makeEvent(arrival: Date(), departure: .distantFuture)
XCTAssertEqual(event.stayDurationSeconds, 0)
}
}
11 件全部はここには貼らないですが、9 年先(閾値未満)が isOpen == false になることや、departureDate < arrivalDate のような壊れた値で stayDurationSeconds == 0 を返すことも入れています。境界値を 1 つ書いたら、その反対側を必ず書く、を徹底しただけ。テストケース 11 件、と言うとそれっぽいですが、要は「西暦 4001 年問題が二度と CI を通らない」ためのお守りです。
補助線: closed-wins upsert と固定順序(R1/R2)
isOpen を直したあと、それでもまだ Visit リストに「同一 arrivalDate の closed/open ペア」と「滞在中が複数」が出るケースが残っていました。これが R1 と R2 で、record(visit:) の構造そのものに原因があります。
R1 は単純 append。record(visit:) が history.append(incoming) だけを呼んでいたので、CoreLocation が同じ visit を arrival 段階と departure 段階の 2 回配信してくると、配列に 2 件入る。R2 はその副作用で、新しい visit が届いた時に「先行している open」を誰も close しないので、open が積み上がる。
修正は 2 段。1 つは closed-wins upsert、もう 1 つは record(visit:) の固定順序。
closed-wins upsert はこういう挙動です。
/// history に incoming を upsert する。既存 closed を open で上書きしない (closed-wins)。
private func upsertClosedWins(history: inout [VisitEvent], incoming: VisitEvent) -> Int {
if let existingIndex = history.firstIndex(where: { $0.arrivalDate == incoming.arrivalDate }) {
let existing = history[existingIndex]
if !existing.isOpen && incoming.isOpen {
// 既に closed 済みのレコードに、後から arrival 再配信 (open) が来た場合は無視。
return existingIndex
}
history[existingIndex] = incoming
return existingIndex
}
history.append(incoming)
return history.count - 1
}
要点は closed → open に巻き戻すパターンだけ拒否すること。既に離脱が確定して closed になっている visit に対して、iOS が遅れて arrival イベント(open)を再配信してきた場合、それを採用すると「2,025 年滞在」が復活してしまう。なので closed が勝つ。それ以外(open → closed の確定、open → open の同値再配信)は普通に上書きします。
固定順序は、record(visit:) を毎回同じステップで処理させる設計です。
- accuracy フィルタ
- history に upsert(closed-wins)
- 先行する open を、
incoming.arrivalDateで close する maxVisitEventHistoryを超えたら古い方からトリム- ストレージに永続化
guard !visit.isOpenで、open(= arrival 再配信)はここで return- relocation / clustering / home 確定 / visit departure 評価
ここで重要なのは 6 番の guard !visit.isOpen。open visit は home 確定や departure 評価に進ませない。これが入っていないと、arrival 段階の visit がクラスタリング処理に流れ込んで、stayDurationSeconds == 0 の状態でも別の経路で home 候補に乱入する余地が残ります。
固定順序にすることで、CoreLocation の再配信タイミングがどうなろうが、record(visit:) の中の動作は決定的になります。テストもこの順序を前提にして書けるので、CI で守れる。
まとめ — sentinel は「等価で見る値」ではなく「閾値で見る幅」だった
整理しておきます。
Date.distantFutureを==で見るな。Codable round-trip や CoreLocation の実機挙動で、近傍値(distantFuture - 1s等)が普通に混ざってくる。- sentinel は「閾値」で扱う。
timeIntervalSinceNow > 10 * 365 * 86400のように「実用上ありえないほど遠い未来かどうか」で判定する。境界値テストを反対側まで両方書く。 - 派生計算(
stayDurationSeconds等)は sentinel 判定の上に組む。isOpenがtrueなら必ず 0 を返す、のように、異常値が下流に漏れない設計にする。 - CoreLocation の Visit Monitoring は再配信前提。closed-wins upsert と
record(visit:)の固定順序で、配信タイミングの揺れに耐える。
stay = 1,065,210,042 min という極端な数字は、結局のところ Apple のサンプルどおりに == を書いただけで出てきました。本当に怖いのは、これがデバッグ画面の片隅に出るだけで済んだ今回ではなく、ユーザーの home クラスタ判定や通知タイミングの計算に紛れ込むケースです。今回はデバッグ画面に派手に 2,025 年と出てくれたから気づけたので、ありがたい裏切られ方ではありました(無言で通知時刻だけ 2,025 年ズレていたら、たぶんレビューで星 1 を 2,025 個もらっていた)。
CoreLocation を実機で運用するなら、Date.distantFuture を見ている箇所は全部「閾値判定」に置き換えておくと、後で痛い目に遭わずに済みます。PollenShield (LP / App Store) は今回の修正を含めた v1.1 として配信中です。Visit Monitoring を実機で動かしている人で、似たような数字を見た人は気軽に話しかけてください。
合わせて読みたい:
- 設定画面ゼロの花粉アプリを作るのに、裏で決めないといけなかった閾値の話 — 自宅判定・出発判定・引っ越し判定の閾値設計
- 位置情報常時取得の花粉アプリを App Store に通すまでに、やっておけばよかった申請まわりの作業メモ — 同じ PollenShield の申請プロセスの話


