Skip to content

SVWorldDataManager

Petrus Pradella edited this page Aug 14, 2026 · 4 revisions

SVWorldDataManager

Per-block data - world -> chunk -> block -> YourValue - on any EveryDatabase backend. A block value is whatever your plugin needs to remember about one position: who placed that machine, how full that crate is, when that spawner last fired.

br.com.finalcraft.evernifecore.blockdata.SVWorldDataManager<O> is the whole facade: a builder, the read/write API, and the flush lifecycle.

It is a write-back store, and that is the one thing to internalize. A write mutates the chunk entity living in the cache and returns; a periodic flush persists it later. There is no second copy of the data - the WorldChunkData<O> instance the cache holds is the state - so every read and write goes through the manager, which takes that entity's monitor for the few instructions it needs.

The storage is yours: it comes from an ECStorage your plugin opened from its own config, never from the core's storage.yml.


The canonical use

import br.com.finalcraft.evernifecore.blockdata.SVWorldDataManager;
import br.com.finalcraft.evernifecore.storage.ECStorage;

// onEnable - open ONCE. A core storage reload never touches this: the plugin's reference
// registry keeps its identity, so the store and every Ref through it stay valid.
STORAGE = ECStorage.openOrReload(getEcPluginData(), config.getConfigSection("storage"), STORAGE).join();
BLOCKS  = SVWorldDataManager.targeting(MyBlockInfo.class)
        .on(STORAGE, "my_blocks")
        .cache(config.getConfigSection("block-cache"))          // the admin's preset
        .onChange((world, pos, oldValue, newValue) -> { /* ... */ })
        .build().join();

// onDisable - the store first (it flushes), the storage after.
BLOCKS.close();
STORAGE.close().join();

If your own config reload re-opens the storage, close the old store first. A store holds the Ref registration for WorldChunkData, and ECStorage.openOrReload does not release it - it only knows about the managers it opened. Skip the close() and the second build() fails with a StorageConfigException saying the plugin already has a SHARED store. Closing also flushes what that store still owed, which is why it goes before the handle is reopened, not after.

build() answers a CompletableFuture because it may read the backend (the grid check, and the preload). Joining it in onEnable is fine and is what the example does.


The builder

targeting(...) opens a staircase: the value type, then the collection, then the optional settings.

SVWorldDataManager.targeting(MyBlockInfo.class)   // static <O> StepStorage<O>
        .on(STORAGE, "my_blocks")                 // ECStorage + collection - the production path
        .cache(section)                           // or cache(CacheOptions)
        .autoFlushEvery(Duration.ofMinutes(5))    // default 10 minutes; Duration.ZERO turns it off
        .refs(RefParticipation.ISOLATED)          // default SHARED
        .onChange(listener)                       // repeatable
        .importingLegacyFrom(new File("plugins/MyPlugin/WorldData"))
        .build();                                 // CompletableFuture<SVWorldDataManager<O>>
Step Default What it decides
on(ECStorage, String) - The backend, the on-disk format and the Ref graph, all inherited from the handle.
on(Storage, String) - TEST-ONLY. A bare EveryDatabase Storage: compact JSON regardless of the backend, no plugin Ref graph.
cache(CacheOptions) CacheOptions.of(CachePolicy.always()) The cache in code, when the shape is the plugin's call and not the admin's.
cache(ConfigSection) - The admin preset, seeding its own defaults into the section.
autoFlushEvery(Duration) 10 minutes How often dirty chunks are persisted. Duration.ZERO leaves flushing entirely to you.
refs(RefParticipation) SHARED Whether this store answers Refs from the rest of the plugin - see Refs.
onChange(BlockChangeListener) none Called after each change; add as many as you like.
importingLegacyFrom(File) none The region-YAML folder importLegacy() reads.

build() resolves the codec, registers the manager in the Ref registry, checks the stored grid against the running one, preloads if the preset asked for it, and only then arms the flush tick. It fails the future with a StorageConfigException when the plugin already has a SHARED store, or when the collection was written under a different chunk size. A preset value that makes no sense (NOCACHE, a typo) throws from cache(...) itself, before build() is ever reached.


Reading and writing

Every method addressed by a block comes in two shapes: (String world, BlockPos pos) and a WorldBlockPos overload for a position that already carries its world. getChunk addresses a chunk, so it takes a ChunkPos and has no overload.

BlockPos pos = BlockPos.of(5, 64, 7);

MyBlockInfo info = BLOCKS.getBlock("world", pos).join();     // null when there is nothing stored
MyBlockInfo cached = BLOCKS.peekBlock("world", pos);         // cache only, no I/O, may be null
Map<BlockPos, MyBlockInfo> chunk = BLOCKS.getChunk("world", pos.getChunkPos()).join();
List<BlockRecord<MyBlockInfo>> around = BLOCKS.getRange("world", pos, 16).join();

BLOCKS.setBlock("world", pos, new MyBlockInfo(...));          // value must not be null
BLOCKS.removeBlock("world", pos);
BLOCKS.computeBlock("world", pos, current -> { current.addFuel(64); return current; });
Method Answers Notes
getBlock(world, pos) CompletableFuture<O> null = nothing stored. Loads the chunk when it is not cached; a read never creates a chunk.
getBlock(WorldBlockPos) CompletableFuture<O>
peekBlock(world, pos) @Nullable O Cache only. null means absent or not loaded - getBlock is the one that tells them apart.
peekBlock(WorldBlockPos) @Nullable O
getChunk(world, chunkPos) CompletableFuture<Map<BlockPos, O>> Immutable snapshot taken under the chunk's monitor. Empty for a chunk holding nothing.
getRange(world, center, radius) CompletableFuture<List<BlockRecord<O>>> The cube of side 2*radius+1 - see below.
getRange(WorldBlockPos, radius) CompletableFuture<List<BlockRecord<O>>>
setBlock(world, pos, value) CompletableFuture<Void> value must not be null; use removeBlock.
setBlock(WorldBlockPos, value) CompletableFuture<Void>
removeBlock(world, pos) CompletableFuture<Void> An emptied chunk is deleted by the flush, not here.
removeBlock(WorldBlockPos) CompletableFuture<Void>
computeBlock(world, pos, mutator) CompletableFuture<O> Applies the UnaryOperator<O> under the chunk's monitor; returning null removes the block.
computeBlock(WorldBlockPos, mutator) CompletableFuture<O>

getRange is a cube, and it costs one read

getRange(world, center, radius) answers every stored block inside the cube of side 2*radius+1 centered on center - as far up and down as it reaches sideways, chebyshev in 3D. A radius of 0 asks about the center block alone.

The chunks the cube covers are fetched together: the cached ones answer from memory and every other one comes back in a single call, however wide the radius. Chunks holding nothing contribute nothing and are not created. The returned List<BlockRecord<O>> is immutable and unordered; each record carries getWorld(), getPos(), getValue() and getWorldPos().

A negative radius is a programming error, not a backend failure: it throws IllegalArgumentException on the spot, not through the future.

Never retain a value and mutate it

A value handed out by getBlock or getChunk is the live instance. Changing it from outside computeBlock races the encode a flush may start at any moment, and leaves the chunk clean - so the change is silently lost. computeBlock is the only safe read-modify-write:

// WRONG - the chunk is never marked dirty, and the flush may be encoding this very object
MyBlockInfo info = BLOCKS.getBlock("world", pos).join();
info.addFuel(64);

// RIGHT
BLOCKS.computeBlock("world", pos, info -> { info.addFuel(64); return info; });

Which thread you come back on

Every future completes on whichever thread the backend answered on - a storage callback thread, never the server thread. Hop to your own scheduler before touching the game:

BLOCKS.getBlock("world", pos).thenAccept(info ->
        McFCScheduler.INSTANCE.runSync(() -> player.sendMessage("owner: " + info.getOwner())));

The cache preset an admin owns

cache(ConfigSection) reads four keys and writes each default into the section when absent, so the block arrives documented in the admin's own config:

block-cache:
  policy: ALWAYS        # ALWAYS | TTL
  ttl-seconds: 600      # used when policy=TTL
  max-chunks: 0         # 0 = unbounded (LRU above 0)
  preload: AUTO         # AUTO | ALWAYS | NEVER
  • policy: NOCACHE is refused, and so is CacheOptions.of(CachePolicy.noCache()) in code. This is a write-back store: a changed block lives in the cache until the flush persists it, so an uncached store would have nowhere to hold it. The message points at TTL with a short ttl-seconds.
  • preload: AUTO loads the whole collection at boot exactly when the cache would end up holding it anyway - that is, policy: ALWAYS and max-chunks: 0. ALWAYS preloads regardless of the cache shape, NEVER leaves every chunk to be read on first touch.
  • Which means: a store built with no cache(...) call at all preloads itself. The default is ALWAYS and unbounded, which is precisely the shape AUTO preloads for. Worth knowing before pointing a store at a collection with a hundred thousand chunks in it.

The preload walks a paginated scan rather than a plain bulk read for one reason: a chunk whose stored payload does not decode is named in the log with its key and its cause, instead of quietly coming back missing. That chunk is absent from the boot; every other one loads. A scan that fails outright is logged the same way and the build still succeeds - an unread chunk is read on first touch, so the store works either way.

A TTL cache may drop a chunk nobody touched, and it will never drop an unsaved write: a dirty cell is pinned against the LRU, exempt from expiry, and served even past its TTL. Once its write lands it is as collectable as any other.


The flush

FlushReport report = BLOCKS.flush().join();
report.getSavedChunks();     // changed chunks written
report.getDeletedChunks();   // chunks that lost their last block, deleted from the backend
report.getFailures();        // Map<String chunkKey, Throwable> - empty on a clean flush
report.hasFailures();

It runs on its own every autoFlushEvery period, on a daemon thread of its own (ec-blockdata-flush-<collection>), with the period jittered by up to a minute so several servers sharing one backend never hit it on the same beat. A tick whose predecessor has not answered yet is skipped rather than queued, and each pass ends with a purge of the expired cells. Call flush() directly only when something must be on disk now - a manual save command, a backup.

What one pass decides, per cached chunk:

flowchart TD
    A["cached chunk"] --> B{"dirty?"}
    B -->|"no"| Z["left alone"]
    B -->|"yes"| C["under its monitor:<br/>mark clean, snapshot"]
    C --> D{"any blocks left?"}
    D -->|"yes"| E["the snapshot joins the batch"]
    D -->|"no"| F["its entity is deleted<br/>and the cell evicted"]
    E --> G["ONE batched write"]
    G --> H{"did the write land?"}
    F --> H
    H -->|"yes"| I["counted in savedChunks<br/>or deletedChunks"]
    H -->|"no"| J["dirty again, named in the report,<br/>retried by the next flush"]
Loading

Two consequences worth spelling out:

  • What gets encoded is a snapshot, never the live map. It is taken while the chunk's monitor is held, which is what keeps an encode from iterating a map a writer is still mutating.
  • A failed write loses nothing. The batch answers with one failure for the whole list, so every chunk in it goes back to dirty and the next flush retries them. Re-saving one that did land costs a write; losing one that did not would be silent.

The report is always logged: at debug level when the flush was clean, and loudly - naming up to ten chunks - when it was not.

close()

Stops the tick and persists what is still dirty, waiting up to 30 seconds for the backend: a storage that hung delays a shutdown, it never owns it. Idempotent.

  • It does not close the Storage behind the collection. That belongs to your ECStorage and usually serves other collections too.
  • It does release the Ref registration, which is what lets a reload build the store again on the same handle.

Change listeners

.onChange((world, pos, oldValue, newValue) -> { ... })

An insert has a null oldValue, a removal a null newValue, and a replace fires ONCE carrying both sides - never a removal followed by an insert. Listeners are called outside every internal lock, on whichever thread completed the write, so keep them short and hop to your scheduler for anything touching the game. A listener that throws is logged and skipped: the write already happened, and failing it afterwards would report a loss that did not occur.


Refs: one SHARED store per plugin

A store registers itself in a RefRegistry, and RefParticipation picks which one:

SHARED (default) ISOLATED
Registers in your plugin's registry, the one your PDSections use a child registry, invisible from outside
A Ref elsewhere in the plugin resolves the live chunk entity of this store resolves nothing - it is not there
A Ref inside a block value resolves the plugin's graph resolves the plugin's graph too (a child falls back to its parent)
How many per plugin one as many as you like

A registry answers one resolver per entity type, and every store shares the same entity type (WorldChunkData) - so a second SHARED store on the same plugin fails its build() with a message naming both ways out: build it ISOLATED, or close the store that holds the type.

.refs(RefParticipation.ISOLATED)     // the second, third, ... store of the same plugin

Resolving a Ref against a SHARED store hands back the live entity the store writes into, not a copy of it.


The grid sentinel

A chunk key names a chunk of a specific size, so a collection written under one grid cannot be read under another - the keys would point at the wrong blocks. The store guards that itself: the reserved $meta key of the same collection holds the chunk size the collection was written with (RegionGridOptions.getCurrent().getChunkSize(), 16 on Minecraft, 32 on Hytale). It is written the first time the collection is opened, and checked on every open after that.

A mismatch fails build() with a StorageConfigException naming both sizes and the two ways out: put the grid back, or migrate the collection and drop its $meta entry so the new size is recorded.

The sentinel holds no blocks and is not a chunk: it never enters the chunk cache, never shows up in a range query, and survives the sweep that deletes emptied chunks.


Importing a legacy region-YAML folder

An older block store left <folder>/<world>/r.X.Z.yml files on disk. Point the builder at that folder and call importLegacy() once, before the store takes its first write:

.importingLegacyFrom(new File(getDataFolder(), "WorldData"))
...
int importedValues = BLOCKS.importLegacy().join();

It answers how many block values landed (0 right away when no folder was configured), and runs on a thread of its own. Its guarantees:

  • Idempotent - a chunk key the collection already stores is skipped, so a re-run never overwrites more recent data. One key-only read per region file answers that for every chunk the file holds.
  • Never deletes a YAML file - the folder is moved to <folder>-Imported/ when the run ends.
  • A broken region file never aborts the run - it is reported and every other file still imports. It is archived along with the rest, so retrying it means moving it back into the legacy folder.

Limits and gotchas

  • A world name may not start with $meta. That prefix opens the key space the store reserves for its own metadata, so the write is refused (IllegalArgumentException, thrown on the spot) instead of being accepted and never persisted. Any other character is fine - including /: a world named nether/deep round-trips, because the key is read right to left.
  • ConfigLifecycle hooks of your value type do not fire on decode. The envelope is decoded through a Jackson tree, so a value type that needs work on load has to do it in its own (de)serializer. See Configuration for the codec options.
  • The manager() escape hatch is for reading, not writing. It hands back the CachingManager<String, WorldChunkData<O>> behind the store for statistics, a manual eviction or wiring something the facade does not expose. Persisting through it bypasses the flush's snapshot-under-lock; writes stay with the facade.
  • on(Storage, String) is test-only. It exists so a test can hand in a scripted backend. There is no plugin behind it, so entities are encoded as compact JSON whatever the backend would have configured, and nothing takes part in a plugin's Ref graph.
  • The block value type is whatever the backend's Jackson mapper can serialize - which, on an ECStorage, means the platform types (ItemStack, Location, ...) and Ref fields come along for free, in the format the admin configured for that backend.

Debug logging

Every block store of every plugin answers to one switch, so turning it on shows all of them and leaving it off costs nothing:

# plugins/EverNifeCore/config.yml
DebugMode:
  enabled: true
  DebugModules:
    SV_WORLD_DATA: true

It logs what a preload read and what a flush wrote. The loud half - a flush that failed, a listener that threw, a preload that could not read - is not gated by it and goes to the owning plugin's log.


See also

Clone this wiki locally