Saltar al contenido principal

MySQL

Database bridge powering cross-server Fact, Asset, and player data synchronization with Redis/Valkey pub/sub.

The MySQL Extension replaces the default local file storage with a robust remote database system supporting MySQL, PostgreSQL, Redis, and Valkey. It enables real-time synchronization of Facts, Assets, and player data across multi-server networks via dual-mechanism sync (polling + optional Redis/Valkey pub/sub).


Architecture

Storage Tables

TypeTableDescription
Fact{prefix}factsKey-value storage for game state (quest progress, player stats, flags)
Asset{prefix}assetsBinary/serialized data (configs, skins, schematics)
ServersserversServer registry with heartbeat, TPS, player counts, memory
Extensions{prefix}game_mode_extensionsPer-extension enabled/disabled, network mode, profile mode
Game Modes{prefix}game_modesGame mode definitions with extension bindings

Features

Database Agnostic

Supports MySQL 8+ and PostgreSQL 14+ with automatic dialect detection. SQL syntax adapts automatically — ON DUPLICATE KEY UPDATE for MySQL, ON CONFLICT DO UPDATE for PostgreSQL. JDBC URL is constructed dynamically.

# config.yml
mysql:
database-type: MYSQL # or POSTGRESQL
host: localhost
port: 3306 # 5432 for PostgreSQL
database: typewriter_data
username: user
password: pass
use-ssl: false
allow-public-key-retrieval: true
table-prefix: "ty_"

Cross-Server Data Sync (Dual Mechanism)

The engine's FactDatabase now includes native cross-server sync via loadFactsSince() and syncFactsFromStorage() called every 5 seconds. The MySQL Extension complements this with:

MechanismTriggerLatencyFallback
Engine sync (always active)Every 5 seconds, via FactDatabase.syncFactsFromStorage()5sN/A — always on
Extension polling (always active)Every sync-interval-seconds (default 30s)30sN/A — always on
Redis/Valkey Pub/Sub (optional)Instant on write\< 500msFalls back to polling if Redis down

Race condition protection: Both the engine's syncFactsFromStorage() and the extension's updateFactCache() use lastUpdate timestamp comparison — a server never overwrites its local cache with stale data. Dirty facts (modified but not yet flushed) are also protected from being overwritten.

Engine-Level Dirty Tracking

The FactDatabase now includes full dirty tracking with debounced flush:

  • dirtyFacts — tracks facts modified since last persistence
  • deletedFacts — tracks facts that need to be removed from storage
  • Debounce 50ms — rapid sequential writes are batched into a single flush
  • flushDirtyFacts() — calls upsertFacts() / deleteFacts() on the storage

This ensures that every write to a fact is eventually flushed to MySQL without the 3-minute wait.

Non-Destructive Store & UPSERT

The FactStorage interface now exposes three distinct operations instead of one:

MethodBehaviorDestructive?
storeFacts()Full sync — upserts all given facts (no DELETE)❌ No
upsertFacts()Inserts or updates — only writes given facts❌ No
deleteFacts()Deletes specific facts by (entry_id, group_id, game_mode)✅ Yes (intentional)

The old storeFacts() pre-deleted facts not present in the input, causing data loss in cross-server scenarios. It now behaves as a non-destructive upsert. deleteFacts() is reserved for explicit deletions (expired facts, cache cleanup).

Deadlock Protection (3 Layers)

MySqlFactStorage uses three-layer protection against MySQL deadlocks:

  1. SELECT ... FOR UPDATE — acquires exclusive row locks immediately, preventing conflicting transactions
  2. withRetryOnDeadlock() — up to 3 retries with exponential backoff (100ms → 200ms → 400ms)
  3. Batch within a transaction — all writes committed atomically
private suspend fun <T> withRetryOnDeadlock(operation: String, block: () -> T): T {
for (attempt in 1..MAX_RETRIES) {
try {
return block()
} catch (e: SQLException) {
if (isDeadlock(e) && attempt < MAX_RETRIES) {
delay(RETRY_BASE_DELAY_MS * (1L shl (attempt - 1)))
} else {
throw e
}
}
}
}

Player Data Flush

The engine only persists fact caches to storage every 3 minutes (FACT_STORAGE_DELAY=180). Between cycles, player data lives only in memory. The MySQL Extension guarantees no data loss on player disconnect:

  • PlayerQuitEvent and PlayerKickEvent trigger an immediate, async flush via PlayerDataFlushListener
  • The flush runs on a Dispatchers.IO coroutine — never blocks the main thread
  • Data written at T0 is in MySQL by T+200ms (not T+180s)
  • Uses factDatabase.getCacheSnapshot() (public API) — no reflection
Player writes fact → cache updated → Player quits

PlayerDataFlushListener
(fire-and-forget coroutine)

DataSyncService.forceFlushToMySql()
(uses getCacheSnapshot() + upsertFacts)

MySQL ← data persisted immediately

Shutdown Safety

During server reload or plugin disable, the extension follows a strict shutdown order to prevent HikariDataSource has been closed errors:

  1. Set isShuttingDown = true — immediately, before any cleanup
  2. Unregister PlayerDataFlushListener — via its shutdown() callback
  3. Stop DataSyncService — cancels polling coroutine
  4. Stop discovery services — removes heartbeat
  5. Unload Koin module — releases DI bindings
  6. Close DataSourcelast step, no one is using it anymore

All database operations (forceFlushToMySql, syncFactsFromMySql, flushBeforeDisconnect) check isShuttingDown before proceeding. If the flag is set, they return immediately with a FINE-level log message instead of crashing.

[18:42:13 WARN]: DataSync: forceFlush failed: HikariDataSource has been closed. ← ÉLIMINÉ
[18:42:13 ERROR]: Could not pass event PlayerQuitEvent to Typewriter ← ÉLIMINÉ

Redis & Valkey Pub/Sub

Both Redis 7+ and Valkey 8+ are supported. Valkey uses the identical RESP protocol — the Jedis client works transparently with either backend.

redis:
enabled: true
type: redis # redis or valkey
host: localhost
port: 6379
username: "" # Optional (Valkey ACL)
password: ""
channel: "typewriter:events"
server-name: "survival" # Unique per server

Pub/Sub channels:

ChannelDirectionContent
typewriter:events/data_updateServer → AllFact/asset change notification ("fact:quest_score")
typewriter:events/config_updateAdmin → AllHot-reload config trigger
typewriter:events/server_updateAll → AllPlayer count + slots broadcast (5s interval)
typewriter:events/player_trackingServer ↔ Serverwhere_is, player_location, summon

Auto-reconnect: If Redis disconnects, the subscriber thread retries every 5 seconds. The polling loop continues unaffected.

Server Discovery

Each server sends a heartbeat every 30 seconds to the servers table. Stale servers (60s without heartbeat) are automatically cleaned up. On JVM shutdown, the server deregisters itself.

-- Auto-created table
CREATE TABLE IF NOT EXISTS servers (
server_id VARCHAR(191) PRIMARY KEY,
ip VARCHAR(255) NOT NULL,
port INT NOT NULL,
game_mode VARCHAR(64) NOT NULL,
last_heartbeat DATETIME(6) NOT NULL,
tps DOUBLE,
version VARCHAR(64),
player_count INT,
max_players INT,
free_memory BIGINT,
max_memory BIGINT,
extensions TEXT
);

Extension Auto-Discovery

On startup, the extension scans all loaded TypeWriter extensions via the engine's ExtensionLoader and registers them in the database. Each extension has configurable:

SettingOptionsDescription
enabledtrue / falseWhether this extension uses MySQL storage
profileModeSHARED / PER_PROFILEData shared or per-profile?
networkModeSHARED / ISOLATEDData shared across game modes or isolated?

Key normalization handles all variants: TypeWriter-ProfilesExtensionprofiles, RPGCorerpgcore, friendsparty (alias).

Cross-Server Player Redirection

RedirectionManager enables cross-server player teleport via Redis pub/sub:

  • /locate <player> — finds which server a player is on, then teleports
  • /summon <player> — pulls a player from another server
  • Load balancingredirectPlayer() picks the server with the lowest fill percentage

Config Hot-Reload

Sending a config_update message via Redis triggers an automatic config reload on all servers:

Redis: PUBLISH typewriter:events "config_update:profiles:enabled:true"
→ All servers reload MySqlExtensionConfig
→ ExtensionDiscoveryService re-registers extensions

Configuration Reference

Full mysql/config.yml:

mysql:
enabled: true
database-type: MYSQL # MYSQL or POSTGRESQL
host: localhost
port: 3306
database: typewriter_data
username: user
password: pass
use-ssl: false
allow-public-key-retrieval: true
table-prefix: "ty_"
migrate-from-files: true # Import local data on first start
sync-interval-seconds: 30 # Polling interval for cross-server sync

pool:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
max-lifetime: 1800000
validation-timeout: 5000
keepalive-time: 0

game-mode:
current: "default" # This server's game mode

extensions:
profiles:
enabled: true
profile-mode: per-profile # shared or per-profile
network-mode: shared # shared or isolated
cosmetics:
enabled: true
profile-mode: shared
network-mode: shared
rpgcore:
enabled: true
profile-mode: per-profile
network-mode: isolated
dungeon:
enabled: true
profile-mode: per-profile
network-mode: isolated
# ... all extensions configurable

redis:
enabled: false
type: redis # redis or valkey
host: localhost
port: 6379
username: ""
password: ""
channel: "typewriter:events"
server-name: "survival"

profiles:
enabled: true
max-profiles: 5
default-profile-name: "Principal"
save-statistics: true
save-inventory: true
save-ender-chest: true
require-permission: false
auto-create-default: true

Key Configuration Options

OptionDefaultDescription
sync-interval-seconds30Seconds between MySQL polling cycles
redis.typeredisredis or valkey
redis.server-namesurvivalUnique per-server identifier
extensions.<key>.network-modeisolatedshared = cross-game-mode, isolated = per-mode
extensions.<key>.profile-modesharedshared = all profiles, per-profile = separated
pool.maximum-pool-size10HikariCP max connections
pool.keepalive-time0Keepalive in ms (0 = disabled)

Web UI

Access the MySQL administration page via the Server Management sidebar tab in the Typewriter web editor.

Tab Overview

TabPurpose
ServersView registered servers, TPS, player counts, online/offline status
Game ModesCreate/edit/delete game modes, enable/disable extensions, configure network/profile modes
Health & OpsRedis latency, database connection pool stats, MySQL logs
TopologyVisual graph of connected servers and their relationships
Entry StorageBrowse fact/asset tables, view individual entries, table schemas

Socket.IO Events

EventDirectionDescription
fetchServerListClient → ServerGet all registered servers with health data
fetchGameModesClient → ServerGet game mode definitions
createGameMode / deleteGameModeClient → ServerManage game modes
updateGameModeExtensionClient → ServerToggle extension enabled/network/profile mode
setExtensionEnabledClient → ServerQuick enable/disable toggle
fetchEntryFiltersClient → ServerGet per-extension entry filters
fetchStorageOverviewClient → ServerGet all fact/asset tables
fetchTableEntriesClient → ServerBrowse entries in a table
fetchTableSchemaClient → ServerView table column definitions
serverHealthCheckClient → ServerCheck Redis + DB connectivity
fetchMySqlLogsClient → ServerRecent MySQL operation logs

Permissions

PermissionDescription
typewriter.admin.server.listView servers, game modes, storage, logs
typewriter.admin.server.healthView health status and logs
typewriter.admin.server.deregisterRemove servers from registry
typewriter.admin.gamemode.createCreate or delete game modes
typewriter.admin.gamemode.configureConfigure extensions, entry filters, storage overrides

Dependencies

  • TypeWriter Engine (required)
  • MySQL 8+ or PostgreSQL 14+ (required)
  • Redis 7+ or Valkey 8+ (recommended for cross-server sync)
  • HikariCP 6.x (connection pooling, bundled)
  • Jedis 5.x (Redis/Valkey client, bundled)
  • JDBC drivers — MySQL Connector/J + PostgreSQL JDBC (bundled)

Testing

The extension has a comprehensive test suite split into two pillars:

Pillar 1 — Unit Tests (71 tests)

Located in TypeWriter-MySqlExtension/src/test/kotlin/. No infrastructure needed.

Test classCoverage
SqlDialectTestUPSERT generation (MySQL + PostgreSQL), quoting, type mappings
ConfigParsingTestProfileMode, NetworkMode, CacheBackend, DatabaseType parsing
MessageParsingTestRedis wire format: data_update, server_update, player_tracking
ExtensionKeyTestKey normalization, alias resolution, fallback chain, game mode isolation

Run: .\gradlew.bat :TypeWriter-MySqlExtension:test --no-daemon

Pillar 2 — Integration Tests (16 scenarios)

Located in mysql-integration-tests/. Uses TestContainers (real MySQL 8.4, PostgreSQL 16, Redis 7, Valkey 8) and simulated servers — no Minecraft required.

Scenario fileWhat it validates
CrossServerSyncTestPolling sync, game mode isolation, global visibility, race condition guard
RedisPubSubTestRedis PING, Valkey PING, pub/sub delivery, instant cross-server sync, polling fallback
DeadlockRetryTest4-thread concurrent writes, FOR UPDATE + retry, data integrity
PlayerFlushTestQuit flush completeness, async non-blocking behavior, sub-200ms persistence

Run: .\gradlew.bat :mysql-integration-tests:test --no-daemon (requires Docker)