Multi-server data lifecycle, cache coherency, distributed locking and indexed leaderboards#1
Merged
Merged
Conversation
added 8 commits
July 23, 2026 23:26
Previously a container serialized only the fields touched this session and saved that as a full overwrite, so the first write from a node that had not read every field silently dropped the unread fields from storage. When a player moved between server instances this lost data. The container now retains the full backing document it last saw in storage and, on serialize, merges the touched fields over it, so untouched fields are preserved. A null-set records a tombstone that suppresses the field from the merged output, so clearing a value still deletes it instead of resurrecting the stored one. DataFormat gains readRaw/writeRaw for whole-document access, implemented for the JSON format, and the managers warm the backing document once per entity rather than reloading storage on every field access.
Adds an explicit lifecycle to the data API so a node can warm an entity's whole document into its cache in a single storage read, flush pending changes, and evict the entity when finished. load/loadAsync/flush/unload/isLoaded/loadedPlayers cover players and loadLink/flushLink/unloadLink/isLinkLoaded cover shared entities such as an island or coop. Eviction is what keeps a multi-server deployment correct: without it a container lingered in a node's cache forever, so a player who changed data on another server and later returned would be served the stale cached value and, on the next write, overwrite the fresh data. unload now flushes and drops the container so a later visit reloads from storage. load is also the primitive a proxy needs to prepare a player's data on the target server before moving them there. When a distributed event bus is present the API now keeps loaded containers coherent with peer writes: a remote change to a currently-loaded entity updates the local view in place, preserving the prior dirty state so a read-only node does not re-persist stale sibling fields. Coherency applies to fields that have a subscription registered on the receiving node. An optional deferred-persistence mode buffers writes until flush or unload so a play session can be written back once instead of per field; shutdown flushes everything so no buffered write is lost.
Transactions previously guarded only a JVM-local monitor, so two servers mutating the same shared entity (e.g. a coop or island) could interleave and lose updates despite the API presenting transactions as atomic. A DistributedLock can now be supplied; when present, transactions take a cross-node lock keyed by the entity in addition to the local monitor, and a public lock(key, timeout) helper exposes the same primitive for app-level critical sections that span more than one field or entity. When absent, behaviour is unchanged. Ships a Redis implementation using SET NX PX with a compare-and-delete release so a lock is only released by its owner and a lease bounds a crashed holder, plus a reentrant in-memory implementation for single-node use and tests.
getTop, getTopPaged and the query helpers previously loaded and deserialized every stored player on each call, an O(N) scan that does not hold up for a live leaderboard. Storage backends may now implement the optional LeaderboardIndex capability (Redis sorted sets, or an in-memory index for single-node and tests). A field is opted in with trackLeaderboard and a scorer; each persisted write to it updates the index, so getTop and getTopPaged read the ranked slice directly and then load only that page's values, turning the query into O(log N + page). rebuildLeaderboard backfills the index from data written before the field was tracked. Backends without the capability, or untracked fields, fall through to the existing scan, so the change is purely additive.
The indexed leaderboard previously kept a scan-based path and chose it whenever the field was not tracked or the storage lacked an index, so forgetting to register a leaderboard silently restored the O(N) scan the index was meant to replace. Leaderboards are now index-only: getTop and getTopPaged require a registered field and read the sorted index directly, and both trackLeaderboard (on a storage without an index) and ranking an unregistered field fail fast with a clear error. getTop(field, limit, comparator) remains as the explicit scan-based escape hatch for ad-hoc custom orderings.
Ranking a field no longer requires a trackLeaderboard call. The first getTop/getTopPaged for a field builds its index from existing data in one scan, and from then on the index exists in shared storage so every node maintains it on write via a new updateScoreIfPresent (an atomic exists-then-add). Numeric values are scored automatically, so the common case needs zero setup; a field that is never ranked never gets an index and its writes cost nothing. trackLeaderboard now only registers a score function for ranking a non-numeric field, and rebuildLeaderboard(field) forces a rebuild. Storage without an index still throws on ranking rather than silently scanning.
The net.swofty.api package held its plumbing (PlayerDataManager, LinkedDataManager, DataContainer, BulkOperationExecutor, TransactionManager, ExpirationManager, LinkRegistryImpl, Validation) as public classes, so with no module descriptor they were importable from the published jar and invited callers to bypass DataAPI and reach into internals. Nothing outside the package references them — only DataAPIImpl is used externally — so they are now package-private. DataAPIImpl and the DataAPI interface are unchanged; this only removes accidental public surface and drops the internals from the generated Javadoc.
Swofty-Developments
marked this pull request as ready for review
July 23, 2026 14:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.