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

18 KiB

sessionId
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

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)

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

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

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.