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:
anthorob committed 2026-09-25 07:35:45 -04:00
1 parent 9f2e15a622
commit 05b4a2ab85
19 files changed
+1020 -291

No files matched your search

@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.tasks;
*/
import me.bobert.netneutrality.core.context.CommandContext;
import org.json.JSONObject;
/**
* Represents a task that can be persisted and restored later.
@@ -13,6 +14,23 @@ import me.bobert.netneutrality.core.context.CommandContext;
* so that it can be stored in a database.
*/
public interface PersistableTask extends Task {
/**
* Stable logical type id stored in {@code tasks.task_class}.
* Defaults to {@link Task#getName()} so package moves and class renames are not breaking changes.
*
* @return the stable type id of this task type
*/
default String typeId() {
return getName();
}
/**
* Rebuilds this instance's state from a payload previously produced by {@link #toPayload()}.
*
* @param payload the persisted payload, must not be {@code null}
*/
void restore(JSONObject payload);
void insert(CommandContext context);
void save();
@@ -15,6 +15,7 @@ import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException;
@@ -45,6 +46,7 @@ public class TaskManagerImpl implements TaskManager {
private final Map<String, CommandContext> deletableTasksContext;
private final Map<String, ReentrantLock> tasksLock;
private final ApplicationContext context;
private final PersistableTaskRegistry persistableTaskRegistry;
/**
* Creates a task manager that can inject context-registered dependencies into reflectively
@@ -58,8 +60,9 @@ public class TaskManagerImpl implements TaskManager {
this.pendingTasks = new ConcurrentHashMap<>();
this.deletableTasksContext = new ConcurrentHashMap<>();
this.tasksLock = new ConcurrentHashMap<>();
this.taskService = new TaskService(this);
this.context = context;
this.persistableTaskRegistry = new PersistableTaskRegistry(context);
this.taskService = new TaskService(this, persistableTaskRegistry);
}
@Override
@@ -76,6 +79,7 @@ public class TaskManagerImpl implements TaskManager {
logger.info("Initializing task manager...");
loadBuiltInTasks();
persistableTaskRegistry.scan();
taskService.loadTasks();
}
@@ -60,9 +60,21 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe
taskManager().removeTask(this);
}
/**
* No-op default: only tasks carrying extra state need to override this.
*
* @param payload the persisted payload
*/
@Override
public void restore(JSONObject payload) {
// Nothing to restore by default
}
@Override
public String toPayload() {
JSONObject payload = new JSONObject(this);
payload.put("id", getId());
payload.put("lockId", getLockId());
return payload.toString();
}
@@ -7,15 +7,16 @@ package me.bobert.netneutrality.core.tasks.impl;
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import org.json.JSONObject;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public abstract class EmojiTask extends AbstractPersistableTask {
private final Map<String, String> emojiMapping = new ConcurrentHashMap<>();
private final String guildId;
private final String channelId;
private final String messageId;
private String guildId;
private String channelId;
private String messageId;
public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) {
super(taskManager);
@@ -24,6 +25,31 @@ public abstract class EmojiTask extends AbstractPersistableTask {
this.messageId = messageId;
}
/**
* Constructor used by the persistable task registry: the message coordinates and the emoji
* mapping are hydrated afterwards by {@link #restore(JSONObject)}.
*
* @param taskManager the application task manager, must not be {@code null}
*/
public EmojiTask(TaskManager taskManager) {
super(taskManager);
}
@Override
public void restore(JSONObject payload) {
this.guildId = payload.optString("guildId", null);
this.channelId = payload.optString("channelId", null);
this.messageId = payload.optString("messageId", null);
emojiMapping.clear();
JSONObject mapping = payload.optJSONObject("emojiMapping");
if (mapping != null) {
for (String emoji : mapping.keySet()) {
emojiMapping.put(emoji, mapping.getString(emoji));
}
}
}
public String getGuildId() {
return guildId;
}
@@ -0,0 +1,102 @@
package me.bobert.netneutrality.core.tasks.persistence;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import me.bobert.netneutrality.core.tasks.PersistableTask;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONObject;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Registry of the persistable task types available on the classpath.
*
* <p>Types are discovered by scanning for concrete {@link PersistableTask} implementations and are
* keyed by their {@link PersistableTask#typeId()}, which is the value stored in
* {@code tasks.task_class}. Restoring a row creates a fresh instance through the regular DI path
* and lets the task hydrate itself from its payload.
*/
public final class PersistableTaskRegistry {
private static final Logger logger = LogManager.getLogger(PersistableTaskRegistry.class);
private final Map<String, Class<? extends PersistableTask>> typesById = new ConcurrentHashMap<>();
private final ApplicationContext context;
/**
* @param context the DI context used to instantiate discovered task types, must not be {@code null}
*/
public PersistableTaskRegistry(ApplicationContext context) {
this.context = Objects.requireNonNull(context, "ApplicationContext must not be null");
}
/**
* Discovers every concrete {@link PersistableTask} implementation and registers it by its type id.
*
* @throws IllegalStateException if two task types declare the same type id
*/
public void scan() {
typesById.clear();
ReflectionHelper.loadTasks(PersistableTask.class, context, this::register);
logger.info("Registered {} persistable task type(s): {}", typesById.size(), typesById.keySet());
}
void register(PersistableTask prototype) {
String typeId = prototype.typeId();
Class<? extends PersistableTask> type = prototype.getClass();
Class<? extends PersistableTask> previous = typesById.putIfAbsent(typeId, type);
if (previous != null && !previous.equals(type)) {
throw new IllegalStateException(String.format(
"Duplicate persistable task type id '%s' declared by %s and %s",
typeId, previous.getName(), type.getName()));
}
}
/**
* @param typeId the type id to look up
* @return {@code true} if a task type is registered under the given id
*/
public boolean isRegistered(String typeId) {
return typeId != null && typesById.containsKey(typeId);
}
/**
* @return the registered type ids
*/
public Set<String> getTypeIds() {
return Collections.unmodifiableSet(typesById.keySet());
}
/**
* Creates a fresh task instance for the given type id and hydrates it from the payload.
*
* @param typeId the registered type id
* @param payload the payload previously produced by {@link PersistableTask#toPayload()}
* @return the restored task
* @throws IllegalArgumentException if no task type is registered under {@code typeId}
* @throws ReflectiveOperationException if the task type cannot be instantiated
*/
public PersistableTask create(String typeId, JSONObject payload) throws ReflectiveOperationException {
Class<? extends PersistableTask> type = typesById.get(typeId);
if (type == null) {
throw new IllegalArgumentException("No persistable task type registered for id: " + typeId);
}
PersistableTask task = ReflectionHelper.instantiate(type, context);
task.restore(payload);
return task;
}
}
@@ -1,81 +0,0 @@
package me.bobert.netneutrality.core.tasks.persistence;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.tasks.PersistableTask;
import org.json.JSONArray;
import org.json.JSONObject;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.Map;
public class TaskFactory {
public enum ParamType {
STRING(String.class),
INT(Integer.class),
MAP(Map.class);
private final Class<?> typeClass;
ParamType(Class<?> typeClass) {
this.typeClass = typeClass;
}
public Class<?> getTypeClass() {
return typeClass;
}
public static ParamType fromString(String type) {
return switch (type.toLowerCase()) {
case "string" -> STRING;
case "int" -> INT;
case "map" -> MAP;
default -> throw new IllegalArgumentException("Unsupported type: " + type);
};
}
}
public static PersistableTask createTaskInstance(String className, JSONArray params, JSONArray types, JSONObject payload) throws Exception {
if (params.length() != types.length()) {
throw new RuntimeException("Wrong number of parameters and types found in task mappings");
}
Class<?> clazz = Class.forName(className);
if (!PersistableTask.class.isAssignableFrom(clazz)) {
throw new IllegalArgumentException("Class does not implement PersistableTask.");
}
Class<?>[] classTypes = new Class<?>[types.length()];
Object[] classParams = new Object[params.length()];
for (int i = 0; i < params.length(); i++) {
ParamType paramType = ParamType.fromString(types.getString(i));
String paramName = params.getString(i);
classTypes[i] = paramType.getTypeClass();
classParams[i] = extractValue(paramType, paramName, payload);
}
try {
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
} catch (NoSuchMethodException e) {
throw new NoSuchMethodException("No matching constructor found with parameters: " + Arrays.toString(classTypes));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid arguments for constructor. Expected: " + Arrays.toString(classParams), e);
} catch (InvocationTargetException e) {
throw new RuntimeException("Constructor threw an exception", e.getCause());
}
}
private static Object extractValue(ParamType type, String paramName, JSONObject payload) {
return switch (type) {
case STRING -> payload.getString(paramName);
case INT -> payload.getInt(paramName);
case MAP -> payload.getJSONObject(paramName).toMap();
};
}
}
@@ -1,94 +0,0 @@
package me.bobert.netneutrality.core.tasks.persistence;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.tasks.PersistableTask;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
public class TaskRegistry {
@FunctionalInterface
public interface TaskCreator {
PersistableTask fromPayload(JSONObject payload);
}
private static final Logger logger = LogManager.getLogger(TaskRegistry.class);
private static final Map<String, TaskCreator> taskCreators = new HashMap<>();
private static final String FILE_NAME = "persistable-tasks.json";
// JSON keys as constants
private static final String KEY_TASKS = "tasks";
private static final String KEY_CLASS = "class";
private static final String KEY_PARAMS = "params";
private static final String KEY_TYPES = "types";
public static TaskCreator getTaskCreator(String className) {
return taskCreators.get(className);
}
public static void loadTaskConfig() {
logger.info("Reading task mappings from {}..", FILE_NAME);
try (InputStream is = TaskFactory.class.getClassLoader().getResourceAsStream(FILE_NAME)) {
if (is == null) {
throw new FileNotFoundException("Could not load: " + FILE_NAME);
}
String jsonString = IOUtils.toString(is, StandardCharsets.UTF_8);
JSONObject json = new JSONObject(jsonString);
JSONArray tasksArray = json.optJSONArray(KEY_TASKS);
if (tasksArray == null) {
logger.warn("No tasks found in JSON: {}", FILE_NAME);
return;
}
for (int i = 0; i < tasksArray.length(); i++) {
JSONObject taskJson = tasksArray.getJSONObject(i);
String className = taskJson.optString(KEY_CLASS, "").trim();
if (className.isEmpty()) {
logger.warn("Missing JSON key '{}' at index {}", KEY_CLASS, i);
continue;
}
JSONArray params = taskJson.optJSONArray(KEY_PARAMS);
if (params == null) {
logger.warn("Missing JSON key '{}' for class: {}", KEY_PARAMS, className);
continue;
}
JSONArray types = taskJson.optJSONArray(KEY_TYPES);
if (types == null) {
logger.warn("Missing JSON key '{}' for class: {}", KEY_TYPES, className);
continue;
}
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;
}
});
}
logger.info("{} mappings registered", taskCreators.size());
} catch (Exception e) {
logger.error(e);
}
}
}
@@ -25,6 +25,9 @@ public class OTask {
@Column(name = "task_id", nullable = false)
private String taskId;
/**
* Stable logical task type id ({@code PersistableTask#typeId()}), not a class name.
*/
@Column(name = "task_class", nullable = false)
private String taskClass;
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.db.service;
import me.bobert.netneutrality.core.tasks.PersistableTask;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.persistence.TaskRegistry;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
import me.bobert.netneutrality.db.entities.OTask;
import me.bobert.netneutrality.db.repository.TaskRepository;
import org.apache.logging.log4j.LogManager;
@@ -21,36 +21,29 @@ public class TaskService {
private final TaskManager taskManager;
private static final Logger logger = LogManager.getLogger(TaskService.class);
private final TaskRepository taskRepository;
private final PersistableTaskRegistry registry;
public TaskService(TaskManager taskManager) {
public TaskService(TaskManager taskManager, PersistableTaskRegistry registry) {
this.taskManager = taskManager;
this.taskRepository = new TaskRepository();
this.registry = registry;
}
public void loadTasks() {
TaskRegistry.loadTaskConfig();
logger.info("Loading persistable tasks...");
List<OTask> tasks = taskRepository.findAll();
for (OTask oTask : tasks) {
String taskId = oTask.getTaskId();
String taskClass = oTask.getTaskClass();
logger.debug("Trying to load task with ID: {} and class: {}", taskId, taskClass);
String typeId = oTask.getTaskClass();
logger.debug("Trying to load task with ID: {} and type: {}", taskId, typeId);
String payload = oTask.getPayload();
try {
TaskRegistry.TaskCreator creator = TaskRegistry.getTaskCreator(taskClass);
if (creator == null) {
throw new IllegalArgumentException("No task creator registered for class: " + taskClass);
}
JSONObject json = new JSONObject(payload);
PersistableTask task = creator.fromPayload(json);
if (task == null) {
continue;
}
PersistableTask task = registry.create(typeId, json);
validateTask(task, oTask);
taskManager.insertPersistableTask(task);
@@ -81,9 +74,9 @@ public class TaskService {
}
public void persistTask(PersistableTask task) {
String taskClass = task.getClass().getName();
if (TaskRegistry.getTaskCreator(taskClass) == null) {
logger.error("No TaskCreator found for class: {}", taskClass);
String typeId = task.typeId();
if (!registry.isRegistered(typeId)) {
logger.error("No persistable task type registered for id: {}", typeId);
return;
}
@@ -92,7 +85,7 @@ public class TaskService {
if (oTask == null) {
oTask = new OTask();
oTask.setTaskId(task.getId());
oTask.setTaskClass(task.getClass().getName());
oTask.setTaskClass(typeId);
}
oTask.setPayload(task.toPayload());
@@ -18,12 +18,20 @@ public class EmojiReactionRoleTask extends EmojiTask {
super(taskManager, guildId, channelId, messageId);
}
// constructor for auto loading
public EmojiReactionRoleTask(TaskManager taskManager, String guildId, String channelId, String messageId, Map<String, String> emojiMapping) {
super(taskManager, guildId, channelId, messageId);
getEmojiMapping().putAll(emojiMapping);
}
/**
* Constructor used by the persistable task registry.
*
* @param taskManager the application task manager, must not be {@code null}
*/
public EmojiReactionRoleTask(TaskManager taskManager) {
super(taskManager);
}
@Override
public String getName() {
return "emoji-reaction-role";
@@ -0,0 +1,11 @@
--
-- `tasks`.`task_class` now stores a stable logical task type id
-- (PersistableTask#typeId()) instead of a fully-qualified class name,
-- so package moves and class renames no longer break restoring tasks.
--
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'
);
-19
View File
@@ -1,19 +0,0 @@
{
"tasks": [
{
"class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask",
"params": [
"guildId",
"channelId",
"messageId",
"emojiMapping"
],
"types": [
"string",
"string",
"string",
"map"
]
}
]
}