fix(store): keep the strict restore file format with an optional otherApplied
CI / Backend tests (Maven / Java 21) (push) Successful in 1m10s
CI / Backend tests (Maven / Java 21) (push) Successful in 1m10s
The strict reading from the store review rejected every file without the new otherApplied field, so after both fixes every restore would have gone to quarantine. A file record now marks the five real fields required and leaves otherApplied optional; a null setpoint or due time is still corrupt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e901d4c351
commit
7cbbc788ef
2 files changed
+49
-8
No files matched your search
@@ -1,5 +1,7 @@
|
||||
package home.gateway.adapters.store;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
@@ -16,6 +18,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The pending restores as one JSON file, replaced atomically (ADR-005). */
|
||||
@@ -25,9 +29,8 @@ public class JsonRestoreStore implements RestoreStore {
|
||||
private final ObjectMapper json = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
// Jackson would otherwise turn a missing or null number into 0, i.e. a restore of register 0.
|
||||
.enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
|
||||
.enable(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES)
|
||||
// Jackson would otherwise turn a null number into 0, i.e. a restore of register 0; missing
|
||||
// fields are caught by the required flags on Stored.
|
||||
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
|
||||
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||
@@ -43,11 +46,15 @@ public class JsonRestoreStore implements RestoreStore {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
var restores = json.readValue(file.toFile(), new TypeReference<List<Restore>>() { });
|
||||
if (restores == null || restores.contains(null)) {
|
||||
var stored = json.readValue(file.toFile(), new TypeReference<List<Stored>>() { });
|
||||
if (stored == null || stored.contains(null)) {
|
||||
throw new IOException("not a list of restores");
|
||||
}
|
||||
return restores;
|
||||
var restores = new ArrayList<Restore>();
|
||||
for (Stored one : stored) {
|
||||
restores.add(one.restore());
|
||||
}
|
||||
return List.copyOf(restores);
|
||||
} catch (IOException e) {
|
||||
throw quarantine(e);
|
||||
}
|
||||
@@ -64,7 +71,7 @@ public class JsonRestoreStore implements RestoreStore {
|
||||
var temp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
try (var channel = FileChannel.open(temp, StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
|
||||
channel.write(ByteBuffer.wrap(json.writeValueAsBytes(restores)));
|
||||
channel.write(ByteBuffer.wrap(json.writeValueAsBytes(restores.stream().map(Stored::of).toList())));
|
||||
// Without this a power cut after the move can leave an empty file on the SD card.
|
||||
channel.force(true);
|
||||
}
|
||||
@@ -80,4 +87,26 @@ public class JsonRestoreStore implements RestoreStore {
|
||||
// The file itself is already durable; only the rename may be lost.
|
||||
}
|
||||
}
|
||||
|
||||
/** The file format: every field required except otherApplied, which only a lost reply sets. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Stored(@JsonProperty(required = true) String setpoint,
|
||||
@JsonProperty(required = true) int register,
|
||||
@JsonProperty(required = true) int originalValue,
|
||||
@JsonProperty(required = true) int appliedValue,
|
||||
@JsonProperty(required = true) Instant due,
|
||||
Integer otherApplied) {
|
||||
|
||||
static Stored of(Restore r) {
|
||||
return new Stored(r.setpoint(), r.register(), r.originalValue(), r.appliedValue(), r.due(),
|
||||
r.otherApplied());
|
||||
}
|
||||
|
||||
Restore restore() throws IOException {
|
||||
if (setpoint == null || due == null) {
|
||||
throw new IOException("restore without setpoint or due time");
|
||||
}
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due, otherApplied);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,9 @@ class JsonRestoreStoreTest {
|
||||
void anUnusableFileIsMovedAsideAndReportedNeverSilentlyDropped() throws IOException {
|
||||
var cases = List.of("{not json", "", "garbage \u0000\u0001", "null", "{}", "[null]",
|
||||
"[{\"setpoint\":\"x\"}]", restoreJson("4144", "\"5\""), restoreJson("null", "5"),
|
||||
restoreJson("4144", "5").replace("}]", ",\"extra\":1}]"));
|
||||
restoreJson("4144", "5").replace("}]", ",\"extra\":1}]"),
|
||||
restoreJson("4144", "5").replace("\"x\"", "null"),
|
||||
restoreJson("4144", "5").replace("\"2026-10-24T11:00:00Z\"", "null"));
|
||||
for (var content : cases) {
|
||||
var file = dir.resolve("restores.json");
|
||||
Files.writeString(file, content);
|
||||
@@ -85,6 +87,16 @@ class JsonRestoreStoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLostReplyValueSurvivesSaveAndLoad() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z")).alsoApplied(47);
|
||||
|
||||
new JsonRestoreStore(file).save(List.of(restore));
|
||||
|
||||
assertEquals(List.of(restore), new JsonRestoreStore(file).load());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validFileWithAllFieldsStillLoads() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
|
||||
Reference in new issue
Block a user