Class CloudStateStore
CloudOutbox and the CommandDedupeSet -- in
<server root>/.ultikits/cloud-outbox.json (D-15, D-17, S-15), so unacknowledged events and recently seen
command ids survive a restart.
Why this write path cannot overwrite operator content (D-31). cloud-outbox.json is framework-owned,
replayable state, not operator configuration: only this class writes it, no shipped file or documentation asks an
operator to edit it, and it lives in .ultikits/ beside credentials.json, outside every default
editable root of the remote file surface. It is never read from or written to an operator-editable root. Its content
is buffered events and command ids, never an operator's setting, and losing it costs at most the buffered player
join/leave events (a documented limitation). That is also why an unreadable file is moved aside and replaced rather
than preserved in place, unlike CredentialStore's credential file, which is an identity (S-15).
Format. {"version":1, "serverId":..., "events":[{"id","type","ts","payload"}], "dropped":N,
"commandIds":[{"id","seenAt","receipt"?}]}, with ts and seenAt in epoch milliseconds (contract answer
5). receipt (plan 22-21, S-65) is optional: the JSON object the framework answered a
player.link.completed with, at most CommandDedupeSet.MAX_RECEIPT_BYTES of compact JSON, written only
for an id that has one. The file stays version: 1: a file written before receipts existed has no such field
and loads with no receipts, and a version 1 reader that predates them never sees one written by a store that
knew none.
Written through OwnerOnlyAtomicFile, the writer CredentialStore uses: owner-only on POSIX, forced
to disk, atomically moved into place from a temporary file cloud-outbox.json.<ULID>.tmp unique to that
write.
Loading. The file is read whole, up to MAX_FILE_BYTES. A file that is larger, is not a JSON object
of FORMAT_VERSION, or holds any invalid entry (an event id that is not a ULID, a missing type, a payload
that is not an object, a repeated id, a malformed command id) is unreadable as a whole: it is moved to
cloud-outbox.json.unreadable (replacing an older one), one WARNING names both paths, and the store starts
empty. A crafted or hand-edited file is not repaired entry by entry (the niche-case rule), with one exception: a
receipt that is not a JSON object, or is larger than CommandDedupeSet.MAX_RECEIPT_BYTES, is dropped
with one WARNING and its id kept, because losing a receipt costs only one more question to the module that owns the
player-link handler, while losing the id could run a command twice. A file written for
another server id is discarded with one INFO line. Payloads are never logged: they hold player names and UUIDs.
Detaching. detachAndDelete() is used when the server identity is cleared (22-07): it deletes the
file and makes every later flush of this store a no-op, so the file cannot reappear for an identity that no longer
exists.
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionprivate static final classThe validated content of a state file.private static final classA state file that cannot be used as a whole; the message names the reason, never the content. -
Field Summary
FieldsModifier and TypeFieldDescriptionprivate Runnableprivate final CommandDedupeSetprivate booleanSet bydetachAndDelete(); once set, no flush writes.private final Path(package private) static final StringThe state file's bare name, besidecredentials.json.(package private) static final intThe only format version this class reads and writes.private static final com.google.gson.Gsonprivate static final Patternprivate Logger(package private) static final longThe largest fileload(String)reads, 8 MiB; a larger one is unreadable.private final CloudOutboxprivate static final String(package private) static final StringAppended toFILE_NAMEfor the moved-aside copy of an unreadable file.private static final ObjectSerialises every load, flush, flushIfDirty and detachAndDelete of the state file, across every instance and thread of this JVM: a replaced session's store and its successor's store point at the same file, and the periodic flush, the session invalidation and the shutdown flush run on different threads (S-32). -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescription(package private) CommandDedupeSetdedupe()(package private) static Pathprivate void(package private) booleanMarks this store detached and deletes the state file, under the write lock: a flush that already holds the lock finishes first, and every later flush of this store sees the flag and writes nothing (S-32).(package private) voidWrites the state unconditionally; does nothing on a detached store.(package private) booleanflushIfDirty(String serverId) Writes the state if either structure changed since the last successful write.(package private) boolean(package private) voidReplaces the in-memory state with the file's content.private voidprivate void(package private) CloudOutboxoutbox()private static CloudStateStore.Parsedparse(byte[] bytes) private byte[]private static com.google.gson.JsonArrayrequireArray(com.google.gson.JsonElement element, String what) private static longrequireLong(com.google.gson.JsonElement element, String what) Accepts only an integer literal: Gson's lenient number conversion would turn1.5into 1.private static com.google.gson.JsonObjectrequireObject(com.google.gson.JsonElement element, String what) private static StringrequireString(com.google.gson.JsonElement element, String what) private static StringrequireUlid(com.google.gson.JsonElement element, String what) (package private) voidTest-only: runhookbetween writing the temporary file and the atomic move of every flush.(package private) voidsetLoggerForTesting(Logger logger) Test-only: log throughloggerinstead of the plugin's logger.private voidprivate voidwriteLocked(String serverId)
-
Field Details
-
FILE_NAME
The state file's bare name, besidecredentials.json.- See Also:
-
UNREADABLE_SUFFIX
Appended toFILE_NAMEfor the moved-aside copy of an unreadable file.- See Also:
-
FORMAT_VERSION
static final int FORMAT_VERSIONThe only format version this class reads and writes.- See Also:
-
MAX_FILE_BYTES
static final long MAX_FILE_BYTESThe largest fileload(String)reads, 8 MiB; a larger one is unreadable.- See Also:
-
TEMP_SUFFIX
- See Also:
-
INTEGER
-
GSON
private static final com.google.gson.Gson GSON -
WRITE_LOCK
Serialises every load, flush, flushIfDirty and detachAndDelete of the state file, across every instance and thread of this JVM: a replaced session's store and its successor's store point at the same file, and the periodic flush, the session invalidation and the shutdown flush run on different threads (S-32).Lock order: taken after any session monitor and before the
CloudOutboxandCommandDedupeSetmonitors, which are leaf locks; never held while waiting for another thread. -
file
-
outbox
-
dedupe
-
detached
private volatile boolean detachedSet bydetachAndDelete(); once set, no flush writes. Written underWRITE_LOCK. -
beforeMoveHook
-
loggerOverride
-
-
Constructor Details
-
CloudStateStore
CloudStateStore(Path file, CloudOutbox outbox, CommandDedupeSet dedupe) - Parameters:
file- the state file, normallydefaultPath()outbox- the event buffer this store persistsdedupe- the command-id set this store persists
-
-
Method Details
-
defaultPath
- Returns:
<server root>/.ultikits/cloud-outbox.json, beside the credential file- Throws:
DataAccessException- if no live UltiTools instance (or test override) resolves the server root
-
setLoggerForTesting
Test-only: log throughloggerinstead of the plugin's logger. -
setBeforeMoveHookForTesting
Test-only: runhookbetween writing the temporary file and the atomic move of every flush. -
outbox
CloudOutbox outbox() -
dedupe
CommandDedupeSet dedupe() -
isDetached
boolean isDetached()- Returns:
- whether
detachAndDelete()has run on this store
-
load
Replaces the in-memory state with the file's content. Deletes leftovercloud-outbox.json.*.tmpfiles of an interrupted write first. An absent file loads as empty; an unreadable or foreign file loads as empty and marks both structures dirty, so the next flush writes a fresh file. Does nothing on a detached store.- Parameters:
expectedServerId- the linked server's id
-
flushIfDirty
Writes the state if either structure changed since the last successful write.- Parameters:
serverId- the linked server's id- Returns:
- whether a write happened; always
falseon a detached store - Throws:
DataAccessException- if the write fails, asflush(String)
-
flush
Writes the state unconditionally; does nothing on a detached store.- Parameters:
serverId- the linked server's id- Throws:
DataAccessException- if the write fails; the previous file is left intact, no temporary file remains, and the dirty flags stay set, so the next flush retries
-
detachAndDelete
boolean detachAndDelete()Marks this store detached and deletes the state file, under the write lock: a flush that already holds the lock finishes first, and every later flush of this store sees the flag and writes nothing (S-32). A failed delete is logged; the store stays detached.- Returns:
- whether the file existed
-
startEmpty
private void startEmpty() -
deleteTempLeftovers
private void deleteTempLeftovers() -
moveAside
-
readCapped
-
parse
private static CloudStateStore.Parsed parse(byte[] bytes) throws CloudStateStore.UnreadableException -
requireArray
private static com.google.gson.JsonArray requireArray(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException -
requireObject
private static com.google.gson.JsonObject requireObject(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException -
requireString
private static String requireString(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException -
requireUlid
private static String requireUlid(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException -
requireLong
private static long requireLong(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException Accepts only an integer literal: Gson's lenient number conversion would turn1.5into 1. -
writeLocked
-
log
-