Save controller plugin data on the save hook (#513)

exp_groups and exp_roles only saved their datastores on shutdown, and
exp_scenario saved the player store but not the feature store on the
save hook. The controller runs the save hook on its autosave interval,
so a crash or kill lost every change since the controller started.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BastiaanandClaude Opus 5.5 authored and GitHub committed 2026-10-25 21:57:21 +00:00
1 parent 82abc2c332
commit 20ca539af3
5 files changed
+33 -5

No files matched your search

+3 -2
View File
@@ -80,11 +80,12 @@ export class ControllerPlugin {
controller.handle(messages.RoleMappingGetRequest, plugin.handleRoleMappingGetRequest.bind(plugin));
controller.handle(messages.RoleMappingListRequest, plugin.handleRoleMappingListRequest.bind(plugin));
controller.hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin));
controller.hooks.save.attach(plugin.name, plugin.onSave.bind(plugin));
controller.hooks.shutdown.attach(plugin.name, plugin.onSave.bind(plugin));
return plugin;
}
async onShutdown() {
async onSave() {
await Promise.all([
this.groups.save(),
this.manualAssignments.save(),
+3 -2
View File
@@ -55,13 +55,14 @@ export class ControllerPlugin {
controller.handle(messages.AssignmentUpdateRequest, plugin.handleAssignmentUpdateRequest.bind(plugin));
const hooks = controller.hooks;
hooks.shutdown.attach(plugin.name, plugin.onShutdown.bind(plugin));
hooks.save.attach(plugin.name, plugin.onSave.bind(plugin));
hooks.shutdown.attach(plugin.name, plugin.onSave.bind(plugin));
hooks.controllerConfigFieldChanged.attach(plugin.name, plugin.onControllerConfigFieldChanged.bind(plugin));
hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin));
return plugin;
}
async onShutdown() {
async onSave() {
await this.roleMeta.save();
}
+15 -1
View File
@@ -1,4 +1,6 @@
import t from "tap";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { Controller } from "@clusterio/controller";
import entrypoint, { ControllerPlugin } from "../dist/node/controller.js";
@@ -54,7 +56,7 @@ t.test("entrypoint", async t2 => {
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
const controller = new Controller(logger, [], controllerConfig);
await entrypoint({ controller, logger, plugin: { name: "exp_roles" }, metrics: undefined });
for (const hook of ["shutdown", "controllerConfigFieldChanged", "playerEvent"]) {
for (const hook of ["save", "shutdown", "controllerConfigFieldChanged", "playerEvent"]) {
t2.ok([...controller.hooks[hook].attached].includes("exp_roles"), `attached to ${hook}`);
}
await controller.hooks.shutdown.invoke();
@@ -208,6 +210,18 @@ t.test("class ControllerPlugin", t2 => {
t3.strictSame(plugin.roleMeta.get(5).shortHand, "Mod", "the properties are stored");
});
t2.test("role properties are written on the save hook", async t3 => {
const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] });
await plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest(
new messages.RoleMetaRecord(5, 2, 0, "Mod"),
));
await controller.hooks.save.invoke();
const file = path.join(controller.config.get("controller.database_directory"), "exp_roles", "role_meta.json");
const stored = JSON.parse(await fs.readFile(file, "utf8"));
t3.ok(stored.some(record => record.id === 5 && record.short_hand === "Mod"), "the update is on disk");
});
t2.test(".handleAssignmentListRequest() returns the current assignments", async t3 => {
const { plugin, controller } = await startPlugin(t3, {
roles: [role(1, "Player"), role(5, "Moderator")],
+1
View File
@@ -239,6 +239,7 @@ export default async function (context: ControllerPluginContext) {
});
controller.hooks.save.attach(plugin.name, async () => {
await featureStore.save();
await playerStore.save();
});
+11
View File
@@ -216,6 +216,17 @@ t.test("controller plugin", t2 => {
);
});
t2.test("features are saved on the save hook", async t3 => {
const databaseDirectory = t3.testdir();
const { updateFeature, save } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
await updateFeature(new messages.FeatureUpdateRequest("afk_kick", false, { afk_minutes: 5 }));
await save();
const { listFeatures } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
const record = await getFeature(listFeatures, "afk_kick");
t3.strictSame([record.enabled, record.values], [false, { afk_minutes: 5 }], "the saved record is loaded");
});
t2.test("stored features are kept across restarts", async t3 => {
const databaseDirectory = t3.testdir();
const { updateFeature, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory });