Skip to content

Commit 2a1bd7a

Browse files
committed
feat(scheduler): add TaskData typed wrapper for input data
Replace Map<String, String> inputData with a typed TaskData class that provides: - Typed accessors (getString, getInt, getLong, getBoolean, getDouble) - No silent nulls or manual parsing in tasks - Builder DSL for ergonomic enqueue-time configuration - Backward-compatible equals/hashCode and operator get Update all usages across scheduler and testing modules, documentation, and fix a pre-existing smart cast issue in TestDesktopTaskScheduler.
1 parent be3c89c commit 2a1bd7a

8 files changed

Lines changed: 176 additions & 45 deletions

File tree

docs/runtime/scheduler.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Repeat at a fixed interval (minimum 15 minutes):
9191
```kotlin
9292
scheduler.enqueue(
9393
TaskRequest.periodic("backup", 30.minutes) {
94-
inputData("target", "/tmp/backup")
94+
inputData { putString("target", "/tmp/backup") }
9595
retryPolicy(RetryPolicy.ExponentialBackoff())
9696
existingTaskPolicy(ExistingTaskPolicy.REPLACE)
9797
}
@@ -128,28 +128,37 @@ scheduler.enqueue(TaskRequest.onBoot("login-sync"))
128128

129129
### Input data
130130

131-
Pass key-value pairs to tasks at enqueue time, then read them in `doWork()`:
131+
Pass typed key-value pairs to tasks at enqueue time via `TaskData`, then read them with typed accessors in `doWork()`:
132132

133133
```kotlin
134134
// Enqueue
135135
scheduler.enqueue(
136136
TaskRequest.periodic("sync", 1.hours) {
137-
inputData("endpoint", "https://api.example.com")
138-
inputData("token", "abc123")
137+
inputData {
138+
putString("endpoint", "https://api.example.com")
139+
putString("token", "abc123")
140+
putInt("retries", 3)
141+
putBoolean("verbose", false)
142+
}
139143
}
140144
)
141145

142146
// In the task
143147
class SyncTask : DesktopTask {
144148
override suspend fun doWork(context: TaskContext): TaskResult {
145-
val endpoint = context.inputData["endpoint"]
146-
val token = context.inputData["token"]
149+
val endpoint = context.inputData.getString("endpoint")
150+
?: return TaskResult.Failure("missing endpoint")
151+
val token = context.inputData.getString("token")
152+
val retries = context.inputData.getInt("retries", default = 3)
153+
val verbose = context.inputData.getBoolean("verbose")
147154
// ...
148155
return TaskResult.Success
149156
}
150157
}
151158
```
152159

160+
Available typed accessors: `getString`, `getInt`, `getLong`, `getBoolean`, `getDouble`. All accept an optional `default` parameter (except `getString`, which returns `null` when absent).
161+
153162
### Task results and retry
154163

155164
Return `TaskResult.Success`, `TaskResult.Failure`, or `TaskResult.Retry` from `doWork()`:
@@ -339,7 +348,7 @@ Builder DSL:
339348

340349
| Method | Description |
341350
|--------|-------------|
342-
| `inputData(key, value)` | Attach a key-value pair, retrievable via `TaskContext.inputData`. |
351+
| `inputData { ... }` | Attach typed key-value pairs via `TaskData.Builder` (see [Input data](#input-data)). |
343352
| `retryPolicy(policy)` | Set the retry strategy (`ExponentialBackoff` or `Linear`). |
344353
| `existingTaskPolicy(policy)` | `KEEP` (default) or `REPLACE` if same task ID exists. |
345354
| `runImmediately(enabled)` | Run the task immediately when scheduled (periodic tasks only). Default: `false`. |
@@ -367,7 +376,7 @@ Builder DSL:
367376
| Property | Type | Description |
368377
|----------|------|-------------|
369378
| `taskId` | `String` | The unique task identifier. |
370-
| `inputData` | `Map<String, String>` | Key-value pairs from enqueue time. |
379+
| `inputData` | `TaskData` | Typed key-value data from enqueue time. Use `getString`, `getInt`, `getLong`, `getBoolean`, `getDouble`. |
371380
| `runAttemptCount` | `Int` | 1-based attempt counter (increments on retry). |
372381

373382
### `TaskResult`
@@ -475,7 +484,7 @@ dependencies {
475484
val result = TestTaskRunner.runTask(
476485
task = SyncTask(),
477486
taskId = "sync",
478-
inputData = mapOf("endpoint" to "https://test.api"),
487+
inputData = TaskData.Builder().putString("endpoint", "https://test.api").build(),
479488
runAttemptCount = 1,
480489
)
481490
assertEquals(TaskResult.Success, result)
@@ -598,7 +607,7 @@ constraintChecker.uninstall()
598607

599608
| Method | Returns | Description |
600609
|--------|---------|-------------|
601-
| `runTask(task, taskId?, inputData?, runAttemptCount?)` | `TaskResult` | Calls `doWork()` with a controlled `TaskContext`. |
610+
| `runTask(task, taskId?, inputData?, runAttemptCount?)` | `TaskResult` | Calls `doWork()` with a controlled `TaskContext`. `inputData` is a `TaskData` instance (default: `TaskData.EMPTY`). |
602611

603612
### `TestDesktopTaskScheduler`
604613

scheduler-testing/src/main/kotlin/io/github/kdroidfilter/nucleus/scheduler/testing/TestDesktopTaskScheduler.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,9 @@ public class TestDesktopTaskScheduler :
280280
val fires = mutableListOf<PendingFire>()
281281

282282
for ((taskId, request) in tasks) {
283-
if (request.type != TaskRequest.Type.PERIODIC || request.interval == null) continue
284-
val intervalMs = request.interval.inWholeMilliseconds
283+
val interval = request.interval
284+
if (request.type != TaskRequest.Type.PERIODIC || interval == null) continue
285+
val intervalMs = interval.inWholeMilliseconds
285286
val baseMs = enqueueTimeMs[taskId] ?: 0L
286287

287288
// Compute the next fire time after current virtualTimeMs

scheduler-testing/src/main/kotlin/io/github/kdroidfilter/nucleus/scheduler/testing/TestTaskRunner.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.github.kdroidfilter.nucleus.scheduler.testing
22

33
import io.github.kdroidfilter.nucleus.scheduler.DesktopTask
44
import io.github.kdroidfilter.nucleus.scheduler.TaskContext
5+
import io.github.kdroidfilter.nucleus.scheduler.TaskData
56
import io.github.kdroidfilter.nucleus.scheduler.TaskResult
67

78
/**
@@ -13,7 +14,7 @@ import io.github.kdroidfilter.nucleus.scheduler.TaskResult
1314
* val result = TestTaskRunner.runTask(
1415
* task = SyncTask(),
1516
* taskId = "sync",
16-
* inputData = mapOf("endpoint" to "https://test.api"),
17+
* inputData = TaskData.Builder().putString("endpoint", "https://test.api").build(),
1718
* )
1819
* assertEquals(TaskResult.Success, result)
1920
* ```
@@ -24,13 +25,13 @@ public object TestTaskRunner {
2425
*
2526
* @param task the task to execute
2627
* @param taskId the task identifier passed in the context (default: `"test-task"`)
27-
* @param inputData key-value pairs available via [TaskContext.inputData]
28+
* @param inputData typed key-value data available via [TaskContext.inputData]
2829
* @param runAttemptCount 1-based attempt counter (default: `1`)
2930
*/
3031
public suspend fun runTask(
3132
task: DesktopTask,
3233
taskId: String = "test-task",
33-
inputData: Map<String, String> = emptyMap(),
34+
inputData: TaskData = TaskData.EMPTY,
3435
runAttemptCount: Int = 1,
3536
): TaskResult {
3637
val context =

scheduler-testing/src/test/kotlin/io/github/kdroidfilter/nucleus/scheduler/testing/SchedulerTestingTest.kt

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import io.github.kdroidfilter.nucleus.scheduler.DesktopTask
44
import io.github.kdroidfilter.nucleus.scheduler.DesktopTaskScheduler
55
import io.github.kdroidfilter.nucleus.scheduler.ExistingTaskPolicy
66
import io.github.kdroidfilter.nucleus.scheduler.TaskContext
7+
import io.github.kdroidfilter.nucleus.scheduler.TaskData
78
import io.github.kdroidfilter.nucleus.scheduler.TaskRegistry
89
import io.github.kdroidfilter.nucleus.scheduler.TaskRequest
910
import io.github.kdroidfilter.nucleus.scheduler.TaskResult
@@ -38,7 +39,7 @@ class RetryTask : DesktopTask {
3839
}
3940

4041
class InputEchoTask : DesktopTask {
41-
var receivedData: Map<String, String> = emptyMap()
42+
var receivedData: TaskData = TaskData.EMPTY
4243

4344
override suspend fun doWork(context: TaskContext): TaskResult {
4445
receivedData = context.inputData
@@ -79,9 +80,9 @@ class TestTaskRunnerTest {
7980
val task = InputEchoTask()
8081
TestTaskRunner.runTask(
8182
task = task,
82-
inputData = mapOf("key" to "value"),
83+
inputData = TaskData.Builder().putString("key", "value").build(),
8384
)
84-
assertEquals(mapOf("key" to "value"), task.receivedData)
85+
assertEquals("value", task.receivedData.getString("key"))
8586
}
8687

8788
@Test
@@ -206,7 +207,7 @@ class TestDesktopTaskSchedulerTest {
206207

207208
val request =
208209
TaskRequest.periodic("success", 1.hours) {
209-
inputData("key", "value")
210+
inputData { putString("key", "value") }
210211
}
211212
DesktopTaskScheduler.enqueue(request)
212213

@@ -224,12 +225,12 @@ class TestDesktopTaskSchedulerTest {
224225

225226
DesktopTaskScheduler.enqueue(
226227
TaskRequest.periodic("success", 1.hours) {
227-
inputData("version", "1")
228+
inputData { putString("version", "1") }
228229
},
229230
)
230231
DesktopTaskScheduler.enqueue(
231232
TaskRequest.periodic("success", 2.hours) {
232-
inputData("version", "2")
233+
inputData { putString("version", "2") }
233234
},
234235
)
235236

@@ -246,12 +247,12 @@ class TestDesktopTaskSchedulerTest {
246247

247248
DesktopTaskScheduler.enqueue(
248249
TaskRequest.periodic("success", 1.hours) {
249-
inputData("version", "1")
250+
inputData { putString("version", "1") }
250251
},
251252
)
252253
DesktopTaskScheduler.enqueue(
253254
TaskRequest.periodic("success", 2.hours) {
254-
inputData("version", "2")
255+
inputData { putString("version", "2") }
255256
existingTaskPolicy(ExistingTaskPolicy.REPLACE)
256257
},
257258
)
@@ -277,14 +278,16 @@ class TestDesktopTaskSchedulerTest {
277278

278279
DesktopTaskScheduler.enqueue(
279280
TaskRequest.periodic("echo", 1.hours) {
280-
inputData("endpoint", "https://test.api")
281-
inputData("token", "abc123")
281+
inputData {
282+
putString("endpoint", "https://test.api")
283+
putString("token", "abc123")
284+
}
282285
},
283286
)
284287

285288
scheduler.runTask("echo", customRegistry)
286-
assertEquals("https://test.api", task.receivedData["endpoint"])
287-
assertEquals("abc123", task.receivedData["token"])
289+
assertEquals("https://test.api", task.receivedData.getString("endpoint"))
290+
assertEquals("abc123", task.receivedData.getString("token"))
288291
} finally {
289292
scheduler.uninstall()
290293
}

scheduler/src/main/kotlin/io/github/kdroidfilter/nucleus/scheduler/TaskContext.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ package io.github.kdroidfilter.nucleus.scheduler
44
* Execution context passed to [DesktopTask.doWork].
55
*
66
* @property taskId the unique identifier of this task
7-
* @property inputData key-value data passed at enqueue time
7+
* @property inputData typed key-value data passed at enqueue time
88
* @property runAttemptCount 1-based attempt counter (1 = first run, 2 = first retry, etc.)
99
*/
1010
public data class TaskContext(
1111
val taskId: String,
12-
val inputData: Map<String, String> = emptyMap(),
12+
val inputData: TaskData = TaskData.EMPTY,
1313
val runAttemptCount: Int = 1,
1414
)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package io.github.kdroidfilter.nucleus.scheduler
2+
3+
/**
4+
* Typed key-value container for task input data.
5+
*
6+
* Values are stored internally as strings (backed by a `.properties` file) but
7+
* retrieved via typed accessors, eliminating manual parsing and silent `null`s.
8+
*
9+
* Build instances with [Builder]:
10+
* ```kotlin
11+
* TaskRequest.periodic("sync", 1.hours) {
12+
* inputData {
13+
* putString("endpoint", "https://api.example.com")
14+
* putInt("retries", 3)
15+
* putBoolean("verbose", true)
16+
* }
17+
* }
18+
* ```
19+
*
20+
* Read values in [DesktopTask.doWork]:
21+
* ```kotlin
22+
* val endpoint = context.inputData.getString("endpoint") ?: return TaskResult.Failure("missing endpoint")
23+
* val retries = context.inputData.getInt("retries", default = 3)
24+
* ```
25+
*/
26+
public class TaskData internal constructor(
27+
internal val map: Map<String, String> = emptyMap(),
28+
) {
29+
// -- Typed accessors ------------------------------------------------------
30+
31+
/** Returns the string value for [key], or `null` if absent. */
32+
public fun getString(key: String): String? = map[key]
33+
34+
/** Returns the int value for [key], or [default] if absent or not parseable. */
35+
public fun getInt(
36+
key: String,
37+
default: Int = 0,
38+
): Int = map[key]?.toIntOrNull() ?: default
39+
40+
/** Returns the long value for [key], or [default] if absent or not parseable. */
41+
public fun getLong(
42+
key: String,
43+
default: Long = 0L,
44+
): Long = map[key]?.toLongOrNull() ?: default
45+
46+
/** Returns the boolean value for [key], or [default] if absent or not parseable. */
47+
public fun getBoolean(
48+
key: String,
49+
default: Boolean = false,
50+
): Boolean = map[key]?.toBooleanStrictOrNull() ?: default
51+
52+
/** Returns the double value for [key], or [default] if absent or not parseable. */
53+
public fun getDouble(
54+
key: String,
55+
default: Double = 0.0,
56+
): Double = map[key]?.toDoubleOrNull() ?: default
57+
58+
// -- Convenience ----------------------------------------------------------
59+
60+
/** Returns the string value for [key], or `null` if absent. Shorthand for [getString]. */
61+
public operator fun get(key: String): String? = map[key]
62+
63+
/** Returns `true` if no key-value pairs are present. */
64+
public fun isEmpty(): Boolean = map.isEmpty()
65+
66+
/** Returns `true` if at least one key-value pair is present. */
67+
public fun isNotEmpty(): Boolean = map.isNotEmpty()
68+
69+
// -- Builder --------------------------------------------------------------
70+
71+
/** Builds a [TaskData] instance by accumulating typed key-value pairs. */
72+
public class Builder {
73+
private val map = mutableMapOf<String, String>()
74+
75+
public fun putString(
76+
key: String,
77+
value: String,
78+
): Builder = apply { map[key] = value }
79+
80+
public fun putInt(
81+
key: String,
82+
value: Int,
83+
): Builder = apply { map[key] = value.toString() }
84+
85+
public fun putLong(
86+
key: String,
87+
value: Long,
88+
): Builder = apply { map[key] = value.toString() }
89+
90+
public fun putBoolean(
91+
key: String,
92+
value: Boolean,
93+
): Builder = apply { map[key] = value.toString() }
94+
95+
public fun putDouble(
96+
key: String,
97+
value: Double,
98+
): Builder = apply { map[key] = value.toString() }
99+
100+
public fun build(): TaskData = TaskData(map.toMap())
101+
}
102+
103+
// -- Equality & companion -------------------------------------------------
104+
105+
override fun equals(other: Any?): Boolean {
106+
if (this === other) return true
107+
if (other !is TaskData) return false
108+
return map == other.map
109+
}
110+
111+
override fun hashCode(): Int = map.hashCode()
112+
113+
override fun toString(): String = "TaskData($map)"
114+
115+
public companion object {
116+
/** Empty [TaskData] with no key-value pairs. */
117+
public val EMPTY: TaskData = TaskData()
118+
}
119+
}

0 commit comments

Comments
 (0)