Skip to content

refactor(scheduler)!: typed TaskId, LocalTime, @Serializable inputData#204

Merged
kdroidFilter merged 15 commits intomainfrom
feat/scheduler-typed-api
Apr 17, 2026
Merged

refactor(scheduler)!: typed TaskId, LocalTime, @Serializable inputData#204
kdroidFilter merged 15 commits intomainfrom
feat/scheduler-typed-api

Conversation

@kdroidFilter
Copy link
Copy Markdown
Owner

Summary

Three breaking refactors of the public scheduler API for stronger typing.

  • TaskId value class@JvmInline value class TaskId(val value: String) with regex validation in init. Replaces String taskId across TaskRequest, TaskRegistry, TaskContext, TaskInfo, PlatformScheduler, all 4 backends (Linux/macOS/Windows/Noop), TaskMetadataStore, TaskWrapperScript, DesktopBootReceiver, and scheduler-testing. Raw strings are unwrapped only at JNI/filesystem boundaries; the boot-receiver rejects malformed CLI IDs before any lookup.
  • CronExpression takes LocalTime — removed all (hour: Int, minute: Int) overloads. Now everyDayAt(LocalTime), everyWeekdayAt(LocalTime), everyWeekdayAt(DayOfWeek, LocalTime), everyMondayAt(LocalTime), everyHour(). Bounds checks come from LocalTime itself.
  • @Serializable inputData — dropped string-keyed TaskData.Builder.putString/putInt/.... TaskData is now an opaque wrapper around a JSON String?. New API: TaskRequest.Builder.inputData(value) reified inline (and an explicit-serializer overload), context.inputData<T>() to decode. TaskMetadataStore persists the payload in a single reserved _inputDataJson key.

kotlinx-serialization-json is added as api to scheduler so consumers don't have to wire it themselves; the kotlinx serialization plugin is enabled in scheduler, scheduler-testing, and scheduler-demo. Demo (SampleTasks, SchedulerDemoView) and tests rewritten against the new API. docs/runtime/scheduler.md rewritten to match.

Breaking changes

  • Consumers passing String IDs to enqueue/cancel/isScheduled/getTaskInfo, registering with register("foo") { ... }, or building requests with TaskRequest.periodic("foo", ...) must wrap in TaskId(...).
  • Calls like CronExpression.everyDayAt(9) / everyWeekdayAt(18) must switch to LocalTime.of(...).
  • Anything reading context.inputData.getString("k") / writing inputData { putString("k", "v") } must define a @Serializable data class and switch to inputData(value) / context.inputData<T>().
  • Persistence format changed: pre-existing <taskId>.properties files with arbitrary keys are ignored on next run (only _inputDataJson and the underscore-prefixed bookkeeping keys are read). No migration shim — assumed acceptable for an unreleased module.

Test plan

  • ./gradlew :scheduler:compileKotlin :scheduler:compileTestKotlin :scheduler-testing:compileTestKotlin :scheduler-demo:compileKotlin — green
  • ./gradlew :scheduler:test :scheduler-testing:test — green
  • Manual smoke test: enqueue a periodic task in scheduler-demo on Linux/Windows/macOS, verify it fires and context.inputData<BackupInput>() decodes the payload
  • Verify mkdocs serve renders the updated scheduler.md cleanly

- Replace String taskId with @JvmInline value class TaskId, validated
  against [a-zA-Z0-9_-]+ in init. Threads through TaskRequest, registry,
  context, info, all backends, metadata store and wrapper scripts.
- CronExpression factory methods now take java.time.LocalTime instead of
  raw (hour, minute) ints; validation comes for free from LocalTime.
- Replace string-keyed TaskData with an opaque JSON wrapper backed by
  kotlinx.serialization. New API: inputData(value) reified inline on
  TaskRequest.Builder, context.inputData<T>() on TaskContext, with
  explicit-serializer overloads. Persistence consolidates to a single
  _inputDataJson key in the per-task .properties file.
- Demo and scheduler-testing tests rewritten against the new typed API;
  mkdocs scheduler page updated.

Breaking change for any consumer still passing String IDs, raw hour/minute
ints to CronExpression, or the old TaskData.Builder/getString/getInt API.
…esult

- TaskResult.Failure(message) and TaskResult.Retry(message) lose their
  default-null message: callers must pass a String. Removes the silent
  "unknown" fallback and the temptation to skip explanations.
- New LastTaskResult sealed @serializable interface with Success / Failure
  / Retry / ConstraintsNotMet variants.
- TaskInfo.lastResult: String? -> LastTaskResult? — consumers now
  pattern-match instead of parsing free-form strings.
- TaskMetadataStore stores the last result as a JSON-encoded
  LastTaskResult under _lastResult; recordConstraintSkip gains an
  incrementAttempt flag so calendar/on-boot constraint failures bump the
  attempt counter while keeping the typed ConstraintsNotMet payload.
- TestDesktopTaskScheduler in-memory metadata mirrors the new typed model.
- Tests, demo and mkdocs page updated.

Breaking for any consumer that called TaskResult.Failure() / .Retry() with
no message, or that read TaskInfo.lastResult as a String.
…ix Constraints doc

- Avoid the property/extension shadowing on TaskContext (where
  context.inputData returned a TaskData while context.inputData<T>()
  returned T?). The wrapper is now exposed as rawInputData; the typed
  inputData<T>() / inputData(serializer) extensions stay the canonical
  read path.
- Fix the Constraints API reference table that listed a non-existent
  requiresStorageNotLow: Boolean. The actual constraint is
  minimumStorageBytes: Long? (already correctly described in the usage
  sections).
…add UPDATE_DATA

- KEEP becomes a strict no-op on every backend. Previously Linux already
  did this, but macOS and Windows silently overwrote the persisted
  inputData / constraints / taskType — surprising behavior for callers
  enqueuing on every app start.
- New ExistingTaskPolicy.UPDATE_DATA covers the previous macOS/Windows
  behavior explicitly: keep the OS-level schedule, refresh the persisted
  metadata.
- Each backend now factors the metadata-write into a private
  persistMetadata(request) helper so KEEP / UPDATE_DATA / REPLACE branch
  cleanly.
- TestDesktopTaskScheduler mirrors the new tri-state policy and gets a
  test covering UPDATE_DATA.
- Doc: dedicated runImmediately section (covers constraint interaction
  and the no-op-on-calendar/onBoot detail), Existing-task-policy table
  rewritten, periodic-tasks section spells out the IllegalArgumentException
  thrown when interval < 15 minutes.
Asymmetric API — Monday had a shorthand but the other six days didn't.
Use everyWeekdayAt(DayOfWeek.MONDAY, time) instead.
… and co-manage TestConstraintChecker

- ExecutionRecord.result is now LastTaskResult instead of TaskResult.
  Constraint-skipped fires (periodic AND calendar/onBoot) now produce a
  Record with LastTaskResult.ConstraintsNotMet — previously periodic
  skips were invisible in execution history and calendar/onBoot skips
  carried a synthetic TaskResult.Retry.
- runTask() returns null for both periodic and calendar/onBoot constraint
  skips (was: null vs synthetic Retry). Calendar/onBoot still bumps
  runAttemptCount to mirror prod retry semantics.
- advanceTimeBy() now includes records for skipped fires.
- TestDesktopTaskScheduler constructor takes a TestConstraintChecker?,
  whose install/uninstall are co-managed by install() / close(). The
  earlier two-step setup (separate install for the checker, mutable
  property assignment after install) collapses into a single use { }.
- Doc: minimum-interval gets its own anchor so the platform-support
  table links to the exact paragraph; testing-constraints example
  rewritten for the co-managed lifecycle, with an admonition that
  ConstraintChecker is @InternalSchedulerApi (test seam only, not for
  production gating).
…rification

Earlier text claimed launchd 'fails the load and stops trying' and that
the .plist gets reclaimed when Application Support is removed. Both wrong:

- launchd retries forever, throttled by ThrottleInterval (default 10s),
  spamming 'cannot spawn' into system.log on every attempt.
- ~/Library/LaunchAgents/ orphans are never auto-cleaned by macOS.

Doc now spells out the active cleanup an uninstaller should perform:
'launchctl bootout gui/$(id -u) <plist>' followed by 'rm <plist>'.
…on, surface cancelAll() as in-app cleanup primitive
Verified against help.dropbox.com — Dropbox's official uninstall doc does
NOT mention LaunchAgents or plist cleanup. The 'manual plist removal'
pattern only appears in third-party uninstall guides, not the apps' own
documentation. Reframed as an unattributed ecosystem limitation.
@kdroidFilter kdroidFilter merged commit f990ff4 into main Apr 17, 2026
21 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant