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
|
||||
}
|
||||
}
|
||||
+65
@@ -222,4 +222,69 @@ class StopwatchRepositoryTest {
|
||||
)
|
||||
assertThat(repository.laps().first().map { it.index }).containsExactly(1)
|
||||
}
|
||||
|
||||
// --- M3: the post-reboot repair ---
|
||||
|
||||
@Test
|
||||
fun `pausing after a reboot banks what was accumulated and invents no segment`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val repository = repository(tempDir)
|
||||
repository.start()
|
||||
clock.reboot(20.seconds)
|
||||
|
||||
repository.pauseAfterReboot()
|
||||
|
||||
val run = repository.run().first()
|
||||
assertThat(run.state).isEqualTo(StopwatchState.PAUSED)
|
||||
assertThat(run.accumulated).isEqualTo(Duration.ZERO)
|
||||
assertThat(run.startedAtElapsedRealtime).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing after a reboot leaves a paused or idle run untouched`(@TempDir tempDir: Path) = runTest {
|
||||
val repository = repository(tempDir)
|
||||
repository.start()
|
||||
clock.value = 1_030.seconds
|
||||
repository.pause()
|
||||
val paused = repository.run().first()
|
||||
|
||||
repository.pauseAfterReboot()
|
||||
|
||||
assertThat(repository.run().first()).isEqualTo(paused)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an idle run is not written at all by the reboot repair`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("stopwatch_idle_test.preferences_pb").toFile() },
|
||||
)
|
||||
val repository = StopwatchRepositoryImpl(dao, StopwatchStateStore(PrefStore(dataStore)), clock)
|
||||
|
||||
repository.pauseAfterReboot()
|
||||
|
||||
assertThat(dataStore.data.first().asMap()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the record written by the reboot repair still holds no wall-clock value`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val dataStore = PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("stopwatch_keys_test.preferences_pb").toFile() },
|
||||
)
|
||||
val repository = StopwatchRepositoryImpl(dao, StopwatchStateStore(PrefStore(dataStore)), clock)
|
||||
repository.start()
|
||||
clock.reboot(20.seconds)
|
||||
|
||||
repository.pauseAfterReboot()
|
||||
|
||||
val snapshot = dataStore.data.first().asMap()
|
||||
assertThat(snapshot.values.filterIsInstance<Long>().filter { it >= 1_000_000_000_000L }).isEmpty()
|
||||
assertThat(snapshot.keys.map { it.name }).containsExactly(
|
||||
"stopwatch_state",
|
||||
"stopwatch_accumulated_millis",
|
||||
"stopwatch_last_lap_cumulative_millis",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,4 +299,92 @@ class TimerRepositoryTest {
|
||||
assertThat(repository.timers().first()).isEqualTo(before)
|
||||
assertThat(repository.find(999L)).isNull()
|
||||
}
|
||||
|
||||
// --- M3: the post-reboot repair ---
|
||||
|
||||
@Test
|
||||
fun `the repair re-anchors a running timer from its wall-clock fallback`() = runTest {
|
||||
val id = startedTimer()
|
||||
elapsed.reboot(50.seconds)
|
||||
wallClock.advance(2.minutes)
|
||||
|
||||
repository.repairAfterReboot()
|
||||
|
||||
val timer = repository.find(id)!!
|
||||
assertThat(timer.state).isEqualTo(TimerState.RUNNING)
|
||||
assertThat(timer.remaining).isEqualTo(3.minutes)
|
||||
assertThat(timer.endsAtElapsedRealtime).isEqualTo(50.seconds + 3.minutes)
|
||||
assertThat(timer.startedAtElapsedRealtime).isEqualTo(50.seconds)
|
||||
assertThat(timer.endsAtWallClock).isEqualTo(T0 + 5.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running timer whose wall-clock end has passed is expired by the repair`() = runTest {
|
||||
val id = startedTimer()
|
||||
elapsed.reboot(50.seconds)
|
||||
wallClock.advance(10.minutes)
|
||||
|
||||
repository.repairAfterReboot()
|
||||
|
||||
val timer = repository.find(id)!!
|
||||
assertThat(timer.state).isEqualTo(TimerState.EXPIRED)
|
||||
assertThat(timer.remaining).isEqualTo(Duration.ZERO)
|
||||
assertThat(
|
||||
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
|
||||
).containsExactly(null, null, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the repair leaves every timer that was not running exactly as it was`() = runTest {
|
||||
val paused = repository.create(TimerDraft(5.minutes, "paused"))
|
||||
repository.start(paused)
|
||||
elapsed.value = 1_100.seconds
|
||||
repository.pause(paused)
|
||||
repository.create(TimerDraft(3.minutes, "idle"))
|
||||
val expired = repository.create(TimerDraft(1.minutes, "expired"))
|
||||
repository.markExpired(expired)
|
||||
elapsed.reboot(50.seconds)
|
||||
wallClock.advance(2.minutes)
|
||||
val before = dao.stored
|
||||
|
||||
repository.repairAfterReboot()
|
||||
|
||||
assertThat(dao.stored).isEqualTo(before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running timer with no wall-clock fallback is paused at what it banked`() = runTest {
|
||||
val id = startedTimer()
|
||||
dao.update(dao.findById(id)!!.copy(endsAtWallClockMillis = null))
|
||||
elapsed.reboot(50.seconds)
|
||||
|
||||
repository.repairAfterReboot()
|
||||
|
||||
val timer = repository.find(id)!!
|
||||
assertThat(timer.state).isEqualTo(TimerState.PAUSED)
|
||||
assertThat(timer.remaining).isAtLeast(Duration.ZERO)
|
||||
assertThat(timer.remaining).isEqualTo(5.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the repair closes the blind spot a reboot opens under a running timer`() = runTest {
|
||||
val id = repository.create(TimerDraft(2.hours, "Roast"))
|
||||
repository.start(id)
|
||||
|
||||
// A new boot whose uptime has already climbed past the stored start anchor,
|
||||
// and half an hour of wall-clock time gone with it.
|
||||
elapsed.reboot(5_000.seconds)
|
||||
wallClock.advance(30.minutes)
|
||||
|
||||
val beforeRepair = repository.find(id)!!.snapshotAt(elapsed.elapsedRealtime(), wallClock.now())
|
||||
assertThat(beforeRepair.isRunning).isTrue()
|
||||
assertThat(beforeRepair.anchorIsStale).isFalse()
|
||||
assertThat(beforeRepair.remaining).isNotEqualTo(90.minutes)
|
||||
|
||||
repository.repairAfterReboot()
|
||||
|
||||
val repaired = repository.find(id)!!.snapshotAt(elapsed.elapsedRealtime(), wallClock.now())
|
||||
assertThat(repaired.remaining - 90.minutes).isLessThan(1.seconds)
|
||||
assertThat(90.minutes - repaired.remaining).isLessThan(1.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.clockula.system
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SystemPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.time.BootId
|
||||
import de.jeanlucmakiola.clockula.testing.FakeBootIdProvider
|
||||
import de.jeanlucmakiola.clockula.testing.RecordingStopwatchRepository
|
||||
import de.jeanlucmakiola.clockula.testing.RecordingTimerRepository
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.File
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/**
|
||||
* The gate that runs the reboot repair exactly once per boot, over a real
|
||||
* DataStore file so "survives process death" is a real assertion.
|
||||
*/
|
||||
class RebootRepairTest {
|
||||
|
||||
private val bootIds = FakeBootIdProvider(BootId(bootCount = 7, approximateBootInstant = T0))
|
||||
private val timers = RecordingTimerRepository()
|
||||
private val stopwatch = RecordingStopwatchRepository()
|
||||
|
||||
private fun TestScope.bootStateStore(file: File, job: Job = Job()) =
|
||||
BootStateStore(PrefStore(prefStore(file, job)))
|
||||
|
||||
private fun TestScope.prefStore(file: File, job: Job) = PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + job),
|
||||
produceFile = { file },
|
||||
)
|
||||
|
||||
private fun repair(bootState: BootStateStore) = RebootRepair(bootIds, bootState, timers, stopwatch)
|
||||
|
||||
private fun Path.prefsFile(): File = resolve("clockula_prefs_test.preferences_pb").toFile()
|
||||
|
||||
@Test
|
||||
fun `the very first run ever counts as a new boot and repairs`(@TempDir dir: Path) = runTest {
|
||||
val bootState = bootStateStore(dir.prefsFile())
|
||||
|
||||
val repaired = repair(bootState).repairIfRebooted()
|
||||
|
||||
assertThat(repaired).isTrue()
|
||||
assertThat(timers.repairCalls).isEqualTo(1)
|
||||
assertThat(stopwatch.pauseCalls).isEqualTo(1)
|
||||
assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same boot repairs nothing`(@TempDir dir: Path) = runTest {
|
||||
val bootState = bootStateStore(dir.prefsFile())
|
||||
bootState.setLastBootId(bootIds.bootId)
|
||||
|
||||
val repaired = repair(bootState).repairIfRebooted()
|
||||
|
||||
assertThat(repaired).isFalse()
|
||||
assertThat(timers.repairCalls).isEqualTo(0)
|
||||
assertThat(stopwatch.pauseCalls).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different boot repairs and stores the new id`(@TempDir dir: Path) = runTest {
|
||||
val bootState = bootStateStore(dir.prefsFile())
|
||||
bootState.setLastBootId(BootId(bootCount = 6, approximateBootInstant = T0 - 5.hours))
|
||||
|
||||
val repaired = repair(bootState).repairIfRebooted()
|
||||
|
||||
assertThat(repaired).isTrue()
|
||||
assertThat(timers.repairCalls).isEqualTo(1)
|
||||
assertThat(stopwatch.pauseCalls).isEqualTo(1)
|
||||
assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calling twice in one boot repairs once`(@TempDir dir: Path) = runTest {
|
||||
val repair = repair(bootStateStore(dir.prefsFile()))
|
||||
|
||||
val first = repair.repairIfRebooted()
|
||||
val second = repair.repairIfRebooted()
|
||||
|
||||
assertThat(listOf(first, second)).containsExactly(true, false).inOrder()
|
||||
assertThat(timers.repairCalls).isEqualTo(1)
|
||||
assertThat(stopwatch.pauseCalls).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed stored id reads as no known boot and is replaced`(@TempDir dir: Path) = runTest {
|
||||
val file = dir.prefsFile()
|
||||
val store = PrefStore(prefStore(file, Job()))
|
||||
store.set(SystemPrefs.lastBootId, "not-a-boot-id")
|
||||
val bootState = BootStateStore(store)
|
||||
|
||||
val repaired = repair(bootState).repairIfRebooted()
|
||||
|
||||
assertThat(repaired).isTrue()
|
||||
assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the gate survives process death, so the repair does not run twice per boot`(@TempDir dir: Path) =
|
||||
runTest {
|
||||
val file = dir.prefsFile()
|
||||
val firstProcess = Job()
|
||||
repair(bootStateStore(file, firstProcess)).repairIfRebooted()
|
||||
firstProcess.cancel()
|
||||
|
||||
val afterRestart = bootStateStore(file, Job())
|
||||
|
||||
assertThat(afterRestart.lastBootId()).isEqualTo(bootIds.bootId)
|
||||
assertThat(repair(afterRestart).repairIfRebooted()).isFalse()
|
||||
assertThat(timers.repairCalls).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user