Skip to main content

MySQL Extension

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).

Dual Database Support

MySQL and PostgreSQL with automatic dialect switching.

Redis & Valkey

Instant cross-server data sync and cache invalidation.

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)