Class CloudStateStore

java.lang.Object
com.ultikits.ultitools.utils.CloudStateStore

final class CloudStateStore extends Object
Persists the reliable-delivery state -- the 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.

  • Field Details

    • FILE_NAME

      static final String FILE_NAME
      The state file's bare name, beside credentials.json.
      See Also:
    • UNREADABLE_SUFFIX

      static final String UNREADABLE_SUFFIX
      Appended to FILE_NAME for the moved-aside copy of an unreadable file.
      See Also:
    • FORMAT_VERSION

      static final int FORMAT_VERSION
      The only format version this class reads and writes.
      See Also:
    • MAX_FILE_BYTES

      static final long MAX_FILE_BYTES
      The largest file load(String) reads, 8 MiB; a larger one is unreadable.
      See Also:
    • TEMP_SUFFIX

      private static final String TEMP_SUFFIX
      See Also:
    • INTEGER

      private static final Pattern INTEGER
    • GSON

      private static final com.google.gson.Gson GSON
    • WRITE_LOCK

      private static final Object 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 CloudOutbox and CommandDedupeSet monitors, which are leaf locks; never held while waiting for another thread.

    • file

      private final Path file
    • outbox

      private final CloudOutbox outbox
    • dedupe

      private final CommandDedupeSet dedupe
    • detached

      private volatile boolean detached
      Set by detachAndDelete(); once set, no flush writes. Written under WRITE_LOCK.
    • beforeMoveHook

      private volatile Runnable beforeMoveHook
    • loggerOverride

      private volatile Logger loggerOverride
  • Constructor Details

    • CloudStateStore

      CloudStateStore(Path file, CloudOutbox outbox, CommandDedupeSet dedupe)
      Parameters:
      file - the state file, normally defaultPath()
      outbox - the event buffer this store persists
      dedupe - the command-id set this store persists
  • Method Details

    • defaultPath

      static Path 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

      void setLoggerForTesting(Logger logger)
      Test-only: log through logger instead of the plugin's logger.
    • setBeforeMoveHookForTesting

      void setBeforeMoveHookForTesting(Runnable hook)
      Test-only: run hook between writing the temporary file and the atomic move of every flush.
    • outbox

      CloudOutbox outbox()
    • dedupe

    • isDetached

      boolean isDetached()
      Returns:
      whether detachAndDelete() has run on this store
    • load

      void load(String expectedServerId)
      Replaces the in-memory state with the file's content. Deletes leftover cloud-outbox.json.*.tmp files 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

      boolean flushIfDirty(String serverId)
      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 false on a detached store
      Throws:
      DataAccessException - if the write fails, as flush(String)
    • flush

      void flush(String serverId)
      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

      private void moveAside(String reason)
    • readCapped

      private byte[] readCapped() throws CloudStateStore.UnreadableException
      Throws:
      CloudStateStore.UnreadableException
    • parse

      private static CloudStateStore.Parsed parse(byte[] bytes) throws CloudStateStore.UnreadableException
      Throws:
      CloudStateStore.UnreadableException
    • requireArray

      private static com.google.gson.JsonArray requireArray(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException
      Throws:
      CloudStateStore.UnreadableException
    • requireObject

      private static com.google.gson.JsonObject requireObject(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException
      Throws:
      CloudStateStore.UnreadableException
    • requireString

      private static String requireString(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException
      Throws:
      CloudStateStore.UnreadableException
    • requireUlid

      private static String requireUlid(com.google.gson.JsonElement element, String what) throws CloudStateStore.UnreadableException
      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 turn 1.5 into 1.
      Throws:
      CloudStateStore.UnreadableException
    • writeLocked

      private void writeLocked(String serverId)
    • log

      private void log(Level level, String message)