Version: 0.10.0
Architecture documentation for the HyperFactions data persistence system.
HyperFactions uses an interface-based storage layer with:
- Storage Interfaces - Abstract contracts for data operations
- JSON Implementations - File-based storage with pretty-printed JSON
- Async Operations - All I/O returns
CompletableFuturefor non-blocking - Data Models - Java records for immutable data structures
- Auto-Save - Periodic saves with configurable interval
- Safe-Save - Atomic writes with SHA-256 checksums, backup recovery,
.bakauto-cleanup - Per-UUID Locking -
JsonPlayerStorageuses per-UUID locks to prevent concurrent load-modify-save race conditions (e.g., simultaneous deaths losing kill/death increments) - Migration Support - Automatic config (v1→v6) and data (v0→v1) format upgrades
- Backup System - GFS rotation with hourly/daily/weekly/manual/migration types
- Import Directories - Data import from ElbaphFactions and HyFactions
Storage Interface Implementation
│ │
FactionStorage ────────────────► JsonFactionStorage
PlayerStorage ────────────────► JsonPlayerStorage
ZoneStorage ────────────────► JsonZoneStorage
│ │
└──────── Data Models ◄────────────┘
│
Faction, PlayerPower,
Zone, FactionClaim, etc.
Backup System
│
BackupManager ─────────────────► ZIP archives in backups/
│ (GFS rotation: hourly, daily, weekly)
│
└── BackupMetadata ──────► Filename-encoded metadata
<server>/mods/com.hyperfactions_HyperFactions/
├── config/ # Configuration files
│ ├── factions.json # Faction gameplay settings
│ ├── server.json # Server behavior settings
│ └── ... # Other module configs
├── data/ # All data files (migrated from root in v0→v1)
│ ├── factions/ # Per-faction JSON files
│ │ └── {uuid}.json
│ ├── players/ # Per-player power data
│ │ └── {uuid}.json
│ ├── chat/ # Per-faction chat history
│ │ └── {factionId}.json
│ ├── economy/ # Per-faction treasury data
│ │ └── {factionId}.json
│ ├── zones.json # All zones in one file
│ ├── invites.json # Pending faction invites
│ ├── join_requests.json # Pending join requests
│ └── .version # Data layout version marker (currently: 1)
└── backups/ # Backup archives
├── hourly_2025-01-15_12-00-00.zip
├── daily_2025-01-15_00-00-00.zip
├── weekly_2025-01-13_00-00-00.zip
├── manual_my-backup.zip
└── migration_v3-to-v4_2025-01-15_00-00-00.zip
The BackupManager implements GFS (Grandfather-Father-Son) rotation for automatic backup management.
| Type | Auto-Rotated | Default Retention |
|---|---|---|
HOURLY |
Yes | Last 24 |
DAILY |
Yes | Last 7 |
WEEKLY |
Yes | Last 4 |
MANUAL |
No | Keep all (configurable) |
MIGRATION |
No | Keep all |
Each ZIP archive contains:
data/factions/— All faction JSON filesdata/players/— All player power JSON filesdata/chat/— Per-faction chat history filesdata/economy/— Per-faction treasury data filesdata/zones.json— Zone definitionsdata/invites.json— Pending faction invitesdata/join_requests.json— Pending join requestsconfig/— Configuration files (factions.json, server.json, etc.)
| Method | Description |
|---|---|
createBackup(type) |
Create async ZIP backup |
restoreBackup(name) |
Async ZIP extraction + reload |
listBackups() |
List sorted by timestamp (newest first) |
performRotation() |
GFS cleanup of old backups |
startScheduledBackups() |
Schedule hourly backups (72,000 ticks) |
See Data Import & Migration for import directory details and config migration.
| Class | Path | Purpose |
|---|---|---|
| FactionStorage | storage/FactionStorage.java |
Faction storage interface |
| PlayerStorage | storage/PlayerStorage.java |
Player power storage interface |
| ZoneStorage | storage/ZoneStorage.java |
Zone storage interface |
| JsonFactionStorage | storage/json/JsonFactionStorage.java |
JSON faction storage |
| JsonPlayerStorage | storage/json/JsonPlayerStorage.java |
JSON player storage |
| JsonZoneStorage | storage/json/JsonZoneStorage.java |
JSON zone storage |
| ChatHistoryStorage | storage/ChatHistoryStorage.java |
Chat history storage interface |
| JsonChatHistoryStorage | storage/json/JsonChatHistoryStorage.java |
JSON chat history storage |
| JsonEconomyStorage | storage/JsonEconomyStorage.java |
JSON economy/treasury storage |
| StorageUtils | storage/StorageUtils.java |
Atomic write, checksum, backup recovery |
| StorageHealth | storage/StorageHealth.java |
Storage health monitoring |
<server>/mods/com.hyperfactions_HyperFactions/
├── config/ # Configuration files (see config.md)
│ ├── factions.json # Faction gameplay settings
│ ├── server.json # Server behavior settings
│ └── ... # Other module configs
├── data/ # All data files
│ ├── factions/ # One file per faction
│ │ └── {uuid}.json
│ ├── players/ # One file per player
│ │ └── {uuid}.json
│ ├── chat/ # Per-faction chat history
│ │ └── {factionId}.json
│ ├── economy/ # Per-faction treasury data
│ │ └── {factionId}.json
│ ├── zones.json # All zones in single file
│ ├── invites.json # Pending faction invites
│ ├── join_requests.json # Pending join requests
│ └── .version # Data layout version (1)
├── update_preferences.json # Update notification preferences
└── backups/ # Backup storage (ZIP archives)
└── backup_*.zip
public interface FactionStorage {
/**
* Initialize storage (create directories, etc.).
*/
CompletableFuture<Void> init();
/**
* Shutdown storage (flush pending writes).
*/
CompletableFuture<Void> shutdown();
/**
* Load a single faction by ID.
*/
CompletableFuture<Optional<Faction>> loadFaction(UUID factionId);
/**
* Save a faction (create or update).
*/
CompletableFuture<Void> saveFaction(Faction faction);
/**
* Delete a faction.
*/
CompletableFuture<Void> deleteFaction(UUID factionId);
/**
* Load all factions.
*/
CompletableFuture<Collection<Faction>> loadAllFactions();
}public interface PlayerStorage {
CompletableFuture<Void> init();
CompletableFuture<Void> shutdown();
CompletableFuture<Optional<PlayerPower>> loadPlayerPower(UUID playerUuid);
CompletableFuture<Void> savePlayerPower(PlayerPower power);
CompletableFuture<Void> deletePlayerPower(UUID playerUuid);
CompletableFuture<Collection<PlayerPower>> loadAllPlayerPower();
}public interface ZoneStorage {
CompletableFuture<Void> init();
CompletableFuture<Void> shutdown();
CompletableFuture<Collection<Zone>> loadAllZones();
CompletableFuture<Void> saveAllZones(Collection<Zone> zones);
}storage/json/JsonFactionStorage.java
Stores one JSON file per faction in factions/ directory:
public class JsonFactionStorage implements FactionStorage {
private final Path factionsDir;
private final Gson gson;
public JsonFactionStorage(Path dataDir) {
this.factionsDir = dataDir.resolve("factions");
this.gson = new GsonBuilder()
.setPrettyPrinting()
.serializeNulls()
.create();
}
@Override
public CompletableFuture<Void> saveFaction(Faction faction) {
return CompletableFuture.runAsync(() -> {
Path file = factionsDir.resolve(faction.id() + ".json");
try (Writer writer = Files.newBufferedWriter(file)) {
gson.toJson(factionToJson(faction), writer);
}
});
}
private Path getFactionFile(UUID factionId) {
return factionsDir.resolve(factionId.toString() + ".json");
}
}storage/json/JsonPlayerStorage.java
Stores one JSON file per player in players/ directory. Uses per-UUID locking to prevent race conditions from concurrent kill/death tracking:
public class JsonPlayerStorage implements PlayerStorage {
private final Path playersDir;
private final ConcurrentHashMap<UUID, ReentrantLock> playerLocks = new ConcurrentHashMap<>();
@Override
public CompletableFuture<Void> savePlayerPower(PlayerPower power) {
return CompletableFuture.runAsync(() -> {
Path file = playersDir.resolve(power.uuid() + ".json");
// Write JSON...
});
}
/**
* Atomically update player data under a per-UUID lock.
* Prevents lost updates from concurrent deaths/kills.
*/
public CompletableFuture<Void> updatePlayerData(UUID uuid, UnaryOperator<PlayerData> updater) {
return CompletableFuture.runAsync(() -> {
ReentrantLock lock = playerLocks.computeIfAbsent(uuid, k -> new ReentrantLock());
lock.lock();
try {
PlayerData data = loadPlayerDataSync(uuid);
PlayerData updated = updater.apply(data);
savePlayerDataSync(updated);
} finally {
lock.unlock();
}
});
}
}The updatePlayerData method ensures that concurrent operations (e.g., two simultaneous deaths) do not lose increments through unsynchronized load-modify-save cycles.
storage/json/JsonZoneStorage.java
Stores all zones in a single zones.json file (zones are typically few in number):
public class JsonZoneStorage implements ZoneStorage {
private final Path zonesFile;
@Override
public CompletableFuture<Void> saveAllZones(Collection<Zone> zones) {
return CompletableFuture.runAsync(() -> {
// Write all zones as JSON array
});
}
}Mutable entity with builder-style setters:
public class Faction {
private final UUID id;
private String name;
private String description;
private String tag;
private String color;
private long createdAt;
private boolean open;
private FactionHome home;
private final List<FactionMember> members;
private final List<FactionClaim> claims;
private final List<FactionRelation> relations;
private final List<FactionLog> logs;
private FactionPermissions permissions;
// Getters and builder-style setters
public Faction setName(String name) {
this.name = name;
return this;
}
}JSON Structure (factions/{uuid}.json):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Warriors",
"description": "A mighty faction",
"tag": "WAR",
"color": "c",
"createdAt": 1706745600000,
"open": false,
"home": {
"world": "world",
"x": 100.5,
"y": 64.0,
"z": 200.5,
"yaw": 90.0,
"pitch": 0.0,
"setAt": 1706745600000,
"setBy": "player-uuid"
},
"members": [
{
"uuid": "player-uuid",
"username": "PlayerName",
"role": "LEADER",
"joinedAt": 1706745600000,
"lastOnline": 1706832000000
}
],
"claims": [
{
"world": "world",
"chunkX": 10,
"chunkZ": 20,
"claimedAt": 1706745600000,
"claimedBy": "player-uuid"
}
],
"relations": [
{
"targetFactionId": "other-uuid",
"type": "ALLY",
"since": 1706745600000
}
],
"logs": [
{
"type": "MEMBER_JOIN",
"message": "PlayerName joined",
"timestamp": 1706745600000,
"actorUuid": "player-uuid"
}
],
"permissions": {
"outsiderBreak": false,
"memberBreak": true,
"pvpEnabled": true
}
}public record FactionMember(
UUID uuid,
String username,
FactionRole role,
long joinedAt,
long lastOnline
) {}public enum FactionRole {
LEADER, // Full control
OFFICER, // Can manage members, claims
MEMBER // Basic permissions
}public record PlayerPower(
UUID uuid,
double power,
double maxPower,
long lastDeath,
long lastRegen
) {}JSON Structure (players/{uuid}.json):
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"power": 15.5,
"maxPower": 20.0,
"lastDeath": 1706745600000,
"lastRegen": 1706832000000
}public class Zone {
private final UUID id;
private String name;
private ZoneType type;
private String world;
private final Set<ChunkKey> chunks;
private long createdAt;
private UUID createdBy;
private final Map<String, Boolean> flags;
}JSON Structure (zones.json):
[
{
"id": "zone-uuid",
"name": "Spawn",
"type": "SAFE",
"world": "world",
"chunks": [
{ "x": 0, "z": 0 },
{ "x": 0, "z": 1 }
],
"createdAt": 1706745600000,
"createdBy": "admin-uuid",
"flags": {
"pvp_enabled": false,
"build_allowed": false
}
}
]Immutable identifier for a chunk:
public record ChunkKey(String world, int x, int z) {
@Override
public int hashCode() {
return Objects.hash(world, x, z);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ChunkKey other)) return false;
return x == other.x && z == other.z && world.equals(other.world);
}
}All storage operations are async to prevent blocking the main thread:
// In manager
public void loadAll() {
factionStorage.loadAllFactions()
.thenAccept(factions -> {
for (Faction faction : factions) {
cache.put(faction.id(), faction);
}
})
.join(); // Block only during startup
}
public void saveFaction(Faction faction) {
// Fire and forget during normal operation
factionStorage.saveFaction(faction);
}During startup, .join() is used to ensure data is loaded before the plugin is ready:
// In HyperFactions.enable()
factionStorage.init().join();
playerStorage.init().join();
zoneStorage.init().join();
factionManager.loadAll().join();
powerManager.loadAll().join();
zoneManager.loadAll().join();During normal operation, saves are fire-and-forget:
// In FactionManager
public void updateFaction(Faction faction) {
cache.put(faction.id(), faction);
factionStorage.saveFaction(faction); // Async, doesn't block
}Configured in config/server.json:
{
"autoSave": {
"enabled": true,
"intervalMinutes": 5
}
}Implementation in HyperFactions.java:
private void startAutoSaveTask() {
int intervalMinutes = ConfigManager.get().getAutoSaveIntervalMinutes();
int periodTicks = intervalMinutes * 60 * 20;
autoSaveTaskId = scheduleRepeatingTask(periodTicks, periodTicks, this::saveAllData);
}
public void saveAllData() {
Logger.info("Auto-saving data...");
factionManager.saveAll().join();
powerManager.saveAll().join();
zoneManager.saveAll().join();
Logger.info("Auto-save complete");
}All 7 storage types use StorageUtils.writeAtomic() for crash-safe writes:
- Write content to a temp file (
file.{counter}.tmp) - Compute SHA-256 checksum of content
- Read back temp file and verify checksum matches
- Copy existing file to
.bakbackup - Atomic rename: temp → target
- Delete
.bakfile (cleanup after successful write)
If the process crashes during steps 1-4, the original file is untouched. If it crashes during step 5, the .bak file provides recovery. On startup, cleanupOrphanedFiles() removes any stray .tmp or orphaned .bak files.
| Storage | File Pattern | Notes |
|---|---|---|
| JsonFactionStorage | data/factions/{uuid}.json |
One file per faction |
| JsonPlayerStorage | data/players/{uuid}.json |
One file per player |
| JsonZoneStorage | data/zones.json |
Single file for all zones |
| JsonChatHistoryStorage | data/chat/{factionId}.json |
One file per faction |
| JsonEconomyStorage | data/economy/{factionId}.json |
One file per faction |
| InviteManager | data/invites.json |
Single file |
| JoinRequestManager | data/join_requests.json |
Single file |
migration/migrations/data/DataV0ToV1Migration.java
Moves data files from the plugin root into a data/ subdirectory. The migration:
- Creates
data/directory - Moves:
factions/,players/,chat/,economy/,zones.json,invites.json,join_requests.json - Also moves any
.bakfiles alongside their data files - Writes
data/.versionwith1(last step — if crash before this, migration re-runs) - MigrationRunner creates ZIP backup before execution for rollback support
Detection: Runs when data/.version doesn't exist AND at least one old-path item exists.
migration/MigrationRunner.java
Handles automatic data format upgrades:
Old single-chunk format:
{
"id": "...",
"world": "world",
"chunkX": 10,
"chunkZ": 20
}Migrates to multi-chunk format:
{
"id": "...",
"world": "world",
"chunks": [{ "x": 10, "z": 20 }]
}Migration is detected and run automatically on load.
Monitors storage system health:
public class StorageHealth {
private final AtomicLong lastSaveTime = new AtomicLong();
private final AtomicInteger failedSaves = new AtomicInteger();
public void recordSave() {
lastSaveTime.set(System.currentTimeMillis());
}
public void recordFailure() {
failedSaves.incrementAndGet();
}
public boolean isHealthy() {
// Check if saves are succeeding
return failedSaves.get() < MAX_CONSECUTIVE_FAILURES;
}
}To add database support, implement the storage interfaces:
public class MySqlFactionStorage implements FactionStorage {
private final DataSource dataSource;
@Override
public CompletableFuture<Void> saveFaction(Faction faction) {
return CompletableFuture.runAsync(() -> {
try (Connection conn = dataSource.getConnection()) {
// SQL INSERT/UPDATE
}
});
}
@Override
public CompletableFuture<Optional<Faction>> loadFaction(UUID factionId) {
return CompletableFuture.supplyAsync(() -> {
try (Connection conn = dataSource.getConnection()) {
// SQL SELECT
}
});
}
}Then configure in HyperFactions:
// In HyperFactions.enable()
if (ConfigManager.get().isUsingDatabase()) {
factionStorage = new MySqlFactionStorage(dataSource);
} else {
factionStorage = new JsonFactionStorage(dataDir);
}Storage integrates with the backup system:
// In BackupManager
public void createBackup(BackupType type) {
// Save all data first
hyperFactions.saveAllData();
// Copy data directories to backup (from data/ subdirectory)
Path dataPath = dataDir.resolve("data");
copyDirectory(dataPath.resolve("factions"), backupDir);
copyDirectory(dataPath.resolve("players"), backupDir);
copyDirectory(dataPath.resolve("chat"), backupDir);
copyDirectory(dataPath.resolve("economy"), backupDir);
copyFile(dataPath.resolve("zones.json"), backupDir);
copyFile(dataPath.resolve("invites.json"), backupDir);
copyFile(dataPath.resolve("join_requests.json"), backupDir);
copyDirectory(dataDir.resolve("config"), backupDir);
}JSON files can be manually edited while the server is stopped:
- Stop the server
- Edit JSON files
- Start the server (data loads fresh)
Warning: Editing while the server is running may cause data loss due to auto-save overwriting changes.
| Class | Path |
|---|---|
| FactionStorage | storage/FactionStorage.java |
| PlayerStorage | storage/PlayerStorage.java |
| ZoneStorage | storage/ZoneStorage.java |
| JsonFactionStorage | storage/json/JsonFactionStorage.java |
| JsonPlayerStorage | storage/json/JsonPlayerStorage.java |
| JsonZoneStorage | storage/json/JsonZoneStorage.java |
| Faction | data/Faction.java |
| PlayerPower | data/PlayerPower.java |
| Zone | data/Zone.java |
| ChunkKey | data/ChunkKey.java |
| ChatHistoryStorage | storage/ChatHistoryStorage.java |
| JsonChatHistoryStorage | storage/json/JsonChatHistoryStorage.java |
| JsonEconomyStorage | storage/JsonEconomyStorage.java |
| StorageUtils | storage/StorageUtils.java |
| DataV0ToV1Migration | migration/migrations/data/DataV0ToV1Migration.java |
| BackupManager | backup/BackupManager.java |