feat(system): repair running timers and the stopwatch after a reboot
Closes M2's known blind spot. On the first pass of a new boot, every RUNNING timer has its monotonic anchor rewritten from its wall-clock fallback — one already past its end becomes EXPIRED, one with no fallback becomes PAUSED at its banked remaining. The stopwatch keeps no wall-clock fallback at all, so it is paused at what it had banked rather than being allowed to invent a segment it never ran. The gate is persisted, so the repair runs once per boot and survives a process death in between. ARCHITECTURE §5 booked the stopwatch half of this to M7; it belongs here with the timer half, and the roadmap is amended to say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,4 +19,7 @@ interface StopwatchRepository {
|
||||
|
||||
/** Marks a lap at the current elapsed value. Returns null unless the stopwatch is RUNNING. */
|
||||
suspend fun lap(): Lap?
|
||||
|
||||
/** PAUSED at the banked `accumulated`, discarding the segment the reboot lost. No-op otherwise. */
|
||||
suspend fun pauseAfterReboot()
|
||||
}
|
||||
|
||||
+13
@@ -79,4 +79,17 @@ class StopwatchRepositoryImpl @Inject constructor(
|
||||
stateStore.set(run.copy(lastLapCumulative = cumulative))
|
||||
return LapMapper.toDomain(entity)
|
||||
}
|
||||
|
||||
/**
|
||||
* The segment since [StopwatchRun.startedAtElapsedRealtime] ran on a clock the
|
||||
* reboot reset, so it cannot be measured — and the stopwatch has no wall-clock
|
||||
* fallback by design. Banking what was already accumulated loses the segment
|
||||
* honestly; carrying the anchor over would invent one.
|
||||
*/
|
||||
override suspend fun pauseAfterReboot() {
|
||||
val run = stateStore.current()
|
||||
if (run.state != StopwatchState.RUNNING) return
|
||||
|
||||
stateStore.set(run.copy(state = StopwatchState.PAUSED, startedAtElapsedRealtime = null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ abstract class TimerDao {
|
||||
@Query("SELECT * FROM timers WHERE id = :id LIMIT 1")
|
||||
abstract suspend fun findById(id: Long): TimerEntity?
|
||||
|
||||
@Query("SELECT * FROM timers WHERE state = 'RUNNING'")
|
||||
abstract suspend fun running(): List<TimerEntity>
|
||||
|
||||
@Query("SELECT MAX(sort_order) FROM timers")
|
||||
abstract suspend fun maxSortOrder(): Int?
|
||||
|
||||
|
||||
@@ -27,4 +27,13 @@ interface TimerRepository {
|
||||
suspend fun markExpired(id: Long)
|
||||
|
||||
suspend fun reorder(idsInOrder: List<Long>)
|
||||
|
||||
/**
|
||||
* Rewrites every RUNNING timer's monotonic anchors from its wall-clock
|
||||
* fallback. Call once at the top of a new boot, before the new uptime can
|
||||
* climb past a stored anchor. A timer already past its wall-clock end
|
||||
* becomes EXPIRED; one with no wall-clock fallback becomes PAUSED at its
|
||||
* banked remaining.
|
||||
*/
|
||||
suspend fun repairAfterReboot()
|
||||
}
|
||||
|
||||
@@ -100,6 +100,32 @@ class TimerRepositoryImpl @Inject constructor(
|
||||
dao.reorder(idsInOrder.filter { it in known })
|
||||
}
|
||||
|
||||
/**
|
||||
* A reboot invalidates every monotonic anchor, and the new boot's uptime
|
||||
* eventually climbs past the stored one — at which point a dead anchor looks
|
||||
* live. The wall-clock end is the only thing a reboot leaves standing, so
|
||||
* every RUNNING timer is re-anchored from it.
|
||||
*/
|
||||
override suspend fun repairAfterReboot() {
|
||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||
val wall = wallClock.now()
|
||||
for (entity in dao.running()) {
|
||||
edit(entity.id, wall) { timer ->
|
||||
val endsAt = timer.endsAtWallClock
|
||||
// No fallback to re-anchor from: bank what it had rather than
|
||||
// invent a remainder.
|
||||
?: return@edit timer.copy(state = TimerState.PAUSED).cleared()
|
||||
|
||||
val left = endsAt - wall
|
||||
if (left <= Duration.ZERO) {
|
||||
timer.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO).cleared()
|
||||
} else {
|
||||
timer.anchored(left, now, wall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row is read, transformed and written back in one DAO transaction, so a
|
||||
* second writer racing this one cannot have its write swallowed whole. A
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.clockula.system
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The boot-id gate. Once per boot it re-anchors running timers and pauses the
|
||||
* stopwatch, both of which hold monotonic anchors a reboot invalidated. The gate
|
||||
* is persisted rather than held in memory, so a process death inside a boot does
|
||||
* not make the repair run twice — and a `BOOT_COMPLETED` the system never
|
||||
* delivered still gets repaired at the next launch.
|
||||
*/
|
||||
@Singleton
|
||||
open class RebootRepair @Inject constructor(
|
||||
private val bootIds: BootIdProvider,
|
||||
private val bootState: BootStateStore,
|
||||
private val timers: TimerRepository,
|
||||
private val stopwatch: StopwatchRepository,
|
||||
) {
|
||||
/**
|
||||
* True when this is the first call of a new boot (including the very first
|
||||
* run ever, where there is no stored id). Idempotent within a boot.
|
||||
*/
|
||||
open suspend fun repairIfRebooted(): Boolean {
|
||||
val current = bootIds.current()
|
||||
val last = bootState.lastBootId()
|
||||
if (last != null && last.isSameBootAs(current)) return false
|
||||
|
||||
timers.repairAfterReboot()
|
||||
stopwatch.pauseAfterReboot()
|
||||
bootState.setLastBootId(current)
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user