Refactor persistable-task system with self-registering tasks
Replaced metadata-driven reflection with self-describing `PersistableTask` types and a new `PersistableTaskRegistry`. Removed `persistable-tasks.json` and `TaskRegistry`, migrating to type-safe DI instantiation and explicit `restore()` methods. Fixed task restoration issues, ensuring `EmojiReactionRoleTask` and its `emojiMapping` now persist and restore correctly. Introduced `typeId()` for stable task identification, updated payload serialization for symmetry, and applied database migration to map FQCNs to logical IDs. Added unit tests for robust type discovery, payload symmetry, and registry behavior, ensuring no breaking changes.
This commit is contained in:
1 parent
9f2e15a622
commit
05b4a2ab85
19 files changed
+1020
-291
No files matched your search
+45
-69
@@ -111,94 +111,70 @@ under `tasks/generic`).
|
||||
## 3.1 Persistable tasks
|
||||
|
||||
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
|
||||
`core.tasks.persistence.TaskRegistry`, `core.tasks.persistence.TaskFactory`;
|
||||
resource `src/main/resources/persistable-tasks.json`; entity
|
||||
`core.tasks.persistence.PersistableTaskRegistry`; entity
|
||||
[`OTask`](persistence.md#entity-catalog).
|
||||
|
||||
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup. The
|
||||
rules for *how* a persistable task can be defined and reconstructed live in `TaskRegistry` and
|
||||
`TaskFactory`.
|
||||
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup.
|
||||
There is **no mapping file** — task types are discovered on the classpath and each task knows how to
|
||||
rebuild itself.
|
||||
|
||||
### The mapping file — `persistable-tasks.json`
|
||||
### The contract
|
||||
|
||||
The file declares, for each persistable task class, the constructor parameters and their types:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask",
|
||||
"params": ["guildId", "channelId", "messageId", "emojiMapping"],
|
||||
"types": ["string", "string", "string", "map"]
|
||||
}
|
||||
]
|
||||
```java
|
||||
public interface PersistableTask extends Task {
|
||||
default String typeId() { return getName(); } // stable logical id stored in the DB
|
||||
void restore(JSONObject payload); // rebuild state from a previous toPayload()
|
||||
void insert(CommandContext context);
|
||||
void save();
|
||||
void delete();
|
||||
String toPayload();
|
||||
}
|
||||
```
|
||||
|
||||
Schema:
|
||||
- **`typeId()`** is the value written to `tasks.task_class`. It defaults to `getName()`, so a package
|
||||
move or class rename never breaks restoring existing rows.
|
||||
- **`restore(JSONObject)`** is the counterpart of `toPayload()`. `AbstractPersistableTask` provides a
|
||||
no-op default, so only tasks that carry extra state override it.
|
||||
- **`toPayload()`** serializes the bean getters (`new JSONObject(this)`) and always includes `id` and
|
||||
`lockId`, which `TaskService.validateTask` verifies against the stored row.
|
||||
|
||||
| Key | Meaning |
|
||||
|----------|------------------------------------------------------------------|
|
||||
| `tasks` | array of task mappings |
|
||||
| `class` | fully-qualified class name; **must** implement `PersistableTask` |
|
||||
| `params` | ordered list of payload field names |
|
||||
| `types` | parallel list of types for those fields |
|
||||
### `PersistableTaskRegistry` — discovery and restore
|
||||
|
||||
Supported types (`TaskFactory.ParamType`): `string` → `String`, `int` → `Integer`, `map` → `Map`.
|
||||
`PersistableTaskRegistry.scan()` runs once during `TaskManagerImpl.init()` and uses the same
|
||||
`ReflectionHelper.loadTasks(..., ApplicationContext, ...)` discovery used for
|
||||
[built-in](#32-built-in-tasks) and [scheduled](#34-scheduled-tasks) tasks: every concrete
|
||||
`PersistableTask` under `me.bobert.netneutrality` is instantiated once and indexed by its `typeId()`.
|
||||
Two types declaring the same id fail fast at startup with both class names in the message.
|
||||
|
||||
### `TaskRegistry` — loading the mappings
|
||||
|
||||
`TaskRegistry.loadTaskConfig()` reads `persistable-tasks.json` from the classpath and, for each entry,
|
||||
registers a `TaskCreator` (a functional interface `PersistableTask fromPayload(JSONObject payload)`)
|
||||
keyed by class name:
|
||||
`create(typeId, payload)` then produces a **fresh** instance per row:
|
||||
|
||||
```java
|
||||
taskCreators.put(className, payload -> {
|
||||
try {
|
||||
return TaskFactory.createTaskInstance(className, params, types, payload);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to create instance of '{}': {}", className, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
});
|
||||
PersistableTask task = ReflectionHelper.instantiate(type, context);
|
||||
task.restore(payload);
|
||||
```
|
||||
|
||||
Entries missing `class`, `params`, or `types` are logged and skipped. Consumers later look up a
|
||||
creator via `TaskRegistry.getTaskCreator(className)`.
|
||||
Because instantiation goes through the DI context, a restored task needs a constructor whose
|
||||
parameters are all resolvable from the `ApplicationContext` — in practice `(TaskManager)`. Fields
|
||||
that only exist in the payload (ids, mappings) are assigned by `restore()` before the instance is
|
||||
handed to the `TaskManager`.
|
||||
|
||||
### `TaskFactory` — reconstructing an instance
|
||||
|
||||
`TaskFactory.createTaskInstance(className, params, types, payload)`:
|
||||
|
||||
1. Validates that `params.length == types.length`.
|
||||
2. Loads the class and verifies it implements `PersistableTask`.
|
||||
3. For each declared parameter, converts the JSON `types[i]` to a `ParamType`, then extracts
|
||||
`payload[params[i]]` as the corresponding Java type:
|
||||
- `string` → `payload.getString(name)`
|
||||
- `int` → `payload.getInt(name)`
|
||||
- `map` → `payload.getJSONObject(name).toMap()`
|
||||
4. Reflectively resolves the constructor whose parameter types match `types` and instantiates it with
|
||||
the extracted values.
|
||||
|
||||
```java
|
||||
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
|
||||
```
|
||||
|
||||
> **Rule of thumb for adding a persistable task:** create a class that implements `PersistableTask`
|
||||
> (usually by extending `AbstractPersistableTask`), give it a constructor whose parameters match the
|
||||
> `params`/`types` you declare, ensure it serializes those same fields into its JSON payload, and add
|
||||
> a matching entry to `persistable-tasks.json`. The payload is stored in the
|
||||
> [`OTask.payload`](persistence.md#entity-catalog) column and reconstructed via the flow above on
|
||||
> startup.
|
||||
> **Adding a persistable task:** write **one class** extending `AbstractPersistableTask` (or
|
||||
> `EmojiTask`), give it a `(TaskManager)` constructor for the registry plus whatever constructors the
|
||||
> feature code needs, and override `restore()` if it holds state beyond the bean getters. Nothing
|
||||
> else has to be registered — the scan picks it up and `getName()` becomes its `typeId()`.
|
||||
|
||||
### Persistence round-trip
|
||||
|
||||
```
|
||||
insert(task) ──▶ TaskService serializes payload ──▶ OTask row (task_id, class, payload JSON)
|
||||
startup ──▶ TaskService reads OTask rows ──▶ TaskRegistry.getTaskCreator(class)
|
||||
.fromPayload(payload) ──▶ TaskFactory ──▶ instance
|
||||
insert(task) ──▶ TaskService.persistTask ──▶ OTask row (task_id, task_class = typeId, payload JSON)
|
||||
startup ──▶ PersistableTaskRegistry.scan() (typeId ──▶ class)
|
||||
──▶ TaskService reads OTask rows ──▶ registry.create(task_class, payload)
|
||||
──▶ DI instantiate ──▶ task.restore(payload)
|
||||
```
|
||||
|
||||
Rows whose `task_class` is not a registered `typeId` are logged and skipped; the remaining rows still
|
||||
load.
|
||||
|
||||
## 3.2 Built-in tasks
|
||||
|
||||
**Classes:** `BuiltInTask`, `AbstractBuiltInTask`.
|
||||
@@ -296,8 +272,8 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in
|
||||
`EmojiReactionRoleTask`.
|
||||
4. That `EmojiReactionRoleTask` is `insert`ed into `TaskManager` — because it is a
|
||||
[persistable task](#31-persistable-tasks) it is written to the `tasks` table and will be restored
|
||||
on the next startup via the [`persistable-tasks.json`](#the-mapping-file--persistable-tasksjson)
|
||||
mapping.
|
||||
on the next startup via the
|
||||
[`PersistableTaskRegistry`](#persistabletaskregistry--discovery-and-restore).
|
||||
|
||||
### Worked example — GitLab bug report (modal)
|
||||
|
||||
|
||||
Reference in new issue
Block a user