diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 864c6d1872..b286ce8473 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -46,6 +46,9 @@ final class IntelligenceEngine: ObservableObject { /// `defer` re-invokes `analyzeRecent(force: true)` ONCE when it clears. A single re-arm (the flag is /// cleared BEFORE the re-invoke) bounds it to one extra pass , no recompute storm. private var pendingForcedRescore = false + /// Uptime the pass holding `computing` started at, and how many days it covers; nil when none is running. + private var runningPassStart: UInt64? + private var runningPassDays = 0 /// #899 heal bound: true while the last heal already re-armed a rescore, so a heal firing again on /// the very next pass cannot re-arm a second time (the Android twin is hard-bounded to exactly one /// re-pass; this mirrors it). Reset by any pass whose heal finds nothing, restoring the budget. @@ -656,7 +659,18 @@ final class IntelligenceEngine: ObservableObject { // in-flight pass already covers the same window). But a FORCED call is a real update path (a // post-backfill rescore after a sync) , dropping it would leave a freshly-synced night unscored // until the next cycle. Re-arm instead: flag it so the running pass's `defer` re-invokes once. - guard !computing else { if force { pendingForcedRescore = true }; return } + guard !computing else { + if force { + // Said once per running pass, not per trigger: a pass that holds the lock for hours otherwise + // turns every post-offload re-score into a silent no-op, and the log shows syncs but no scores. + if !pendingForcedRescore, let started = runningPassStart { + let heldFor = Int(Double(DispatchTime.now().uptimeNanoseconds &- started) / 1_000_000_000) + diagnosticSink?("re-score: queued behind a \(runningPassDays)-day pass running for \(heldFor) s", nil) + } + pendingForcedRescore = true + } + return + } guard let store = await repo.storeHandle() else { note = String(localized: "No on-device store yet."); return } guard let hrvCfg = Baselines.metricCfg["hrv"], let rhrCfg = Baselines.metricCfg["resting_hr"], @@ -738,6 +752,8 @@ final class IntelligenceEngine: ObservableObject { let reScoreCPUStart = RescoreBackgroundScheduler.processCPUSeconds() let reScoreExpiriesAtStart = RescoreBackgroundScheduler.assertionExpiries computing = true + runningPassStart = reScoreStart + runningPassDays = maxDays // #1538: the pass is now past every gate and will do real work. Mark it started durably, so that a // process killed mid-pass leaves evidence a LATER process can read — the killed process itself gets // no chance to record anything. Cleared beside the watermark at the end; there is no early return @@ -763,6 +779,7 @@ final class IntelligenceEngine: ObservableObject { // `computing` is already false, so its own `guard !computing` passes and it rescores the new data. defer { computing = false + runningPassStart = nil if pendingForcedRescore { pendingForcedRescore = false // Carry THIS pass's window into the re-pass: a heal firing during a wide one-shot pass diff --git a/Strand/Data/RescoreBackgroundPolicy.swift b/Strand/Data/RescoreBackgroundPolicy.swift index fbda6a5164..30f68a1084 100644 --- a/Strand/Data/RescoreBackgroundPolicy.swift +++ b/Strand/Data/RescoreBackgroundPolicy.swift @@ -51,10 +51,23 @@ enum RescoreBackgroundPolicy { /// spent mid-unit; resting for all of it would stall a pass that has already been idle. static let maxBackgroundRestSeconds: Double = 30 - /// Seconds to rest after `workSeconds` of re-score work. Zero in the foreground, where no CPU limit - /// applies and the user is waiting on the result. A non-finite or non-positive measurement rests zero. + /// How much work a backgrounded pass does between rests. + /// + /// Resting after every unit (every night) turned out to be the expensive part. A rest is a `Task.sleep`, + /// and a backgrounded process that is only sleeping is exactly what iOS suspends, until the next + /// bluetooth wake about ten minutes later. Each unit took milliseconds to a few seconds of CPU, so a pass + /// advanced roughly one night per wake. On one phone a 21-night pass ran 2 h 27 min, and a one-time + /// full-history pass held the re-score lock from 21:34 until after 11:00 the next day. Every post-offload + /// pass in between returned at the lock, so that morning's night was never scored. Working for ten seconds + /// before resting ten keeps the same ~50% ceiling under iOS's 80%-over-60 s kill, with one suspension + /// opportunity per ten seconds of work instead of one per night. + static let backgroundWorkQuantumSeconds: Double = 10 + + /// Seconds to rest after `workSeconds` of re-score work done since the last rest. Zero until a quantum of + /// work has accumulated (`backgroundWorkQuantumSeconds`), and always zero in the foreground, where no CPU + /// limit applies and the user is waiting on the result. A non-finite measurement rests zero. static func restSeconds(afterWorkSeconds workSeconds: Double, isBackground: Bool) -> Double { - guard isBackground, workSeconds.isFinite, workSeconds > 0 else { return 0 } + guard isBackground, workSeconds.isFinite, workSeconds >= backgroundWorkQuantumSeconds else { return 0 } return min(workSeconds * backgroundRestPerWorkSecond, maxBackgroundRestSeconds) } diff --git a/Strand/System/RescoreBackgroundScheduler.swift b/Strand/System/RescoreBackgroundScheduler.swift index 491bca141f..a971de04f4 100644 --- a/Strand/System/RescoreBackgroundScheduler.swift +++ b/Strand/System/RescoreBackgroundScheduler.swift @@ -225,15 +225,17 @@ enum RescoreBackgroundScheduler { + " backgrounded=\(backgroundedAtEnd)" } - /// Rest after a unit of re-score work when backgrounded, so the pass stays under iOS's background CPU + /// Rest between units of re-score work when backgrounded, so the pass stays under iOS's background CPU /// limit instead of being killed by it (`RescoreBackgroundPolicy.backgroundRestPerWorkSecond`). `mark` is - /// the uptime the unit started at, in nanoseconds; it is reset to the end of the rest for the next unit. + /// the uptime the work since the last rest started at, in nanoseconds. It is left alone until a quantum of + /// work has built up (`backgroundWorkQuantumSeconds`), so short units run back to back, and it is reset + /// after a rest or in the foreground. nonisolated static func paceIfBackgrounded(since mark: inout UInt64) async { let workSeconds = Double(DispatchTime.now().uptimeNanoseconds &- mark) / 1_000_000_000 let background = await MainActor.run { isBackgrounded } let rest = RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: workSeconds, isBackground: background) if rest > 0 { try? await Task.sleep(nanoseconds: UInt64(rest * 1_000_000_000)) } - mark = DispatchTime.now().uptimeNanoseconds + if rest > 0 || !background { mark = DispatchTime.now().uptimeNanoseconds } } /// Hold an execution assertion for the duration of `work` so a SHORT pass is not suspended halfway. diff --git a/StrandTests/RescoreBackgroundPolicyTests.swift b/StrandTests/RescoreBackgroundPolicyTests.swift index e666f9a810..0958545db1 100644 --- a/StrandTests/RescoreBackgroundPolicyTests.swift +++ b/StrandTests/RescoreBackgroundPolicyTests.swift @@ -75,7 +75,16 @@ final class RescoreBackgroundPolicyTests: XCTestCase { /// Resting as long as it worked holds a backgrounded pass near 50% CPU, under the 80% iOS kills at. func testABackgroundedPassRestsAsLongAsItWorked() { - XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 6, isBackground: true), 6) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 12, isBackground: true), 12) + } + + /// Short units run back to back until a quantum of work has built up: every rest is a chance for iOS to + /// suspend the process until the next wake, so resting after each night advanced a pass one night a wake. + func testWorkUnderAQuantumDoesNotRest() { + let quantum = RescoreBackgroundPolicy.backgroundWorkQuantumSeconds + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 0.05, isBackground: true), 0) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: quantum - 0.01, isBackground: true), 0) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: quantum, isBackground: true), quantum) } /// No CPU limit applies in the foreground, and the user is waiting on the result.