Files
netneutrality/.junie/plans/persistable-task-registry-refactor.md
T
anthorob 05b4a2ab85 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.
2026-09-25 07:35:45 -04:00

280 lines
18 KiB
Markdown

---
sessionId: session-260925-070147-1a6u
---
# Requirements
### Overview & Goals
The persistable-task system currently requires a hand-maintained resource file, `src/main/resources/persistable-tasks.json`, that duplicates each task class's fully-qualified name, constructor parameter names and parameter types. Adding a persistable task means editing code *and* JSON; changing a constructor or moving a package silently breaks restore at runtime with only a log line.
This is not hypothetical — the file is **already broken**:
| Declared in JSON | Actual code |
|---|---|
| `me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask` | `me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask` |
| ctor `(String, String, String, Map)` | ctor `(TaskManager, String, String, String, Map)` |
So today the only registered persistable task **cannot be restored at all**: `Class.forName` fails on the stale package, and even with the right package `getConstructor` would miss because the DI refactor prepended `TaskManager`. On top of that, `EmojiTask.emojiMapping` is populated only by the "auto loading" constructor, so any mismatch loses the entire emoji→role mapping.
The goal: **delete `persistable-tasks.json`** and make persistable tasks self-describing and self-registering, consistent with how `BuiltInTask` and `ScheduledTask` are already discovered via `ReflectionHelper` + `ApplicationContext`.
### Scope
**In scope**
- Remove `persistable-tasks.json` and `TaskRegistry`/`TaskFactory`'s metadata-driven reflection.
- Classpath-scan discovery of `PersistableTask` implementations.
- A `restore(JSONObject)` contract implemented by each task, replacing reflective constructor matching.
- A stable logical type id persisted in `tasks.task_class` instead of the FQCN, with a Flyway migration.
- Fix the `EmojiReactionRoleTask` restore breakage and the `emojiMapping` data loss.
- Symmetric payload serialization (what is written is what is read back).
**Out of scope**
- Replacing `org.json` with Jackson.
- Changing `Task`/`StepTask`/`BuiltInTask` lifecycles or `TaskManagerImpl` concurrency behaviour.
- Reworking `Repository`/`TaskRepository` or the Hibernate setup.
- The unrelated issues tracked in `.junie/plans/improve-task-manager.md`.
### User Stories
- As a bot developer, I want to add a new persistable task by writing **one class** and nothing else, so I cannot forget to update a mapping file.
- As a bot developer, I want a constructor-signature change or package move to be caught by the **compiler or a test**, not by a runtime log at startup.
- As a server operator, I want tasks persisted before a restart — including their emoji mappings — to come back intact after a restart.
### Functional Requirements
- **FR-1** `persistable-tasks.json` is deleted; no resource file is read during persistable-task loading.
- **FR-2** Persistable task types are discovered by scanning `me.bobert.netneutrality` for concrete `PersistableTask` subtypes, the same package root already used by `ReflectionHelper.loadTasks`.
- **FR-3** Each persistable task exposes a stable `typeId()` (defaulting to its existing `getName()`), unique across all discovered types; a duplicate id fails fast at startup with a clear error.
- **FR-4** Each persistable task implements `restore(JSONObject payload)` to rebuild its own state; instantiation happens through the existing DI path (`ReflectionHelper.instantiate` with `ApplicationContext`).
- **FR-5** `toPayload()` and `restore()` are symmetric: a task serialized and restored yields an equal logical state, including `emojiMapping`.
- **FR-6** `tasks.task_class` stores the `typeId`; existing rows are migrated from FQCN (including the stale `feature.reaction.task.*` value) to the new id.
- **FR-7** A row whose `typeId` is unknown is logged and skipped without aborting the load of remaining tasks — current behaviour is preserved.
- **FR-8** `TaskService.persistTask` refuses to persist a task whose type is not registered, as it does today.
### Non-Functional Requirements
- Classpath scanning happens **once** at startup (`TaskManagerImpl.init()`), not per task row.
- No new runtime dependency is introduced; `org.reflections` and `org.json` are already in use.
- Existing persisted rows remain loadable after deployment — the migration must be the only manual step.
# Technical Design
### Current Implementation
```
App.main
└─ TaskManagerImpl(context) // holds ApplicationContext, creates TaskService(this)
└─ init()
├─ loadBuiltInTasks() // ReflectionHelper.loadTasks(BuiltInTask, context, ..) ← scanning + DI
└─ taskService.loadTasks()
├─ TaskRegistry.loadTaskConfig() // reads persistable-tasks.json
└─ for each OTask row:
TaskRegistry.getTaskCreator(oTask.taskClass) // keyed by FQCN
└─ TaskFactory.createTaskInstance(class, params, types, payload)
├─ Class.forName(className)
├─ map "string"/"int"/"map" → Class<?>[]
├─ pull each named value out of the payload
└─ clazz.getConstructor(types).newInstance(args)
```
Key files:
- `core/tasks/persistence/TaskRegistry.java` — parses the JSON into `Map<String, TaskCreator>`.
- `core/tasks/persistence/TaskFactory.java` — `ParamType` enum (only `string`/`int`/`map`) + reflective ctor call.
- `core/tasks/PersistableTask.java` — `insert`/`save`/`delete`/`toPayload`.
- `core/tasks/impl/AbstractPersistableTask.java` — `toPayload()` = `new JSONObject(this)` (bean getters).
- `db/service/TaskService.java` — `loadTasks`/`persistTask`/`unpersistTask`, `validateTask` checks `id`/`lockId`.
- `db/entities/OTask.java` — `task_id` (unique), `task_class`, `payload` (`@Lob`).
- `bootstrap/ReflectionHelper.java` — `loadClassesFromPackage` + `instantiate(type, ApplicationContext)`.
The asymmetry is the root cause: **writing** uses bean introspection over all getters, **reading** uses a hand-declared subset of constructor params. Nothing keeps the two in sync.
### Key Decisions
1. **Self-restoring tasks** (confirmed with user). A task is created via the normal DI constructor and then told to hydrate itself from the payload. This eliminates parameter metadata entirely and makes the round trip visible in one class.
2. **Reuse `ReflectionHelper` + `ApplicationContext`** rather than a bespoke scanner, so persistable tasks follow the exact same discovery convention as `BuiltInTask` and `ScheduledTask`.
3. **Stable `typeId` in the DB** (confirmed with user). `getName()` already returns `"emoji-reaction-role"`, so `typeId()` defaults to `getName()` and no task needs a new literal. Package moves and class renames stop being breaking changes.
4. **Prototype-per-type registry.** The scan produces one throwaway instance per type used only to read `typeId()`; each restore creates a *fresh* instance via `ReflectionHelper.instantiate` so no state leaks between rows.
5. **Keep `org.json`.** Jackson was considered and rejected — it would touch every task's field mapping for no gain here.
### Proposed Changes
**A. Contract — `core/tasks/PersistableTask.java`**
```java
public interface PersistableTask extends Task {
/** Stable logical type id stored in tasks.task_class. Defaults to getName(). */
default String typeId() { return getName(); }
/** Rebuild this instance's state from a payload previously produced by toPayload(). */
void restore(JSONObject payload);
void insert(CommandContext context);
void save();
void delete();
String toPayload();
}
```
**B. `core/tasks/persistence/PersistableTaskRegistry.java`** (replaces `TaskRegistry`, no longer static)
```java
public final class PersistableTaskRegistry {
private final Map<String, Class<? extends PersistableTask>> typesById;
private final ApplicationContext context;
public void scan(); // ReflectionHelper.loadTasks(PersistableTask.class, context, ..)
public boolean isRegistered(String typeId);
public PersistableTask create(String typeId, JSONObject payload); // instantiate + restore
}
```
- `scan()` skips abstract types (`AbstractPersistableTask`, `EmojiTask` are filtered by the existing `Modifier.isAbstract` check in `loadClassesFromPackage`).
- Duplicate `typeId` → `IllegalStateException` at startup, naming both classes.
- `create()` calls `ReflectionHelper.instantiate(clazz, context)` then `task.restore(payload)`.
**C. Delete** `TaskFactory.java` (and its `ParamType` enum) and `src/main/resources/persistable-tasks.json`.
**D. `AbstractPersistableTask`**
- `toPayload()` keeps `new JSONObject(this)` but the base class becomes responsible for guaranteeing `id` and `lockId` are present, since `TaskService.validateTask` reads both.
- Add a no-op `restore(JSONObject)` default so leaf tasks only override when they carry extra state.
**E. `EmojiTask` / `EmojiReactionRoleTask` — the actual bug fix**
- `EmojiTask.restore(payload)` reads `guildId`/`channelId`/`messageId` and repopulates `emojiMapping` from `payload.getJSONObject("emojiMapping")`.
- Because `guildId`/`channelId`/`messageId` are `final` and set by the ctor, they become non-final assigned-on-restore fields, or the restore path uses a dedicated protected initializer — whichever keeps `getId()` (`String.format(EmojiTaskContext.ID, guildId, messageId)`) correct **before** the task is handed to `TaskManager`.
- The vestigial "constructor for auto loading" overload in `EmojiReactionRoleTask` is removed; it exists only to serve `TaskFactory`.
**F. `db/service/TaskService.java`**
- Takes the `PersistableTaskRegistry` (constructed by `TaskManagerImpl`, which already owns the `ApplicationContext`).
- `loadTasks()` drops `TaskRegistry.loadTaskConfig()` and resolves by `oTask.getTaskClass()` treated as a **type id**.
- `persistTask()` writes `task.typeId()` instead of `task.getClass().getName()`, and guards with `registry.isRegistered(...)`.
- `validateTask` is unchanged.
**G. Migration — `src/main/resources/db/migration/V2__task_type_id.sql`**
```sql
UPDATE tasks SET task_class = 'emoji-reaction-role'
WHERE task_class IN (
'me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask',
'me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask'
);
```
Column stays `task_class` (renaming it is cosmetic and would churn `OTask`); a comment documents that it now holds a logical id.
### File Structure
| File | Change |
|---|---|
| `src/main/resources/persistable-tasks.json` | **deleted** |
| `core/tasks/persistence/TaskFactory.java` | **deleted** |
| `core/tasks/persistence/TaskRegistry.java` | **replaced** by `PersistableTaskRegistry.java` |
| `core/tasks/PersistableTask.java` | + `typeId()`, + `restore(JSONObject)` |
| `core/tasks/impl/AbstractPersistableTask.java` | default `restore`, payload guarantees |
| `core/tasks/impl/EmojiTask.java` | implements `restore`, hydrates `emojiMapping` |
| `tasks/reaction/EmojiReactionRoleTask.java` | drop auto-loading ctor |
| `core/tasks/TaskManagerImpl.java` | build + scan registry, pass to `TaskService` |
| `db/service/TaskService.java` | registry injection, type-id keying |
| `src/main/resources/db/migration/V2__task_type_id.sql` | **new** |
| `docs/tasks.md`, `docs/persistence.md` | document the new flow, drop JSON references |
### Architecture Diagram
```mermaid
graph LR
A[TaskManagerImpl.init] --> B[PersistableTaskRegistry.scan]
B --> C[Reflections: PersistableTask subtypes]
C --> D[typeId to Class map]
A --> E[TaskService.loadTasks]
E --> F[TaskRepository.findAll]
F --> G[registry.create typeId payload]
D --> G
G --> H[ReflectionHelper.instantiate DI]
H --> I[task.restore payload]
I --> J[TaskManager.insertPersistableTask]
```
### Risks
- **Final-field hydration in `EmojiTask`.** Restoring after construction means `guildId`/`messageId` cannot stay `final`. Mitigation: hydrate inside `PersistableTaskRegistry.create()` *before* the instance escapes, and assert `getId()` matches `OTask.task_id` in `validateTask` (already done today).
- **Scan picks up unintended subtypes.** Any concrete `PersistableTask` in `me.bobert.netneutrality` becomes registered. Mitigation: duplicate-id detection plus a test asserting the registered set.
- **Migration ordering.** Flyway runs before `TaskManagerImpl.init()` in the existing startup lifecycle, so V2 is applied before any load; worth re-verifying in `App.main` wiring.
- **DI constructor ambiguity.** `ReflectionHelper.instantiate` picks the longest fully-resolvable constructor; removing the auto-loading overload leaves exactly one candidate for `EmojiReactionRoleTask`, which is the intent.
# Testing
### Validation Approach
Compile and run the existing suite (`TaskManagerImplTest`, `ReflectionInjectionTest`) to confirm no regression, then add focused unit tests around the new registry and the round trip. The key invariant — *a task can be serialized and restored without loss* — becomes a test rather than a hand-checked JSON file.
### Key Scenarios
- **Discovery**: `PersistableTaskRegistry.scan()` over `me.bobert.netneutrality` registers `emoji-reaction-role` and does **not** register the abstract `AbstractPersistableTask` / `EmojiTask`.
- **Round trip**: build an `EmojiReactionRoleTask` with a non-empty `emojiMapping`, call `toPayload()`, feed it to `registry.create("emoji-reaction-role", payload)`, and assert `getId()`, `getLockId()`, `guildId`, `channelId`, `messageId` and the full `emojiMapping` all match. This is the regression test for the current data-loss bug.
- **DI**: the restored instance has a non-null `taskManager` injected via `ApplicationContext`, proving the DI path is used.
- **Persist keying**: `TaskService.persistTask` stores `emoji-reaction-role` in `OTask.task_class`, not an FQCN.
- **End-to-end load**: a stubbed `TaskRepository` returning one row with the new type id results in `taskManager.insertPersistableTask` being called once.
### Edge Cases
- Unknown `typeId` in a row → logged, skipped, remaining rows still load.
- Duplicate `typeId` across two classes → startup fails fast with both class names in the message.
- Payload missing `emojiMapping` → restores to an empty map rather than throwing.
- `validateTask` mismatches (`id`, payload `id`, payload `lockId`) still reject the row.
- No `persistable-tasks.json` on the classpath → nothing references it; a test asserts the resource is absent so it cannot be reintroduced by accident.
### Test Changes
- New `PersistableTaskRegistryTest` — discovery, duplicate detection, unknown id.
- New `EmojiReactionRoleTaskPersistenceTest` — payload round trip including emoji mapping.
- Update `TaskManagerImplTest` where it constructs the task service / registry.
- Remove any assertions tied to `TaskFactory.ParamType`.
# Delivery Steps
### ✓ Step 1: Introduce the self-describing PersistableTask contract
`PersistableTask` carries its own type id and restore logic, with a compiling default for existing tasks.
- Add `default String typeId() { return getName(); }` to `core/tasks/PersistableTask.java`.
- Add `void restore(JSONObject payload)` to the interface.
- Give `AbstractPersistableTask` a no-op `restore` default so only tasks with extra state override it.
- Harden `AbstractPersistableTask.toPayload()` to guarantee `id` and `lockId` are always present, since `TaskService.validateTask` reads both.
- Leave `TaskRegistry`/`TaskFactory` in place at this stage so the build stays green.
### ✓ Step 2: Add the scanning PersistableTaskRegistry and retire the JSON
Persistable task types are discovered from the classpath and `persistable-tasks.json` is gone.
- Create `core/tasks/persistence/PersistableTaskRegistry` with `scan()`, `isRegistered(String)` and `create(String typeId, JSONObject payload)`.
- Implement `scan()` on top of `ReflectionHelper.loadTasks(PersistableTask.class, context, ..)`, mapping `typeId()` to the concrete class and throwing on duplicate ids naming both classes.
- Implement `create()` as `ReflectionHelper.instantiate(clazz, context)` followed by `task.restore(payload)`.
- Delete `core/tasks/persistence/TaskFactory.java`, `core/tasks/persistence/TaskRegistry.java` and `src/main/resources/persistable-tasks.json`.
- Add `PersistableTaskRegistryTest` covering discovery of `emoji-reaction-role`, exclusion of abstract types, and duplicate-id failure.
### ✓ Step 3: Fix EmojiTask restore and the lost emoji mapping
An `EmojiReactionRoleTask` survives a persist/restore cycle with its emoji mapping intact.
- Implement `restore(JSONObject)` in `core/tasks/impl/EmojiTask.java`, hydrating `guildId`, `channelId`, `messageId` and repopulating `emojiMapping` from the payload.
- Adjust the field declarations so restore-after-construction is possible while `getId()` stays correct before the instance escapes the registry.
- Remove the now-dead "constructor for auto loading" overload from `tasks/reaction/EmojiReactionRoleTask.java`.
- Handle a missing `emojiMapping` key by restoring an empty map instead of throwing.
- Add `EmojiReactionRoleTaskPersistenceTest` asserting a full round trip including a non-empty mapping and a DI-injected `taskManager`.
### ✓ Step 4: Key persistence by stable type id and migrate existing rows
`tasks.task_class` holds a logical type id, decoupling stored rows from package structure.
- Change `TaskService.persistTask` to write `task.typeId()` instead of `task.getClass().getName()`, guarded by `registry.isRegistered(...)`.
- Change `TaskService.loadTasks` to resolve rows through `PersistableTaskRegistry.create(...)` and drop the `TaskRegistry.loadTaskConfig()` call.
- Inject the registry into `TaskService` from `TaskManagerImpl`, which already owns the `ApplicationContext`, and trigger `scan()` during `TaskManagerImpl.init()`.
- Add `src/main/resources/db/migration/V2__task_type_id.sql` rewriting both the stale `feature.reaction.task.*` and current `tasks.reaction.*` FQCNs to `emoji-reaction-role`.
- Keep unknown-type rows as logged-and-skipped, and update `TaskManagerImplTest` for the new construction signature.
### ✓ Step 5: Update the task and persistence documentation
The docs describe the scan-and-restore flow with no references to the deleted resource file.
- Rewrite the persistable-task section of `docs/tasks.md` to describe `typeId()`, `restore()` and classpath discovery, replacing the `TaskRegistry`/`TaskFactory` JSON description.
- Add a short "how to add a persistable task" recipe showing that a single class is sufficient.
- Update the `TaskService` bullet and the `OTask` row in `docs/persistence.md` to state that `task_class` stores a logical type id.
- Note the `V2__task_type_id.sql` migration in the schema section of `docs/persistence.md`.