Tag prefetched copies with the controller's run token

If the controller crashes after sending generation N but before saving it, the destination keeps that prefetch while the controller restarts at N-1, and another instance can then upload different data as generation N. The controller now picks a random token per run, sends it with every prefetch and acquire reply, and the scenario applies a held copy only when its generation and token both match, and lets a new run's copy replace one with another token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ILLISISandClaude Opus 5.5 committed 2026-10-20 12:24:24 +11:00
1 parent ca7caedfbe
commit 5e4dcada50
6 files changed
+84 -18

No files matched your search

+5 -1
View File
@@ -2,6 +2,7 @@ import type { ControllerPluginContext, InstanceRecord } from "@clusterio/control
import type { IpcPlayerData } from "./messages.js";
import { mergePlayerData } from "./components.js";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "path";
import * as lib from "@clusterio/lib";
@@ -67,6 +68,8 @@ async function saveDatabase(
export default async function(context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
// Differs on every controller start: a prefetched copy holding another run's token may be one this run never saved
const runToken = crypto.randomBytes(8).toString("hex");
const acquiredPlayers = new Map<string, { instanceId: number, expiresMs?: number }>();
const { playerDatastore, tombstones } = await loadDatabase(controller.config, logger);
let playerDatastoreDirty = false;
@@ -125,7 +128,7 @@ export default async function(context: ControllerPluginContext) {
return;
}
try {
controller.sendTo({ instanceId: targetId }, new msg.PrefetchEvent(playerData));
controller.sendTo({ instanceId: targetId }, new msg.PrefetchEvent(playerData, runToken));
logger.verbose(`Prefetching ${playerData.name} to ${target.config.get("instance.name")}`);
} catch (err: any) {
// sendEvent throws when the instance's host isn't connected; the upload is stored either way
@@ -186,6 +189,7 @@ export default async function(context: ControllerPluginContext) {
playerDatastore.has(playerName),
undefined,
epoch || undefined,
runToken,
);
});