時計が巻き戻っても、過去の画面に戻さないスケジューラ
時計どおりに動かすだけでは足りない
店頭のサイネージを、10時に「営業中」、18時に「本日は終了しました」へ切り替えるとします。
単純に現在時刻だけから表示内容を決めると、時計が18時から15時へ戻ったとき、画面も「営業中」に戻ってしまいます。NTPの補正やRTCの異常を考えると、これは机上の話ではありません。
Glyphaでは、次の方針にしました。
時刻は戻ってもよい。ただし、システムの履歴は戻さない。
「最後に消化した時刻」を保存する
Glyphaのスケジューラは、現在時刻だけでなく、どこまで切り替えを消化したかを示すカーソルを持ちます。
たとえば、日次スケジュールが次の内容だとします。
10:00 → open
13:00 → afternoon
18:00 → closed
14時に問い合わせが来たら、10時の画面を経由せず、13時の afternoon を直接選びます。カーソルも13時まで進めます。
その後、時計が11時へ戻っても、カーソルより前の切り替えはすでに消化済みです。open は再生せず、afternoon を表示し続けます。
Goの実装では、半開区間 (cursor, now] にある最新の切り替えだけを返します。
func (d *Daily) LatestBetween(cursor, now int64) (Occurrence, bool, error) {
if now <= cursor {
return Occurrence{}, false, nil
}
o, err := d.ApplicableAt(now)
if err != nil {
return Occurrence{}, false, err
}
if o.Second <= cursor {
return Occurrence{}, false, nil
}
return o, true, nil
}
ポイントは、now <= cursor なら何もしないことです。時計が戻っても、消化済みの履歴は巻き戻りません。
また、現在時刻に適用される場面を直接計算するため、通信が途切れて何時間経過しても、中間の画面を順番に再生する必要はありません。
再起動しても履歴を失わない
カーソルがメモリにしかなければ、再起動時に過去の画面が復活します。
そこでGlyphaは、カーソルと現在の表示対象をSQLiteへ同じトランザクションで保存し、コミットに成功してから応答します。
カーソルと表示対象を保存
↓
コミット成功
↓
画面へ応答
保存に失敗した場合は、曖昧な状態で処理を続けず、サーバーを失敗状態にします。画面側は、その間も最後に正常表示できた内容を出し続けます。
代わりに失うものもある
この方式にも弱点があります。
時計が誤って翌年へ飛び、そこでカーソルを進めてから正しい日時へ戻ると、現在時刻がカーソルへ追いつくまで切り替わりません。Glyphaでは、新しいコンテンツを公開してカーソルを初期化する運用で復旧します。
「過去を再生しないこと」と「大きな時計異常から自動復旧すること」は、単純には両立しません。Glyphaはサイネージの用途を考え、前者を選びました。
まとめ
時計に従って表示を切り替えるシステムでも、判断材料を現在時刻だけにすると、時計の巻き戻しで過去を再生してしまいます。
Glyphaでは、次の3点でこれを防いでいます。
- 切り替え時刻との完全一致ではなく、未消化のうち最新の場面を選ぶ
- 消化済み範囲をカーソルで表し、一方向にだけ進める
- カーソルと表示対象を永続化してから応答する
時刻は信用できなくても、履歴を単調に進めることはできます。
スケジューラを設計するときは、「何時なら何を出すか」だけでなく、時計が戻ったときに過去を再生するのかを先に決めておくと、実装の方針が明確になります。