Scheduler API (cmp-worker-scheduler)¶
📦 Latest version:
— replace
LATESTin the snippet below with that string.
High-level Koin-injectable WorkScheduler façade. Schedule daily syncs, periodic syncs, one-time-at-instant syncs, and exact-time syncs from any commonMain module without touching WorkManager directly.
Available since 3.1.1. Bundled into cmp-worker-compose-all, so any consumer already depending on the umbrella gets the scheduler "for free."
1. Library responsibility = scheduling. Consumer responsibility = the work itself.¶
cmp-worker-scheduler schedules sync work — when it runs (daily 9am, every 6 hours, at an exact instant). It does not render notifications, update UI, or perform any work-specific action; those are the consumer's job inside their own CoroutineWorker.doWork().
| Concern | Owner | How |
|---|---|---|
| When sync runs | Library | scheduler.scheduleDailyDataSync<AppSyncWorker>(LocalTime(9, 0)) |
| Sync implementation | Consumer | Extend AbstractDataSyncWorker + list Syncable adopters in constructor |
| Sync source variety | Consumer chooses | Implement Syncable on a repo · or wrap a function with FetcherSyncable · or wrap a Store5 Store with StoreSyncable |
| When a notification fires | Consumer | Build oneTimeWorkRequest<MyNotificationWorker> + workManager.enqueue(...) |
| Notification rendering | Consumer | NotificationCompat.Builder(...) (Android) / UNUserNotificationCenter (iOS) / etc. inside MyNotificationWorker.doWork() |
| Any custom domain worker | Consumer | Extend CoroutineWorker; schedule via raw WorkManager.enqueue(oneTimeWorkRequest<YourWorker> { ... }) |
The library bundles cmp-worker-kmp.WorkManager via api(), so consumers get both the high-level WorkScheduler and the low-level WorkManager from one dep.
2. Add the dependency¶
If you already use cmp-worker-compose-all, you're done — scheduler + Store5 (with adapters) are re-exported via api(). Otherwise:
// build.gradle.kts (commonMain) — replace LATEST with the version from the badge above
implementation("io.github.mobilebytelabs:worker-scheduler:LATEST")
// Optional, only if you sync from Store5 instances — bundles the adapters:
implementation("io.github.mobilebytelabs:worker-store5:LATEST")
3. Five-minute setup¶
// 1. Mark your repository Syncable — implement on a repo class
class CurrencyRepository(private val api: CurrencyApi, private val dao: CurrencyDao) : Syncable {
override suspend fun syncWith(synchronizer: Synchronizer): Boolean = synchronizer.snapshotSync(
name = "currency-rates",
fetcher = { dao.upsertAll(api.fetchCurrencies()) },
)
}
// 2. Subclass AbstractDataSyncWorker — 3 lines
class AppSyncWorker(
ctx: WorkerContext,
currencyRepository: CurrencyRepository,
persister: SyncStatePersister,
) : AbstractDataSyncWorker(ctx, syncables = listOf(currencyRepository), persister = persister)
// 3. Wire Koin (commonMain) — single<WorkScheduler> stays stateless across all worker types
val SyncModule = module {
single { SyncStatePersister() }
single<WorkScheduler> { DefaultWorkScheduler(workManager = get(), persister = get()) }
factory { (ctx: WorkerContext) ->
AppSyncWorker(ctx, currencyRepository = get(), persister = get())
}
}
WorkerRegistry.register<AppSyncWorker> { ctx -> get { parametersOf(ctx) } }
// 4. Schedule from anywhere in commonMain — pass YOUR worker type at the call site
val scheduler: WorkScheduler by KoinPlatform.getKoin().inject()
scheduler.scheduleDailyDataSync<AppSyncWorker>(timeOfDay = LocalTime(9, 0))
The reified <AppSyncWorker> is how the scheduler knows which class WorkManager should instantiate. Same DSL shape as oneTimeWorkRequest<W> { ... } from cmp-worker-kmp.
4. The 7 WorkScheduler methods¶
All 5 schedule methods are reified — the worker class is supplied at the call site, so one WorkScheduler instance serves any number of distinct worker classes. Each also accepts an optional configure lambda that runs on the underlying OneTimeWorkRequestBuilder<W> or PeriodicWorkRequestBuilder<W> AFTER the library's defaults (so any consumer override wins).
| Method (reified signature) | Use case | Backed by |
|---|---|---|
enqueueDataSync<W>(mode, payload) { configure } |
One-shot, immediate (or expedited if Foreground) | OneTimeWorkRequest |
scheduleDailyDataSync<W>(timeOfDay, tz, payload) { configure } |
Sync once a day at HH:MM | PeriodicWorkRequest 24h + setInitialDelay(nextOccurrence) + KEEP |
schedulePeriodicDataSync<W>(interval, initialDelay, payload) { configure } |
Sync every N (≥15min) | PeriodicWorkRequest + KEEP |
scheduleDataSyncAt<W>(instant, mode, payload) { configure } |
One-shot at specific instant (flex window ~5min) | OneTimeWorkRequest + setInitialDelay |
scheduleDataSyncAtExact<W>(instant, mode, payload) { configure } |
One-shot at exact instant (battery-impacting) | AlarmManager.setExactAndAllowWhileIdle on Android; BGProcessingTaskRequest on iOS |
observeWork(name) |
Flow<WorkStatus> for live progress |
WorkManager.getWorkInfosByTag flow |
cancelWork(name) |
Cancel all work tagged with name |
WorkManager.cancelAllWorkByTag |
W is constrained to W : AbstractDataSyncWorker. For non-sync workers, use raw WorkManager.enqueue(oneTimeWorkRequest<W> { ... }) directly (see §8).
Per-call configuration via the builder lambda¶
// Tighter network constraint + exponential backoff + custom tag for one scheduled call
scheduler.scheduleDailyDataSync<AppSyncWorker>(timeOfDay = LocalTime(9, 0)) {
setConstraints(Constraints { setRequiredNetworkType(NetworkType.UNMETERED) })
setBackoffCriteria(BackoffPolicy.EXPONENTIAL, RetryConfig.DEFAULT)
addTag("morning-refresh")
}
The library applies SyncConstraints + standard tag + payload + foreground-expedited mapping FIRST, then runs the lambda — so anything the consumer sets in the lambda overrides the default.
Multi-worker example¶
// One scheduler serves multiple worker classes — no extra DI binding needed
scheduler.scheduleDailyDataSync<UserSyncWorker>(timeOfDay = LocalTime(7, 0)) // morning user refresh
scheduler.scheduleDailyDataSync<AnalyticsRollupWorker>(timeOfDay = LocalTime(23, 0)) // nightly rollup
scheduler.schedulePeriodicDataSync<MetricsExportWorker>(interval = 1.hours) // hourly metrics
scheduler.enqueueDataSync<UserSyncWorker>(payload = workDataOf("trigger" to "manual"))
Custom WorkScheduler implementations¶
DefaultWorkScheduler is open — subclass to override any single method (project-wide telemetry, swap SyncConstraints, change the unique-name scheme, etc.) or implement WorkScheduler from scratch for fully custom behavior.
class TracedWorkScheduler(workManager: WorkManager, persister: SyncStatePersister) :
DefaultWorkScheduler(workManager, persister) {
override fun <W : AbstractDataSyncWorker> enqueueDataSync(
workerClass: KClass<W>, mode: WorkMode, payload: WorkData,
configure: OneTimeWorkRequestBuilder<W>.() -> Unit,
): WorkHandle {
tracer.event("enqueueDataSync:${workerClass.simpleName}")
return super.enqueueDataSync(workerClass, mode, payload, configure)
}
}
5. Per-platform exact-time notes¶
Android — AlarmManager + WorkManager¶
scheduleDataSyncAtExactrequires theSCHEDULE_EXACT_ALARMpermission inAndroidManifest.xml(Android 12+, API 31+; user-grantable from Settings).- When denied, falls back automatically to flex-window
scheduleDataSyncAtwith aLog.w("ExactAlarmScheduler", "...")log. - Doze respects
setExactAndAllowWhileIdle(wakes device); consecutive exact alarms within ~9-min are throttled.
iOS — BGTaskScheduler¶
scheduleDataSyncAtExactsubmits aBGProcessingTaskRequestwithearliestBeginDate = instant.toNSDate(). iOS does not guarantee exact timing — the OS decides actual run time based on battery / connectivity / app-usage heuristics. For user-visible exact-time triggers, fire your ownUNUserNotificationCenternotification from inside yourCoroutineWorker.doWork()(consumer responsibility).- Consumer-app
Info.plistmust register the identifierio.github.mobilebytelabs.worker.scheduler.exact_syncunderBGTaskSchedulerPermittedIdentifiers.
Desktop (JVM) — ScheduledExecutorService¶
scheduleDataSyncAtExactusesScheduledExecutorService.schedule(...)— in-process only, lost on JVM restart. v1.1 will layer oncmp-worker-desktop-daemonwhen its alpha gains execute-capability.
Web (wasmJs + js) — setTimeout¶
scheduleDataSyncAtExactusessetTimeout(handler, delayMs)— in-tab only. Background scheduling requires Service WorkerperiodicSync(Chrome-only, requires bundler config +sw.jsscaffold; ships as a separate epic in 3.2.0).
6. Synchronizer / Syncable — multi-source pattern¶
Sync contracts live in io.github.mobilebytelabs.worker.scheduler.sync.*. Three ergonomic ways to author a Syncable, depending on where your data lives:
interface Synchronizer {
suspend fun getChangeListVersions(): ChangeListVersions
suspend fun updateChangeListVersions(update: ChangeListVersions.() -> ChangeListVersions)
}
interface Syncable {
suspend fun syncWith(synchronizer: Synchronizer): Boolean
suspend fun syncWith(synchronizer: Synchronizer, payload: WorkData): Boolean = syncWith(synchronizer)
}
6.1 Repository pattern (preferred when your repo already exists)¶
Implement Syncable directly on the repository class. Two extension functions cover the two common server shapes:
// Snapshot API — server returns the full canonical payload (Frankfurter, WorldBank)
class CurrencyRepository(...) : Syncable {
override suspend fun syncWith(sync: Synchronizer): Boolean = sync.snapshotSync("currency-rates") {
dao.upsertAll(api.fetchLatest("USD"))
}
}
// Change-list API — server returns deltas since version N (NiA topics, GitHub events)
class TopicsRepository(...) : Syncable {
override suspend fun syncWith(sync: Synchronizer): Boolean = sync.changeListSync(
versionReader = { it.versions["topics"]?.toInt() ?: 0 },
changeListFetcher = { since -> api.fetchTopicChanges(since) },
versionUpdater = { latest -> copy(versions = versions + ("topics" to latest.toLong())) },
modelDeleter = { ids -> dao.deleteByIds(ids) },
modelUpdater = { ids -> dao.upsertByIds(api.fetchByIds(ids)) },
)
}
6.2 Function pattern via FetcherSyncable (for one-off lambdas)¶
Wraps a suspend (WorkData) -> Unit block without defining a class. Same staleness semantics as snapshotSync:
val currencySyncable = FetcherSyncable("currency-rates") { payload ->
val base = payload.getString("currency.base") ?: "USD"
dao.upsertAll(api.fetchLatest(base))
}
class AppSyncWorker(
ctx: WorkerContext,
persister: SyncStatePersister,
) : AbstractDataSyncWorker(ctx, syncables = listOf(currencySyncable), persister = persister)
6.3 Store5 pattern via StoreSyncable (in cmp-worker-store5)¶
cmp-worker-store5 provides Syncable wrappers for Mobile Native Foundation Store5 instances. The module already exists (it ships StoreBackedWorker); the new adapters live alongside under the .scheduler sub-package and add an api(cmp-worker-scheduler) dep so the Syncable contract is on consumers' classpath.
import io.github.mobilebytelabs.worker.store5.scheduler.StoreSyncable
import io.github.mobilebytelabs.worker.store5.scheduler.MutableStoreSyncable
import org.mobilenativefoundation.store.store5.Store
import org.mobilenativefoundation.store.store5.MutableStore
// Read-path sync — forces store.fresh(key) which triggers a network fetch
val currencyStoreSyncable = StoreSyncable(
name = "currency-rates",
key = "USD",
store = appStoreRegistry.currencyStore, // Store<String, FrankfurterResponse>
)
// Write-path sync — pushes a pending mutation via mutableStore.write(...)
val profileEditSyncable = MutableStoreSyncable(
name = "profile-edit-${edits.id}",
key = edits.id,
value = edits,
mutableStore = appStoreRegistry.profileMutableStore, // MutableStore<UserId, UserProfile>
)
class AppSyncWorker(
ctx: WorkerContext,
persister: SyncStatePersister,
) : AbstractDataSyncWorker(
ctx,
syncables = listOf(currencyStoreSyncable, profileEditSyncable), // both fan out in parallel
persister = persister,
)
For payload routing (e.g. picking the Store key from WorkData), use the lower-level extension functions inside a custom Syncable:
class CurrencyStoreSyncable(
private val store: Store<String, FrankfurterResponse>,
) : Syncable {
override suspend fun syncWith(sync: Synchronizer, payload: WorkData): Boolean {
val base = payload.getString("currency.base") ?: "USD"
return sync.storeSync(name = "currency-rates-$base", key = base, store = store)
}
override suspend fun syncWith(sync: Synchronizer) = syncWith(sync, workDataOf("currency.base" to "USD"))
}
6.4 Sequencing via CompositeSyncable (rare — only when one sync depends on another)¶
val onboarding = CompositeSyncable(
settingsRepo, // sync 1st
userProfileRepo, // sync 2nd (depends on settings being current)
userContentRepo, // sync 3rd (depends on profile being current)
)
For independent syncables, list them directly in AbstractDataSyncWorker.syncables — those run in parallel (awaitAll).
Fan-out semantics¶
AbstractDataSyncWorker.doWork() iterates the syncables list inside coroutineScope { … async { it.syncWith(this@AbstractDataSyncWorker, inputData) } … awaitAll() }. All true → success. Any false → retry. Any throw → failure.
class AppSyncWorker(
ctx: WorkerContext,
currencyRepository: CurrencyRepository,
macroIndicatorsRepository: MacroIndicatorsRepository,
currencyStoreSyncable: StoreSyncable<String, FrankfurterResponse>,
persister: SyncStatePersister,
) : AbstractDataSyncWorker(
ctx,
syncables = listOf(currencyRepository, macroIndicatorsRepository, currencyStoreSyncable),
persister = persister,
)
7. Module structure¶
| Module | Coords | Purpose |
|---|---|---|
cmp-worker-scheduler |
io.github.mobilebytelabs:worker-scheduler |
WorkScheduler + AbstractDataSyncWorker + Synchronizer + Syncable + FetcherSyncable + CompositeSyncable |
cmp-worker-store5 |
io.github.mobilebytelabs:worker-store5 |
StoreBackedWorker + StoreSyncable + MutableStoreSyncable + Synchronizer.storeSync + Synchronizer.mutableStoreSync. Depends on cmp-worker-scheduler via api(...). |
cmp-worker-compose-all |
io.github.mobilebytelabs:worker-compose-all |
Umbrella re-exporting both above + cmp-worker-kmp + cmp-worker-koin + per-platform engines |
Standalone usage: the io.github.mobilebytelabs.worker.scheduler.sync.* types (Synchronizer, Syncable, changeListSync, snapshotSync) have no WorkManager dependency at the contract level — you can use them as a NiA-shaped pattern in any KMP project without engaging the scheduler.
8. Scheduling non-sync workers (notifications, etc.)¶
Use raw WorkManager.enqueue(...) for any worker class that isn't AbstractDataSyncWorker:
class MyNotificationWorker(ctx: WorkerContext) : CoroutineWorker(ctx) {
override suspend fun doWork(): WorkResult {
val title = inputData.getString("title") ?: return WorkResult.failure("missing title")
// Consumer-owned rendering — NotificationCompat (Android),
// UNUserNotificationCenter (iOS), SystemTray (Desktop), etc.
renderNotification(title)
return WorkResult.success()
}
}
// Scheduling — use raw WorkManager, NOT WorkScheduler:
val request = oneTimeWorkRequest<MyNotificationWorker> {
setInputData(workDataOf("title" to "Loan due", "body" to "Payment due today"))
setInitialDelay(15.minutes)
}
workManager.enqueue(request)
cmp-worker-scheduler bundles cmp-worker-kmp via api(), so injecting WorkManager in the same module that uses WorkScheduler requires no extra dep.
9. Migrating from sample-side sync (3.1.0 → 3.1.1)¶
From the old (3.1.0) typed-less API¶
| Before (3.1.0) | After (3.1.1) |
|---|---|
scheduler.enqueueDataSync(...) |
scheduler.enqueueDataSync<AppSyncWorker>(...) |
scheduler.scheduleDailyDataSync(...) |
scheduler.scheduleDailyDataSync<AppSyncWorker>(...) |
scheduler.schedulePeriodicDataSync(...) |
scheduler.schedulePeriodicDataSync<AppSyncWorker>(...) |
scheduler.scheduleDataSyncAt(...) |
scheduler.scheduleDataSyncAt<AppSyncWorker>(...) |
scheduler.scheduleDataSyncAtExact(...) |
scheduler.scheduleDataSyncAtExact<AppSyncWorker>(...) |
From samples/.../sync (any version)¶
| Old package | New package |
|---|---|
org.mifos.sync.WorkScheduler |
io.github.mobilebytelabs.worker.scheduler.WorkScheduler |
org.mifos.sync.DefaultWorkScheduler |
io.github.mobilebytelabs.worker.scheduler.DefaultWorkScheduler |
org.mifos.sync.WorkMode / WorkHandle / WorkStatus |
io.github.mobilebytelabs.worker.scheduler.* |
org.mifos.core.data.infra.Synchronizer / Syncable / ChangeListVersions / NetworkChange |
io.github.mobilebytelabs.worker.scheduler.sync.* |
org.mifos.core.data.util.SyncManager |
io.github.mobilebytelabs.worker.scheduler.sync.SyncManager |
org.mifos.core.datastore.SyncStatePersister |
io.github.mobilebytelabs.worker.scheduler.sync.SyncStatePersister |
Hand-rolled DataSyncWorker with fan-out logic |
Extend AbstractDataSyncWorker(ctx, syncables = listOf(...), persister) |
org.mifos.sync.NotificationWorker + NotificationContent + renderNotification |
Stay in consumer code — library doesn't ship these |
WorkScheduler.scheduleNotification(...) / scheduleNotificationAt(...) |
Removed from library — use workManager.enqueue(oneTimeWorkRequest<MyNotificationWorker> { setInputData(...); setInitialDelay(...) }) |