Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f07aac5
feat(api): add placement and suppression model
remdui Sep 2, 2026
6decd31
feat(cluster): add generic replica configuration model
remdui Sep 2, 2026
34f22a1
feat(cluster): add fenced DataProvider backend
remdui Sep 2, 2026
e688b19
feat(runtime): enforce suppression before construction and writes
remdui Sep 2, 2026
cf80732
feat(cluster): orchestrate replica startup and synchronization
remdui Sep 2, 2026
e585d09
fix(toolkit): make mutation denial safely serializable
remdui Sep 2, 2026
ed25561
feat(toolkit): allow replica policy attachment before host start
remdui Sep 2, 2026
d98bf04
fix(cluster): rollback partial configuration materialization
remdui Sep 2, 2026
69dde53
fix(cluster): make managed-write denial serializable
remdui Sep 2, 2026
dd69381
feat(platform): expose replica host control on Paper
remdui Sep 2, 2026
72bf7fc
feat(platform): expose replica host control on Velocity
remdui Sep 2, 2026
9398e94
feat(runtime): support post-build replica policy attachment
remdui Sep 2, 2026
e9668f3
feat(platform): attach replica controller to Paper hosts
remdui Sep 2, 2026
23f38e7
feat(platform): attach replica controller to Velocity hosts
remdui Sep 2, 2026
d187e96
fix(cluster): enforce authoritative startup and attach host policies
remdui Sep 2, 2026
e09be9c
test(cluster): cover replica lifecycle convergence and recovery
remdui Sep 2, 2026
fd4df91
test(cluster): run real DataProvider backend integration tests
remdui Sep 2, 2026
683b0e4
test(cluster): qualify raw SQL generations and Redis authority
remdui Sep 2, 2026
ef54678
test(core): prove suppression happens before construction
remdui Sep 2, 2026
d1000bc
fix(cluster): validate schema contract and active manifest pointer
remdui Sep 2, 2026
e639981
docs(cluster): document static replica group operations
remdui Sep 2, 2026
e615cb3
docs: index replica group guide
remdui Sep 2, 2026
94b5ecf
ci: align shared Maven policy with HauntedPlatform 1.5
remdui Sep 2, 2026
2cd2de3
test(cluster): satisfy strict checkstyle imports
remdui Sep 2, 2026
d0a8d73
test(core): apply suppression through runtime policy API
remdui Sep 2, 2026
038e2e4
fix(cluster): bound managed paths to storage contract
remdui Sep 2, 2026
5f41b4e
fix(cluster): keep MySQL manifest key within InnoDB limits
remdui Sep 2, 2026
84a2325
fix(cluster): enforce authority deadline independently
remdui Sep 2, 2026
2851750
test(cluster): fence blocked renewals before lease expiry
remdui Sep 2, 2026
47a967b
test(cluster): enforce manifest storage path bound
remdui Sep 2, 2026
8e503ab
test(core): lock replica placement graph rules
remdui Sep 2, 2026
cc062d3
fix(cluster): validate control-plane schema on backend open
remdui Sep 2, 2026
71c1c5b
fix(cluster): gate singleton activation until host startup completes
remdui Sep 2, 2026
f2a7104
fix(cluster): reconcile only on authority transitions
remdui Sep 2, 2026
969e3e8
fix(cluster): validate critical schema-v1 column shape
remdui Sep 2, 2026
4fc987f
test(cluster): assert stale fencing by preserved generation state
remdui Sep 2, 2026
33ec120
fix(cluster): reject late lease renewal responses after fencing cutoff
remdui Sep 2, 2026
c08120f
test(cluster): reject late successful renewal after watchdog cutoff
remdui Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ permissions:
jobs:
maven-policy:
name: Shared Maven policy
uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.4.1
uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.5.0
with:
maven-command: ./mvnw -U -B -ntp -DskipTests verify
secrets:
Expand All @@ -33,4 +33,4 @@ jobs:
if (( ${#scripts[@]} == 0 )); then
exit 0
fi
shellcheck "${scripts[@]}"
shellcheck "${scripts[@]}"
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ If FeatureFramework is new to you, read the [feature mental model](concepts/FEAT
- [Text, formatting, and safe player input](toolkits/TEXT-AND-FORMATTING.md)
- [Configuration and localization](guides/CONFIGURATION-AND-LOCALIZATION.md)
- [Programmatic message themes](guides/THEMES.md)
- [Static replica groups](guides/REPLICA-GROUPS.md) — leader-only placement, immutable configuration generations, fencing, LKG, drift repair, and operational behavior.

## Guides and reference

Expand All @@ -39,4 +40,4 @@ If FeatureFramework is new to you, read the [feature mental model](concepts/FEAT

- [All examples](../examples/README.md)
- [Paper](../examples/paper/README.md)
- [Velocity](../examples/velocity/README.md)
- [Velocity](../examples/velocity/README.md)
182 changes: 182 additions & 0 deletions docs/guides/REPLICA-GROUPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Static replica groups

FeatureFramework 2.0 can coordinate multiple copies of one Paper or Velocity application as a **static replica group**. The group has one manually configured leader and zero or more followers. This subsystem coordinates feature placement and replicated configuration; it does not elect nodes, discover platform identity, or provide automatic failover.

## Model

Applications supply a physical `ReplicaNodeIdentity` and a `ReplicaGroupIdentity(namespace, applicationId, groupId, configuredLeader)`. FeatureFramework deliberately has no DataRegistry dependency: an application may adapt DataRegistry identity, environment configuration, or another exact identity source into `ReplicaNodeIdentity`.

Only `nodeId == configuredLeader` is allowed to acquire authority. Followers never attempt acquisition. The DataProvider adapter uses the Redis resource key:

```text
ff:<namespace>:<applicationId>:<groupId>
```

and an owner of:

```text
<nodeId>/<bootUuid>
```

Normal leadership uses fenced `acquire()`, not authoritative `claim()`. The default lease is 15 seconds, renewed every 3 seconds, with a 2-second safety margin. The controller also measures local monotonic elapsed time since the last proven renewal; leader-only features are suppressed before the safe authority window can expire.

There is no automatic follower promotion in v1.

## Feature placement

Features default to:

```java
FeaturePlacement.ALL_NODES
```

A singleton feature or ingress component may declare:

```java
@FeatureDeclaration(
name = "Ingress",
version = "1.0.0",
placement = FeaturePlacement.GROUP_LEADER_ONLY
)
```

Placement is checked before feature context creation, feature construction, resource allocation, or initialization. An enabled but ineligible feature appears as `FeatureState.SUPPRESSED` with structured `FeatureSuppression` detail rather than as a failure.

A required dependency from `ALL_NODES` to `GROUP_LEADER_ONLY` is rejected because followers could never satisfy it. Leader-only to all-node, leader-only to leader-only, and optional cross-placement dependencies are valid.

## Application bootstrap

Create and prepare the replica controller before building the host, then attach it before host startup:

```java
ReplicaController controller = ...;
controller.prepareBeforeHost();

PaperFeatureHost<MyPlugin, String> host = PaperFeatureHost
.builder(plugin, MyPlugin.class, BuiltInFeatures.collection())
.build();

controller.attach(host);
host.start();
controller.afterHostStarted();
```

`attach()` installs both the activation policy and the configuration mutation policy on the already constructed host. No separate host-bootstrap framework is required.

Close the controller during application shutdown after the host is no longer accepting application work. A held authority lease is released best-effort.

## Durable control plane

`featureframework-cluster-dataprovider` uses `RelationalDataAccess` directly. It does not use Hibernate and it never creates or alters schema at runtime.

Apply the shipped schema explicitly:

```text
featureframework-cluster-dataprovider/src/main/resources/schema/mysql-v1.sql
```

It defines:

- `ff_replica_group`
- `ff_config_generation`
- `ff_config_file`
- `ff_replica_node_state`

The repository validates the required schema-v1 tables and columns before an application enables replicated mode. The active group pointer stores both the active generation and its manifest hash; loading the pointer verifies that it still matches the immutable generation.

Every database key is scoped by `(namespace, application_id, group_id)`.

## Immutable generations and fencing

MySQL is the durable source of truth for configuration. Redis is used only for fenced authority.

Published generations are immutable and monotonically increasing. The current generation pointer never moves backwards. Rolling back generation 40 to the contents of generation 25 therefore publishes generation 41 with:

```text
generation = 41
source_generation = 25
```

A publisher must present a fencing token that is not older than the group’s highest accepted token. A process that loses authority cannot overwrite or reactivate configuration after a newer owner has been fenced in.

## First generation

For a new group with no active generation:

1. the configured leader acquires authority;
2. normal all-node features may start and generate their defaults;
3. leader-only activation remains suppressed while the group is uninitialized;
4. the host must start successfully;
5. the controller snapshots the managed configuration and publishes generation 1;
6. that generation becomes local LKG;
7. leader-only features are reconciled and may become active.

A follower connected to a healthy database that reports no active generation fails startup with a message directing the operator to start the configured leader first. A stale local LKG is **not** used to invent an initialized group.

## Existing-group startup and LKG

On normal startup, a follower downloads and verifies the active generation before host construction. A configured leader also loads the active generation; intentional local differences are treated as a startup candidate and the previous remote generation remains the rollback point until publication succeeds.

Local recovery data lives under:

```text
.replica/
state.json
generations/
staging/
drift/
```

Outage behavior is intentionally asymmetric:

- MySQL unavailable + verified compatible LKG: start from LKG.
- MySQL unavailable + no valid LKG: fail replicated startup.
- MySQL reachable + no active generation on a follower: fail as uninitialized; do not use LKG.
- Redis unavailable on a follower: ordinary features continue normally because followers do not need authority.
- Redis unavailable or authority unproven on the configured leader: all-node features continue, leader-only features are suppressed.

LKG files and manifests are hash-verified before use.

## Managed configuration

Framework defaults include only known configuration paths:

```text
config.yml
features/<Feature>/config.yml
features/<Feature>/messages.yml
features/<Feature>/<explicit language file>.yml
```

The application may extend `ManagedFileSet` with exact additional paths. `local/*.yml` is never recursively managed by default; secrets, keys, queues, runtime state, logs, dumps, and other node-local files must remain outside the replicated set.

On a follower, writes to managed paths are denied centrally through `ConfigMutationPolicy` with `ReplicaManagedConfigurationException`. This applies to file creation, normal YAML saves, reset-to-empty operations, and optional-file deletion, so individual features do not need cluster-aware write guards.

## Runtime synchronization and drift

Nodes poll the active generation (default about every 5 seconds); Redis pub/sub is not required. When a follower sees a newer compatible generation it:

1. downloads and verifies the manifest and file hashes;
2. stages the complete managed generation;
3. materializes it transactionally with rollback to the previous managed snapshot if any filesystem step fails;
4. reconciles `host.reloadGraph()`;
5. writes the new LKG only after success.

If host reconciliation rejects the new generation, the previous managed files and graph are restored and the node reports `OUT_OF_SYNC`.

If a follower’s managed files drift without a generation change, the controller backs the edited files up under `.replica/drift/` before restoring the authoritative generation. Operator edits are therefore not silently destroyed.

## Configuration compatibility

Applications supply a `ConfigCompatibility(applicationVersion, configCompatibilityVersion)`. Binary versions do not have to match as long as the configuration compatibility version does. For example:

```text
ProxyFeatures 5.0.0 -> config compatibility 1
ProxyFeatures 5.0.1 -> config compatibility 1
```

can participate in the same group during a rolling upgrade. A generation with an incompatible configuration version is not materialized and the node reports `OUT_OF_SYNC`.

## Backend module

Use `featureframework-cluster` for generic model/orchestration types and `featureframework-cluster-dataprovider` when the application uses DataProvider for MySQL + Redis. The backend opens a dedicated `featureframework.cluster` `DataProviderScope`, keeping its lifecycle isolated from feature-owned database registrations.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package nl.hauntedmc.featureframework.api.feature;

import java.util.Objects;
import java.util.Optional;

/** Result of evaluating whether one feature may proceed in a lifecycle phase. */
public record ActivationDecision(boolean allowed, Optional<FeatureSuppression> suppression) {
public ActivationDecision {
suppression = suppression == null ? Optional.empty() : suppression;
if (allowed && suppression.isPresent()) {
throw new IllegalArgumentException("An allowed activation decision cannot carry suppression");
}
if (!allowed && suppression.isEmpty()) {
throw new IllegalArgumentException("A denied activation decision must carry suppression");
}
}

public static ActivationDecision allow() {
return new ActivationDecision(true, Optional.empty());
}

public static ActivationDecision suppress(FeatureSuppression suppression) {
return new ActivationDecision(false, Optional.of(Objects.requireNonNull(suppression, "suppression")));
}

public static ActivationDecision suppress(FeatureSuppressionReason reason, String message) {
return suppress(new FeatureSuppression(reason, message));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package nl.hauntedmc.featureframework.api.feature;

/** Lifecycle phase at which an activation policy is being evaluated. */
public enum FeatureActivationPhase {
/** The host may need a feature instance only to materialize/validate its managed configuration. */
PREPARATION,

/** The host is deciding whether the live feature may be started. */
ACTIVATION
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package nl.hauntedmc.featureframework.api.feature;

import java.util.Objects;

/** Host-supplied policy that decides whether a feature may be prepared or activated. */
@FunctionalInterface
public interface FeatureActivationPolicy {
ActivationDecision evaluate(FeatureMetadata metadata, FeatureActivationPhase phase);

static FeatureActivationPolicy allowAll() {
return (metadata, phase) -> {
Objects.requireNonNull(metadata, "metadata");
Objects.requireNonNull(phase, "phase");
return ActivationDecision.allow();
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
/** Widest observable boundary; coordination details remain an implementation concern. */
FeatureScope scope();

/** Replica placement for this feature. */
FeaturePlacement placement() default FeaturePlacement.ALL_NODES;

/** Whether a newly created configuration enables this feature. */
boolean enabledByDefault() default false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public record FeatureMetadata(
Set<String> requiredResourceExtensions,
Set<String> providedCapabilities,
Set<FeatureRole> roles,
FeatureScope scope
FeatureScope scope,
FeaturePlacement placement
) {
public FeatureMetadata {
Objects.requireNonNull(id, "id");
Expand All @@ -32,6 +33,23 @@ public record FeatureMetadata(
providedCapabilities = providedCapabilities == null ? Set.of() : Set.copyOf(providedCapabilities);
roles = roles == null ? Set.of() : Set.copyOf(roles);
Objects.requireNonNull(scope, "scope");
placement = placement == null ? FeaturePlacement.ALL_NODES : placement;
}

/** Compatibility constructor for metadata that predates explicit replica placement. */
public FeatureMetadata(
FeatureId id,
String displayName,
String version,
Set<FeatureId> requiredFeatures,
Set<String> requiredPlugins,
Set<String> requiredResourceExtensions,
Set<String> providedCapabilities,
Set<FeatureRole> roles,
FeatureScope scope
) {
this(id, displayName, version, requiredFeatures, requiredPlugins, requiredResourceExtensions,
providedCapabilities, roles, scope, FeaturePlacement.ALL_NODES);
}

private static String requireText(String value, String field) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package nl.hauntedmc.featureframework.api.feature;

/** Declares where a feature is eligible to run within a replica group. */
public enum FeaturePlacement {
/** The feature runs independently on every node. */
ALL_NODES,

/** The feature runs only while this node holds configured group-leader authority. */
GROUP_LEADER_ONLY
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public record FeatureSnapshot(
FeatureMetadata metadata,
boolean configuredEnabled,
FeatureState state,
Optional<FeatureSuppression> suppression,
Optional<String> failure,
Optional<FeatureFailure> failureDetail,
Set<FeatureId> unavailableDependencies,
Expand All @@ -21,13 +22,37 @@ public record FeatureSnapshot(
public FeatureSnapshot {
Objects.requireNonNull(metadata, "metadata");
state = Objects.requireNonNull(state, "state");
suppression = suppression == null ? Optional.empty() : suppression;
failure = failure == null ? Optional.empty() : failure.filter(value -> !value.isBlank());
failureDetail = failureDetail == null ? Optional.empty() : failureDetail;
unavailableDependencies = unavailableDependencies == null ? Set.of() : Set.copyOf(unavailableDependencies);
lastTransitionAt = Objects.requireNonNull(lastTransitionAt, "lastTransitionAt");
lastSuccessfulActivationAt = lastSuccessfulActivationAt == null ? Optional.empty() : lastSuccessfulActivationAt;
if (generation < 0) throw new IllegalArgumentException("generation must be non-negative");
observedAt = Objects.requireNonNull(observedAt, "observedAt");
if (state == FeatureState.SUPPRESSED && suppression.isEmpty()) {
throw new IllegalArgumentException("SUPPRESSED state requires suppression detail");
}
if (state != FeatureState.SUPPRESSED && suppression.isPresent()) {
throw new IllegalArgumentException("suppression detail is only valid for SUPPRESSED state");
}
}

/** Compatibility constructor for callers that do not project suppression. */
public FeatureSnapshot(
FeatureMetadata metadata,
boolean configuredEnabled,
FeatureState state,
Optional<String> failure,
Optional<FeatureFailure> failureDetail,
Set<FeatureId> unavailableDependencies,
Instant lastTransitionAt,
Optional<Instant> lastSuccessfulActivationAt,
long generation,
Instant observedAt
) {
this(metadata, configuredEnabled, state, Optional.empty(), failure, failureDetail, unavailableDependencies,
lastTransitionAt, lastSuccessfulActivationAt, generation, observedAt);
}

/** Creates a snapshot from the typed failure projection used by framework hosts. */
Expand All @@ -46,6 +71,7 @@ public FeatureSnapshot(
metadata,
configuredEnabled,
state,
Optional.empty(),
failureDetail == null ? Optional.empty() : failureDetail.flatMap(FeatureFailure::message),
failureDetail,
unavailableDependencies,
Expand All @@ -60,6 +86,10 @@ public boolean active() {
return state == FeatureState.ACTIVE;
}

public boolean suppressed() {
return state == FeatureState.SUPPRESSED;
}

public boolean failed() {
return state == FeatureState.FAILED;
}
Expand Down
Loading
Loading