Safe saves. Ported from release-0.108 (0.108.107)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,7 @@ internal object ChairLearning {
|
||||
}.getOrDefault(LearningState())
|
||||
fun on(file: File): Boolean = load(file).on
|
||||
private fun save(file: File, s: LearningState) = runCatching {
|
||||
file.writeText(JSONObject().put("on", s.on).put("confirmed", JSONArray(s.confirmed.toList())).put("dismissed", JSONArray(s.dismissed.toList())).toString())
|
||||
writeSafely(file, JSONObject().put("on", s.on).put("confirmed", JSONArray(s.confirmed.toList())).put("dismissed", JSONArray(s.dismissed.toList())).toString())
|
||||
}
|
||||
fun set(file: File, on: Boolean) = load(file).let { save(file, LearningState(on, it.confirmed, it.dismissed)) }
|
||||
/** Confirms ([confirm] true) or dismisses a lesson by its key; either way it's no longer a proposal. */
|
||||
|
||||
@@ -190,7 +190,7 @@ internal class ChannelScheduler(private val store: ScheduleStore,
|
||||
EventIdeaHistory.dismissed(historyFile), limit = (EVENT_IDEAS_PER_WEEK - shownThisWeek).coerceAtLeast(0), current = current)
|
||||
EventIdeaHistory.record(historyFile, ideas, now)
|
||||
val thisWeek = EventIdeaHistory.load(historyFile).filter(::live)
|
||||
runCatching { file.writeText(JSONObject().put("day", today.toString()).put("rules", EVENT_IDEA_RULES).put("ideas", JSONArray(thisWeek.map { h ->
|
||||
runCatching { writeSafely(file, JSONObject().put("day", today.toString()).put("rules", EVENT_IDEA_RULES).put("ideas", JSONArray(thisWeek.map { h ->
|
||||
val ids = h.getJSONArray("films").let { a -> (0 until a.length()).map { a.getString(it) } }
|
||||
JSONObject(h.toString()).put("details", JSONArray(ids.mapNotNull { films[it] ?: read.firstOrNull { r -> r.id == it } }.map { f ->
|
||||
JSONObject().put("title", f.title).put("year", f.year ?: JSONObject.NULL).put("community", f.community ?: JSONObject.NULL).put("rating", f.rating) }))
|
||||
|
||||
@@ -78,5 +78,5 @@ internal object ContinueWatching {
|
||||
if (next != marks) { marks = next; save() }
|
||||
}
|
||||
fun remove(number: Int, startMs: Long) { marks = marks.filterNot { it.number == number && it.startMs == startMs }; save() }
|
||||
private fun save() { runCatching { file?.writeText(JSONArray(marks.map { it.toJson() }).toString()) } }
|
||||
private fun save() { runCatching { file?.let { writeSafely(it, JSONArray(marks.map { m -> m.toJson() }).toString()) } } }
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us
|
||||
history = loadDecisions(decisions), learning = ChairLearning.load(store.learningFile()))
|
||||
val ideas = DeskEventIdea.fromFile(store.eventIdeasFile())
|
||||
val reports = reports0.mapValues { (_, r) -> DeskReport.of(r.toJson().put("eventIdeas", JSONArray(ideas.map { it.toJson() }))) }
|
||||
runCatching { cache.writeText(JSONObject().put("key", key).put("savedMs", nowMs).put("reports", JSONObject().apply { reports.forEach { (m, r) -> put(m.name, r.toJson()) } }).toString()) }
|
||||
runCatching { writeSafely(cache, JSONObject().put("key", key).put("savedMs", nowMs).put("reports", JSONObject().apply { reports.forEach { (m, r) -> put(m.name, r.toJson()) } }).toString()) }
|
||||
val b = reports.getValue(DirectorMode.Balanced)
|
||||
android.util.Log.i("OrbitSchedule", "director newlyReleased=${b.newlyReleased} read=${b.scanned} strong=${b.strong} asks=${b.asks.size} collections=${b.collections} tookMs=${b.tookMs}")
|
||||
reports
|
||||
|
||||
@@ -275,10 +275,10 @@ internal object EventIdeaHistory {
|
||||
}
|
||||
val all = load(historyFile)
|
||||
if (all.none { it.optString("signature") == signature }) return@runCatching false
|
||||
historyFile.writeText(JSONArray(all.map(::update)).toString())
|
||||
writeSafely(historyFile, JSONArray(all.map(::update)).toString())
|
||||
runCatching { JSONObject(ideasFile.readText()).let { root ->
|
||||
val ideas = root.getJSONArray("ideas")
|
||||
ideasFile.writeText(root.put("ideas", JSONArray((0 until ideas.length()).map { update(ideas.getJSONObject(it)) })).toString())
|
||||
writeSafely(ideasFile, root.put("ideas", JSONArray((0 until ideas.length()).map { update(ideas.getJSONObject(it)) })).toString())
|
||||
} }
|
||||
ApprovedDriveInEvents.refresh(historyFile)
|
||||
true
|
||||
@@ -296,7 +296,7 @@ internal object EventIdeaHistory {
|
||||
fun withdrawOld(file: File) = runCatching {
|
||||
val all = load(file)
|
||||
if (all.none { it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed" }) return@runCatching
|
||||
file.writeText(JSONArray(all.map { if (it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed") JSONObject(it.toString()).put("decision", "withdrawn: rules changed") else it }).toString())
|
||||
writeSafely(file, JSONArray(all.map { if (it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed") JSONObject(it.toString()).put("decision", "withdrawn: rules changed") else it }).toString())
|
||||
}
|
||||
fun record(file: File, ideas: List<EventIdea>, nowMs: Long) = runCatching {
|
||||
// An idea withdrawn only because the rules changed, and proposed again under the new ones, is shown again: its old entry goes.
|
||||
@@ -307,6 +307,6 @@ internal object EventIdeaHistory {
|
||||
JSONObject().put("signature", i.signature).put("theme", i.theme).put("source", i.source).put("format", i.format.name).put("evening", i.evening?.toString())
|
||||
.put("films", JSONArray(i.films.map { it.id })).put("titles", JSONArray(i.films.map { it.title })).put("reasons", JSONArray(i.reasons)).put("shownMs", nowMs).put("decision", "proposed").put("rules", EVENT_IDEA_RULES)
|
||||
}
|
||||
if (added.isNotEmpty() || all.size != load(file).size) file.writeText(JSONArray(all + added).toString())
|
||||
if (added.isNotEmpty() || all.size != load(file).size) writeSafely(file, JSONArray(all + added).toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ internal class CollectionRefresh(private val file: java.io.File) {
|
||||
fun due(nowMs: Long): Boolean = finishedAt()?.let { next.exists() || nowMs - it >= COLLECTIONS_REFRESH_MS } ?: true
|
||||
/** A full read reached the end: a refresh replaces the file; either way the time is noted. */
|
||||
fun finish(refreshing: Boolean, nowMs: Long) {
|
||||
if (refreshing && next.exists()) { if (!next.renameTo(file)) { file.writeText(next.readText()); next.delete() } }
|
||||
state.writeText(JSONObject().put("finishedAt", nowMs).toString())
|
||||
if (refreshing && next.exists()) { if (!next.renameTo(file)) { writeSafely(file, next.readText()); next.delete() } }
|
||||
writeSafely(state, JSONObject().put("finishedAt", nowMs).toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,16 +100,16 @@ internal fun mergeFamilyFiles(dir: File, files: JSONObject, nowMs: Long = System
|
||||
val target = File(dir, name).also { it.parentFile?.mkdirs() }
|
||||
val here = if (target.exists()) target.readLines().mapNotNull { TasteEvent.of(it) } else emptyList()
|
||||
val merged = compactTaste(here + text.lines().mapNotNull { TasteEvent.of(it) }, nowMs)
|
||||
target.writeText(merged.joinToString("") { it.toLine() + "\n" })
|
||||
writeSafely(target, merged.joinToString("") { it.toLine() + "\n" })
|
||||
}
|
||||
name.startsWith("taste/") && name.endsWith(".state.json") && TasteFiles.matches(name.removePrefix("taste/")) ->
|
||||
File(dir, name).let { if (!it.exists()) { it.parentFile?.mkdirs(); it.writeText(text) } }
|
||||
File(dir, name).let { if (!it.exists()) { writeSafely(it, text) } }
|
||||
FamilyFiles.matches(name) -> {
|
||||
val target = File(dir, name)
|
||||
fun marks(t: String) = runCatching { JSONArray(t).let { a -> (0 until a.length()).mapNotNull { runCatching { ContinueMark.fromJson(a.getJSONObject(it)) }.getOrNull() } } }.getOrDefault(emptyList())
|
||||
val all = (if (target.exists()) marks(target.readText()) else emptyList()) + marks(text)
|
||||
val latest = all.groupBy { it.number to it.startMs }.values.map { same -> same.maxBy { it.atMs } }.sortedByDescending { it.atMs }.take(20)
|
||||
target.writeText(JSONArray(latest.map { it.toJson() }).toString())
|
||||
writeSafely(target, JSONArray(latest.map { it.toJson() }).toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ internal object RoomBundle {
|
||||
dir.mkdirs()
|
||||
fun write(files: JSONObject?) = files?.keys()?.forEach { n ->
|
||||
if (Regex("(choices-\\d+|episodes-\\d+|yours|director-decisions-0|director-learning-0|event-ideas-history-0)\\.json").matches(n) && !File(dir, n).exists())
|
||||
File(dir, n).writeText(files.getString(n))
|
||||
writeSafely(File(dir, n), files.getString(n))
|
||||
}
|
||||
var lineup = emptyList<Int>()
|
||||
if (RoomPart.Channels in parts) bundle.optJSONObject(RoomPart.Channels.name)?.let { c ->
|
||||
@@ -201,7 +201,7 @@ internal object RoomBundle {
|
||||
// Only with the channels: a schedule is for a channel that's on. Never over a schedule this box already has.
|
||||
if (sameSchedules && RoomPart.Channels in parts) bundle.optString("schedules").takeIf { it.isNotBlank() }?.let { packed ->
|
||||
runCatching { JSONObject(gunzip(packed)).let { files -> files.keys().forEach { n ->
|
||||
if (ScheduleFiles.matches(n) && !File(dir, n).exists()) File(dir, n).writeText(files.getString(n)) } } }
|
||||
if (ScheduleFiles.matches(n) && !File(dir, n).exists()) writeSafely(File(dir, n), files.getString(n)) } } }
|
||||
}
|
||||
return lineup
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* Saving a whole file so a power cut can't leave it half-written (8 November 2026, audit A5): the new contents go to a file
|
||||
* beside it, are flushed to the disk, then take its place in one step. Until that step the old file is untouched, so a
|
||||
* failure at any point leaves either the old contents or the new, never a cut-short file that reads as empty.
|
||||
*/
|
||||
internal fun writeSafely(file: File, text: String) = writeSafely(file) { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||
|
||||
internal fun writeSafely(file: File, write: (OutputStream) -> Unit) {
|
||||
file.parentFile?.mkdirs()
|
||||
val temp = File(file.parentFile, file.name + ".writing")
|
||||
try {
|
||||
java.io.FileOutputStream(temp).use { out -> write(out); out.flush(); out.fd.sync() }
|
||||
try { Files.move(temp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) }
|
||||
catch (_: AtomicMoveNotSupportedException) { Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) }
|
||||
} catch (failure: Throwable) {
|
||||
temp.delete(); throw failure
|
||||
}
|
||||
}
|
||||
@@ -161,11 +161,7 @@ internal class TasteStore(val directory: File) {
|
||||
.put("latest", JSONArray(all.takeLast(20).reversed().map { JSONObject().put("kind", it.kind.key).put("title", it.title).put("channel", it.channel ?: JSONObject.NULL).put("at", it.atMs) }))
|
||||
}
|
||||
|
||||
private fun writeWhole(file: File, text: String) {
|
||||
val temp = File(directory, file.name + ".tmp")
|
||||
temp.writeText(text)
|
||||
if (!temp.renameTo(file)) { file.writeText(text); temp.delete() }
|
||||
}
|
||||
private fun writeWhole(file: File, text: String) = writeSafely(file, text)
|
||||
}
|
||||
|
||||
/** The box's Taste: the store for the server in use, and what's being watched now. */
|
||||
|
||||
@@ -32,10 +32,10 @@ internal class UnplayableLog(private val file: File) {
|
||||
@Synchronized fun add(itemId: String, title: String, channel: Int?, reason: String, nowMs: Long) {
|
||||
val o = runCatching { JSONObject(file.readText()) }.getOrDefault(JSONObject())
|
||||
o.put(itemId, JSONObject().put("title", title.take(160)).put("channel", channel ?: JSONObject.NULL).put("reason", reason.take(240)).put("at", nowMs))
|
||||
file.writeText(o.toString())
|
||||
writeSafely(file, o.toString())
|
||||
}
|
||||
@Synchronized fun remove(itemId: String) {
|
||||
val o = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return
|
||||
o.remove(itemId); file.writeText(o.toString())
|
||||
o.remove(itemId); writeSafely(file, o.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/** 8 November 2026 (audit A5): a save that fails part-way leaves the old file as it was, never a cut-short one. */
|
||||
class SafeWriteTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
@Test fun aFinishedSaveReplacesTheFileAndLeavesNothingBeside() {
|
||||
val f = File(tmp.root, "continue-grown.json").apply { writeText("[old]") }
|
||||
writeSafely(f, "[new]")
|
||||
assertEquals("[new]", f.readText())
|
||||
assertEquals(listOf("continue-grown.json"), tmp.root.list()!!.toList())
|
||||
}
|
||||
@Test fun aSaveThatFailsHalfwayKeepsTheOldContents() {
|
||||
val f = File(tmp.root, "director-learning-0.json").apply { writeText("""{"on":true}""") }
|
||||
assertThrows(java.io.IOException::class.java) {
|
||||
writeSafely(f) { out -> out.write("""{"on":fa""".toByteArray()); throw java.io.IOException("power cut") }
|
||||
}
|
||||
assertEquals("""{"on":true}""", f.readText())
|
||||
assertFalse(File(tmp.root, "director-learning-0.json.writing").exists())
|
||||
}
|
||||
@Test fun aFirstSaveMakesTheFolderAndTheFile() {
|
||||
val f = File(tmp.root, "taste/grown.state.json")
|
||||
writeSafely(f, "{}")
|
||||
assertEquals("{}", f.readText())
|
||||
}
|
||||
@Test fun continueWatchingStillReadsBackWhatItSaved() {
|
||||
val dir = tmp.newFolder("schedule")
|
||||
java.io.File(dir, "continue-catchup-only").writeText("")
|
||||
ContinueWatching.load(dir, "grown")
|
||||
val show = SampleBroadcast(ScheduledMedia(SampleItem("Terror Train", "Movie", 97, "", "6048906", 97 * 60_000L), "src", 97 * 60_000L), 1_794_000_000_000L)
|
||||
ContinueWatching.note(304, show, 30 * 60_000L, 1_794_000_000_000L + 30 * 60_000L)
|
||||
ContinueWatching.load(dir, "grown")
|
||||
assertEquals(listOf("6048906"), ContinueWatching.marks.map { it.itemId })
|
||||
assertFalse(dir.list()!!.any { it.endsWith(".writing") })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user