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
7 changes: 5 additions & 2 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,9 @@ final class AppModel: ObservableObject {
/// so that it cannot mark unscored data as scored — so gating on the fingerprint here would be asking
/// a question whose answer is already known to be "yes, there is work".
func runDeferredRescoreIfOwed() async {
guard RescoreBackgroundScheduler.isRescoreOwed else { return }
// A pass already running here holds the owed mark itself and settles it when it finishes; forcing
// another would only queue a second full pass behind it.
guard RescoreBackgroundScheduler.isRescoreOwed, !intelligence.computing else { return }
// #2238: force only when the debt is UNPROVEN — an interrupted pass, whose watermark was
// deliberately never advanced. A pass that COMPLETED and was merely outvoted by a token recorded
// mid-pass did advance it, so asking the fingerprint is a real question with a real answer, and a
Expand Down Expand Up @@ -715,7 +717,8 @@ final class AppModel: ObservableObject {
// the #1538 report while never producing a score. Decide first whether this pass can finish here,
// and hand it to a background-processing task when it cannot. A no-op on macOS, and on iOS a
// foreground pass is never deferred.
await RescoreBackgroundScheduler.run(log: { [live] line in live.append(log: line) }) {
await RescoreBackgroundScheduler.run(passInProgress: intelligence.computing,
log: { [live] line in live.append(log: line) }) {
await intelligence.analyzeRecent(skipIfUnchanged: true)
}
await refreshV5Signals()
Expand Down
4 changes: 4 additions & 0 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1062,7 +1062,9 @@ final class IntelligenceEngine: ObservableObject {
try? await store.rrIntervals(deviceId: o, from: f, to: t, limit: StreamReadCap.rr,
unlabelledAliasOfWhoop5: activeWhoop5RR && o == Repository.whoopSource)
}
var paceMark = DispatchTime.now().uptimeNanoseconds
for offset in 0..<maxDays {
if offset > 0 { await RescoreBackgroundScheduler.paceIfBackgrounded(since: &paceMark) }
let dayStart = nowLocalMidnight - offset * 86_400
let day = AnalyticsEngine.dayString(dayStart, offsetSec: tzOffset)
// Read a generous window around the night that ends on `day`; the stager finds the span.
Expand Down Expand Up @@ -2052,7 +2054,9 @@ final class IntelligenceEngine: ObservableObject {
nowLocalMidnight: nowLocalMidnight, now: now, offsetSec: tzOffset,
maxDays: maxDays, strictCanonicalAlias: strictCanonicalAlias)
var appliedLegacySnapshots: [String: LegacyScoreSnapshot] = [:]
var paceMark = DispatchTime.now().uptimeNanoseconds
for night in scoredNights {
await RescoreBackgroundScheduler.paceIfBackgrounded(since: &paceMark)
// #299: scope the edits to THIS day before folding. A userEdited row / hand-logged nap belongs
// to exactly ONE day — the day its night ENDS on, matching the daily's end-day bucket. `endTs`
// is stable under a bedtime edit (only the onset/`startTsAdjusted` moves), so end-day is the
Expand Down
79 changes: 36 additions & 43 deletions Strand/Data/RescoreBackgroundPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation
/// backgrounded — it stays alive as a `bluetooth-central` to receive the offload in the first place. But
/// `analyzeRecent` is all-or-nothing: pass 1 writes nothing, every store write happens after both loops,
/// and the watermark advances only at the very end so an interrupted run can never mark unscored data as
/// scored. On a heavy install the pass measured **474,778 ms** — nearly eight minutes. iOS suspends the
/// scored. On a heavy install the pass measured **474,778 ms** — nearly eight minutes. iOS ends the
/// process long before that, so the work is lost in full.
///
/// The lost work is not the worst of it. Because the watermark never advanced, the NEXT trigger still saw
Expand All @@ -17,7 +17,9 @@ import Foundation
/// guarantees it will be attempted again. The score appeared 1 h 57 m after the data was complete, and only
/// because the app happened to stay foregrounded for eight unbroken minutes.
///
/// So this decides, before spending anything: is this a pass that can plausibly finish here?
/// What ends those passes is the background CPU limit, not suspension (see `backgroundRestPerWorkSecond`):
/// a suspended pass resumes on the next wake, a killed one does not. A backgrounded pass therefore paces
/// itself under that limit, and this decides only whether one should start here at all.
///
/// Deliberately NOT a fix for how long the pass takes. A cold process still re-scores every night in the
/// window, because the per-day reuse cache is in-memory and starts empty (`IntelligenceEngine.dayScanCache`).
Expand All @@ -35,65 +37,56 @@ enum RescoreBackgroundPolicy {
case deferToBackgroundTask(reason: String)
}

/// What a background execution assertion is worth relying on, in seconds.
/// How long a backgrounded pass rests per second of work it just did.
///
/// `beginBackgroundTask` buys roughly 30 s on current iOS, and that figure is a courtesy rather than a
/// contract — it shrinks under memory pressure and in Low Power Mode. 20 s leaves headroom for the
/// assertion to be granted late and for the pass's own store writes to land, since being killed
/// mid-write is the one outcome worth spending real caution to avoid.
static let backgroundBudgetSeconds: Double = 20
/// What actually killed the background passes was CPU, not time: iOS terminates a background process
/// that holds more than 80% CPU over 60 s (`cpu_resource_fatal`). A cold pass is roughly 144 s of
/// near-continuous CPU on a large install, so every overnight attempt was killed about 52 s in — 26 kills
/// on one phone in five nights, each leaving the debt for the next attempt to be killed on. Resting as
/// long as it worked holds the pass near 50%. A suspension between rests is harmless: the pass is not
/// killed by it, it resumes on the next wake, so a pass longer than any single wake still completes.
static let backgroundRestPerWorkSecond: Double = 1.0

/// The longest measurement that can describe a pass's own cost, in seconds.
///
/// The heaviest install on record (#1538) took about eight minutes. A figure far past that is not a
/// slow pass but a suspended one: the timing used a wall clock until the uptime clock replaced it, so
/// an install still carries whatever an overnight suspension banked (19 003 s on one phone). Read as
/// a cost, that value deferred every background re-score after it — and only a completed pass ever
/// overwrites it, which is the very thing it prevented.
static let maxPlausiblePassSeconds: Double = 30 * 60
/// The longest single rest. Work measured on the uptime clock can include a suspension the process
/// 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.
static func restSeconds(afterWorkSeconds workSeconds: Double, isBackground: Bool) -> Double {
guard isBackground, workSeconds.isFinite, workSeconds > 0 else { return 0 }
return min(workSeconds * backgroundRestPerWorkSecond, maxBackgroundRestSeconds)
}

/// - Parameters:
/// - isBackground: whether the app is currently backgrounded. A foregrounded app is never deferred:
/// the user is looking at the screen, there is no suspension deadline, and the existing behaviour
/// is correct.
/// - isRealUpdate: the trigger carries new data that must be scored (an offload), as opposed to the
/// steady-state backstop tick. A backgrounded backstop does not run: a paced pass costs minutes,
/// the tick cannot tell live HR from a real change, and every real update already runs its own.
/// - rescoreAlreadyOwed: a re-score is outstanding — either a pass marked itself started and never
/// marked itself finished (it was killed; the mark survives process death, which is the point,
/// because the killed process gets no chance to record anything) or an earlier trigger already
/// deferred one. Both mean the same operationally: the work is spoken for, and starting it here
/// would duplicate a pass that something better placed is going to run. This is the
/// self-correcting part — the FIRST background attempt on an install we know nothing about is
/// allowed to run, and from then on the work escalates instead of being re-killed on every
/// offload.
/// - lastCompletedPassSeconds: how long the last pass that ran to completion took, or nil if none
/// has. Measured rather than assumed — the cost varies by more than an order of magnitude with
/// history size, and a fixed guess would either defer installs that finish comfortably or wave
/// through ones that never could.
/// - budgetSeconds: see `backgroundBudgetSeconds`; a parameter so the tests can state the boundary
/// rather than inherit it.
/// deferred one. The work is spoken for by the processing task this escalated to.
/// - passInProgress: a pass is running in THIS process. Its own started-mark is what reads as owed, so
/// it is not evidence of a killed pass; the engine re-arms one follow-up pass for a trigger that
/// lands mid-run. Deferring instead recorded a newer debt, the running pass then finished without
/// settling it (#1681), and every offload after that deferred on it.
static func decide(isBackground: Bool,
isRealUpdate: Bool = true,
rescoreAlreadyOwed: Bool,
lastCompletedPassSeconds: Double?,
budgetSeconds: Double = backgroundBudgetSeconds,
maxPlausibleSeconds: Double = maxPlausiblePassSeconds) -> Decision {
passInProgress: Bool = false) -> Decision {
guard isBackground else { return .run }

if rescoreAlreadyOwed {
guard isRealUpdate else {
return .deferToBackgroundTask(
reason: "a re-score is already outstanding from an earlier trigger")
reason: "the backstop tick does not re-score while backgrounded; offloads run their own")
}

// Only a FINITE, positive, PLAUSIBLE measurement can justify deferring. A nil (nothing has ever
// completed), a zero, a NaN/infinity from a corrupted default, or a figure past
// `maxPlausibleSeconds` all mean "unknown", and unknown must fall through to running: refusing to
// score on the strength of a value we cannot read would be a far worse failure than one wasted pass.
if budgetSeconds > 0,
let measured = lastCompletedPassSeconds,
measured.isFinite, measured > 0,
measured <= maxPlausibleSeconds,
measured > budgetSeconds {
if rescoreAlreadyOwed, !passInProgress {
return .deferToBackgroundTask(
reason: "last completed pass took \(Int(measured.rounded()))s, over the "
+ "\(Int(budgetSeconds.rounded()))s a background wake can be relied on for")
reason: "a re-score is already outstanding from an earlier trigger")
}

return .run
Expand Down
19 changes: 17 additions & 2 deletions Strand/System/RescoreBackgroundScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,18 @@ enum RescoreBackgroundScheduler {
/// would conjure a forced full pass for a processing task to run when very likely nothing changed,
/// which is the churn #1146 exists to avoid. A debt an earlier real pass already recorded is
/// untouched either way.
/// - Parameter passInProgress: a pass is already running in this process; see
/// `RescoreBackgroundPolicy.decide`.
static func run(isBackground: Bool? = nil,
owesOnDefer: Bool = true,
passInProgress: Bool = false,
log: @escaping (String) -> Void,
work: () async -> Void) async {
let decision = RescoreBackgroundPolicy.decide(
isBackground: isBackground ?? isBackgrounded,
isRealUpdate: owesOnDefer,
rescoreAlreadyOwed: isRescoreOwed,
lastCompletedPassSeconds: lastCompletedPassSeconds)
passInProgress: passInProgress)

switch decision {
case .deferToBackgroundTask(let reason):
Expand All @@ -193,6 +197,17 @@ enum RescoreBackgroundScheduler {
}
}

/// Rest after a unit 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.
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
}

/// Hold an execution assertion for the duration of `work` so a SHORT pass is not suspended halfway.
/// A long one still outlives the grant; the assertion's expiry handler is where that becomes visible
/// in the log and where the work is escalated, rather than the process simply vanishing.
Expand All @@ -206,7 +221,7 @@ enum RescoreBackgroundScheduler {
// the owed mark is still set (only a completed pass clears it) and that is what the next
// decision reads.
MainActor.assumeIsolated {
log("re-score: background time expired before the pass finished — escalating (#1538)")
log("re-score: background time expired mid-pass — it resumes on the next wake (#1538)")
schedule()
assertion.end()
}
Expand Down
Loading