Tier 2 — Runtime: the full engine
The complete engine — the layer every measured result runs on.
The constraint DSL
Bridge.enqueue(workRequest("nightly-sync", "sync") {
network() // any connected network (unmetered() for Wi-Fi-class)
charging()
batteryNotLow()
storageNotLow()
deviceIdle() // JobInfo.setRequiresDeviceIdle
contentTrigger("content://media/photos", descendants = true) // JobInfo.TriggerContentUri
importance(Importance.LOW) // feeds the policy engine, not just the platform
maxThreadPressure(PressureLevel.MEDIUM) // dispatch only while runnable threads <= cores x 2
initialDelay(10 * 60_000L) // exact-path setMinimumLatency
maxAttempts(5)
mustCompleteBy(tomorrow6amMs) // deadline escalation: DEFAULT -> EXPEDITED -> while-idle alarm
})
Enqueue has KEEP semantics per unique name.
Importance
importance() feeds Bridge’s policy engine, not just the platform:
MIN/LOWwork yields under bucket quota and under thread pressure.DEFAULTwork yields only under HIGH thread pressure.HIGHimportance work never waits on thread pressure. Only deadline work (mustCompleteBy) bypasses every policy gate — thermal holds and quota-window admission apply regardless of importance.
Thread pressure
Before dispatching, the policy engine reads the process’s runnable-thread count (from /proc/self/task) and classifies it against CPU cores: LOW (≤ cores), MEDIUM (≤ cores × 2), HIGH (beyond). MEDIUM defers MIN/LOW-importance work; HIGH also defers DEFAULT. maxThreadPressure(level) overrides the mapping per request in either direction. Every hold is journaled and visible in whyPending() with the arithmetic spelled out.
Deadlines
mustCompleteBy(atMs) walks urgency tiers as the deadline approaches: the base tier while more than half the window remains, then a promoted tier, then EXPEDITED (API 31+), and finally a while-idle alarm. Each escalation step is journaled.
Data payloads
Attach input at enqueue; read it as ctx.input; produce output with ctx.setOutput:
Bridge.enqueue(workRequest("greet", "echo") { input("msg" to "hello", "count" to 3) })
class EchoWorker : BridgeWorker {
override suspend fun run(ctx: RunContext): RunResult {
val msg = ctx.input.getString("msg") // typed getters parse on read
ctx.setOutput(bridgeDataOf("echoed" to msg))
return RunResult.Success
}
}
Input is journaled on the Enqueued event and output on Finished (or per ChunkCompleted for chunked workers — so a chain resumed after process death still sees its completed links’ outputs). The ledger therefore shows what every attempt received and produced, not just the latest. Payloads cap at 10 KB — the journal is for coordinates, not cargo. Latest output: Bridge.state(name).lastOutput.
Tags
Bridge.enqueue(workRequest("telemetry-1", "sync") { tag("telemetry") })
Bridge.namesByTag("telemetry") // → [telemetry-1]
Bridge.cancelAllByTag("telemetry") // cancels every live item carrying the tag
Multi-branch chains (prerequisite DAG)
after(names...) gates dispatch until every named work has SUCCEEDED; branch outputs overwrite-merge into the dependent’s ctx.input in declaration order:
Bridge.enqueue(workRequest("resize", "imageWorker") { input("src" to uri) })
Bridge.enqueue(workRequest("caption", "mlWorker") { input("src" to uri) })
Bridge.enqueue(workRequest("publish", "publishWorker") {
after("resize", "caption") // dispatches only after both SUCCEED
})
While gated, whyPending() answers WaitingForPrerequisites(pending = [...]). A FAILED or CANCELLED prerequisite fails the dependent with a journaled reason ("prerequisite 'resize' FAILED") without burning an attempt — WorkManager’s propagation semantics, diagnosable. after() cannot combine with periodic().
Flow observers
Bridge.stateFlow("publish") // Flow<WorkState>: current fold, re-emitted per event
.collect { render(it.runState) }
Bridge.eventsFlow() // Flow<WorkEvent>: every journal commit, app-wide
Both are cold flows over the journal’s listener hook — no polling, no invalidation machinery. For LiveData, apply asLiveData().
Like whyPending() and ledger(), state()/stateFlow() are total reads — no nulls anywhere. An unknown name yields a query-only fold with runState == RunState.UNKNOWN (never journaled), so Bridge.state(name).nextChunk reads clean and honestly returns 0.
Retries and backoff
A worker that returns RunResult.Retry is rescheduled by the platform, not by a Bridge timer: by default every compiled job declares JobInfo.setBackoffCriteria(30s, EXPONENTIAL), so re-deliveries arrive at 30s, 60s, 120s, … up to the platform’s ceiling. (Device-idle and periodic jobs don’t declare backoff — the platform forbids it for both.)
The default is overridable per request:
Bridge.enqueue(workRequest("poll-orders", "sync") {
network()
backoff(60_000L, BackoffPolicy.LINEAR) // 60s, 120s, 180s, ...
})
backoff(initialMs, policy) maps directly to JobInfo.setBackoffCriteria; initialMs floors at 10 s (JobInfo.MIN_BACKOFF_MILLIS), and the builder rejects combining it with deviceIdle() (platform rule) or periodic() (the period itself paces re-runs) at enqueue time.
On top of that ride two Bridge behaviors:
maxAttemptsis the cap. The attempt that exceeds it is journaled as terminalFAILED— the ledger shows which attempt died and why.- Crashes are not retries. A process crash triggers the platform’s own ~30-minute crash backoff; once two or more crash-or-retry stops accumulate, diagnostics surface the gap as
ThrottledAfterCrashes(n)rather than leaving it unexplained. And parks (RunResult.Parked, durable timers/awaits) never burn attempts and never enter the backoff ladder at all.
Periodic work
Bridge.enqueue(workRequest("heartbeat", "sync") {
periodic(30 * 60_000L) // >= 15 min, the platform floor
})
Each cycle is a journaled generation; cancelling ends the series. Periodic work cannot combine with contentTrigger — the platform forbids it, and the builder enforces it at enqueue time.
Chunked resumption
class PhotoBackupWorker : ChunkedWorker {
override suspend fun runChunk(ctx: RunContext, chunkIndex: Int): RunResult {
uploader.upload(part = chunkIndex) // small, independently-committed unit
return RunResult.Success // <- the checkpoint commits HERE
}
}
Bridge.enqueue(workRequest("backup", "photo-backup") {
chunks(40, estimatedUpBytes = 200_000_000L)
unmetered(); charging()
importance(Importance.LOW)
})
Every completed chunk is journaled. After a stop, crash, or force-stop mid-run, the next attempt starts at WorkState.nextChunk, not chunk 0. This is the configuration behind the measured 1-vs-20 replay result — see Results.
chunks(n) is one task, not n tasks
chunks(40) does not enqueue 40 work items. It is still one task — one name, one WorkState, one platform job, one set of constraints, one retry budget. The number tells Bridge how your work divides (“my upload has 40 slices”), and Bridge drives one worker instance through runChunk(ctx, 0) … runChunk(ctx, 39) in a loop, in the same process, back-to-back. Bridge never splits the work itself — it can’t know how to cut your file. It supplies the loop, the per-chunk checkpoint, and the resume index; the chunkIndex argument is how each call knows which slice to do.
If you instead want independently scheduled units with their own constraints and retry counters, that’s a prerequisite DAG (after(...)), not chunks.
The checkpoint contract
You never write checkpoint code — returning RunResult.Success from runChunk is the checkpoint. At that moment Bridge appends a durable ChunkCompleted(chunkIndex, output) event to the on-disk journal; the resume index is derived by folding those events, never tracked by your worker. Three consequences:
- You choose the boundaries by how you slice. Chunk = checkpoint interval. Each
runChunk(ctx, i)should be a unit that is complete in itself and cheap to not repeat. Finer checkpoints → more chunks; coarser → fewer. - Nothing inside a chunk is checkpointed. If the process dies mid-chunk, that whole chunk re-runs from its start on the next attempt. Bridge guarantees at-least-once per chunk, exactly-once per completed chunk — so keep each chunk’s side effects idempotent (an upload the server dedupes, a DB write keyed so a repeat is harmless).
- Any other return breaks the loop.
Retryreschedules the whole item (completed chunks stay completed — the retry resumes atnextChunk);Failureis terminal; a system stop between chunks journalsStoppedand resumes the same way.
Checkpointing state, not just position
Call ctx.setOutput(...) before returning Success and the data is journaled inside that chunk’s ChunkCompleted event. On resume — even in a brand-new process — all completed chunks’ outputs come back overwrite-merged into ctx.input. That’s how a chunk hands a cursor, session id, or running total to its successors across death:
class PagedSyncWorker : ChunkedWorker {
private var cursor: String? = null // in-run carry
override suspend fun runChunk(ctx: RunContext, chunkIndex: Int): RunResult {
// ctx.input is fixed at run start: after a resume it holds the last
// journaled cursor; within a run, carry the cursor in memory.
val from = cursor ?: ctx.input.getString("cursor")
val page = api.fetchPage(from)
db.save(page.items)
cursor = page.nextCursor
ctx.setOutput(bridgeDataOf("cursor" to page.nextCursor)) // part of this checkpoint
return RunResult.Success
}
}
Rules the runtime enforces
- The worker must implement
ChunkedWorkeriff the request declareschunks(n). A mismatch in either direction hard-fails with a journaledstructure mismatchreason instead of silently running chunked work un-chunked. chunks(n)also feeds admission: restrictive standby buckets grant ~10-minute execution windows, and un-chunked work estimated longer than a window is held — chunked work passes, because it can make journaled progress across several short windows.
Watching progress
Bridge.state("backup").nextChunk // resume point / chunks completed so far
Bridge.stateFlow("backup") // re-emits on every ChunkCompleted
Bridge.ledger("backup") // per-attempt chunk ranges: attempt 1 ran 0..5, attempt 2 ran 6..39
Continue to Diagnostics for whyPending, ledger, and report, or Tier 3 for durable coroutines.