fix(restore): retry a failed restore-list save on the next tick

If forget() could not save after a successful restore, the file kept the
done record and a restart would reload it. The failed save is now
remembered and retried each tick until it goes through.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-24 05:15:43 +02:00
1 parent b06dde4587
commit 7263c9e3c3
2 files changed
+43 -1

No files matched your search

@@ -53,6 +53,7 @@ public class CommandService implements Commands {
private final Map<Integer, Restore> restores = new LinkedHashMap<>();
private final Map<Integer, Long> lastWrite = new HashMap<>();
private final Map<Integer, Backoff> backoffs = new HashMap<>();
private boolean unsaved;
private record Backoff(int failures, long retryAtNanos) {
}
@@ -171,6 +172,9 @@ public class CommandService implements Commands {
public void runDueRestores() {
lock.lock();
try {
if (unsaved) {
persist();
}
var now = clock.instant();
restores.values().stream()
.filter(restore -> !restore.due().isAfter(now))
@@ -286,6 +290,7 @@ public class CommandService implements Commands {
restores.put(restore.register(), restore);
try {
store.save(List.copyOf(restores.values()));
unsaved = false;
backoffs.remove(restore.register());
} catch (IOException e) {
if (previous == null) {
@@ -303,11 +308,14 @@ public class CommandService implements Commands {
persist();
}
/** A failed save is retried on the next tick: a stale file would bring a done restore back after a restart. */
private void persist() {
try {
store.save(List.copyOf(restores.values()));
unsaved = false;
} catch (IOException e) {
LOGGER.error("could not save the restore list: {}", e.getMessage());
unsaved = true;
LOGGER.error("could not save the restore list, will retry: {}", e.getMessage());
}
}
@@ -645,6 +645,40 @@ class CommandServiceTest {
assertEquals(48, service.pendingRestores().getFirst().appliedValue());
}
@Test
void aFailedSaveAfterARestoreIsRetriedOnTheNextTick() throws IOException {
var service = boosted();
clock.advance(HOUR);
storeFails = true;
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
assertEquals(1, stored.size());
service.runDueRestores();
assertEquals(1, stored.size());
storeFails = false;
service.runDueRestores();
assertTrue(stored.isEmpty());
}
@Test
void aFailedSaveAfterALastingCommandIsRetriedOnTheNextTick() throws IOException {
var service = boosted();
clock.advance(Duration.ofMinutes(2));
storeFails = true;
assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 40, null)).status());
assertEquals(1, stored.size());
storeFails = false;
service.runDueRestores();
assertTrue(stored.isEmpty());
}
private static final class MutableClock extends Clock {
private Instant now = T0;
private long nanos;