home 候補リストの「現在の滞在時間」が 1,065,210,042 分 と表示されたのが、この記事のきっかけです。年に直すと約2,025年。iPhoneのオーナーは弥生時代からずっと自宅にいたことになります。
Date == Date.distantFuture は「等しいか否か」のはずですよね。そう思って書いていた箇所が、Codable round-trip と CoreLocation の実機配信を経由した瞬間に裏切ってきた、という話です。stay 値が西暦4001年付近の引き算結果として現れました。
自社の PollenShield は「いつ家にいて」「いつ出たか」を CoreLocation で検知するiOSアプリです。家を出る5分前に1通知だけ鳴らします(App Store 配信中)。
今回はその裏で踏んだ Swift の sentinel 値設計のミスの記録です。departureDate の Date.distantFuture を == で見ていた箇所が原因で、デバッグ画面に1,065,210,042分という数字が出ました。
PollenShield の v2 の修正で踏んだ3つの根本原因のうち、== Date.distantFuture の厳密等価比較が壊れた話を中心に書きます。
この記事で扱うこと
CLVisit.departureDate == Date.distantFutureで判定したら何が起きたか- ストレージ往復を経た sentinel 値が「ほぼ distantFuture だが等しくない」値になる現象
- 厳密等価をやめて「10年以上先なら sentinel」と扱う閾値ベース判定への置換
- closed-wins upsert と固定順序設計
Date.distantFuture を sentinel として扱う際にどこで足を滑らせるかに絞ります。
始まりは「滞在中の home 候補が 2,025 年いる」というデバッグ画面
PollenShield には開発ビルド限定の「学習状態デバッグ」画面があり、通勤を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 近傍の値が秒として加算され分換算で出ています。
同じデバッグ画面の Visit リストには、別の不整合も並んでいました。
- 同一
arrivalDateの visit が closed と open のペアで並ぶ - 複数の「滞在中」visit が同時に残存する(07:44出発→07:58帰宅のはずが両方「滞在中」)
- 上記の
stay異常値
数字1件ならログの読み間違いで済みますが、3つ揃って出ていたので設計ミスだと観念しました。
根本原因は 3 つ独立していた
原因を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 を中心に書きます。
R3: なぜ == Date.distantFuture を信じてはいけないか
CLVisit は到着時と離脱時の2段階で配信されるAPIです。ドキュメントにこう書いてあります。
The departure date is equal to
[NSDate distantFuture]if the device hasn't yet left the location.
つまり「まだ離脱していない visit」は departureDate に Date.distantFuture を入れて渡されます。離脱確定後、実日時に書き換わった visit がもう一度配信されます。
最初はほぼ docs そのままの判定でした。
// 最初の素朴な実装(後で壊れる)
struct VisitEvent {
let arrivalDate: Date
let departureDate: Date
var isOpen: Bool {
departureDate == Date.distantFuture
}
}
これで1〜2日くらいは何の問題もなく動きます。その場で判定する分には == で素直に拾えるからです。
問題は、この値を一度ストレージに書いて読み直したときでした。VisitEvent を Codable で UserDefaults に永続化しています。
struct VisitEvent: Codable, Equatable {
let lat: Double
let lng: Double
let arrivalDate: Date
let departureDate: Date
let horizontalAccuracy: Double
}
Date は内部的に timeIntervalSinceReferenceDate を Double で持ちます。Date.distantFuture は 63113904000.0(≒西暦4001年)。実機ではデバッグ画面に近傍値が時々混ざっていました。
arrival=2026-04-23 19:11:42 departure=2026-04-23 19:11:43 (tIsRD=63113903999.999...)
Date.distantFuture の1秒未満の近傍が現実に届くケースがあります。docs は「ぴったりこの値」と読みたくなりますが、実機は「だいたいこの値」を返します。
== で見ていると、この ほぼ distantFuture が false 側に流れ込む。すると判定はこうなります。
departureDate | == Date.distantFuture の結果 | アプリの解釈 |
|---|---|---|
Date.distantFuture ぴったり | true | 滞在中(正しい) |
Date.distantFuture - 1s | false | 「離脱済み」 |
| 2026-04-24 07:44 | false | 離脱済み(正しい) |
問題は2行目です。「離脱済み」のまま計算すると departureDate は西暦4001年付近(≒約2,000年先)です。引き算が63,912,602,520 秒程度になり、秒→分換算で約1,065,210,042分。デバッグ画面の数字とぴったり一致しました。
つまり == 判定は「1ナノ秒の誤差も入らない」という強すぎる前提でした。その二択を信じた瞬間に西暦4001年が滑り込んでくるわけです。
直し方は「等価」をやめて「閾値」に切り替えること
設計判断はシンプルで、10年以上先の departure は全部 sentinel として扱うというものです。10年連続で1件の visit に滞在し続ける状況は存在しません。Date.distantFuture もその近傍値も「妙に遠い未来」も全部まとめて 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)
}
}
ポイントは3つ。
isOpenは==を使わない。departureDate.timeIntervalSinceNow > 10 * 365 * 86400で「10年以上先か」を見るだけstayDurationSecondsをisOpenベースに書き換える。trueなら問答無用で0を返す10 * 365 * 86400は意図的にざっくり。境界値の数日ズレはsentinel判定に無関係
「等価で見るな、幅で見ろ」と言葉にすれば1行ですが、実機で2,025年が出てから初めて辿り着く結論です。
テストで境界値を 11 件並べる
境界が壊れないことを保証するため VisitEventTests を新規で11件書きました。代表的な 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)
}
}
9年先が isOpen == false になることや、壊れた値で0を返すことも入れています。境界値を1つ書いたら反対側も書くを徹底しました。
補助線: closed-wins upsert と固定順序(R1/R2)
isOpen を直したあとも、Visit リストに「closed/open ペア」「滞在中が複数」が残っていました。R1/R2、record(visit:) の構造に原因があります。
R1は単純append。同じvisitが2回配信されると2件入ります。R2はその副作用で先行openが積み上がります。
修正は2段。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 に遅れて open が再配信された場合、採用すると「2,025年滞在」が復活するため closed が勝ちます。
固定順序は、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 確定に進ませません。固定順序で再配信タイミングによらず動作が決定的になり、CIで守れます。
まとめ — sentinel は「等価で見る値」ではなく「閾値で見る幅」だった
Date.distantFutureを==で見るな。近傍値(distantFuture - 1s等)が普通に混ざってくる- sentinel は「閾値」で扱う。「実用上ありえないほど遠い未来か」で判定し、境界値テストを反対側まで両方書く
- 派生計算は sentinel 判定の上に組む。
isOpenがtrueなら必ず0を返す設計にする - Visit Monitoring は再配信前提。closed-wins upsert と固定順序で配信タイミングの揺れに耐える
stay = 1,065,210,042 min という極端な数字は、結局Apple のサンプルどおりに == を書いただけで出てきました。本当に怖いのは、home クラスタ判定に紛れ込むケースです。
Date.distantFuture を見ている箇所は全部「閾値判定」に置き換えると後で痛い目に遭わずに済みます。PollenShield (LP / App Store) は今回の修正を含めたv1.1として配信中です。
合わせて読みたい:



