feat: Implement runtime API cache store and related configurations

- Added RuntimeApiCacheStore for managing API cache entries in memory.
- Introduced StorageTarget enum to define various storage targets.
- Created StoredCacheEntry record to encapsulate cache entries.
- Defined SyncState enum to represent the synchronization state of cache entries.
- Added ControlPlaneConfigurationSource enum for different configuration sources.
- Developed ControlPlaneRuntimeConfig class for runtime configuration management.
- Implemented ControlPlaneStartupConfigResolver for resolving configuration from various sources.
- Created ControlPlaneStartupSettings to hold startup configuration values.
- Added ResolvedControlPlaneStartupConfig record to encapsulate resolved configuration.
- Implemented tests for batch client and cache client functionalities.
- Added tests for ControlPlaneStartupConfigResolver to validate configuration resolution logic.
This commit is contained in:
2026-06-05 07:00:14 -07:00
parent 7a4a0bd4bd
commit 59330bdf84
56 changed files with 3627 additions and 490 deletions
+214
View File
@@ -0,0 +1,214 @@
# API Response Storage
The cache layer stores API results and controls when data should be pulled from or pushed to the control plane.
Use it when multiple plugins need shared ownership of:
- API response freshness
- stale fallback behavior
- player-persistent API data
- dirty local data that should be pushed later
Entry point:
```java
ApiDataCacheClient cache = NetworkPlugin.client().cache();
```
## Storage Targets
Every cached call explicitly chooses a target.
```java
StorageTarget.RUNTIME_SHARED
StorageTarget.PLAYER
StorageTarget.ASSET_STORE
```
`RUNTIME_SHARED`
- process-local in-memory cache
- best for server profile, config, catalog reads, active announcements
- does not survive restart
`PLAYER`
- stored on `NetworkApiDataComponent`
- requires a `PlayerCacheContext`
- persists with Hytale player component data
`ASSET_STORE`
- requires an `AssetStoreCacheAdapter`
- intended for plugins that can map API data into concrete Hytale asset types
- core-network owns freshness metadata, the adapter owns asset conversion
## Register A Cached Call
```java
CachedApiCall<ServerProfile> serverProfileCall = CachedApiCall
.builder(ServerProfile.class)
.id("server-profile")
.method("GET")
.uri("/api/v1/server/profile")
.storageTarget(StorageTarget.RUNTIME_SHARED)
.maxAge(Duration.ofSeconds(30))
.build();
cache.register(serverProfileCall);
```
Read cached or pull if stale:
```java
CompletableFuture<ServerProfile> profile =
cache.getOrPull(serverProfileCall, CacheContext.shared());
```
Force refresh:
```java
CompletableFuture<ServerProfile> profile =
cache.forcePull(serverProfileCall, CacheContext.shared());
```
## Player Storage
Player storage requires explicit context. Core-network does not infer player identity from the API URI.
```java
PlayerCacheContext context = new PlayerCacheContext(playerId, playerRef, entityStore);
CachedApiCall<PlayerSessionProfile> sessionProfile = CachedApiCall
.builder(PlayerSessionProfile.class)
.id("player-session-profile")
.method("GET")
.uri(() -> "/api/v1/players/" + playerId + "/session-profile")
.storageTarget(StorageTarget.PLAYER)
.maxAge(Duration.ofMinutes(5))
.build();
CompletableFuture<PlayerSessionProfile> profile =
cache.getOrPull(sessionProfile, context);
```
`NetworkPlugin.setup()` registers `NetworkApiDataComponent` with the entity store registry.
## Freshness Policies
Auto-refresh when older than max age:
```java
.maxAge(Duration.ofMinutes(5))
```
Never auto-refresh:
```java
.neverRefresh()
```
`forcePull(...)` always bypasses freshness checks.
## Dirty Push Policies
Update local data and mark it dirty:
```java
cache.updateLocal(call, context, value);
```
Push immediately:
```java
cache.forcePush(call, context);
```
Push after dirty TTL:
```java
CachedApiCall<MyModel> call = CachedApiCall
.builder(MyModel.class)
.id("my-writeback")
.method("GET")
.uri("/api/v1/some/read/path")
.dirtyMaxAge(Duration.ofSeconds(10))
.push(
"POST",
(context, value) -> "/api/v1/some/write/path",
(context, value) -> value
)
.build();
```
The plugin scheduler calls:
```java
cache.flushDirty();
```
Manual-only push:
```java
.manualPushOnly()
```
## Failed Sync Metadata
If refresh fails and an old value exists:
- stale value is returned
- `failedSyncCount` increments
- `lastError` is stored
- `syncState` becomes `FAILED`
If refresh fails and no value exists:
- the future completes exceptionally
Read metadata:
```java
Optional<ApiCacheMetadata> metadata =
cache.metadata(call, context);
```
Successful pull or push resets `failedSyncCount` to `0`.
## Asset Store Adapter
Asset-store-backed calls need a plugin adapter.
```java
public final class MyAssetCacheAdapter implements AssetStoreCacheAdapter {
@Override
public Optional<ApiCacheEntry> read(ApiCacheKey key, CacheContext context) {
// Read concrete Hytale asset and convert to ApiCacheEntry.
}
@Override
public void write(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) {
// Convert entry payload into concrete asset and load/write through AssetStore.
}
}
```
Use it like:
```java
CachedApiCall<JsonObject> call = CachedApiCall
.builder(JsonObject.class)
.id("asset-backed-config")
.method("GET")
.uri("/api/v1/configs/active")
.storageTarget(StorageTarget.ASSET_STORE)
.assetStoreAdapter(new MyAssetCacheAdapter())
.maxAge(Duration.ofMinutes(10))
.build();
```
## Batching Behavior
When `ControlPlaneClient.batchingEnabled()` is true, cache pulls and pushes use `BatchClient`.
When batching is disabled, cache operations call the HTTP transport directly.
+102
View File
@@ -0,0 +1,102 @@
# Batched API Calls
Batching lets multiple plugins enqueue control-plane calls that are sent as one HTTP request to `/api/v1/batch`.
Use batching when:
- many plugins read data at the same time
- looped tasks report state periodically
- call latency can be handled asynchronously
Do not use batching when:
- the caller needs a blocking result immediately
- the endpoint should bypass queueing for operational safety
## One-Off Calls
```java
CompletableFuture<ServerProfile> future = NetworkPlugin.client()
.batch()
.get(ProfileEndpoint.VALUE, ProfileEndpoint.VALUE.uri(), ServerProfile.class);
```
```java
CompletableFuture<JsonObject> future = NetworkPlugin.client()
.batch()
.enqueueJson("GET", "/api/v1/server/profile", null);
```
Queued calls complete when the batch loop flushes, or when code calls:
```java
NetworkPlugin.client().batch().flush();
```
## Looped Calls
```java
LoopHandle handle = NetworkPlugin.client().batch().registerLoopedCall(
LoopedApiCall.builder(ServerProfile.class)
.id("server-profile-loop")
.interval(Duration.ofSeconds(5))
.method("GET")
.uri("/api/v1/server/profile")
.onSuccess(profile -> {
// update local state
})
.onFailure(error -> {
// log or mark degraded
})
.build()
);
```
Cancel when no longer needed:
```java
handle.cancel();
```
## Batch Endpoint Contract
Request:
```json
{
"requests": [
{
"id": "generated-call-id",
"method": "GET",
"uri": "/api/v1/server/profile",
"body": null
}
]
}
```
Response:
```json
{
"responses": [
{
"id": "generated-call-id",
"status": 200,
"body": { "data": {} },
"error": null
}
]
}
```
Each item completes independently. A failed item does not fail unrelated futures.
## Defaults
- endpoint: `/api/v1/batch`
- flush interval: `250ms`
- max batch size: `100`
- batching enabled: `true`
Configure with `ControlPlaneClient.builder()`.
+82
View File
@@ -0,0 +1,82 @@
# Control Plane Client
`ControlPlaneClient` is the main public entry point for plugins.
```java
ControlPlaneClient client = NetworkPlugin.client();
```
## Public Clients
```java
client.server(); // ServerClient
client.presence(); // PresenceClient
client.players(); // PlayersClient
client.batch(); // BatchClient
client.cache(); // ApiDataCacheClient
```
## Handwritten Domain Clients
`ServerClient`
```java
ServerProfile profile = client.server().currentProfile();
```
`PresenceClient`
```java
client.presence().register(instanceId, registrationPayload);
client.presence().heartbeat(instanceId, HeartbeatPayload.running(12, 150));
client.presence().shutdown(instanceId, "restart");
```
`PlayersClient`
```java
PlayerSessionProfile profile = client.players().getSessionProfile(playerId);
List<PlayerEntitlement> entitlements = client.players().getEntitlements(playerId);
```
## Error Handling
Transport errors use checked `ControlPlaneException` subclasses:
- `AuthException` for `401` and `403`
- `NotFoundException` for `404`
- `ValidationException` for `422`
- `RemoteException` for `5xx`
- `ControlPlaneException` for other transport or parsing failures
Example:
```java
try {
ServerProfile profile = client.server().currentProfile();
} catch (AuthException e) {
// token is missing, invalid, inactive, or lacks ability
} catch (ControlPlaneException e) {
// generic control-plane failure
}
```
## Endpoint Descriptors
Generated endpoint descriptors are available under:
```text
net.kewwbec.network.generated.api.v1
```
Use them for stable route metadata and URI templates. Resolve path variables with `PathBuilder`.
```java
String uri = PathBuilder.resolve(
SessionProfileEndpoint.VALUE.uri(),
"player",
playerId
);
```
Do not edit generated descriptors manually. Update routes in `core-control-plane`, then run the SDK sync command from that repo.
+65
View File
@@ -0,0 +1,65 @@
# Generated SDK
The generated SDK is a Java view of the control-plane route contract.
Generated files live under:
```text
src/main/java/net/kewwbec/network/generated/api/v1
```
These files include:
- route names
- allowed HTTP methods
- URI templates
- required abilities
- middleware metadata
- request field descriptors
- response type descriptors
## Usage
Import the generated root:
```java
import net.kewwbec.network.generated.api.v1.ControlPlaneApiV1;
```
Use domain APIs to inspect route descriptors:
```java
ControlPlaneApiV1 api = ControlPlaneApiV1.INSTANCE;
String version = api.apiVersion();
int endpointCount = api.endpointCount();
```
Use route endpoint constants with handwritten clients or batching:
```java
String uri = PathBuilder.resolve(
SessionProfileEndpoint.VALUE.uri(),
"player",
playerId
);
```
## Regeneration
The source of truth is `../core-control-plane`.
Run from `core-control-plane`:
```bash
php artisan control-plane:sync-network-sdk --dry-run
php artisan control-plane:sync-network-sdk
```
Review generated churn carefully before committing. Broad generated diffs can happen if `core-control-plane` has unrelated route changes.
## Rules
- Do not manually patch generated Java.
- Fix Laravel routes, requests, resources, or the generator upstream.
- Re-run the sync after route changes.
- Keep handwritten Java under `net.kewwbec.network`, outside `generated`.
+134
View File
@@ -0,0 +1,134 @@
# Getting Started
## Build
From `core-network`:
```bat
gradlew.bat compileJava
gradlew.bat test
gradlew.bat shadowJar
```
`compileJava` and `test` are the fastest checks for SDK/runtime changes. `shadowJar` builds the plugin jar.
## Configure The Shared Client
`core-network` auto-configures `NetworkPlugin.client()` during plugin setup when startup configuration is present.
Recommended production setup uses environment variables:
```powershell
$env:KWB_CONTROL_PLANE_BASE_URL = "https://control-plane.internal"
$env:KWB_CONTROL_PLANE_TOKEN = "server-issued-token"
```
Supported optional environment variables:
```text
KWB_CONTROL_PLANE_BATCHING_ENABLED=true
KWB_CONTROL_PLANE_BATCH_FLUSH_INTERVAL_MS=250
KWB_CONTROL_PLANE_BATCH_MAX_SIZE=100
KWB_CONTROL_PLANE_BATCH_ENDPOINT=/api/v1/batch
```
Launcher-controlled startup can use JVM properties:
```text
-Dkweebec.controlPlane.baseUrl=https://control-plane.internal
-Dkweebec.controlPlane.token=server-issued-token
-Dkweebec.controlPlane.batching.enabled=true
-Dkweebec.controlPlane.batch.flushIntervalMs=250
-Dkweebec.controlPlane.batch.maxSize=100
-Dkweebec.controlPlane.batch.endpoint=/api/v1/batch
```
For small local deployments, `core-network` creates `control-plane.json` in the plugin data directory. Fill in the placeholders:
```json
{
"BaseUrl": "https://control-plane.internal",
"Token": "local-dev-token",
"BatchingEnabled": true,
"BatchFlushIntervalMs": 250,
"MaxBatchSize": 100,
"BatchEndpoint": "/api/v1/batch"
}
```
Precedence is environment variables, then JVM properties, then `control-plane.json`.
Manual setup still works and overrides auto-config:
```java
NetworkPlugin.configure(
"https://control-plane.internal",
System.getenv("KWB_CONTROL_PLANE_TOKEN")
);
```
Dependent plugins then use:
```java
ControlPlaneClient client = NetworkPlugin.client();
```
You can inspect the source that configured the shared client:
```java
NetworkPlugin.configurationSource();
```
## Dependency Setup
Dependent jars should declare `core-network` as a plugin dependency in their manifest:
```json
{
"Dependencies": {
"core-network": "*"
}
}
```
## Common Direct Calls
```java
ServerProfile profile = NetworkPlugin.client()
.server()
.currentProfile();
```
```java
PlayerSessionProfile profile = NetworkPlugin.client()
.players()
.getSessionProfile(playerId);
```
```java
NetworkPlugin.client()
.presence()
.heartbeat(instanceId, HeartbeatPayload.running(currentPlayers, maxPlayers));
```
## Runtime Scheduling
`NetworkPlugin` starts a scheduled loop when the Hytale plugin starts. The loop:
- flushes due batched API calls
- flushes dirty cached API entries
Default batch cadence is `250ms`.
Builder overrides:
```java
ControlPlaneClient client = ControlPlaneClient.builder()
.baseUrl("https://control-plane.internal")
.token(token)
.batchingEnabled(true)
.batchFlushInterval(Duration.ofMillis(250))
.maxBatchSize(100)
.batchEndpoint("/api/v1/batch")
.build();
```
+42
View File
@@ -0,0 +1,42 @@
# Core Network Wiki
`core-network` is the Hytale plugin-side runtime for Kweebec Network control-plane access.
It provides:
- a shared `ControlPlaneClient`
- handwritten convenience clients for common server/player operations
- generated API endpoint descriptors from `core-control-plane`
- batched HTTP calls through `/api/v1/batch`
- API response storage policies for runtime, player, and asset-store-backed data
## Pages
- [Getting Started](Getting-Started.md)
- [Control Plane Client](Control-Plane-Client.md)
- [Batched API Calls](Batched-API-Calls.md)
- [API Response Storage](API-Response-Storage.md)
- [Generated SDK](Generated-SDK.md)
- [Troubleshooting](Troubleshooting.md)
## Package Map
```text
net.kewwbec.network
ControlPlaneClient.java shared client entry point
NetworkPlugin.java Hytale plugin entry point and scheduler hook
client/ handwritten domain clients
http/ low-level JSON HTTP transport
batch/ one-off and looped batched calls
cache/ cached API data and storage policies
generated/api/v1/ generated endpoint descriptors
models/ handwritten response/request models
```
## Design Rules
- Do not edit `net.kewwbec.network.generated` manually.
- Add handwritten runtime behavior outside `generated`.
- Treat `core-control-plane` as the API owner.
- Use batching for repeated or concurrent calls that can tolerate async completion.
- Use cache policies when plugin code needs shared ownership of API freshness or push timing.
+116
View File
@@ -0,0 +1,116 @@
# Troubleshooting
## `NetworkPlugin has not been configured`
No startup source produced both a control-plane base URL and token before another plugin requested `NetworkPlugin.client()`.
Fix with one of:
- environment variables: `KWB_CONTROL_PLANE_BASE_URL` and `KWB_CONTROL_PLANE_TOKEN`
- JVM properties: `kweebec.controlPlane.baseUrl` and `kweebec.controlPlane.token`
- local plugin config: `control-plane.json`
- explicit startup call: `NetworkPlugin.configure(baseUrl, token)`
Check which source configured the client:
```java
NetworkPlugin.configurationSource();
```
`Optional.empty()` means no source configured the shared client.
## Startup Config Precedence
Startup values are resolved in this order:
1. environment variables
2. JVM system properties
3. `control-plane.json`
Environment variables and JVM properties override only the fields they set. Missing fields can still come from a lower-priority source.
## Config File Token Warning
`control-plane.json` is a short-term local deployment fallback. Prefer environment variables for production or control-plane-provisioned servers.
JVM `-D` properties are supported, but some host tooling can expose process arguments. Avoid logging launcher commands that contain tokens.
## Auth Failures
`AuthException` maps to HTTP `401` or `403`.
Common causes:
- missing token
- expired or invalid token
- inactive service account
- token missing required ability
- server-instance token trying to access a different instance route
## Batch Futures Never Complete
Likely causes:
- batching scheduler has not started
- `NetworkPlugin` is not loaded as the plugin main class
- code enqueued calls but never called `batch().flush()` in tests
- control plane does not expose `/api/v1/batch`
In tests, call:
```java
client.batch().flush();
```
## Cached Reads Keep Returning Stale Data
This can be expected behavior. If a refresh fails and an old value exists, cache APIs return the stale value and increment failed sync metadata.
Check:
```java
cache.metadata(call, context)
```
Look at:
- `failedSyncCount`
- `lastError`
- `syncState`
## `PLAYER cache calls require PlayerCacheContext`
Player storage is explicit. Use:
```java
new PlayerCacheContext(playerId, playerRef, entityStore)
```
Core-network will not infer player identity from `/players/{player}` URIs.
## `NetworkApiDataComponent has not been registered`
The player cache component is registered in `NetworkPlugin.setup()`.
This error usually means:
- tests are using player storage without plugin setup
- `NetworkPlugin` is not the active Hytale plugin main class
- code is accessing player cache before component registration
## Asset Store Cache Does Nothing
`ASSET_STORE` storage requires a plugin-provided `AssetStoreCacheAdapter`.
Core-network does not create a generic Hytale asset store because Hytale assets require concrete asset classes, codecs, keys, and load/write behavior.
## Manifest Rewrites During Gradle Tasks
`processResources` depends on `updatePluginManifest`, which rewrites `src/main/resources/manifest.json` from Gradle properties.
If the manifest changes unexpectedly after tests/builds, check:
- `includes_pack` in `gradle.properties`
- `version` in `gradle.properties`
Avoid committing unrelated manifest churn.