Mobile Computing · Background Execution

Diagnosing a Wakeup That Never Arrived

A method for distinguishing a scheduler that declined from a request that was never made, using the platform logs and the app own durable records as two independent witnesses.

The report is always the same shape: the sync did not happen. Between that sentence and the cause there are at least five distinct failures, and they have nothing in common except the symptom. The app never asked. The app asked and the request was rejected. The app asked, the request was accepted, and the platform declined to run it. The platform ran it and the app’s code failed early. The platform ran it, the code succeeded, and the record of success was lost.

Separating those five requires two independent witnesses. The app’s own durable record says what it intended to do; the platform’s log says what the system decided. Neither is sufficient alone, and the reason each is insufficient is different.

The app’s record answers whether the request was made

A durable row is evidence of intent. It carries the enqueue time, the constraint set, the attempt count, and the state. Reading it answers the first two questions directly: if there is no row, the app never asked, and the bug is in the code path that was supposed to create one. If there is a row in a terminal state, the app asked and gave up, and the row’s reason field says why.

The record has a blind spot that is easy to forget: it is written by the same process that is failing. A crash between enqueue and commit leaves no row and no evidence that an enqueue was attempted. A code path that swallows an exception from the database layer leaves a row absent while the caller believes the work is scheduled, which is the ordering defect described in Durable Deferred Work and the Persistence Boundary. The record proves intent only when the write itself is trustworthy.

The remedy is to make the record’s own history visible. A monotonic counter of enqueue attempts, incremented before the insert and persisted separately, distinguishes “never asked” from “asked and the write failed” — the two cases that look identical in the row table.

The platform log answers “what did the system decide”

Both platforms log scheduler decisions, and the log is the only place where a decline is recorded. On Android, the job scheduler’s state is exposed through the dumpsys tool: the pending jobs with their constraints, the jobs currently running, the history of recent executions and their completion reasons, and the standby bucket for each app. Reading that output answers whether the job exists, whether its constraints are met, and whether the platform has been running it.

The distinction between “pending with unmet constraints” and “pending with met constraints that has not run” is the one that resolves most reports. The first is a design problem: the job requires an unmetered network and the device is on a metered one, so the job is correctly waiting. The second is a policy problem: the job is eligible and the platform is choosing not to run it, which happens when the app is in a restricted standby bucket or the device is in doze.

On Apple’s platforms the equivalent evidence comes from the unified logging system. Background task launches, expirations, and the reasons a request was not granted are recorded by the system’s own subsystems, and the log can be filtered by the app’s process and by the relevant subsystem. Because the logging system stores messages in a ring buffer and the app may be asked to supply a log archive after the fact, the messages that matter are the ones written at a level that survives.

The blind spot on this side is the opposite of the app’s. The platform log records decisions about requests it knows about, and it does not record the absence of a request. A log with no entry for a background launch is consistent both with “the system declined” and with “the app never submitted”, and the log cannot tell them apart.

# Reading a job's state out of dumpsys output is a text operation,
# not an API call: there is no public interface to the scheduler's
# internal history. Filtering by the job's own identifier is what
# makes the output attributable to one feature.
import re, subprocess

def job_states(package: str, job_id: int) -> list[str]:
    out = subprocess.run(
        ["dumpsys", "jobscheduler"], capture_output=True, text=True
    ).stdout
    # The registry section lists each job under its package and id.
    block = re.search(
        rf"JOB #{job_id}.*?(?=JOB #|\Z)", out, re.S
    )
    return block.group(0).splitlines() if block else []

The fragility of parsing that output is itself a diagnostic fact: the format is not a stable interface and changes between releases, so a diagnostic that depends on it is a tool for a specific investigation rather than something to build into the app.

Timestamps are where the two witnesses disagree

The most useful thing to compare is not presence but time. The app records when it enqueued; the platform records when it ran. The difference is the deferral, and its magnitude classifies the cause.

A deferral of seconds means the request was honoured promptly and the failure is downstream. A deferral of minutes with a repeating pattern suggests coalescing. A deferral of hours, especially one that lands on a round boundary, suggests a maintenance window. A deferral that never resolves suggests a constraint that cannot be satisfied on that device — a charging requirement on a device that is never plugged in, an idle requirement on a device in constant use.

Comparing the timestamps requires both clocks to be comparable, which they often are not. The app’s row may store wall-clock time while the platform logs elapsed time since boot, and the two diverge across a reboot and across a time correction. Recording both clocks at enqueue time, as discussed in Process Suspension and the Background Execution Grant, makes the comparison possible later.

Ruling out the code before blaming the scheduler

A launch that happens and fails immediately looks, from the outside, exactly like a launch that never happened, because the app’s durable record shows a row that never reached a terminal state. The distinction requires the app to record the fact of having been launched, before doing anything else.

The cheapest form is a counter incremented as the first statement of the entry point, persisted in a way that survives the launch. If the counter does not increment, the launch did not happen. If it increments and the row is untouched, the launch happened and the code failed before reaching the row — which points at initialization, at a dependency that was not ready, or at an exception thrown by the platform’s own callback before the app’s code ran.

The second form is a watchdog inside the launch: a timestamp written when the entry point begins and cleared when it ends, so that a launch which never completed is visible as a stale begin without an end. This is the same pattern as a lease in a job table, applied to a single launch rather than to a row.

Facts the platform does not expose

Three facts are structurally unavailable, and a diagnostic method that assumes otherwise wastes effort.

The exact time a deferred request will run is not exposed, because the scheduler’s decision depends on state the app cannot see: the global job queue, the battery level of other apps’ requests, the thermal state, the standby bucket computation. A test that asserts a run within a specific interval is a test of the device’s current mood, not of the app.

The reason for a specific decline is usually not exposed either. The log may say that a launch was not granted, but not which of the several conditions failed. The practical workaround is differential: change one constraint, observe whether the behaviour changes, and infer.

Whether the user has disabled background refresh is exposed as a setting the app can read, but whether the user has done something equivalent through a vendor-specific battery manager is not. On devices where the vendor layers additional restrictions, the platform’s APIs report the platform’s state and the vendor’s state is invisible, which is why a report of “it works on my device” is not evidence of anything.

Building the diagnostic into the work

The method above is retrospective, and it depends on records that were designed for execution rather than for diagnosis. Making it cheap requires deciding, at design time, which facts are worth recording.

Four are almost always worth the bytes: the enqueue timestamp on both clocks, the attempt count and the time of the last attempt, the outcome reason in an enumerated form, and a launch counter incremented at the entry point. Those four turn the five-way ambiguity into a lookup, and they cost a handful of columns in a table that already exists.

The fifth, a log of the platform’s decisions, cannot be built into the app, because the app does not make them. It can only be collected, and collection is an operational task: an archive of the platform log captured while the failure is reproducible, filtered to the app’s process. Without that archive, the app’s record proves intent and proves nothing about the outcome, which is exactly the state most reports arrive in.

The remaining uncertainty

Even with both witnesses, one case stays ambiguous: work that ran, succeeded, and was not recorded. The app’s row shows an attempt with no terminal state, the platform log shows a launch, and the external effect — the server receiving the request — is the only evidence that the work completed. That is why idempotency keys matter beyond retry safety: they turn the external system into a third witness, able to answer whether an operation with a given key was ever applied. See Retry Accounting When the Scheduler Chooses the Moment for the record shape that makes the key available after the fact.

Where no external witness exists, the honest position is that the outcome is unknown, and the correct action is to redo the work rather than to assume either outcome. A redo costs a duplicate that an idempotent receiver absorbs; an assumption costs a silent loss that nothing will ever detect.

References

  1. Android Developers — dumpsys — Android Developers
  2. Apple Developer Documentation — Logger — Apple Inc.
  3. Apple Developer Documentation — MetricKit — Apple Inc.
  4. Android Developers — JobScheduler — Android Developers