fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
The plugin now answers a save once the files are written, with pending and a writeId, and reports the reload later as a config.outcome event. The save is recorded as reloading with a settle_by of two plugin ceilings plus slack on the database's clock; ingest settles the row by (server, writeId), only while it is still reloading, so a replay moves nothing and a late outcome still lands. A row past settle_by reads as lost. GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page polls it every two seconds, holds the Save button while it waits, and says whether a rolled-back plugin came back on its old file. config.outcome is a staff kind: it carries the server's log tail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -18,11 +18,11 @@ const core = require('../../core')
|
||||
* silence.
|
||||
*/
|
||||
async function recordWrite(row) {
|
||||
await core.query(
|
||||
const result = await core.query(
|
||||
`INSERT INTO rust_config_writes
|
||||
(server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded,
|
||||
changes, version_before, version_after, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
changes, version_before, version_after, detail, write_id, settle_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`,
|
||||
[
|
||||
row.serverId,
|
||||
row.path,
|
||||
@@ -36,8 +36,55 @@ async function recordWrite(row) {
|
||||
row.versionBefore || null,
|
||||
row.versionAfter || null,
|
||||
row.detail ? String(row.detail).slice(0, 500) : null,
|
||||
row.writeId || null,
|
||||
// Seconds from now, on the database's clock — the one `lost` is read against.
|
||||
// NULL for a write nothing is waiting on, and DATE_ADD of NULL is NULL.
|
||||
row.settleSeconds != null ? Number(row.settleSeconds) : null,
|
||||
],
|
||||
)
|
||||
|
||||
return result && result.insertId != null ? Number(result.insertId) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a `reloading` write from the plugin's `config.outcome` (protocol 13).
|
||||
*
|
||||
* Found by the plugin's `writeId`, scoped to the server that sent it — a write
|
||||
* id is only unique within one plugin. Only a row still `reloading` moves, so a
|
||||
* replayed frame settles nothing twice. A row whose `settle_by` has passed is
|
||||
* still `reloading` in the table (it reads as `lost`), so a late outcome still
|
||||
* lands. Returns whether a row moved.
|
||||
*/
|
||||
async function settleWrite(serverId, outcome) {
|
||||
const result = await core.query(
|
||||
`UPDATE rust_config_writes
|
||||
SET outcome = ?, reloaded = ?, restored = ?, version_after = ?, detail = ?,
|
||||
log_tail = ?, settled_at = NOW()
|
||||
WHERE server_id = ? AND write_id = ? AND outcome = 'reloading'`,
|
||||
[
|
||||
outcome.outcome,
|
||||
outcome.reloaded ? 1 : 0,
|
||||
outcome.restored == null ? null : outcome.restored ? 1 : 0,
|
||||
outcome.versionAfter || null,
|
||||
outcome.detail ? String(outcome.detail).slice(0, 500) : null,
|
||||
outcome.log || null,
|
||||
serverId,
|
||||
outcome.writeId,
|
||||
],
|
||||
)
|
||||
|
||||
return Number((result && result.affectedRows) || 0) > 0
|
||||
}
|
||||
|
||||
/** One write, by its row id, for the page that is waiting on it. */
|
||||
async function getWrite(serverId, id) {
|
||||
const rows = await core.query(
|
||||
`${SELECT_WRITE}
|
||||
WHERE server_id = ? AND id = ?`,
|
||||
[serverId, id],
|
||||
)
|
||||
|
||||
return rows[0] ? shapeWrite(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,21 +96,40 @@ async function recordWrite(row) {
|
||||
*/
|
||||
async function recentWrites(serverId, limit = 50) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
|
||||
user_id AS userId, outcome, reloaded, changes,
|
||||
version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt
|
||||
FROM rust_config_writes
|
||||
`${SELECT_WRITE}
|
||||
WHERE server_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`,
|
||||
[serverId, Math.max(1, Math.min(Number(limit) || 50, 200))],
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
return rows.map(shapeWrite)
|
||||
}
|
||||
|
||||
// `lost` is decided by the database's clock, in the same statement that reads
|
||||
// the row, so it cannot disagree with `settle_by`, which that clock also wrote.
|
||||
const SELECT_WRITE = `SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
|
||||
user_id AS userId, outcome, reloaded, restored, changes,
|
||||
version_before AS versionBefore, version_after AS versionAfter, detail,
|
||||
log_tail AS log, write_id AS writeId, settled_at AS settledAt, created_at AS createdAt,
|
||||
(outcome = 'reloading' AND settle_by IS NOT NULL AND settle_by < NOW()) AS lost
|
||||
FROM rust_config_writes`
|
||||
|
||||
/**
|
||||
* A row as the page reads it. A write still `reloading` past its `settle_by`
|
||||
* reads as `lost`: the plugin was reloaded or the link dropped before it could
|
||||
* say, and the files are whatever a re-read shows.
|
||||
*/
|
||||
function shapeWrite(row) {
|
||||
const { lost, ...rest } = row
|
||||
|
||||
return {
|
||||
...rest,
|
||||
outcome: Number(lost) ? 'lost' : row.outcome,
|
||||
reloaded: Boolean(row.reloaded),
|
||||
restored: row.restored == null ? null : Boolean(row.restored),
|
||||
changes: parseChanges(row.changes),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function parseChanges(raw) {
|
||||
@@ -76,4 +142,4 @@ function parseChanges(raw) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordWrite, recentWrites, parseChanges }
|
||||
module.exports = { recordWrite, settleWrite, getWrite, recentWrites, parseChanges, shapeWrite }
|
||||
|
||||
@@ -198,6 +198,12 @@ function summariseReport(report) {
|
||||
ok: report.ok !== false,
|
||||
reloaded: Boolean(report.reloaded),
|
||||
rolledBack: Boolean(report.rolledBack),
|
||||
// Protocol 13: the files are written and the reload has not finished. The
|
||||
// outcome follows as a `config.outcome` frame naming `writeId`.
|
||||
pending: Boolean(report.pending),
|
||||
writeId: report.writeId ? String(report.writeId) : null,
|
||||
ceilingMs: Number(report.ceilingMs) > 0 ? Number(report.ceilingMs) : null,
|
||||
restored: typeof report.restored === 'boolean' ? report.restored : null,
|
||||
reason: report.reason ? String(report.reason) : null,
|
||||
// The plugin only reads this on the failure path, and it is the difference
|
||||
// between "your change was undone" and "your change was undone BECAUSE line
|
||||
@@ -215,6 +221,48 @@ function summariseReport(report) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a pending write may go unanswered before it is lost, in seconds.
|
||||
*
|
||||
* The plugin waits at most one ceiling for the edit's reload and one more for a
|
||||
* restore's; ingest reads the feed every few seconds on top. Past both, with a
|
||||
* margin, nothing is coming — the plugin was reloaded or the link dropped — and
|
||||
* the page says so instead of spinning. A plugin that sent no ceiling gets the
|
||||
* one protocol 13 shipped with.
|
||||
*/
|
||||
const SETTLE_SLACK_SECONDS = 20
|
||||
const DEFAULT_CEILING_MS = 30 * 1000
|
||||
|
||||
function settleSeconds(ceilingMs) {
|
||||
const ceiling = Number(ceilingMs) > 0 ? Number(ceilingMs) : DEFAULT_CEILING_MS
|
||||
return Math.ceil((2 * ceiling) / 1000) + SETTLE_SLACK_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* A `config.outcome` frame as the row it settles, or null when it names no write.
|
||||
*
|
||||
* `applied` covers every case where the edit is on disk — reloaded, or standing
|
||||
* because the framework never reloaded it (the reason says which). A rollback is
|
||||
* `rolled-back` whether or not the plugin came back on the old file; `restored`
|
||||
* says which, and it is the difference between "try again" and "go and look".
|
||||
*/
|
||||
function outcomeOf(frame) {
|
||||
if (!frame || typeof frame !== 'object' || !frame.writeId) return null
|
||||
|
||||
const report = summariseReport(frame)
|
||||
const first = report.files[0]
|
||||
|
||||
return {
|
||||
writeId: String(frame.writeId),
|
||||
outcome: report.rolledBack ? 'rolled-back' : 'applied',
|
||||
reloaded: report.reloaded,
|
||||
restored: report.rolledBack ? report.restored : null,
|
||||
versionAfter: first ? first.version : null,
|
||||
detail: report.reason,
|
||||
log: report.log,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOCKED_KEYS,
|
||||
LOCKED_REASON,
|
||||
@@ -226,4 +274,6 @@ module.exports = {
|
||||
lockedKeysFor,
|
||||
lockedChanges,
|
||||
summariseReport,
|
||||
outcomeOf,
|
||||
settleSeconds,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user