Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
19 changes: 16 additions & 3 deletions Strand/Data/RescoreBackgroundPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
8 changes: 5 additions & 3 deletions Strand/System/RescoreBackgroundScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion StrandTests/RescoreBackgroundPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading