Implement idempotency TTL and enhance world stats collection

This commit is contained in:
2026-08-26 14:59:27 +02:00
parent 19376c3efa
commit d0d466881e
5 changed files with 59 additions and 14 deletions

View File

@@ -1,9 +1,10 @@
-- Registration is deferred to RDB_OpRegisterAll(), called from -- Registration is deferred to register(), called from RDB_Bootstrap's
-- RDB_Bootstrap's onServerStarted, rather than run at file top-level: -- registerOps() (itself called from onServerStarted), rather than run at
-- PZ's require() is best-effort (warns and continues rather than forcing a -- file top-level: PZ's require() is best-effort (warns and continues rather
-- synchronous load), so RDB_OpRegistry may not be defined yet if this file's -- than forcing a synchronous load), so RDB_OpRegistry may not be defined yet
-- top-level code ran immediately at auto-load time. onServerStarted fires -- if this file's top-level code ran immediately at auto-load time.
-- only after every file has auto-loaded, so it's safe there. -- onServerStarted fires only after every file has auto-loaded, so it's safe
-- there.
RDB_OpWorldGetStats = {} RDB_OpWorldGetStats = {}

View File

@@ -1,7 +1,15 @@
-- Processed request-id -> cached response, so a repeated request id replays -- Processed request-id -> cached response, so a repeated request id replays
-- the original response instead of re-executing the op. -- the original response instead of re-executing the op.
-- Persisted via ModData so it survives restarts. -- Persisted via ModData so it survives restarts.
--
-- Entries expire after RDB_Constants.IDEMPOTENCY_TTL_MS (60s by default):
-- a request id is only idempotent for retries within that window, not
-- forever. Without a TTL, a client that reuses a fixed id for routine
-- polling would get the first-ever response back on every call, since a
-- low-traffic server would rarely reach MAX_ENTRIES and trigger the
-- count-based eviction below.
require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log") require("RconDataBridge.RDB_Log")
RDB_Idempotency = {} RDB_Idempotency = {}
@@ -18,20 +26,35 @@ local function ensureStore()
return store return store
end end
local function isExpired(entry)
return getTimestampMs() - (entry.ts or 0) >= RDB_Constants.IDEMPOTENCY_TTL_MS
end
function RDB_Idempotency.has(id) function RDB_Idempotency.has(id)
return ensureStore()[id] ~= nil local entry = ensureStore()[id]
return entry ~= nil and not isExpired(entry)
end end
function RDB_Idempotency.get(id) function RDB_Idempotency.get(id)
local entry = ensureStore()[id] local entry = ensureStore()[id]
if not entry then if not entry or isExpired(entry) then
return nil return nil
end end
return entry.response return entry.response
end end
-- Sweeps expired entries first (the common case that keeps the store small
-- under normal traffic), then falls back to trimming the oldest entries if
-- still over MAX_ENTRIES (a backstop against a burst of distinct ids within
-- a single TTL window).
local function prune(s) local function prune(s)
for id, entry in pairs(s) do
if isExpired(entry) then
s[id] = nil
end
end
local n = 0 local n = 0
for _ in pairs(s) do for _ in pairs(s) do
n = n + 1 n = n + 1

View File

@@ -1,4 +1,4 @@
-- World telemetry. -- World telemetry, sourced from zombie.GameTime (getGameTime()).
require("RconDataBridge.RDB_Constants") require("RconDataBridge.RDB_Constants")
require("RconDataBridge.RDB_Log") require("RconDataBridge.RDB_Log")
@@ -7,15 +7,29 @@ require("RconDataBridge.RDB_OptionsRegistry")
RDB_WorldStats = {} RDB_WorldStats = {}
local cached = { playerCount = 0, generatedAt = 0 } local cached = {}
function RDB_WorldStats.collect() function RDB_WorldStats.collect()
return cached return cached
end end
local function refresh() local function refresh()
local gt = getGameTime()
cached = { cached = {
playerCount = getNumActivePlayers(), worldAgeHours = gt:getWorldAgeHours(),
worldAgeDays = gt:getWorldAgeDaysSinceBegin(),
nightsSurvived = gt:getNightsSurvived(),
date = {
year = gt:getYear(),
month = gt:getMonth(),
day = gt:getDay(),
hour = gt:getHour(),
minute = gt:getMinutes(),
},
isNight = gt:isNight(),
isRaining = gt:isRainingToday(),
timeMultiplier = gt:getMultiplier(),
generatedAt = getTimestampMs(), generatedAt = getTimestampMs(),
} }

View File

@@ -14,8 +14,6 @@
-- path) and read via showoptions, neither of which goes through the -- path) and read via showoptions, neither of which goes through the
-- tokenizer, so they carry raw JSON unmolested. -- tokenizer, so they carry raw JSON unmolested.
-- TODO: does output still base64 encode? otherwise the API is weird (base64 json in, json out)
RDB_Base64 = {} RDB_Base64 = {}
local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

View File

@@ -24,6 +24,15 @@ RDB_Constants.MAX_PAYLOAD_BYTES = 4096
RDB_Constants.OPTION_MAX_LENGTH = 8192 RDB_Constants.OPTION_MAX_LENGTH = 8192
RDB_Constants.POLL_EVERY_TICKS = 30 RDB_Constants.POLL_EVERY_TICKS = 30
-- How long a request id's cached response is replayed for on retry before
-- it's treated as expired and the op runs again. Deliberately short: a
-- client that reuses a fixed id for routine polling (e.g. "ws-poll" every
-- few minutes) should get a fresh answer each time, not the first-ever
-- response forever. A count-based cap alone (MAX_ENTRIES in
-- RDB_Idempotency) doesn't help here, it only evicts under high request
-- volume, never under low-and-slow polling, which is exactly this case.
RDB_Constants.IDEMPOTENCY_TTL_MS = 60 * 1000
RDB_Constants.ERROR_CODES = { RDB_Constants.ERROR_CODES = {
SCHEMA_ERROR = "SCHEMA_ERROR", SCHEMA_ERROR = "SCHEMA_ERROR",
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE", PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE",