diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index d96c5a9..87b62c3 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -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: @@ -33,4 +33,4 @@ jobs: if (( ${#scripts[@]} == 0 )); then exit 0 fi - shellcheck "${scripts[@]}" + shellcheck "${scripts[@]}" \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index a004d84..7617bb9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 @@ -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) \ No newline at end of file diff --git a/docs/guides/REPLICA-GROUPS.md b/docs/guides/REPLICA-GROUPS.md new file mode 100644 index 0000000..4432b71 --- /dev/null +++ b/docs/guides/REPLICA-GROUPS.md @@ -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::: +``` + +and an owner of: + +```text +/ +``` + +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 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//config.yml +features//messages.yml +features//.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. \ No newline at end of file diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/ActivationDecision.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/ActivationDecision.java new file mode 100644 index 0000000..945b658 --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/ActivationDecision.java @@ -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 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)); + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureActivationPhase.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureActivationPhase.java new file mode 100644 index 0000000..7250963 --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureActivationPhase.java @@ -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 +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureActivationPolicy.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureActivationPolicy.java new file mode 100644 index 0000000..5fb6fc9 --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureActivationPolicy.java @@ -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(); + }; + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureDeclaration.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureDeclaration.java index 9fb10c4..95c5ab9 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureDeclaration.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureDeclaration.java @@ -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; diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureMetadata.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureMetadata.java index c3aa8f1..f012139 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureMetadata.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureMetadata.java @@ -19,7 +19,8 @@ public record FeatureMetadata( Set requiredResourceExtensions, Set providedCapabilities, Set roles, - FeatureScope scope + FeatureScope scope, + FeaturePlacement placement ) { public FeatureMetadata { Objects.requireNonNull(id, "id"); @@ -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 requiredFeatures, + Set requiredPlugins, + Set requiredResourceExtensions, + Set providedCapabilities, + Set roles, + FeatureScope scope + ) { + this(id, displayName, version, requiredFeatures, requiredPlugins, requiredResourceExtensions, + providedCapabilities, roles, scope, FeaturePlacement.ALL_NODES); } private static String requireText(String value, String field) { diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeaturePlacement.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeaturePlacement.java new file mode 100644 index 0000000..77dacae --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeaturePlacement.java @@ -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 +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java index 45bee65..f8da6b3 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSnapshot.java @@ -10,6 +10,7 @@ public record FeatureSnapshot( FeatureMetadata metadata, boolean configuredEnabled, FeatureState state, + Optional suppression, Optional failure, Optional failureDetail, Set unavailableDependencies, @@ -21,6 +22,7 @@ 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); @@ -28,6 +30,29 @@ public record FeatureSnapshot( 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 failure, + Optional failureDetail, + Set unavailableDependencies, + Instant lastTransitionAt, + Optional 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. */ @@ -46,6 +71,7 @@ public FeatureSnapshot( metadata, configuredEnabled, state, + Optional.empty(), failureDetail == null ? Optional.empty() : failureDetail.flatMap(FeatureFailure::message), failureDetail, unavailableDependencies, @@ -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; } diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureState.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureState.java index 49ff8c0..ebc3f4a 100644 --- a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureState.java +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureState.java @@ -3,6 +3,7 @@ /** Observable lifecycle state of a built-in feature. */ public enum FeatureState { DISABLED, + SUPPRESSED, STARTING, ACTIVE, STOPPING, diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSuppression.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSuppression.java new file mode 100644 index 0000000..8f1be3c --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSuppression.java @@ -0,0 +1,20 @@ +package nl.hauntedmc.featureframework.api.feature; + +import java.util.Objects; +import java.util.Optional; + +/** Structured explanation for an enabled feature that is deliberately not active. */ +public record FeatureSuppression(FeatureSuppressionReason reason, Optional message) { + public FeatureSuppression { + reason = Objects.requireNonNull(reason, "reason"); + message = message == null ? Optional.empty() : message.map(String::trim).filter(value -> !value.isEmpty()); + } + + public FeatureSuppression(FeatureSuppressionReason reason, String message) { + this(reason, Optional.ofNullable(message)); + } + + public static FeatureSuppression of(FeatureSuppressionReason reason) { + return new FeatureSuppression(reason, Optional.empty()); + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSuppressionReason.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSuppressionReason.java new file mode 100644 index 0000000..ae43efb --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/feature/FeatureSuppressionReason.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.featureframework.api.feature; + +/** Stable reasons why an enabled feature is intentionally not running. */ +public enum FeatureSuppressionReason { + GROUP_LEADER_ONLY, + AUTHORITY_UNAVAILABLE, + DEPENDENCY_SUPPRESSED, + CONFIGURATION_UNAVAILABLE, + CONFIGURATION_INCOMPATIBLE +} diff --git a/featureframework-bom/pom.xml b/featureframework-bom/pom.xml index eebfac7..28077cd 100644 --- a/featureframework-bom/pom.xml +++ b/featureframework-bom/pom.xml @@ -10,7 +10,9 @@ ${project.groupId}featureframework-theme-api${project.version} ${project.groupId}featureframework-toolkit${project.version} ${project.groupId}featureframework-core${project.version} + ${project.groupId}featureframework-cluster${project.version} ${project.groupId}featureframework-dataprovider${project.version} + ${project.groupId}featureframework-cluster-dataprovider${project.version} ${project.groupId}featureframework-dataregistry${project.version} ${project.groupId}featureframework-paper${project.version} ${project.groupId}featureframework-paper-toolkit${project.version} diff --git a/featureframework-cluster-dataprovider/pom.xml b/featureframework-cluster-dataprovider/pom.xml new file mode 100644 index 0000000..fdc384f --- /dev/null +++ b/featureframework-cluster-dataprovider/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + nl.hauntedmc.featureframework + featureframework-parent + ${revision} + + featureframework-cluster-dataprovider + FeatureFramework Cluster DataProvider + Raw-SQL and fenced Redis backend for FeatureFramework static replica groups. + + + ${project.groupId} + featureframework-cluster + ${project.version} + + + nl.hauntedmc.dataprovider + dataprovider-api + ${dataprovider.version} + provided + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + nl.hauntedmc.dataprovider + dataprovider-core + ${dataprovider.version} + test + + + org.testcontainers + mysql + test + + + org.testcontainers + junit-jupiter + test + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + \ No newline at end of file diff --git a/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaBackend.java b/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaBackend.java new file mode 100644 index 0000000..7134844 --- /dev/null +++ b/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaBackend.java @@ -0,0 +1,68 @@ +package nl.hauntedmc.featureframework.cluster.dataprovider; + +import nl.hauntedmc.dataprovider.api.DataProviderAPI; +import nl.hauntedmc.dataprovider.api.DataProviderScope; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDatabaseProvider; +import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider; + +import java.util.Objects; + +/** Owns the isolated DataProvider registrations used by one FeatureFramework replica controller. */ +public final class DataProviderReplicaBackend implements AutoCloseable { + private final DataProviderScope scope; + private final DataProviderReplicaGenerationRepository generations; + private final DataProviderReplicaLeaseCoordinator leases; + + private DataProviderReplicaBackend( + DataProviderScope scope, + DataProviderReplicaGenerationRepository generations, + DataProviderReplicaLeaseCoordinator leases + ) { + this.scope = scope; + this.generations = generations; + this.leases = leases; + } + + /** + * Opens the isolated MySQL/Redis backend and verifies that the explicit replica schema is installed. + * The scope is closed again if registration or validation fails. + */ + public static DataProviderReplicaBackend open( + DataProviderAPI api, + String mysqlConnection, + String redisConnection + ) { + Objects.requireNonNull(api, "api"); + DataProviderScope scope = api.scope("featureframework.cluster"); + try { + RelationalDatabaseProvider relational = scope.registerDatabaseOrThrow( + DatabaseType.MYSQL, text(mysqlConnection, "mysqlConnection"), RelationalDatabaseProvider.class); + KeyValueDatabaseProvider redis = scope.registerDatabaseOrThrow( + DatabaseType.REDIS, text(redisConnection, "redisConnection"), KeyValueDatabaseProvider.class); + DataProviderReplicaGenerationRepository generations = + new DataProviderReplicaGenerationRepository(relational.getDataAccess()); + generations.validateSchema().toCompletableFuture().join(); + return new DataProviderReplicaBackend( + scope, + generations, + new DataProviderReplicaLeaseCoordinator(redis.getCoordinationDataAccess()) + ); + } catch (RuntimeException failure) { + scope.close(); + throw failure; + } + } + + public DataProviderReplicaGenerationRepository generations() { return generations; } + public DataProviderReplicaLeaseCoordinator leases() { return leases; } + public DataProviderScope scope() { return scope; } + + @Override public void close() { scope.close(); } + + private static String text(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + return normalized; + } +} diff --git a/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaGenerationRepository.java b/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaGenerationRepository.java new file mode 100644 index 0000000..d76a777 --- /dev/null +++ b/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaGenerationRepository.java @@ -0,0 +1,319 @@ +package nl.hauntedmc.featureframework.cluster.dataprovider; + +import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess; +import nl.hauntedmc.featureframework.cluster.ConfigGeneration; +import nl.hauntedmc.featureframework.cluster.ConfigManifest; +import nl.hauntedmc.featureframework.cluster.ConfigManifestFile; +import nl.hauntedmc.featureframework.cluster.ReplicaGenerationRepository; +import nl.hauntedmc.featureframework.cluster.ReplicaGroupIdentity; +import nl.hauntedmc.featureframework.cluster.ReplicaNodeIdentity; +import nl.hauntedmc.featureframework.cluster.ReplicaStatus; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.CompletionStage; + +/** Raw-SQL MySQL generation store. It never creates or mutates schema implicitly. */ +public final class DataProviderReplicaGenerationRepository implements ReplicaGenerationRepository { + private static final Map> REQUIRED_SCHEMA = Map.of( + "ff_replica_group", Set.of( + "namespace", "application_id", "group_id", "active_generation", "active_manifest_hash", + "highest_fencing_token", "updated_at"), + "ff_config_generation", Set.of( + "namespace", "application_id", "group_id", "generation", "publisher_node", + "publisher_boot_id", "fencing_token", "application_version", "config_compatibility_version", + "created_at", "source_generation", "manifest_hash"), + "ff_config_file", Set.of( + "namespace", "application_id", "group_id", "generation", "path", "kind", "sha256", + "size", "raw_contents"), + "ff_replica_node_state", Set.of( + "namespace", "application_id", "group_id", "node_id", "applied_generation", "status", + "detail", "last_seen") + ); + + private final RelationalDataAccess sql; + + public DataProviderReplicaGenerationRepository(RelationalDataAccess sql) { + this.sql = Objects.requireNonNull(sql, "sql"); + } + + @Override + public CompletionStage> loadActive(ReplicaGroupIdentity group) { + Objects.requireNonNull(group, "group"); + return sql.queryForSingleOptional( + "SELECT active_generation,active_manifest_hash FROM ff_replica_group " + + "WHERE namespace=? AND application_id=? AND group_id=?", + group.namespace(), group.applicationId(), group.groupId() + ).thenCompose(row -> { + if (row.isEmpty() || column(row.get(), "active_generation") == null) { + return java.util.concurrent.CompletableFuture.completedFuture(Optional.empty()); + } + long generation = ((Number) column(row.get(), "active_generation")).longValue(); + String expectedHash = Objects.toString(column(row.get(), "active_manifest_hash"), ""); + return loadGeneration(group, generation).thenApply(loaded -> { + ConfigGeneration active = loaded.orElseThrow(() -> new IllegalStateException( + "Replica group points to missing active generation " + generation)); + if (!active.manifest().manifestHash().equalsIgnoreCase(expectedHash)) { + throw new IllegalStateException( + "Replica group active manifest hash does not match generation " + generation); + } + return Optional.of(active); + }); + }); + } + + @Override + public CompletionStage> loadGeneration(ReplicaGroupIdentity group, long generation) { + if (generation <= 0) throw new IllegalArgumentException("generation must be positive"); + return sql.executeTransactionally(connection -> loadGeneration(connection, group, generation)); + } + + @Override + public CompletionStage publish( + ReplicaGroupIdentity group, + ConfigGeneration candidate, + long fencingToken + ) { + Objects.requireNonNull(group, "group"); + Objects.requireNonNull(candidate, "candidate").verify(); + if (fencingToken <= 0) throw new IllegalArgumentException("fencingToken must be positive"); + return sql.executeTransactionally(connection -> publish(connection, group, candidate, fencingToken)); + } + + @Override + public CompletionStage recordNodeState( + ReplicaGroupIdentity group, + ReplicaNodeIdentity node, + long appliedGeneration, + ReplicaStatus.State state, + String detail + ) { + return sql.executeUpdate( + "INSERT INTO ff_replica_node_state " + + "(namespace,application_id,group_id,node_id,applied_generation,status,detail,last_seen) " + + "VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP(6)) " + + "ON DUPLICATE KEY UPDATE applied_generation=VALUES(applied_generation),status=VALUES(status)," + + "detail=VALUES(detail),last_seen=CURRENT_TIMESTAMP(6)", + group.namespace(), group.applicationId(), group.groupId(), node.nodeId(), + appliedGeneration <= 0 ? null : appliedGeneration, state.name(), detail + ); + } + + /** Verifies the complete version-1 table/column contract; it never applies DDL. */ + public CompletionStage validateSchema() { + List tables = REQUIRED_SCHEMA.keySet().stream().sorted().toList(); + return sql.queryForList( + "SELECT TABLE_NAME,COLUMN_NAME,DATA_TYPE,CHARACTER_MAXIMUM_LENGTH " + + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " + + "AND TABLE_NAME IN (?,?,?,?)", + tables.get(0), tables.get(1), tables.get(2), tables.get(3) + ).thenAccept(rows -> { + Map> present = new LinkedHashMap<>(); + for (Map row : rows) { + String table = Objects.toString(column(row, "TABLE_NAME"), "").toLowerCase(java.util.Locale.ROOT); + String name = Objects.toString(column(row, "COLUMN_NAME"), "").toLowerCase(java.util.Locale.ROOT); + String dataType = Objects.toString(column(row, "DATA_TYPE"), "").toLowerCase(java.util.Locale.ROOT); + Object maximum = column(row, "CHARACTER_MAXIMUM_LENGTH"); + Long maximumLength = maximum instanceof Number number ? number.longValue() : null; + if (!table.isBlank() && !name.isBlank()) { + present.computeIfAbsent(table, ignored -> new LinkedHashMap<>()) + .put(name, new ColumnShape(dataType, maximumLength)); + } + } + List problems = new ArrayList<>(); + for (Map.Entry> required : REQUIRED_SCHEMA.entrySet()) { + Map available = present.get(required.getKey()); + if (available == null) { + problems.add(required.getKey() + " (missing table)"); + continue; + } + Set missing = new LinkedHashSet<>(required.getValue()); + missing.removeAll(available.keySet()); + if (!missing.isEmpty()) problems.add(required.getKey() + " missing columns " + missing); + } + Map fileColumns = present.get("ff_config_file"); + if (fileColumns != null) { + ColumnShape path = fileColumns.get("path"); + if (path != null && (!"varchar".equals(path.dataType()) + || path.maximumLength() == null + || path.maximumLength() < ConfigManifestFile.MAX_PATH_LENGTH)) { + problems.add("ff_config_file.path must be VARCHAR(" + + ConfigManifestFile.MAX_PATH_LENGTH + ") or wider"); + } + } + if (!problems.isEmpty()) { + throw new IllegalStateException( + "FeatureFramework replica schema v1 is not installed or incompatible: " + problems); + } + }); + } + + private static ConfigGeneration publish( + Connection connection, + ReplicaGroupIdentity group, + ConfigGeneration candidate, + long fencingToken + ) throws Exception { + try (PreparedStatement insertGroup = connection.prepareStatement( + "INSERT IGNORE INTO ff_replica_group " + + "(namespace,application_id,group_id,active_generation,active_manifest_hash,highest_fencing_token,updated_at) " + + "VALUES (?,?,?,NULL,NULL,0,CURRENT_TIMESTAMP(6))")) { + bindGroup(insertGroup, group); + insertGroup.executeUpdate(); + } + + Long activeGeneration = null; + long highestFencingToken; + try (PreparedStatement lock = connection.prepareStatement( + "SELECT active_generation,highest_fencing_token FROM ff_replica_group " + + "WHERE namespace=? AND application_id=? AND group_id=? FOR UPDATE")) { + bindGroup(lock, group); + try (ResultSet rows = lock.executeQuery()) { + if (!rows.next()) throw new IllegalStateException("Replica group row disappeared during publication"); + Object active = rows.getObject("active_generation"); + if (active != null) activeGeneration = ((Number) active).longValue(); + highestFencingToken = rows.getLong("highest_fencing_token"); + } + } + if (fencingToken < highestFencingToken) { + throw new IllegalStateException("Stale fencing token " + fencingToken + " < " + highestFencingToken); + } + + long generation = activeGeneration == null ? 1L : activeGeneration + 1L; + ConfigManifest source = candidate.manifest(); + ConfigManifest manifest = new ConfigManifest( + source.protocolVersion(), group, generation, source.publisherNode(), source.publisherBootId(), + fencingToken, source.applicationVersion(), source.configCompatibilityVersion(), source.createdAt(), + source.sourceGeneration(), source.files(), source.manifestHash()); + ConfigGeneration published = new ConfigGeneration(manifest, candidate.files()); + + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO ff_config_generation " + + "(namespace,application_id,group_id,generation,publisher_node,publisher_boot_id,fencing_token," + + "application_version,config_compatibility_version,created_at,source_generation,manifest_hash) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")) { + int index = bindGroup(insert, group); + insert.setLong(index++, generation); + insert.setString(index++, manifest.publisherNode()); + insert.setString(index++, manifest.publisherBootId()); + insert.setLong(index++, fencingToken); + insert.setString(index++, manifest.applicationVersion()); + insert.setString(index++, manifest.configCompatibilityVersion()); + insert.setTimestamp(index++, Timestamp.from(manifest.createdAt())); + if (manifest.sourceGeneration().isPresent()) { + insert.setLong(index++, manifest.sourceGeneration().getAsLong()); + } else { + insert.setObject(index++, null); + } + insert.setString(index, manifest.manifestHash()); + insert.executeUpdate(); + } + + try (PreparedStatement insertFile = connection.prepareStatement( + "INSERT INTO ff_config_file " + + "(namespace,application_id,group_id,generation,path,kind,sha256,size,raw_contents) " + + "VALUES (?,?,?,?,?,?,?,?,?)")) { + for (ConfigManifestFile file : manifest.files()) { + int index = bindGroup(insertFile, group); + insertFile.setLong(index++, generation); + insertFile.setString(index++, file.path()); + insertFile.setString(index++, file.kind()); + insertFile.setString(index++, file.sha256()); + insertFile.setLong(index++, file.size()); + insertFile.setBytes(index, published.file(file.path())); + insertFile.addBatch(); + } + insertFile.executeBatch(); + } + + try (PreparedStatement update = connection.prepareStatement( + "UPDATE ff_replica_group SET active_generation=?,active_manifest_hash=?,highest_fencing_token=?," + + "updated_at=CURRENT_TIMESTAMP(6) WHERE namespace=? AND application_id=? AND group_id=?")) { + update.setLong(1, generation); + update.setString(2, manifest.manifestHash()); + update.setLong(3, Math.max(highestFencingToken, fencingToken)); + update.setString(4, group.namespace()); + update.setString(5, group.applicationId()); + update.setString(6, group.groupId()); + if (update.executeUpdate() != 1) { + throw new IllegalStateException("Replica group activation update failed"); + } + } + return published; + } + + private static Optional loadGeneration( + Connection connection, + ReplicaGroupIdentity group, + long generation + ) throws Exception { + ConfigManifest header; + try (PreparedStatement query = connection.prepareStatement( + "SELECT publisher_node,publisher_boot_id,fencing_token,application_version,config_compatibility_version," + + "created_at,source_generation,manifest_hash FROM ff_config_generation " + + "WHERE namespace=? AND application_id=? AND group_id=? AND generation=?")) { + int index = bindGroup(query, group); + query.setLong(index, generation); + try (ResultSet row = query.executeQuery()) { + if (!row.next()) return Optional.empty(); + Object source = row.getObject("source_generation"); + header = new ConfigManifest( + ConfigManifest.CURRENT_PROTOCOL_VERSION, group, generation, + row.getString("publisher_node"), row.getString("publisher_boot_id"), + row.getLong("fencing_token"), row.getString("application_version"), + row.getString("config_compatibility_version"), row.getTimestamp("created_at").toInstant(), + source == null ? OptionalLong.empty() : OptionalLong.of(((Number) source).longValue()), + List.of(), row.getString("manifest_hash")); + } + } + + List manifestFiles = new ArrayList<>(); + Map contents = new LinkedHashMap<>(); + try (PreparedStatement queryFiles = connection.prepareStatement( + "SELECT path,kind,sha256,size,raw_contents FROM ff_config_file " + + "WHERE namespace=? AND application_id=? AND group_id=? AND generation=? ORDER BY path")) { + int index = bindGroup(queryFiles, group); + queryFiles.setLong(index, generation); + try (ResultSet rows = queryFiles.executeQuery()) { + while (rows.next()) { + ConfigManifestFile file = new ConfigManifestFile(rows.getString("path"), rows.getString("kind"), + rows.getString("sha256"), rows.getLong("size")); + manifestFiles.add(file); + contents.put(file.path(), rows.getBytes("raw_contents")); + } + } + } + ConfigManifest manifest = new ConfigManifest( + header.protocolVersion(), group, generation, header.publisherNode(), header.publisherBootId(), + header.fencingToken(), header.applicationVersion(), header.configCompatibilityVersion(), + header.createdAt(), header.sourceGeneration(), manifestFiles, header.manifestHash()); + return Optional.of(new ConfigGeneration(manifest, contents)); + } + + private static Object column(Map row, String name) { + for (Map.Entry entry : row.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) return entry.getValue(); + } + return null; + } + + private static int bindGroup(PreparedStatement statement, ReplicaGroupIdentity group) throws Exception { + statement.setString(1, group.namespace()); + statement.setString(2, group.applicationId()); + statement.setString(3, group.groupId()); + return 4; + } + + private record ColumnShape(String dataType, Long maximumLength) { } +} diff --git a/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaLeaseCoordinator.java b/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaLeaseCoordinator.java new file mode 100644 index 0000000..bd294a8 --- /dev/null +++ b/featureframework-cluster-dataprovider/src/main/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaLeaseCoordinator.java @@ -0,0 +1,52 @@ +package nl.hauntedmc.featureframework.cluster.dataprovider; + +import nl.hauntedmc.dataprovider.database.coordination.CoordinationDataAccess; +import nl.hauntedmc.dataprovider.database.coordination.FencedLease; +import nl.hauntedmc.featureframework.cluster.ReplicaAuthority; +import nl.hauntedmc.featureframework.cluster.ReplicaGroupIdentity; +import nl.hauntedmc.featureframework.cluster.ReplicaLeaseCoordinator; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +/** DataProvider Redis adapter for FeatureFramework replica authority. */ +public final class DataProviderReplicaLeaseCoordinator implements ReplicaLeaseCoordinator { + private final CoordinationDataAccess coordination; + + public DataProviderReplicaLeaseCoordinator(CoordinationDataAccess coordination) { + this.coordination = Objects.requireNonNull(coordination, "coordination"); + } + + @Override + public CompletionStage> acquire( + ReplicaGroupIdentity group, + String owner, + Duration ttl + ) { + Objects.requireNonNull(group, "group"); + return coordination.acquire(group.authorityResource(), owner, ttl) + .thenApply(result -> result.map(DataProviderReplicaLeaseCoordinator::adapt)); + } + + @Override + public CompletionStage> renew(ReplicaAuthority authority, Duration ttl) { + Objects.requireNonNull(authority, "authority"); + FencedLease lease = new FencedLease( + authority.resource(), authority.owner(), authority.fencingToken(), authority.expiresAt()); + return coordination.renew(lease, ttl) + .thenApply(result -> result.map(DataProviderReplicaLeaseCoordinator::adapt)); + } + + @Override + public CompletionStage release(ReplicaAuthority authority) { + Objects.requireNonNull(authority, "authority"); + return coordination.release(new FencedLease( + authority.resource(), authority.owner(), authority.fencingToken(), authority.expiresAt())); + } + + private static ReplicaAuthority adapt(FencedLease lease) { + return new ReplicaAuthority(lease.resource(), lease.owner(), lease.fencingToken(), lease.expiresAt()); + } +} diff --git a/featureframework-cluster-dataprovider/src/main/resources/schema/mysql-v1.sql b/featureframework-cluster-dataprovider/src/main/resources/schema/mysql-v1.sql new file mode 100644 index 0000000..7c33fd8 --- /dev/null +++ b/featureframework-cluster-dataprovider/src/main/resources/schema/mysql-v1.sql @@ -0,0 +1,63 @@ +-- FeatureFramework replica control-plane schema v1. +-- Apply explicitly before enabling replicated mode. The runtime validates this schema but never creates it. + +CREATE TABLE IF NOT EXISTS ff_replica_group ( + namespace VARCHAR(128) NOT NULL, + application_id VARCHAR(128) NOT NULL, + group_id VARCHAR(128) NOT NULL, + active_generation BIGINT UNSIGNED NULL, + active_manifest_hash CHAR(64) NULL, + highest_fencing_token BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (namespace, application_id, group_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE IF NOT EXISTS ff_config_generation ( + namespace VARCHAR(128) NOT NULL, + application_id VARCHAR(128) NOT NULL, + group_id VARCHAR(128) NOT NULL, + generation BIGINT UNSIGNED NOT NULL, + publisher_node VARCHAR(128) NOT NULL, + publisher_boot_id VARCHAR(64) NOT NULL, + fencing_token BIGINT UNSIGNED NOT NULL, + application_version VARCHAR(128) NOT NULL, + config_compatibility_version VARCHAR(128) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + source_generation BIGINT UNSIGNED NULL, + manifest_hash CHAR(64) NOT NULL, + PRIMARY KEY (namespace, application_id, group_id, generation), + CONSTRAINT fk_ff_generation_group FOREIGN KEY (namespace, application_id, group_id) + REFERENCES ff_replica_group(namespace, application_id, group_id) + ON UPDATE RESTRICT ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE IF NOT EXISTS ff_config_file ( + namespace VARCHAR(128) NOT NULL, + application_id VARCHAR(128) NOT NULL, + group_id VARCHAR(128) NOT NULL, + generation BIGINT UNSIGNED NOT NULL, + path VARCHAR(255) NOT NULL, + kind VARCHAR(64) NOT NULL, + sha256 CHAR(64) NOT NULL, + size BIGINT UNSIGNED NOT NULL, + raw_contents LONGBLOB NOT NULL, + PRIMARY KEY (namespace, application_id, group_id, generation, path), + CONSTRAINT fk_ff_file_generation FOREIGN KEY (namespace, application_id, group_id, generation) + REFERENCES ff_config_generation(namespace, application_id, group_id, generation) + ON UPDATE RESTRICT ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE IF NOT EXISTS ff_replica_node_state ( + namespace VARCHAR(128) NOT NULL, + application_id VARCHAR(128) NOT NULL, + group_id VARCHAR(128) NOT NULL, + node_id VARCHAR(128) NOT NULL, + applied_generation BIGINT UNSIGNED NULL, + status VARCHAR(32) NOT NULL, + detail VARCHAR(512) NULL, + last_seen TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (namespace, application_id, group_id, node_id), + CONSTRAINT fk_ff_node_group FOREIGN KEY (namespace, application_id, group_id) + REFERENCES ff_replica_group(namespace, application_id, group_id) + ON UPDATE RESTRICT ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/featureframework-cluster-dataprovider/src/test/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaBackendIT.java b/featureframework-cluster-dataprovider/src/test/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaBackendIT.java new file mode 100644 index 0000000..b38ed4b --- /dev/null +++ b/featureframework-cluster-dataprovider/src/test/java/nl/hauntedmc/featureframework/cluster/dataprovider/DataProviderReplicaBackendIT.java @@ -0,0 +1,220 @@ +package nl.hauntedmc.featureframework.cluster.dataprovider; + +import nl.hauntedmc.dataprovider.core.database.keyvalue.impl.redis.RedisDatabase; +import nl.hauntedmc.dataprovider.core.database.relational.impl.mysql.MySQLDatabase; +import nl.hauntedmc.dataprovider.logging.LoggerAdapter; +import nl.hauntedmc.featureframework.cluster.ConfigGeneration; +import nl.hauntedmc.featureframework.cluster.ConfigHashes; +import nl.hauntedmc.featureframework.cluster.ConfigManifest; +import nl.hauntedmc.featureframework.cluster.ConfigManifestFile; +import nl.hauntedmc.featureframework.cluster.ReplicaGroupIdentity; +import nl.hauntedmc.featureframework.cluster.ReplicaNodeIdentity; +import nl.hauntedmc.featureframework.cluster.ReplicaStatus; +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.CommentedConfigurationNode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers(disabledWithoutDocker = true) +class DataProviderReplicaBackendIT { + private static final String REDIS_PASSWORD = "featureframework-it-secret"; + private static final ReplicaGroupIdentity GROUP = + new ReplicaGroupIdentity("integration", "proxyfeatures", "proxy", "proxy-01"); + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>(DockerImageName.parse("mysql:8.4")) + .withDatabaseName("featureframework") + .withUsername("featureframework") + .withPassword("featureframework-secret"); + + @Container + private static final GenericContainer REDIS = + new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")) + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass", REDIS_PASSWORD); + + @Test + void mysqlStorePublishesImmutableFencedGenerationsAndRollbackAsNextGeneration() throws Exception { + MySQLDatabase database = new MySQLDatabase(mysqlConfig(), logger()); + try { + database.connect(); + assertTrue(database.isConnected()); + DataProviderReplicaGenerationRepository repository = + new DataProviderReplicaGenerationRepository(database.getDataAccess()); + + assertThrows(CompletionException.class, + () -> repository.validateSchema().toCompletableFuture().join()); + installSchema(database); + repository.validateSchema().toCompletableFuture().join(); + + ConfigGeneration first = repository.publish(GROUP, + candidate("one", OptionalLong.empty(), 10), 10).toCompletableFuture().join(); + assertEquals(1L, first.manifest().generation()); + assertEquals("one", text(first.file("config.yml"))); + + ConfigGeneration second = repository.publish(GROUP, + candidate("two", OptionalLong.empty(), 10), 10).toCompletableFuture().join(); + assertEquals(2L, second.manifest().generation()); + assertEquals("two", text(second.file("config.yml"))); + + assertThrows(CompletionException.class, + () -> repository.publish(GROUP, + candidate("stale", OptionalLong.empty(), 9), 9).toCompletableFuture().join()); + ConfigGeneration afterStale = repository.loadActive(GROUP).toCompletableFuture().join().orElseThrow(); + assertEquals(2L, afterStale.manifest().generation(), + "stale fencing token must not advance the active generation"); + assertEquals("two", text(afterStale.file("config.yml"))); + assertTrue(repository.loadGeneration(GROUP, 3).toCompletableFuture().join().isEmpty(), + "stale fencing token must not leave a partial immutable generation"); + + ConfigGeneration rollback = repository.publish(GROUP, + candidate("one", OptionalLong.of(1), 10), 10).toCompletableFuture().join(); + assertEquals(3L, rollback.manifest().generation()); + assertEquals(1L, rollback.manifest().sourceGeneration().orElseThrow()); + assertEquals("one", text(rollback.file("config.yml"))); + + ConfigGeneration active = repository.loadActive(GROUP).toCompletableFuture().join().orElseThrow(); + assertEquals(3L, active.manifest().generation()); + assertEquals("one", text(active.file("config.yml"))); + assertEquals("two", text(repository.loadGeneration(GROUP, 2) + .toCompletableFuture().join().orElseThrow().file("config.yml"))); + + repository.recordNodeState(GROUP, new ReplicaNodeIdentity("proxy-02"), 3, + ReplicaStatus.State.READY, "converged").toCompletableFuture().join(); + Map row = database.getDataAccess().queryForSingle( + "SELECT applied_generation,status,detail FROM ff_replica_node_state " + + "WHERE namespace=? AND application_id=? AND group_id=? AND node_id=?", + GROUP.namespace(), GROUP.applicationId(), GROUP.groupId(), "proxy-02").join(); + assertEquals(3L, ((Number) row.get("applied_generation")).longValue()); + assertEquals("READY", row.get("status")); + assertEquals("converged", row.get("detail")); + } finally { + database.disconnect(); + } + } + + @Test + void redisLeaseAdapterUsesAcquireRenewReleaseWithoutDisplacingLiveOwner() throws Exception { + RedisDatabase database = new RedisDatabase(redisConfig(), logger()); + try { + database.connect(); + assertTrue(database.isConnected()); + DataProviderReplicaLeaseCoordinator coordinator = + new DataProviderReplicaLeaseCoordinator(database.getCoordinationDataAccess()); + + var first = coordinator.acquire(GROUP, "proxy-01/boot-a", Duration.ofSeconds(5)) + .toCompletableFuture().join().orElseThrow(); + assertTrue(coordinator.acquire(GROUP, "proxy-01/boot-b", Duration.ofSeconds(5)) + .toCompletableFuture().join().isEmpty()); + + var renewed = coordinator.renew(first, Duration.ofSeconds(5)) + .toCompletableFuture().join().orElseThrow(); + assertEquals(first.fencingToken(), renewed.fencingToken()); + assertEquals(first.owner(), renewed.owner()); + + assertTrue(coordinator.release(renewed).toCompletableFuture().join()); + var second = coordinator.acquire(GROUP, "proxy-01/boot-b", Duration.ofSeconds(5)) + .toCompletableFuture().join().orElseThrow(); + assertTrue(second.fencingToken() > first.fencingToken()); + assertFalse(coordinator.release(first).toCompletableFuture().join()); + assertTrue(coordinator.release(second).toCompletableFuture().join()); + } finally { + database.disconnect(); + } + } + + private static void installSchema(MySQLDatabase database) throws Exception { + String schema; + try (InputStream input = DataProviderReplicaBackendIT.class.getClassLoader() + .getResourceAsStream("schema/mysql-v1.sql")) { + if (input == null) throw new IllegalStateException("Missing schema/mysql-v1.sql test resource"); + schema = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + StringBuilder withoutComments = new StringBuilder(); + for (String line : schema.lines().toList()) { + if (!line.stripLeading().startsWith("--")) withoutComments.append(line).append('\n'); + } + for (String statement : withoutComments.toString().split(";")) { + String sql = statement.trim(); + if (!sql.isEmpty()) database.getDataAccess().executeUpdate(sql).join(); + } + } + + private static ConfigGeneration candidate(String value, OptionalLong source, long token) { + byte[] contents = value.getBytes(StandardCharsets.UTF_8); + ConfigManifestFile file = new ConfigManifestFile( + "config.yml", "ROOT_CONFIG", ConfigHashes.sha256(contents), contents.length); + List files = List.of(file); + ConfigManifest manifest = new ConfigManifest( + ConfigManifest.CURRENT_PROTOCOL_VERSION, + GROUP, + 1, + "proxy-01", + "boot-it", + token, + "5.0.0", + "1", + Instant.parse("2026-09-02T00:00:00Z"), + source, + files, + ConfigHashes.manifestHash(files)); + return new ConfigGeneration(manifest, Map.of("config.yml", contents)); + } + + private static CommentedConfigurationNode mysqlConfig() throws Exception { + CommentedConfigurationNode config = CommentedConfigurationNode.root(); + config.node("host").set(MYSQL.getHost()); + config.node("port").set(MYSQL.getMappedPort(3306)); + config.node("database").set(MYSQL.getDatabaseName()); + config.node("username").set(MYSQL.getUsername()); + config.node("password").set(MYSQL.getPassword()); + config.node("ssl_mode").set("DISABLED"); + config.node("allow_public_key_retrieval").set(true); + config.node("pool_size").set(2); + config.node("min_idle").set(0); + config.node("connection_timeout_ms").set(2_000L); + config.node("connect_timeout_ms").set(2_000); + config.node("socket_timeout_ms").set(2_000); + return config; + } + + private static CommentedConfigurationNode redisConfig() throws Exception { + CommentedConfigurationNode config = CommentedConfigurationNode.root(); + config.node("host").set(REDIS.getHost()); + config.node("port").set(REDIS.getMappedPort(6379)); + config.node("password").set(REDIS_PASSWORD); + config.node("database").set(0); + config.node("network_namespace").set("featureframework-it"); + config.node("pool", "connections").set(2); + config.node("pool", "threads").set(2); + config.node("pool", "min_idle").set(0); + config.node("connection_timeout_ms").set(2_000); + config.node("socket_timeout_ms").set(2_000); + return config; + } + + private static LoggerAdapter logger() { + return (level, message, throwable) -> { }; + } + + private static String text(byte[] value) { + return new String(value, StandardCharsets.UTF_8); + } +} diff --git a/featureframework-cluster/pom.xml b/featureframework-cluster/pom.xml new file mode 100644 index 0000000..ab66342 --- /dev/null +++ b/featureframework-cluster/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + nl.hauntedmc.featureframework + featureframework-parent + ${revision} + + featureframework-cluster + FeatureFramework Cluster + + + ${project.groupId} + featureframework-api + ${project.version} + + + ${project.groupId} + featureframework-toolkit + ${project.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigCompatibility.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigCompatibility.java new file mode 100644 index 0000000..3580ceb --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigCompatibility.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.util.Objects; + +/** Application binary version plus its independently evolving configuration compatibility version. */ +public record ConfigCompatibility( + String applicationVersion, + String configCompatibilityVersion +) { + public ConfigCompatibility { + applicationVersion = text(applicationVersion, "applicationVersion"); + configCompatibilityVersion = text(configCompatibilityVersion, "configCompatibilityVersion"); + } + + public boolean isCompatible(ConfigManifest manifest) { + return configCompatibilityVersion.equals( + Objects.requireNonNull(manifest, "manifest").configCompatibilityVersion()); + } + + private static String text(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + return normalized; + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigGeneration.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigGeneration.java new file mode 100644 index 0000000..5f9101b --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigGeneration.java @@ -0,0 +1,49 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Immutable configuration contents paired with their verified manifest. */ +public final class ConfigGeneration { + private final ConfigManifest manifest; + private final Map files; + + public ConfigGeneration(ConfigManifest manifest, Map files) { + this.manifest = Objects.requireNonNull(manifest, "manifest"); + Map copy = new LinkedHashMap<>(); + Objects.requireNonNull(files, "files").forEach((path, contents) -> + copy.put(Objects.requireNonNull(path, "path"), Objects.requireNonNull(contents, "contents").clone())); + this.files = Map.copyOf(copy); + verify(); + } + + public ConfigManifest manifest() { return manifest; } + + public Map files() { + Map copy = new LinkedHashMap<>(); + files.forEach((path, contents) -> copy.put(path, contents.clone())); + return Map.copyOf(copy); + } + + public byte[] file(String path) { + byte[] contents = files.get(path); + if (contents == null) throw new IllegalArgumentException("Generation does not contain managed file: " + path); + return contents.clone(); + } + + public void verify() { + if (files.size() != manifest.files().size()) { + throw new IllegalArgumentException("Manifest/file count mismatch"); + } + for (ConfigManifestFile file : manifest.files()) { + byte[] contents = files.get(file.path()); + if (contents == null) throw new IllegalArgumentException("Missing manifest file: " + file.path()); + if (contents.length != file.size()) throw new IllegalArgumentException("Size mismatch for " + file.path()); + String hash = ConfigHashes.sha256(contents); + if (!hash.equals(file.sha256())) throw new IllegalArgumentException("Hash mismatch for " + file.path()); + } + String expected = ConfigHashes.manifestHash(manifest.files()); + if (!expected.equals(manifest.manifestHash())) throw new IllegalArgumentException("Manifest hash mismatch"); + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigHashes.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigHashes.java new file mode 100644 index 0000000..f6a54ed --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigHashes.java @@ -0,0 +1,30 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; + +/** Deterministic SHA-256 helpers for replica configuration manifests. */ +public final class ConfigHashes { + private ConfigHashes() { } + + public static String sha256(byte[] contents) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(contents)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + public static String manifestHash(List files) { + StringBuilder canonical = new StringBuilder(); + files.stream().sorted().forEach(file -> canonical + .append(file.path()).append('\n') + .append(file.kind()).append('\n') + .append(file.sha256()).append('\n') + .append(file.size()).append('\n')); + return sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigManifest.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigManifest.java new file mode 100644 index 0000000..190ecbf --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigManifest.java @@ -0,0 +1,55 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.OptionalLong; + +/** Immutable manifest describing one published configuration generation. */ +public record ConfigManifest( + int protocolVersion, + ReplicaGroupIdentity group, + long generation, + String publisherNode, + String publisherBootId, + long fencingToken, + String applicationVersion, + String configCompatibilityVersion, + Instant createdAt, + OptionalLong sourceGeneration, + List files, + String manifestHash +) { + public static final int CURRENT_PROTOCOL_VERSION = 1; + + public ConfigManifest { + if (protocolVersion <= 0) throw new IllegalArgumentException("protocolVersion must be positive"); + group = Objects.requireNonNull(group, "group"); + if (generation <= 0) throw new IllegalArgumentException("generation must be positive"); + publisherNode = text(publisherNode, "publisherNode"); + publisherBootId = text(publisherBootId, "publisherBootId"); + if (fencingToken <= 0) throw new IllegalArgumentException("fencingToken must be positive"); + applicationVersion = text(applicationVersion, "applicationVersion"); + configCompatibilityVersion = text(configCompatibilityVersion, "configCompatibilityVersion"); + createdAt = Objects.requireNonNull(createdAt, "createdAt"); + sourceGeneration = sourceGeneration == null ? OptionalLong.empty() : sourceGeneration; + if (sourceGeneration.isPresent() && sourceGeneration.getAsLong() <= 0) { + throw new IllegalArgumentException("sourceGeneration must be positive when present"); + } + files = files == null ? List.of() : files.stream().sorted(Comparator.naturalOrder()).toList(); + manifestHash = sha(manifestHash); + } + + private static String text(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + return normalized; + } + + private static String sha(String value) { + String normalized = text(value, "manifestHash").toLowerCase(java.util.Locale.ROOT); + if (!normalized.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("manifestHash must be a SHA-256 hash"); + return normalized; + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigManifestFile.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigManifestFile.java new file mode 100644 index 0000000..75001bd --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigManifestFile.java @@ -0,0 +1,51 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.nio.file.Path; +import java.util.Objects; + +/** Hash-addressed metadata for one managed configuration file. */ +public record ConfigManifestFile( + String path, + String kind, + String sha256, + long size +) implements Comparable { + /** Maximum managed relative path length supported by the v1 MySQL control-plane schema. */ + public static final int MAX_PATH_LENGTH = 255; + + public ConfigManifestFile { + path = normalizePath(path); + kind = requireText(kind, "kind"); + sha256 = requireSha256(sha256); + if (size < 0) throw new IllegalArgumentException("size must be non-negative"); + } + + @Override + public int compareTo(ConfigManifestFile other) { + return path.compareTo(Objects.requireNonNull(other, "other").path); + } + + private static String normalizePath(String value) { + String normalized = requireText(value, "path").replace('\\', '/'); + Path pathValue = Path.of(normalized); + if (pathValue.isAbsolute() || normalized.startsWith("../") || normalized.contains("/../")) { + throw new IllegalArgumentException("path must remain relative to the application data directory: " + value); + } + if (normalized.length() > MAX_PATH_LENGTH) { + throw new IllegalArgumentException("path must be at most " + MAX_PATH_LENGTH + " characters"); + } + return normalized; + } + + private static String requireSha256(String value) { + String normalized = requireText(value, "sha256").toLowerCase(java.util.Locale.ROOT); + if (!normalized.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("sha256 must be 64 hexadecimal characters"); + return normalized; + } + + private static String requireText(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + return normalized; + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigurationMaterializer.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigurationMaterializer.java new file mode 100644 index 0000000..beff644 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ConfigurationMaterializer.java @@ -0,0 +1,190 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** Stages, verifies and transactionally materializes managed configuration beneath one application data directory. */ +public final class ConfigurationMaterializer { + private final Path dataDirectory; + private final Path replicaDirectory; + private final ManagedFilePolicy managedFiles; + + public ConfigurationMaterializer(Path dataDirectory, ManagedFilePolicy managedFiles) { + this.dataDirectory = dataDirectory.toAbsolutePath().normalize(); + this.replicaDirectory = this.dataDirectory.resolve(".replica"); + this.managedFiles = java.util.Objects.requireNonNull(managedFiles, "managedFiles"); + } + + public Map snapshot() { + Map snapshot = new LinkedHashMap<>(); + if (Files.notExists(dataDirectory)) return Map.of(); + try (var paths = Files.walk(dataDirectory)) { + for (Path file : paths.filter(Files::isRegularFile).toList()) { + if (file.startsWith(replicaDirectory)) continue; + Path relative = dataDirectory.relativize(file); + if (managedFiles.isManaged(relative)) snapshot.put(normalize(relative), Files.readAllBytes(file)); + } + } catch (IOException exception) { + throw new IllegalStateException("Failed to snapshot managed configuration", exception); + } + return immutableCopy(snapshot); + } + + public boolean matches(ConfigGeneration generation) { + generation.verify(); + return matchesSnapshot(generation.files()); + } + + /** Backs up the complete current managed snapshot before authoritative drift repair. */ + public Path backupDrift() { + Map current = snapshot(); + Path backup = replicaDirectory.resolve("drift").resolve( + Instant.now().toEpochMilli() + "-" + java.util.UUID.randomUUID()); + try { + for (Map.Entry entry : current.entrySet()) { + Path target = backup.resolve(entry.getKey()).normalize(); + if (!target.startsWith(backup)) throw new IllegalStateException("Managed drift path escapes backup directory"); + Files.createDirectories(target.getParent()); + Files.write(target, entry.getValue()); + } + return backup; + } catch (IOException exception) { + throw new IllegalStateException("Failed to back up replica filesystem drift", exception); + } + } + + /** + * Applies one verified generation. Multi-file filesystems cannot provide a group rename, so the + * previous managed snapshot is retained in memory and restored if any move, delete or final + * verification fails. The host only reloads after this method returns successfully. + */ + public void materialize(ConfigGeneration generation) { + generation.verify(); + Path stage = replicaDirectory.resolve("staging").resolve( + "apply-" + generation.manifest().generation() + "-" + java.util.UUID.randomUUID()); + Map previous = snapshot(); + try { + stageGeneration(stage, generation); + applyStaged(stage, generation.files().keySet()); + if (!matches(generation)) throw new IllegalStateException("Materialized generation did not verify on disk"); + } catch (Throwable failure) { + try { + restoreSnapshot(previous); + if (!matchesSnapshot(previous)) { + throw new IllegalStateException("Previous managed configuration did not verify after rollback"); + } + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + if (failure instanceof RuntimeException runtimeFailure) throw runtimeFailure; + throw new IllegalStateException("Failed to materialize replica configuration", failure); + } finally { + try { LastKnownGoodStore.deleteRecursively(stage); } catch (IOException ignored) { } + } + } + + private void stageGeneration(Path stage, ConfigGeneration generation) throws IOException { + Files.createDirectories(stage); + for (Map.Entry entry : generation.files().entrySet()) { + Path relative = Path.of(entry.getKey()); + if (!managedFiles.isManaged(relative)) { + throw new IllegalArgumentException("Generation contains unmanaged path: " + entry.getKey()); + } + Path target = stage.resolve(entry.getKey()).normalize(); + if (!target.startsWith(stage)) throw new IllegalArgumentException("Generation path escapes staging directory"); + Files.createDirectories(target.getParent()); + Files.write(target, entry.getValue()); + } + for (ConfigManifestFile file : generation.manifest().files()) { + Path staged = stage.resolve(file.path()).normalize(); + byte[] bytes = Files.readAllBytes(staged); + if (bytes.length != file.size() || !ConfigHashes.sha256(bytes).equals(file.sha256())) { + throw new IllegalStateException("Staged generation verification failed for " + file.path()); + } + } + } + + private void applyStaged(Path stage, Set desired) throws IOException { + Set existing = new LinkedHashSet<>(snapshot().keySet()); + Map prepared = new LinkedHashMap<>(); + try { + for (String path : desired) { + Path target = target(path); + Files.createDirectories(target.getParent()); + Path temp = Files.createTempFile(target.getParent(), "." + target.getFileName(), ".replica"); + Files.copy(stage.resolve(path).normalize(), temp, StandardCopyOption.REPLACE_EXISTING); + prepared.put(path, temp); + } + for (String obsolete : existing) { + if (!desired.contains(obsolete)) Files.deleteIfExists(target(obsolete)); + } + for (Map.Entry entry : prepared.entrySet()) { + atomicReplace(entry.getValue(), target(entry.getKey())); + } + } finally { + for (Path temp : prepared.values()) Files.deleteIfExists(temp); + } + } + + private void restoreSnapshot(Map previous) throws IOException { + Set existing = new LinkedHashSet<>(snapshot().keySet()); + Map prepared = new LinkedHashMap<>(); + try { + for (Map.Entry entry : previous.entrySet()) { + Path target = target(entry.getKey()); + Files.createDirectories(target.getParent()); + Path temp = Files.createTempFile(target.getParent(), "." + target.getFileName(), ".rollback"); + Files.write(temp, entry.getValue()); + prepared.put(entry.getKey(), temp); + } + for (String obsolete : existing) { + if (!previous.containsKey(obsolete)) Files.deleteIfExists(target(obsolete)); + } + for (Map.Entry entry : prepared.entrySet()) { + atomicReplace(entry.getValue(), target(entry.getKey())); + } + } finally { + for (Path temp : prepared.values()) Files.deleteIfExists(temp); + } + } + + private boolean matchesSnapshot(Map expected) { + Map local = snapshot(); + if (!local.keySet().equals(expected.keySet())) return false; + for (Map.Entry entry : local.entrySet()) { + if (!java.util.Arrays.equals(entry.getValue(), expected.get(entry.getKey()))) return false; + } + return true; + } + + private Path target(String relativePath) { + Path target = dataDirectory.resolve(relativePath).normalize(); + if (!target.startsWith(dataDirectory)) throw new IllegalArgumentException("Generation path escapes data directory"); + if (!managedFiles.isManaged(dataDirectory.relativize(target))) { + throw new IllegalArgumentException("Generation contains unmanaged path: " + relativePath); + } + return target; + } + + private static void atomicReplace(Path source, Path target) throws IOException { + try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static Map immutableCopy(Map source) { + Map copy = new LinkedHashMap<>(); + source.forEach((path, bytes) -> copy.put(path, bytes.clone())); + return Map.copyOf(copy); + } + + private static String normalize(Path path) { return path.toString().replace('\\', '/'); } +} \ No newline at end of file diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/GenerationState.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/GenerationState.java new file mode 100644 index 0000000..27e4415 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/GenerationState.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.featureframework.cluster; + +/** Local/application state of one immutable configuration generation. */ +public enum GenerationState { + AVAILABLE, + APPLIED, + REJECTED +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/LastKnownGoodStore.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/LastKnownGoodStore.java new file mode 100644 index 0000000..ddf485e --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/LastKnownGoodStore.java @@ -0,0 +1,183 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Properties; + +/** Verified local last-known-good store beneath {@code .replica/}. */ +public final class LastKnownGoodStore { + private final Path root; + + public LastKnownGoodStore(Path dataDirectory) { + root = dataDirectory.toAbsolutePath().normalize().resolve(".replica"); + } + + public Path root() { return root; } + + public synchronized void save(ConfigGeneration generation) { + generation.verify(); + Path target = root.resolve("generations").resolve(Long.toString(generation.manifest().generation())); + Path staging = root.resolve("staging").resolve("lkg-" + generation.manifest().generation() + "-" + java.util.UUID.randomUUID()); + try { + Files.createDirectories(staging); + Properties properties = manifestProperties(generation.manifest()); + try (OutputStream output = Files.newOutputStream(staging.resolve("manifest.properties"))) { + properties.store(output, "FeatureFramework replica generation"); + } + Path filesRoot = staging.resolve("files"); + for (Map.Entry entry : generation.files().entrySet()) { + Path file = filesRoot.resolve(entry.getKey()).normalize(); + if (!file.startsWith(filesRoot)) throw new IllegalArgumentException("Generation path escapes LKG store"); + Files.createDirectories(file.getParent()); + Files.write(file, entry.getValue()); + } + Files.createDirectories(target.getParent()); + deleteRecursively(target); + moveDirectory(staging, target); + Files.createDirectories(root); + String state = "{\n \"generation\": " + generation.manifest().generation() + ",\n" + + " \"manifestHash\": \"" + generation.manifest().manifestHash() + "\",\n" + + " \"configCompatibilityVersion\": \"" + + escape(generation.manifest().configCompatibilityVersion()) + "\"\n}\n"; + atomicWrite(root.resolve("state.json"), state.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } catch (IOException exception) { + throw new IllegalStateException("Failed to persist replica last-known-good generation", exception); + } finally { + try { deleteRecursively(staging); } catch (IOException ignored) { } + } + } + + public synchronized Optional load() { + Path state = root.resolve("state.json"); + if (Files.notExists(state)) return Optional.empty(); + try { + String json = Files.readString(state); + long generation = Long.parseLong(extractJson(json, "generation")); + Path directory = root.resolve("generations").resolve(Long.toString(generation)); + ConfigGeneration loaded = readGeneration(directory); + String expectedHash = extractJson(json, "manifestHash"); + if (!expectedHash.equals(loaded.manifest().manifestHash())) { + throw new IllegalStateException("LKG state hash does not match generation manifest"); + } + loaded.verify(); + return Optional.of(loaded); + } catch (IOException | RuntimeException exception) { + throw new IllegalStateException("Failed to load replica last-known-good generation", exception); + } + } + + private static ConfigGeneration readGeneration(Path directory) throws IOException { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(directory.resolve("manifest.properties"))) { + properties.load(input); + } + ReplicaGroupIdentity group = new ReplicaGroupIdentity( + required(properties, "namespace"), required(properties, "applicationId"), + required(properties, "groupId"), required(properties, "configuredLeader")); + int count = Integer.parseInt(required(properties, "file.count")); + List manifestFiles = new ArrayList<>(); + Map files = new LinkedHashMap<>(); + Path filesRoot = directory.resolve("files"); + for (int index = 0; index < count; index++) { + String prefix = "file." + index + "."; + String path = required(properties, prefix + "path"); + ConfigManifestFile file = new ConfigManifestFile(path, required(properties, prefix + "kind"), + required(properties, prefix + "sha256"), Long.parseLong(required(properties, prefix + "size"))); + manifestFiles.add(file); + Path physical = filesRoot.resolve(path).normalize(); + if (!physical.startsWith(filesRoot)) throw new IllegalStateException("Stored LKG path escapes generation"); + files.put(path, Files.readAllBytes(physical)); + } + String source = properties.getProperty("sourceGeneration", "").trim(); + ConfigManifest manifest = new ConfigManifest( + Integer.parseInt(required(properties, "protocolVersion")), group, + Long.parseLong(required(properties, "generation")), required(properties, "publisherNode"), + required(properties, "publisherBootId"), Long.parseLong(required(properties, "fencingToken")), + required(properties, "applicationVersion"), required(properties, "configCompatibilityVersion"), + Instant.parse(required(properties, "createdAt")), + source.isEmpty() ? OptionalLong.empty() : OptionalLong.of(Long.parseLong(source)), + manifestFiles, required(properties, "manifestHash")); + return new ConfigGeneration(manifest, files); + } + + private static Properties manifestProperties(ConfigManifest manifest) { + Properties properties = new Properties(); + properties.setProperty("protocolVersion", Integer.toString(manifest.protocolVersion())); + properties.setProperty("namespace", manifest.group().namespace()); + properties.setProperty("applicationId", manifest.group().applicationId()); + properties.setProperty("groupId", manifest.group().groupId()); + properties.setProperty("configuredLeader", manifest.group().configuredLeader()); + properties.setProperty("generation", Long.toString(manifest.generation())); + properties.setProperty("publisherNode", manifest.publisherNode()); + properties.setProperty("publisherBootId", manifest.publisherBootId()); + properties.setProperty("fencingToken", Long.toString(manifest.fencingToken())); + properties.setProperty("applicationVersion", manifest.applicationVersion()); + properties.setProperty("configCompatibilityVersion", manifest.configCompatibilityVersion()); + properties.setProperty("createdAt", manifest.createdAt().toString()); + if (manifest.sourceGeneration().isPresent()) { + properties.setProperty("sourceGeneration", Long.toString(manifest.sourceGeneration().getAsLong())); + } + properties.setProperty("manifestHash", manifest.manifestHash()); + properties.setProperty("file.count", Integer.toString(manifest.files().size())); + for (int index = 0; index < manifest.files().size(); index++) { + ConfigManifestFile file = manifest.files().get(index); + String prefix = "file." + index + "."; + properties.setProperty(prefix + "path", file.path()); + properties.setProperty(prefix + "kind", file.kind()); + properties.setProperty(prefix + "sha256", file.sha256()); + properties.setProperty(prefix + "size", Long.toString(file.size())); + } + return properties; + } + + private static String required(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) throw new IllegalStateException("Missing generation property: " + key); + return value.trim(); + } + + private static String extractJson(String json, String key) { + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile( + "\\\"" + java.util.regex.Pattern.quote(key) + "\\\"\\s*:\\s*(?:\\\"([^\\\"]*)\\\"|([0-9]+))") + .matcher(json); + if (!matcher.find()) throw new IllegalStateException("Missing LKG state field: " + key); + return matcher.group(1) != null ? matcher.group(1) : matcher.group(2); + } + + private static String escape(String value) { return value.replace("\\", "\\\\").replace("\"", "\\\""); } + + private static void atomicWrite(Path target, byte[] bytes) throws IOException { + Files.createDirectories(target.getParent()); + Path temp = Files.createTempFile(target.getParent(), "." + target.getFileName(), ".tmp"); + try { + Files.write(temp, bytes); + try { Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { Files.deleteIfExists(temp); } + } + + private static void moveDirectory(Path source, Path target) throws IOException { + try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); } + catch (java.nio.file.AtomicMoveNotSupportedException ignored) { Files.move(source, target); } + } + + static void deleteRecursively(Path path) throws IOException { + if (path == null || Files.notExists(path)) return; + try (var stream = Files.walk(path)) { + for (Path entry : stream.sorted(java.util.Comparator.reverseOrder()).toList()) Files.deleteIfExists(entry); + } + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ManagedFilePolicy.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ManagedFilePolicy.java new file mode 100644 index 0000000..de13897 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ManagedFilePolicy.java @@ -0,0 +1,9 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.nio.file.Path; + +/** Decides whether one path belongs to replicated application configuration. */ +@FunctionalInterface +public interface ManagedFilePolicy { + boolean isManaged(Path relativePath); +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ManagedFileSet.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ManagedFileSet.java new file mode 100644 index 0000000..4bcda18 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ManagedFileSet.java @@ -0,0 +1,71 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** Explicit allowlist plus safe FeatureFramework defaults for replicated files. */ +public final class ManagedFileSet implements ManagedFilePolicy { + private final Set explicitPaths; + private final Set languageFiles; + + private ManagedFileSet(Builder builder) { + explicitPaths = Set.copyOf(builder.explicitPaths); + languageFiles = Set.copyOf(builder.languageFiles); + } + + public static Builder builder() { return new Builder(); } + + public static ManagedFileSet defaults() { return builder().build(); } + + @Override + public boolean isManaged(Path relativePath) { + String path = normalize(relativePath); + if (explicitPaths.contains(path)) return true; + if ("config.yml".equals(path)) return true; + String[] parts = path.split("/"); + if (parts.length != 3 || !"features".equals(parts[0])) return false; + String file = parts[2].toLowerCase(Locale.ROOT); + return "config.yml".equals(file) || "messages.yml".equals(file) || languageFiles.contains(file); + } + + public Set explicitPaths() { return explicitPaths; } + public Set languageFiles() { return languageFiles; } + + private static String normalize(Path path) { + Objects.requireNonNull(path, "path"); + if (path.isAbsolute()) throw new IllegalArgumentException("Managed paths must be relative"); + String value = path.normalize().toString().replace('\\', '/'); + if (value.isBlank() || value.startsWith("../") || value.contains("/../")) { + throw new IllegalArgumentException("Managed path escapes the application data directory: " + path); + } + return value; + } + + public static final class Builder { + private final Set explicitPaths = new LinkedHashSet<>(); + private final Set languageFiles = new LinkedHashSet<>(); + + public Builder add(Path path) { + explicitPaths.add(normalize(path)); + return this; + } + + public Builder add(String path) { return add(Path.of(path)); } + + /** Adds one known feature-level language YAML file name, for example {@code nl.yml}. */ + public Builder languageFile(String fileName) { + String normalized = Objects.requireNonNull(fileName, "fileName").trim().toLowerCase(Locale.ROOT); + if (!normalized.matches("[a-z0-9_-]+\\.yml")) { + throw new IllegalArgumentException("language file must be a simple .yml file name"); + } + if ("config.yml".equals(normalized) || "messages.yml".equals(normalized)) return this; + languageFiles.add(normalized); + return this; + } + + public ManagedFileSet build() { return new ManagedFileSet(this); } + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaAuthority.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaAuthority.java new file mode 100644 index 0000000..3cd545b --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaAuthority.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.time.Instant; +import java.util.Objects; + +/** Last successfully proven fenced authority for the configured leader. */ +public record ReplicaAuthority( + String resource, + String owner, + long fencingToken, + Instant expiresAt +) { + public ReplicaAuthority { + resource = text(resource, "resource"); + owner = text(owner, "owner"); + if (fencingToken <= 0) throw new IllegalArgumentException("fencingToken must be positive"); + expiresAt = Objects.requireNonNull(expiresAt, "expiresAt"); + } + + private static String text(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + return normalized; + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaController.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaController.java new file mode 100644 index 0000000..deb4863 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaController.java @@ -0,0 +1,642 @@ +package nl.hauntedmc.featureframework.cluster; + +import nl.hauntedmc.featureframework.api.feature.ActivationDecision; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPhase; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; +import nl.hauntedmc.featureframework.api.feature.FeatureSuppressionReason; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationPolicy; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.LongSupplier; + +/** + * Backend-neutral orchestration for one static replica group. + * + *

Only the manually configured leader ever acquires authority. Followers never attempt + * acquisition and this controller never promotes a follower automatically.

+ */ +public final class ReplicaController implements AutoCloseable { + public static final Duration DEFAULT_LEASE_TTL = Duration.ofSeconds(15); + public static final Duration DEFAULT_RENEW_INTERVAL = Duration.ofSeconds(3); + public static final Duration DEFAULT_SAFETY_MARGIN = Duration.ofSeconds(2); + public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(5); + + private final ReplicaMode mode; + private final ReplicaNodeIdentity node; + private final ReplicaGroupIdentity group; + private final ReplicaGenerationRepository generations; + private final ReplicaLeaseCoordinator leases; + private final ConfigCompatibility compatibility; + private final ManagedFileSet managedFiles; + private final ConfigurationMaterializer materializer; + private final LastKnownGoodStore lkg; + private final String bootId; + private final String owner; + private final Duration leaseTtl; + private final Duration renewInterval; + private final Duration safetyMargin; + private final Duration pollInterval; + private final LongSupplier nanoTime; + private final ScheduledExecutorService scheduler; + private final AtomicReference authority = new AtomicReference<>(); + private final AtomicReference status; + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicBoolean authorityMaintenanceStarted = new AtomicBoolean(); + private final AtomicBoolean hostRuntimeStarted = new AtomicBoolean(); + private volatile long lastSuccessfulRenewalNanos; + private volatile ConfigGeneration appliedGeneration; + private volatile ConfigGeneration startupRollbackGeneration; + private volatile boolean bootstrapPending; + private volatile ReplicaHostControl host; + + private ReplicaController(Builder builder) { + mode = builder.mode; + node = builder.node; + group = builder.group; + generations = builder.generations; + leases = builder.leases; + compatibility = builder.compatibility; + managedFiles = builder.managedFiles; + materializer = new ConfigurationMaterializer(builder.dataDirectory, managedFiles); + lkg = new LastKnownGoodStore(builder.dataDirectory); + bootId = builder.bootId == null ? UUID.randomUUID().toString() : builder.bootId; + owner = node.nodeId() + "/" + bootId; + leaseTtl = builder.leaseTtl; + renewInterval = builder.renewInterval; + safetyMargin = builder.safetyMargin; + pollInterval = builder.pollInterval; + nanoTime = builder.nanoTime; + scheduler = Executors.newScheduledThreadPool(3, runnable -> { + Thread thread = new Thread(runnable, "FeatureFramework-replica-" + node.nodeId()); + thread.setDaemon(true); + return thread; + }); + ReplicaRole role = role(); + status = new AtomicReference<>(new ReplicaStatus(role, + mode == ReplicaMode.STANDALONE ? ReplicaStatus.State.STANDALONE : ReplicaStatus.State.BOOTSTRAPPING, + Optional.empty(), OptionalLong.empty(), Optional.empty())); + } + + public static Builder standalone(Path dataDirectory, ReplicaNodeIdentity node, ConfigCompatibility compatibility) { + return new Builder(ReplicaMode.STANDALONE, dataDirectory, node, compatibility); + } + + public static Builder replicated( + Path dataDirectory, + ReplicaNodeIdentity node, + ReplicaGroupIdentity group, + ConfigCompatibility compatibility, + ReplicaGenerationRepository generations, + ReplicaLeaseCoordinator leases + ) { + return new Builder(ReplicaMode.REPLICATED, dataDirectory, node, compatibility) + .group(group).generations(generations).leases(leases); + } + + public ReplicaMode mode() { return mode; } + public ReplicaNodeIdentity node() { return node; } + public Optional group() { return Optional.ofNullable(group); } + public String bootId() { return bootId; } + public ReplicaRole role() { return mode == ReplicaMode.STANDALONE ? ReplicaRole.STANDALONE + : group.isConfiguredLeader(node) ? ReplicaRole.LEADER : ReplicaRole.FOLLOWER; } + public ReplicaStatus status() { return status.get(); } + public ManagedFileSet managedFiles() { return managedFiles; } + + public FeatureActivationPolicy activationPolicy() { + return (metadata, phase) -> { + Objects.requireNonNull(metadata, "metadata"); + Objects.requireNonNull(phase, "phase"); + if (mode == ReplicaMode.STANDALONE || metadata.placement() == FeaturePlacement.ALL_NODES) { + return ActivationDecision.allow(); + } + if (role() == ReplicaRole.FOLLOWER) { + return ActivationDecision.suppress(FeatureSuppressionReason.GROUP_LEADER_ONLY, + "Feature placement is restricted to configured leader " + group.configuredLeader()); + } + if (authority.get() == null) { + return ActivationDecision.suppress(FeatureSuppressionReason.AUTHORITY_UNAVAILABLE, + "Configured leader cannot currently prove fenced authority"); + } + if (phase == FeatureActivationPhase.ACTIVATION && !hostRuntimeStarted.get()) { + return ActivationDecision.suppress(FeatureSuppressionReason.CONFIGURATION_UNAVAILABLE, + "Leader-only activation waits until host startup has completed"); + } + if (bootstrapPending && phase == FeatureActivationPhase.ACTIVATION) { + return ActivationDecision.suppress(FeatureSuppressionReason.CONFIGURATION_UNAVAILABLE, + "Replica group has not published its first configuration generation yet"); + } + return ActivationDecision.allow(); + }; + } + + public ConfigMutationPolicy configMutationPolicy() { + return (relativePath, operation) -> { + if (mode == ReplicaMode.REPLICATED && role() == ReplicaRole.FOLLOWER && managedFiles.isManaged(relativePath)) { + throw new ReplicaManagedConfigurationException(relativePath, operation, group); + } + }; + } + + /** Materializes an authoritative generation or LKG before the host constructs feature contexts. */ + public synchronized void prepareBeforeHost() { + ensureOpen(); + if (mode == ReplicaMode.STANDALONE) { + setStatus(ReplicaStatus.State.STANDALONE, null); + return; + } + + if (role() == ReplicaRole.LEADER) { + acquireAuthorityIfPossible(); + if (authority.get() != null) startAuthorityMaintenance(); + } + ConfigGeneration remote = null; + Throwable remoteFailure = null; + try { + remote = join(generations.loadActive(group)).orElse(null); + } catch (RuntimeException failure) { + remoteFailure = failure; + } + + if (remote == null && remoteFailure != null) { + remote = compatibleLkg().orElse(null); + if (remote == null) { + throw new IllegalStateException("Replica control-plane database is unavailable and no valid LKG exists", remoteFailure); + } + materializer.materialize(remote); + appliedGeneration = remote; + if (role() == ReplicaRole.LEADER) startAuthorityMaintenance(); + setStatus(ReplicaStatus.State.READY, "Started from verified LKG while database is unavailable"); + return; + } + + if (remote == null) { + if (role() == ReplicaRole.FOLLOWER) { + throw new IllegalStateException( + "Replica group not initialized. Start configured leader " + group.configuredLeader() + " first."); + } + if (authority.get() == null) { + throw new IllegalStateException("Configured leader cannot initialize the replica group without authority"); + } + startAuthorityMaintenance(); + bootstrapPending = true; + setStatus(ReplicaStatus.State.BOOTSTRAPPING, "Waiting to publish first configuration generation"); + return; + } + + requireCompatible(remote); + if (role() == ReplicaRole.LEADER) startAuthorityMaintenance(); + if (role() == ReplicaRole.LEADER) { + Map local = materializer.snapshot(); + if (!local.isEmpty() && !materializer.matches(remote)) { + startupRollbackGeneration = remote; + appliedGeneration = remote; + setStatus(ReplicaStatus.State.BOOTSTRAPPING, + "Leader local configuration will be validated as startup candidate"); + return; + } + } + materializer.materialize(remote); + appliedGeneration = remote; + lkg.save(remote); + setStatus(ReplicaStatus.State.READY, null); + } + + /** Installs lifecycle and write policies on the already constructed host before host start. */ + public synchronized void attach(ReplicaHostControl host) { + ensureOpen(); + if (this.host != null) throw new IllegalStateException("Replica controller is already attached to a host"); + ReplicaHostControl candidate = Objects.requireNonNull(host, "host"); + candidate.installReplicaPolicies(activationPolicy(), configMutationPolicy()); + this.host = candidate; + } + + /** Completes first-generation/startup-candidate publication and starts polling. */ + public synchronized void afterHostStarted() { + ensureOpen(); + if (host == null) throw new IllegalStateException("Attach the FeatureFramework host before afterHostStarted()"); + if (!hostRuntimeStarted.compareAndSet(false, true)) { + throw new IllegalStateException("Replica host runtime has already been started"); + } + if (mode == ReplicaMode.STANDALONE) return; + if (role() == ReplicaRole.LEADER && (bootstrapPending || startupRollbackGeneration != null)) { + publishStartupCandidate(); + } else if (role() == ReplicaRole.LEADER) { + host.reconcileReplicaGraph(); + } + long pollMillis = Math.max(1L, pollInterval.toMillis()); + scheduler.scheduleAtFixedRate(this::safePollTick, pollMillis, pollMillis, TimeUnit.MILLISECONDS); + } + + /** Explicit leader transaction boundary for runtime configuration edits. */ + public synchronized ConfigGeneration publishCurrentConfiguration() { + ensureLeaderAuthority(); + if (host == null) throw new IllegalStateException("Replica host is not attached"); + Map current = materializer.snapshot(); + ConfigGeneration previous = appliedGeneration; + if (!host.reconcileReplicaGraph()) { + if (previous != null) materializer.materialize(previous); + if (previous != null) host.reconcileReplicaGraph(); + throw new IllegalStateException("Candidate configuration failed host graph validation"); + } + try { + ConfigGeneration published = join(generations.publish(group, + candidate(current, OptionalLong.empty()), authority.get().fencingToken())); + acceptPublished(published); + return published; + } catch (RuntimeException failure) { + if (previous != null) { + materializer.materialize(previous); + host.reconcileReplicaGraph(); + } + throw failure; + } + } + + /** Rollback is a new immutable generation; the active pointer never moves backwards. */ + public synchronized ConfigGeneration rollback(long sourceGeneration) { + ensureLeaderAuthority(); + ConfigGeneration source = join(generations.loadGeneration(group, sourceGeneration)) + .orElseThrow(() -> new IllegalArgumentException("Unknown configuration generation " + sourceGeneration)); + requireCompatible(source); + ConfigGeneration previous = appliedGeneration; + materializer.materialize(source); + if (host != null && !host.reconcileReplicaGraph()) { + if (previous != null) materializer.materialize(previous); + if (previous != null) host.reconcileReplicaGraph(); + throw new IllegalStateException("Rollback source generation is not valid for the running host"); + } + try { + ConfigGeneration published = join(generations.publish(group, + candidate(source.files(), OptionalLong.of(sourceGeneration)), authority.get().fencingToken())); + acceptPublished(published); + return published; + } catch (RuntimeException failure) { + if (previous != null) materializer.materialize(previous); + if (previous != null && host != null) host.reconcileReplicaGraph(); + throw failure; + } + } + + private void publishStartupCandidate() { + ensureLeaderAuthority(); + ConfigGeneration previous = startupRollbackGeneration; + try { + ConfigGeneration published = join(generations.publish(group, + candidate(materializer.snapshot(), OptionalLong.empty()), authority.get().fencingToken())); + bootstrapPending = false; + startupRollbackGeneration = null; + acceptPublished(published); + host.reconcileReplicaGraph(); + } catch (RuntimeException failure) { + if (previous != null) { + materializer.materialize(previous); + host.reconcileReplicaGraph(); + appliedGeneration = previous; + } + bootstrapPending = true; + setStatus(ReplicaStatus.State.UNAVAILABLE, "Failed to publish startup configuration generation"); + throw new IllegalStateException("Replica startup publication failed", failure); + } + } + + private void startAuthorityMaintenance() { + if (mode != ReplicaMode.REPLICATED || role() != ReplicaRole.LEADER + || !authorityMaintenanceStarted.compareAndSet(false, true)) return; + long renewNanos = Math.max(1L, renewInterval.toNanos()); + long halfSafety = Math.max(1L, safetyMargin.toNanos() / 2L); + long watchdogNanos = Math.max(1L, Math.min(renewNanos, halfSafety)); + scheduler.scheduleAtFixedRate(this::safeRenewTick, renewNanos, renewNanos, TimeUnit.NANOSECONDS); + scheduler.scheduleAtFixedRate( + this::safeAuthorityWatchdogTick, watchdogNanos, watchdogNanos, TimeUnit.NANOSECONDS); + } + + private void safeRenewTick() { + try { renewTick(); } catch (Throwable failure) { evaluateAuthorityWatchdog(failure); } + } + + private void renewTick() { + if (closed.get() || mode != ReplicaMode.REPLICATED || role() != ReplicaRole.LEADER) return; + ReplicaAuthority current = authority.get(); + if (current == null) { + acquireAuthorityIfPossible(); + if (authority.get() != null) reconcileHostIfStarted(); + return; + } + Optional renewed = join(leases.renew(current, leaseTtl)); + if (closed.get()) return; + if (renewed.isEmpty()) { + if (authority.compareAndSet(current, null)) { + setStatus(ReplicaStatus.State.UNAVAILABLE, "Configured leader lost fenced authority"); + reconcileHostIfStarted(); + } + return; + } + ReplicaAuthority renewedAuthority = renewed.get(); + if (!authority.compareAndSet(current, renewedAuthority)) { + return; + } + lastSuccessfulRenewalNanos = nanoTime.getAsLong(); + refreshStatusAfterAuthorityProof(); + } + + private void safeAuthorityWatchdogTick() { + try { evaluateAuthorityWatchdog(null); } catch (Throwable ignored) { } + } + + private void evaluateAuthorityWatchdog(Throwable failure) { + if (closed.get() || mode != ReplicaMode.REPLICATED || role() != ReplicaRole.LEADER) return; + ReplicaAuthority current = authority.get(); + if (current == null) return; + long safeNanos = leaseTtl.minus(safetyMargin).toNanos(); + long elapsed = Math.max(0L, nanoTime.getAsLong() - lastSuccessfulRenewalNanos); + if (elapsed >= safeNanos && authority.compareAndSet(current, null)) { + String detail = "Authority renewal was not proven inside the TTL safety window"; + if (failure != null) detail += ": " + failure; + setStatus(ReplicaStatus.State.UNAVAILABLE, detail); + reconcileHostIfStarted(); + } + } + + private void reconcileHostIfStarted() { + ReplicaHostControl currentHost = host; + if (hostRuntimeStarted.get() && currentHost != null) currentHost.reconcileReplicaGraph(); + } + + private void safePollTick() { + try { pollTick(); } + catch (Throwable failure) { + setStatus(ReplicaStatus.State.OUT_OF_SYNC, "Replica poll failed: " + failure.getMessage()); + } + } + + private synchronized void pollTick() { + if (closed.get() || mode != ReplicaMode.REPLICATED) return; + ConfigGeneration remote = join(generations.loadActive(group)).orElse(null); + if (remote == null) return; + if (!compatibility.isCompatible(remote.manifest())) { + setStatus(ReplicaStatus.State.OUT_OF_SYNC, + "Active generation requires config compatibility " + + remote.manifest().configCompatibilityVersion()); + return; + } + long applied = appliedGeneration == null ? 0L : appliedGeneration.manifest().generation(); + if (remote.manifest().generation() > applied) { + applyRemote(remote); + } else if (role() == ReplicaRole.FOLLOWER && appliedGeneration != null + && !materializer.matches(appliedGeneration)) { + materializer.backupDrift(); + materializer.materialize(appliedGeneration); + if (host != null && !host.reconcileReplicaGraph()) { + setStatus(ReplicaStatus.State.OUT_OF_SYNC, + "Follower drift repair could not reload previous graph"); + return; + } + setStatus(ReplicaStatus.State.DRIFTED, + "Follower filesystem drift was backed up and repaired"); + } + } + + private void applyRemote(ConfigGeneration remote) { + ConfigGeneration previous = appliedGeneration; + if (previous != null && !materializer.matches(previous) && role() == ReplicaRole.FOLLOWER) { + materializer.backupDrift(); + } + materializer.materialize(remote); + if (host != null && !host.reconcileReplicaGraph()) { + if (previous != null) { + materializer.materialize(previous); + host.reconcileReplicaGraph(); + } + setStatus(ReplicaStatus.State.OUT_OF_SYNC, + "Rejected generation " + remote.manifest().generation()); + return; + } + appliedGeneration = remote; + lkg.save(remote); + setStatus(ReplicaStatus.State.READY, null); + recordNodeStateBestEffort(); + } + + private void acquireAuthorityIfPossible() { + if (closed.get() || mode != ReplicaMode.REPLICATED || role() != ReplicaRole.LEADER) return; + Optional acquired = join(leases.acquire(group, owner, leaseTtl)); + if (closed.get()) return; + authority.set(acquired.orElse(null)); + if (acquired.isPresent()) { + lastSuccessfulRenewalNanos = nanoTime.getAsLong(); + refreshStatusAfterAuthorityProof(); + } else { + refreshStatusAuthority(); + } + } + + private void refreshStatusAfterAuthorityProof() { + ReplicaStatus current = status.get(); + if (current.state() == ReplicaStatus.State.UNAVAILABLE && !bootstrapPending) { + setStatus(ReplicaStatus.State.READY, "Fenced authority restored"); + } else { + refreshStatusAuthority(); + } + } + + private ConfigGeneration candidate(Map files, OptionalLong sourceGeneration) { + List manifestFiles = new ArrayList<>(); + files.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { + String kind = fileKind(entry.getKey()); + manifestFiles.add(new ConfigManifestFile(entry.getKey(), kind, + ConfigHashes.sha256(entry.getValue()), entry.getValue().length)); + }); + String manifestHash = ConfigHashes.manifestHash(manifestFiles); + ReplicaAuthority current = authority.get(); + long token = current == null ? 1L : current.fencingToken(); + ConfigManifest manifest = new ConfigManifest( + ConfigManifest.CURRENT_PROTOCOL_VERSION, group, 1L, node.nodeId(), bootId, token, + compatibility.applicationVersion(), compatibility.configCompatibilityVersion(), Instant.now(), + sourceGeneration, manifestFiles, manifestHash); + return new ConfigGeneration(manifest, files); + } + + private static String fileKind(String path) { + if ("config.yml".equals(path)) return "ROOT_CONFIG"; + if (path.endsWith("/config.yml")) return "FEATURE_CONFIG"; + if (path.endsWith("/messages.yml")) return "FEATURE_MESSAGES"; + return "FEATURE_LANGUAGE"; + } + + private Optional compatibleLkg() { + try { + return lkg.load().filter(value -> compatibility.isCompatible(value.manifest())); + } catch (RuntimeException invalid) { + return Optional.empty(); + } + } + + private void requireCompatible(ConfigGeneration generation) { + generation.verify(); + if (!compatibility.isCompatible(generation.manifest())) { + throw new IllegalStateException("Configuration compatibility mismatch: running " + + compatibility.configCompatibilityVersion() + ", generation " + + generation.manifest().configCompatibilityVersion()); + } + } + + private void acceptPublished(ConfigGeneration generation) { + requireCompatible(generation); + appliedGeneration = generation; + lkg.save(generation); + setStatus(ReplicaStatus.State.READY, null); + recordNodeStateBestEffort(); + } + + private void ensureLeaderAuthority() { + ensureOpen(); + if (mode != ReplicaMode.REPLICATED || role() != ReplicaRole.LEADER) { + throw new IllegalStateException( + "Only the configured replica-group leader may publish configuration"); + } + if (authority.get() == null) { + throw new IllegalStateException("Configured leader does not hold fenced authority"); + } + } + + private void recordNodeStateBestEffort() { + if (mode != ReplicaMode.REPLICATED || appliedGeneration == null) return; + try { + generations.recordNodeState(group, node, appliedGeneration.manifest().generation(), + status.get().state(), status.get().detail().orElse(null)); + } catch (RuntimeException ignored) { } + } + + private void setStatus(ReplicaStatus.State state, String detail) { + long generation = appliedGeneration == null ? 0L : appliedGeneration.manifest().generation(); + status.set(new ReplicaStatus(role(), state, Optional.ofNullable(authority.get()), + generation <= 0 ? OptionalLong.empty() : OptionalLong.of(generation), + Optional.ofNullable(detail))); + } + + private void refreshStatusAuthority() { + ReplicaStatus current = status.get(); + status.set(new ReplicaStatus(role(), current.state(), Optional.ofNullable(authority.get()), + current.appliedGeneration(), current.detail())); + } + + private void ensureOpen() { + if (closed.get()) throw new IllegalStateException("Replica controller is closed"); + } + + private static T join(java.util.concurrent.CompletionStage stage) { + return Objects.requireNonNull(stage, "stage").toCompletableFuture().join(); + } + + @Override + public synchronized void close() { + if (!closed.compareAndSet(false, true)) return; + scheduler.shutdownNow(); + ConfigGeneration rollback = startupRollbackGeneration; + startupRollbackGeneration = null; + if (rollback != null) { + try { + materializer.materialize(rollback); + appliedGeneration = rollback; + } catch (RuntimeException ignored) { } + } + ReplicaAuthority current = authority.getAndSet(null); + if (current != null && leases != null) { + try { join(leases.release(current)); } catch (RuntimeException ignored) { } + } + } + + public static final class Builder { + private final ReplicaMode mode; + private final Path dataDirectory; + private final ReplicaNodeIdentity node; + private final ConfigCompatibility compatibility; + private ReplicaGroupIdentity group; + private ReplicaGenerationRepository generations; + private ReplicaLeaseCoordinator leases; + private ManagedFileSet managedFiles = ManagedFileSet.defaults(); + private String bootId; + private Duration leaseTtl = DEFAULT_LEASE_TTL; + private Duration renewInterval = DEFAULT_RENEW_INTERVAL; + private Duration safetyMargin = DEFAULT_SAFETY_MARGIN; + private Duration pollInterval = DEFAULT_POLL_INTERVAL; + private LongSupplier nanoTime = System::nanoTime; + + private Builder( + ReplicaMode mode, + Path dataDirectory, + ReplicaNodeIdentity node, + ConfigCompatibility compatibility + ) { + this.mode = Objects.requireNonNull(mode, "mode"); + this.dataDirectory = Objects.requireNonNull(dataDirectory, "dataDirectory"); + this.node = Objects.requireNonNull(node, "node"); + this.compatibility = Objects.requireNonNull(compatibility, "compatibility"); + } + + private Builder group(ReplicaGroupIdentity value) { group = value; return this; } + private Builder generations(ReplicaGenerationRepository value) { generations = value; return this; } + private Builder leases(ReplicaLeaseCoordinator value) { leases = value; return this; } + public Builder managedFiles(ManagedFileSet value) { + managedFiles = Objects.requireNonNull(value, "managedFiles"); + return this; + } + public Builder bootId(String value) { + bootId = Objects.requireNonNull(value, "bootId").trim(); + if (bootId.isEmpty()) throw new IllegalArgumentException("bootId must not be blank"); + return this; + } + public Builder leaseTiming(Duration ttl, Duration renew, Duration safety) { + leaseTtl = positive(ttl, "ttl"); + renewInterval = positive(renew, "renew"); + safetyMargin = positive(safety, "safety"); + if (renewInterval.compareTo(leaseTtl) >= 0) { + throw new IllegalArgumentException("renew interval must be less than TTL"); + } + if (safetyMargin.compareTo(leaseTtl) >= 0) { + throw new IllegalArgumentException("safety margin must be less than TTL"); + } + return this; + } + public Builder pollInterval(Duration value) { + pollInterval = positive(value, "pollInterval"); + return this; + } + Builder nanoTime(LongSupplier value) { + nanoTime = Objects.requireNonNull(value, "nanoTime"); + return this; + } + + public ReplicaController build() { + if (mode == ReplicaMode.REPLICATED) { + Objects.requireNonNull(group, "group"); + Objects.requireNonNull(generations, "generations"); + Objects.requireNonNull(leases, "leases"); + } + return new ReplicaController(this); + } + + private static Duration positive(Duration value, String field) { + Duration duration = Objects.requireNonNull(value, field); + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(field + " must be positive"); + } + return duration; + } + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaGenerationRepository.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaGenerationRepository.java new file mode 100644 index 0000000..dedcd1d --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaGenerationRepository.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +/** Durable source of immutable configuration generations. */ +public interface ReplicaGenerationRepository { + CompletionStage> loadActive(ReplicaGroupIdentity group); + + CompletionStage> loadGeneration(ReplicaGroupIdentity group, long generation); + + CompletionStage publish( + ReplicaGroupIdentity group, + ConfigGeneration candidate, + long fencingToken + ); + + CompletionStage recordNodeState( + ReplicaGroupIdentity group, + ReplicaNodeIdentity node, + long appliedGeneration, + ReplicaStatus.State state, + String detail + ); +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaGroupIdentity.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaGroupIdentity.java new file mode 100644 index 0000000..ba6c0fc --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaGroupIdentity.java @@ -0,0 +1,33 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.util.Objects; + +/** Stable identity of one manually configured replica group. */ +public record ReplicaGroupIdentity( + String namespace, + String applicationId, + String groupId, + String configuredLeader +) { + public ReplicaGroupIdentity { + namespace = normalize(namespace, "namespace"); + applicationId = normalize(applicationId, "applicationId"); + groupId = normalize(groupId, "groupId"); + configuredLeader = normalize(configuredLeader, "configuredLeader"); + } + + public String authorityResource() { + return "ff:" + namespace + ":" + applicationId + ":" + groupId; + } + + public boolean isConfiguredLeader(ReplicaNodeIdentity node) { + return configuredLeader.equals(Objects.requireNonNull(node, "node").nodeId()); + } + + private static String normalize(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + if (normalized.indexOf(':') >= 0) throw new IllegalArgumentException(field + " must not contain ':'"); + return normalized; + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaHostControl.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaHostControl.java new file mode 100644 index 0000000..f3ee482 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaHostControl.java @@ -0,0 +1,10 @@ +package nl.hauntedmc.featureframework.cluster; + +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationPolicy; + +/** Minimal host hook used by replica orchestration without creating another bootstrap abstraction. */ +public interface ReplicaHostControl { + void installReplicaPolicies(FeatureActivationPolicy activationPolicy, ConfigMutationPolicy mutationPolicy); + boolean reconcileReplicaGraph(); +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaLeaseCoordinator.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaLeaseCoordinator.java new file mode 100644 index 0000000..c3227a5 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaLeaseCoordinator.java @@ -0,0 +1,18 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +/** Backend-neutral fenced authority operations for one configured replica group. */ +public interface ReplicaLeaseCoordinator { + CompletionStage> acquire( + ReplicaGroupIdentity group, + String owner, + Duration ttl + ); + + CompletionStage> renew(ReplicaAuthority authority, Duration ttl); + + CompletionStage release(ReplicaAuthority authority); +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaManagedConfigurationException.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaManagedConfigurationException.java new file mode 100644 index 0000000..a203a96 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaManagedConfigurationException.java @@ -0,0 +1,35 @@ +package nl.hauntedmc.featureframework.cluster; + +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationDeniedException; + +import java.nio.file.Path; + +/** Denial for a managed replicated path on a follower. */ +public final class ReplicaManagedConfigurationException extends ConfigMutationDeniedException { + private static final long serialVersionUID = 1L; + + private final String namespace; + private final String applicationId; + private final String groupId; + private final String configuredLeader; + + public ReplicaManagedConfigurationException( + Path relativePath, + String operation, + ReplicaGroupIdentity group + ) { + super(relativePath, operation, + "Configuration '" + relativePath + "' is managed by replica group " + group.groupId() + + "; modify configured leader " + group.configuredLeader() + " instead."); + namespace = group.namespace(); + applicationId = group.applicationId(); + groupId = group.groupId(); + configuredLeader = group.configuredLeader(); + } + + public ReplicaGroupIdentity group() { + return new ReplicaGroupIdentity(namespace, applicationId, groupId, configuredLeader); + } + + public String configuredLeader() { return configuredLeader; } +} \ No newline at end of file diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaMode.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaMode.java new file mode 100644 index 0000000..1bd1bae --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaMode.java @@ -0,0 +1,7 @@ +package nl.hauntedmc.featureframework.cluster; + +/** Runtime topology mode for a FeatureFramework host. */ +public enum ReplicaMode { + STANDALONE, + REPLICATED +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaNodeIdentity.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaNodeIdentity.java new file mode 100644 index 0000000..593b912 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaNodeIdentity.java @@ -0,0 +1,16 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.util.Objects; + +/** Stable physical node identity supplied by the hosting application. */ +public record ReplicaNodeIdentity(String nodeId) { + public ReplicaNodeIdentity { + nodeId = normalize(nodeId, "nodeId"); + } + + private static String normalize(String value, String field) { + String normalized = Objects.requireNonNull(value, field).trim(); + if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); + return normalized; + } +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaRole.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaRole.java new file mode 100644 index 0000000..a1fc09f --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaRole.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.featureframework.cluster; + +/** Runtime role of this process inside its configured topology. */ +public enum ReplicaRole { + STANDALONE, + LEADER, + FOLLOWER +} diff --git a/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaStatus.java b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaStatus.java new file mode 100644 index 0000000..7a4d360 --- /dev/null +++ b/featureframework-cluster/src/main/java/nl/hauntedmc/featureframework/cluster/ReplicaStatus.java @@ -0,0 +1,31 @@ +package nl.hauntedmc.featureframework.cluster; + +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** Point-in-time control-plane status for one replica process. */ +public record ReplicaStatus( + ReplicaRole role, + State state, + Optional authority, + OptionalLong appliedGeneration, + Optional detail +) { + public enum State { + STANDALONE, + BOOTSTRAPPING, + READY, + OUT_OF_SYNC, + DRIFTED, + UNAVAILABLE + } + + public ReplicaStatus { + role = Objects.requireNonNull(role, "role"); + state = Objects.requireNonNull(state, "state"); + authority = authority == null ? Optional.empty() : authority; + appliedGeneration = appliedGeneration == null ? OptionalLong.empty() : appliedGeneration; + detail = detail == null ? Optional.empty() : detail.filter(value -> !value.isBlank()); + } +} diff --git a/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ConfigManifestFileTest.java b/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ConfigManifestFileTest.java new file mode 100644 index 0000000..331406e --- /dev/null +++ b/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ConfigManifestFileTest.java @@ -0,0 +1,20 @@ +package nl.hauntedmc.featureframework.cluster; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ConfigManifestFileTest { + private static final String SHA256 = "0".repeat(64); + + @Test + void acceptsStorageMaximumAndRejectsLongerManagedPaths() { + String maximum = "a".repeat(ConfigManifestFile.MAX_PATH_LENGTH); + assertEquals(maximum, new ConfigManifestFile(maximum, "ROOT_CONFIG", SHA256, 0).path()); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> new ConfigManifestFile(maximum + "b", "ROOT_CONFIG", SHA256, 0)); + assertEquals("path must be at most 255 characters", failure.getMessage()); + } +} diff --git a/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ReplicaAuthorityWatchdogTest.java b/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ReplicaAuthorityWatchdogTest.java new file mode 100644 index 0000000..a50df29 --- /dev/null +++ b/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ReplicaAuthorityWatchdogTest.java @@ -0,0 +1,208 @@ +package nl.hauntedmc.featureframework.cluster; + +import nl.hauntedmc.featureframework.api.feature.ActivationDecision; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPhase; +import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.feature.FeatureMetadata; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; +import nl.hauntedmc.featureframework.api.feature.FeatureScope; +import nl.hauntedmc.featureframework.api.feature.FeatureSuppressionReason; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationPolicy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReplicaAuthorityWatchdogTest { + private static final ReplicaGroupIdentity GROUP = + new ReplicaGroupIdentity("hauntedmc", "proxyfeatures", "proxy", "proxy-01"); + private static final ConfigCompatibility COMPATIBILITY = new ConfigCompatibility("5.0.0", "1"); + + @TempDir + Path temp; + + @Test + void blockedRenewalAndLateSuccessCannotKeepLeaderOnlyPlacementAlivePastSafetyCutoff() throws Exception { + Files.writeString(temp.resolve("config.yml"), "authoritative"); + BlockingLeaseCoordinator leases = new BlockingLeaseCoordinator(); + StaticRepository repository = new StaticRepository(generation()); + FakeHost host = new FakeHost(); + + try (ReplicaController controller = ReplicaController.replicated( + temp, new ReplicaNodeIdentity("proxy-01"), GROUP, COMPATIBILITY, repository, leases) + .leaseTiming(Duration.ofMillis(250), Duration.ofMillis(20), Duration.ofMillis(80)) + .pollInterval(Duration.ofSeconds(5)) + .build()) { + controller.prepareBeforeHost(); + controller.attach(host); + controller.afterHostStarted(); + + await(() -> leases.renewCalls.get() > 0); + await(() -> controller.status().state() == ReplicaStatus.State.UNAVAILABLE); + + ActivationDecision decision = host.activationPolicy.evaluate( + leaderOnlyMetadata(), FeatureActivationPhase.ACTIVATION); + assertFalse(decision.allowed()); + assertEquals(FeatureSuppressionReason.AUTHORITY_UNAVAILABLE, + decision.suppression().orElseThrow().reason()); + assertTrue(host.reconcileCalls.get() >= 1, + "authority cutoff must reconcile the live graph even while Redis renewal is blocked"); + + int reconcilesAfterCutoff = host.reconcileCalls.get(); + leases.completeBlockedRenewalAsLateSuccess(Duration.ofSeconds(5)); + await(() -> leases.acquireCalls.get() > 1); + + ActivationDecision afterLateSuccess = host.activationPolicy.evaluate( + leaderOnlyMetadata(), FeatureActivationPhase.ACTIVATION); + assertFalse(afterLateSuccess.allowed(), + "a renewal result received after the safety cutoff must never resurrect authority"); + assertEquals(FeatureSuppressionReason.AUTHORITY_UNAVAILABLE, + afterLateSuccess.suppression().orElseThrow().reason()); + assertEquals(ReplicaStatus.State.UNAVAILABLE, controller.status().state()); + assertEquals(reconcilesAfterCutoff, host.reconcileCalls.get(), + "discarding the stale renewal response must not transiently re-enable the graph"); + } + } + + private static ConfigGeneration generation() { + byte[] contents = "authoritative".getBytes(StandardCharsets.UTF_8); + ConfigManifestFile file = new ConfigManifestFile( + "config.yml", "ROOT_CONFIG", ConfigHashes.sha256(contents), contents.length); + List files = List.of(file); + ConfigManifest manifest = new ConfigManifest( + ConfigManifest.CURRENT_PROTOCOL_VERSION, GROUP, 1, "proxy-01", "previous-boot", 1, + "5.0.0", "1", Instant.parse("2026-09-02T00:00:00Z"), OptionalLong.empty(), files, + ConfigHashes.manifestHash(files)); + return new ConfigGeneration(manifest, Map.of("config.yml", contents)); + } + + private static FeatureMetadata leaderOnlyMetadata() { + return new FeatureMetadata( + FeatureId.of("ingress"), "Ingress", "1", java.util.Set.of(), java.util.Set.of(), + java.util.Set.of(), java.util.Set.of(), java.util.Set.of(), FeatureScope.NODE, + FeaturePlacement.GROUP_LEADER_ONLY); + } + + private static void await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) return; + Thread.sleep(5L); + } + assertTrue(condition.getAsBoolean(), "condition was not reached before timeout"); + } + + private static final class FakeHost implements ReplicaHostControl { + private nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy activationPolicy; + private final AtomicInteger reconcileCalls = new AtomicInteger(); + + @Override + public void installReplicaPolicies( + nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy activationPolicy, + ConfigMutationPolicy mutationPolicy + ) { + this.activationPolicy = activationPolicy; + } + + @Override + public boolean reconcileReplicaGraph() { + reconcileCalls.incrementAndGet(); + return true; + } + } + + private static final class BlockingLeaseCoordinator implements ReplicaLeaseCoordinator { + private final AtomicInteger acquireCalls = new AtomicInteger(); + private final AtomicInteger renewCalls = new AtomicInteger(); + private final CompletableFuture> blockedRenewal = new CompletableFuture<>(); + private ReplicaAuthority current; + + @Override + public synchronized CompletionStage> acquire( + ReplicaGroupIdentity group, + String owner, + Duration ttl + ) { + acquireCalls.incrementAndGet(); + if (current != null) return CompletableFuture.completedFuture(Optional.empty()); + current = new ReplicaAuthority(group.authorityResource(), owner, 1, Instant.now().plus(ttl)); + return CompletableFuture.completedFuture(Optional.of(current)); + } + + @Override + public CompletionStage> renew(ReplicaAuthority authority, Duration ttl) { + renewCalls.incrementAndGet(); + return blockedRenewal; + } + + @Override + public synchronized CompletionStage release(ReplicaAuthority authority) { + current = null; + return CompletableFuture.completedFuture(true); + } + + synchronized void completeBlockedRenewalAsLateSuccess(Duration ttl) { + current = new ReplicaAuthority( + current.resource(), current.owner(), current.fencingToken(), Instant.now().plus(ttl)); + blockedRenewal.complete(Optional.of(current)); + } + } + + private static final class StaticRepository implements ReplicaGenerationRepository { + private final ConfigGeneration active; + + private StaticRepository(ConfigGeneration active) { + this.active = active; + } + + @Override + public CompletionStage> loadActive(ReplicaGroupIdentity group) { + return CompletableFuture.completedFuture(Optional.of(active)); + } + + @Override + public CompletionStage> loadGeneration( + ReplicaGroupIdentity group, + long generation + ) { + return CompletableFuture.completedFuture(generation == active.manifest().generation() + ? Optional.of(active) : Optional.empty()); + } + + @Override + public CompletionStage publish( + ReplicaGroupIdentity group, + ConfigGeneration candidate, + long fencingToken + ) { + return CompletableFuture.failedFuture(new UnsupportedOperationException("not used")); + } + + @Override + public CompletionStage recordNodeState( + ReplicaGroupIdentity group, + ReplicaNodeIdentity node, + long appliedGeneration, + ReplicaStatus.State state, + String detail + ) { + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ReplicaControllerTest.java b/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ReplicaControllerTest.java new file mode 100644 index 0000000..6e19c89 --- /dev/null +++ b/featureframework-cluster/src/test/java/nl/hauntedmc/featureframework/cluster/ReplicaControllerTest.java @@ -0,0 +1,443 @@ +package nl.hauntedmc.featureframework.cluster; + +import nl.hauntedmc.featureframework.api.feature.ActivationDecision; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPhase; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; +import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.feature.FeatureMetadata; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; +import nl.hauntedmc.featureframework.api.feature.FeatureScope; +import nl.hauntedmc.featureframework.api.feature.FeatureSuppressionReason; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationPolicy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReplicaControllerTest { + private static final ReplicaGroupIdentity GROUP = + new ReplicaGroupIdentity("hauntedmc", "proxyfeatures", "proxy", "proxy-01"); + private static final ConfigCompatibility COMPATIBILITY = new ConfigCompatibility("5.0.0", "1"); + + @TempDir + Path temp; + + @Test + void reachableEmptyDatabaseRejectsFollowerEvenWhenStaleLkgExists() { + ConfigGeneration stale = generation(7, "stale", "1", OptionalLong.empty()); + new LastKnownGoodStore(temp).save(stale); + InMemoryRepository repository = new InMemoryRepository(); + InMemoryLeases leases = new InMemoryLeases(); + + try (ReplicaController controller = follower(temp, repository, leases)) { + IllegalStateException failure = assertThrows(IllegalStateException.class, controller::prepareBeforeHost); + assertTrue(failure.getMessage().contains("Replica group not initialized")); + assertEquals(0, leases.acquireCalls.get(), "followers must never acquire authority"); + } + } + + @Test + void databaseOutageUsesVerifiedCompatibleLkg() throws Exception { + ConfigGeneration lkg = generation(4, "from-lkg", "1", OptionalLong.empty()); + new LastKnownGoodStore(temp).save(lkg); + InMemoryRepository repository = new InMemoryRepository(); + repository.failLoads = true; + + try (ReplicaController controller = follower(temp, repository, new InMemoryLeases())) { + controller.prepareBeforeHost(); + assertEquals("from-lkg", Files.readString(temp.resolve("config.yml"))); + assertEquals(ReplicaStatus.State.READY, controller.status().state()); + assertTrue(controller.status().detail().orElseThrow().contains("LKG")); + } + } + + @Test + void firstLeaderPublishesGenerationOneOnlyAfterSuccessfulHostStart() throws Exception { + Files.writeString(temp.resolve("config.yml"), "leader-defaults"); + InMemoryRepository repository = new InMemoryRepository(); + InMemoryLeases leases = new InMemoryLeases(); + FakeHost host = new FakeHost(); + + try (ReplicaController controller = leader(temp, repository, leases)) { + controller.prepareBeforeHost(); + controller.attach(host); + + ActivationDecision beforePublish = host.activationPolicy.evaluate( + leaderOnlyMetadata(), FeatureActivationPhase.ACTIVATION); + assertFalse(beforePublish.allowed()); + assertEquals(FeatureSuppressionReason.CONFIGURATION_UNAVAILABLE, + beforePublish.suppression().orElseThrow().reason()); + assertTrue(repository.active().isEmpty()); + + controller.afterHostStarted(); + + ConfigGeneration published = repository.active().orElseThrow(); + assertEquals(1L, published.manifest().generation()); + assertEquals("leader-defaults", text(published.file("config.yml"))); + assertTrue(host.activationPolicy.evaluate( + leaderOnlyMetadata(), FeatureActivationPhase.ACTIVATION).allowed()); + assertEquals(ReplicaStatus.State.READY, controller.status().state()); + } + } + + @Test + void rollbackPublishesNewMonotonicGeneration() throws Exception { + InMemoryRepository repository = new InMemoryRepository(); + ConfigGeneration generation25 = generation(25, "old", "1", OptionalLong.empty()); + ConfigGeneration generation40 = generation(40, "current", "1", OptionalLong.empty()); + repository.seed(generation25, false); + repository.seed(generation40, true); + Files.writeString(temp.resolve("config.yml"), "current"); + FakeHost host = new FakeHost(); + + try (ReplicaController controller = leader(temp, repository, new InMemoryLeases())) { + controller.prepareBeforeHost(); + controller.attach(host); + controller.afterHostStarted(); + + ConfigGeneration rollback = controller.rollback(25); + + assertEquals(41L, rollback.manifest().generation()); + assertEquals(25L, rollback.manifest().sourceGeneration().orElseThrow()); + assertEquals("old", Files.readString(temp.resolve("config.yml"))); + assertEquals(41L, repository.active().orElseThrow().manifest().generation()); + } + } + + @Test + void followerConvergesThenBacksUpAndRepairsFilesystemDrift() throws Exception { + InMemoryRepository repository = new InMemoryRepository(); + repository.seed(generation(1, "one", "1", OptionalLong.empty()), true); + FakeHost host = new FakeHost(); + + try (ReplicaController controller = follower(temp, repository, new InMemoryLeases(), Duration.ofMillis(10))) { + controller.prepareBeforeHost(); + controller.attach(host); + controller.afterHostStarted(); + + repository.seed(generation(2, "two", "1", OptionalLong.empty()), true); + await(() -> fileEquals(temp.resolve("config.yml"), "two")); + assertTrue(host.reconcileCalls.get() >= 1); + + Files.writeString(temp.resolve("config.yml"), "manual-drift"); + await(() -> fileEquals(temp.resolve("config.yml"), "two") + && controller.status().state() == ReplicaStatus.State.DRIFTED); + + Path drift = temp.resolve(".replica/drift"); + assertTrue(Files.isDirectory(drift)); + try (var entries = Files.list(drift)) { + assertTrue(entries.findAny().isPresent(), "drift must be backed up before repair"); + } + } + } + + @Test + void incompatibleRemoteGenerationIsNotApplied() throws Exception { + InMemoryRepository repository = new InMemoryRepository(); + repository.seed(generation(1, "compatible", "1", OptionalLong.empty()), true); + FakeHost host = new FakeHost(); + + try (ReplicaController controller = follower(temp, repository, new InMemoryLeases(), Duration.ofMillis(10))) { + controller.prepareBeforeHost(); + controller.attach(host); + controller.afterHostStarted(); + + repository.seed(generation(2, "incompatible", "2", OptionalLong.empty()), true); + await(() -> controller.status().state() == ReplicaStatus.State.OUT_OF_SYNC); + + assertEquals("compatible", Files.readString(temp.resolve("config.yml"))); + assertTrue(controller.status().detail().orElseThrow().contains("compatibility")); + } + } + + @Test + void configuredLeaderSuppressesLeaderOnlyFeaturesAfterAuthorityLoss() throws Exception { + InMemoryRepository repository = new InMemoryRepository(); + repository.seed(generation(1, "authoritative", "1", OptionalLong.empty()), true); + Files.writeString(temp.resolve("config.yml"), "authoritative"); + InMemoryLeases leases = new InMemoryLeases(); + FakeHost host = new FakeHost(); + + try (ReplicaController controller = ReplicaController.replicated( + temp, new ReplicaNodeIdentity("proxy-01"), GROUP, COMPATIBILITY, repository, leases) + .leaseTiming(Duration.ofMillis(100), Duration.ofMillis(10), Duration.ofMillis(20)) + .pollInterval(Duration.ofSeconds(5)) + .build()) { + controller.prepareBeforeHost(); + controller.attach(host); + leases.loseOnRenew = true; + controller.afterHostStarted(); + + await(() -> controller.status().state() == ReplicaStatus.State.UNAVAILABLE); + ActivationDecision decision = host.activationPolicy.evaluate( + leaderOnlyMetadata(), FeatureActivationPhase.ACTIVATION); + assertFalse(decision.allowed()); + assertEquals(FeatureSuppressionReason.AUTHORITY_UNAVAILABLE, + decision.suppression().orElseThrow().reason()); + assertTrue(host.reconcileCalls.get() >= 1); + } + } + + @Test + void followerWritePolicyProtectsOnlyManagedPaths() { + InMemoryRepository repository = new InMemoryRepository(); + repository.seed(generation(1, "authoritative", "1", OptionalLong.empty()), true); + FakeHost host = new FakeHost(); + + try (ReplicaController controller = follower(temp, repository, new InMemoryLeases())) { + controller.prepareBeforeHost(); + controller.attach(host); + + ReplicaManagedConfigurationException failure = assertThrows( + ReplicaManagedConfigurationException.class, + () -> host.mutationPolicy.checkMutation(Path.of("config.yml"), "save")); + assertEquals("proxy-01", failure.configuredLeader()); + host.mutationPolicy.checkMutation(Path.of("local/private-state.yml"), "save"); + } + } + + @Test + void managedFileDefaultsNeverRecursivelyIncludeLocalDirectory() { + ManagedFileSet files = ManagedFileSet.builder().languageFile("nl.yml").build(); + assertTrue(files.isManaged(Path.of("config.yml"))); + assertTrue(files.isManaged(Path.of("features/Vote/config.yml"))); + assertTrue(files.isManaged(Path.of("features/Vote/messages.yml"))); + assertTrue(files.isManaged(Path.of("features/Vote/nl.yml"))); + assertFalse(files.isManaged(Path.of("local/keys.yml"))); + assertFalse(files.isManaged(Path.of("features/Vote/random.yml"))); + } + + @Test + void corruptedLkgIsRejected() throws Exception { + ConfigGeneration generation = generation(3, "verified", "1", OptionalLong.empty()); + LastKnownGoodStore store = new LastKnownGoodStore(temp); + store.save(generation); + Files.writeString(temp.resolve(".replica/generations/3/files/config.yml"), "corrupt"); + assertThrows(IllegalStateException.class, store::load); + } + + @Test + void corruptGenerationHashIsRejectedAtConstruction() { + byte[] content = bytes("value"); + ConfigManifestFile file = new ConfigManifestFile( + "config.yml", "ROOT_CONFIG", ConfigHashes.sha256(bytes("other")), content.length); + ConfigManifest manifest = new ConfigManifest( + 1, GROUP, 1, "proxy-01", "boot", 1, "5.0.0", "1", Instant.now(), + OptionalLong.empty(), List.of(file), ConfigHashes.manifestHash(List.of(file))); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> new ConfigGeneration(manifest, Map.of("config.yml", content))); + assertTrue(failure.getMessage().contains("Hash mismatch")); + } + + private static ReplicaController leader( + Path directory, + InMemoryRepository repository, + InMemoryLeases leases + ) { + return ReplicaController.replicated( + directory, new ReplicaNodeIdentity("proxy-01"), GROUP, COMPATIBILITY, repository, leases) + .leaseTiming(Duration.ofSeconds(5), Duration.ofSeconds(1), Duration.ofSeconds(1)) + .pollInterval(Duration.ofSeconds(5)) + .build(); + } + + private static ReplicaController follower( + Path directory, + InMemoryRepository repository, + InMemoryLeases leases + ) { + return follower(directory, repository, leases, Duration.ofSeconds(5)); + } + + private static ReplicaController follower( + Path directory, + InMemoryRepository repository, + InMemoryLeases leases, + Duration pollInterval + ) { + return ReplicaController.replicated( + directory, new ReplicaNodeIdentity("proxy-02"), GROUP, COMPATIBILITY, repository, leases) + .pollInterval(pollInterval) + .build(); + } + + private static ConfigGeneration generation( + long generation, + String config, + String compatibility, + OptionalLong source + ) { + Map contents = Map.of("config.yml", bytes(config)); + ConfigManifestFile file = new ConfigManifestFile( + "config.yml", "ROOT_CONFIG", ConfigHashes.sha256(contents.get("config.yml")), + contents.get("config.yml").length); + List files = List.of(file); + ConfigManifest manifest = new ConfigManifest( + ConfigManifest.CURRENT_PROTOCOL_VERSION, GROUP, generation, "proxy-01", "boot-1", 1, + "5.0.0", compatibility, Instant.parse("2026-09-02T00:00:00Z"), source, + files, ConfigHashes.manifestHash(files)); + return new ConfigGeneration(manifest, contents); + } + + private static FeatureMetadata leaderOnlyMetadata() { + return new FeatureMetadata( + FeatureId.of("leader-only"), "Leader Only", "1", java.util.Set.of(), java.util.Set.of(), + java.util.Set.of(), java.util.Set.of(), java.util.Set.of(), FeatureScope.NODE, + FeaturePlacement.GROUP_LEADER_ONLY); + } + + private static byte[] bytes(String value) { return value.getBytes(StandardCharsets.UTF_8); } + private static String text(byte[] value) { return new String(value, StandardCharsets.UTF_8); } + + private static boolean fileEquals(Path file, String expected) { + try { return Files.exists(file) && expected.equals(Files.readString(file)); } + catch (Exception ignored) { return false; } + } + + private static void await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) return; + Thread.sleep(10L); + } + assertTrue(condition.getAsBoolean(), "condition was not reached before timeout"); + } + + private static final class FakeHost implements ReplicaHostControl { + private FeatureActivationPolicy activationPolicy; + private ConfigMutationPolicy mutationPolicy; + private final AtomicInteger reconcileCalls = new AtomicInteger(); + + @Override + public void installReplicaPolicies( + FeatureActivationPolicy activationPolicy, + ConfigMutationPolicy mutationPolicy + ) { + this.activationPolicy = activationPolicy; + this.mutationPolicy = mutationPolicy; + } + + @Override + public boolean reconcileReplicaGraph() { + reconcileCalls.incrementAndGet(); + return true; + } + } + + private static final class InMemoryLeases implements ReplicaLeaseCoordinator { + private final AtomicInteger acquireCalls = new AtomicInteger(); + private long nextToken = 1; + private ReplicaAuthority current; + private volatile boolean loseOnRenew; + + @Override + public synchronized CompletionStage> acquire( + ReplicaGroupIdentity group, + String owner, + Duration ttl + ) { + acquireCalls.incrementAndGet(); + if (current != null) return CompletableFuture.completedFuture(Optional.empty()); + current = new ReplicaAuthority(group.authorityResource(), owner, nextToken++, Instant.now().plus(ttl)); + return CompletableFuture.completedFuture(Optional.of(current)); + } + + @Override + public synchronized CompletionStage> renew( + ReplicaAuthority authority, + Duration ttl + ) { + if (loseOnRenew || current == null || current.fencingToken() != authority.fencingToken() + || !current.owner().equals(authority.owner())) { + current = null; + return CompletableFuture.completedFuture(Optional.empty()); + } + current = new ReplicaAuthority( + authority.resource(), authority.owner(), authority.fencingToken(), Instant.now().plus(ttl)); + return CompletableFuture.completedFuture(Optional.of(current)); + } + + @Override + public synchronized CompletionStage release(ReplicaAuthority authority) { + boolean owned = current != null && current.fencingToken() == authority.fencingToken() + && current.owner().equals(authority.owner()); + if (owned) current = null; + return CompletableFuture.completedFuture(owned); + } + } + + private static final class InMemoryRepository implements ReplicaGenerationRepository { + private final Map values = new LinkedHashMap<>(); + private ConfigGeneration active; + private volatile boolean failLoads; + + synchronized void seed(ConfigGeneration generation, boolean activate) { + values.put(generation.manifest().generation(), generation); + if (activate) active = generation; + } + + synchronized Optional active() { return Optional.ofNullable(active); } + + @Override + public synchronized CompletionStage> loadActive(ReplicaGroupIdentity group) { + if (failLoads) return CompletableFuture.failedFuture(new IllegalStateException("database unavailable")); + return CompletableFuture.completedFuture(Optional.ofNullable(active)); + } + + @Override + public synchronized CompletionStage> loadGeneration( + ReplicaGroupIdentity group, + long generation + ) { + if (failLoads) return CompletableFuture.failedFuture(new IllegalStateException("database unavailable")); + return CompletableFuture.completedFuture(Optional.ofNullable(values.get(generation))); + } + + @Override + public synchronized CompletionStage publish( + ReplicaGroupIdentity group, + ConfigGeneration candidate, + long fencingToken + ) { + long next = active == null ? 1L : active.manifest().generation() + 1L; + ConfigManifest source = candidate.manifest(); + ConfigManifest manifest = new ConfigManifest( + source.protocolVersion(), group, next, source.publisherNode(), source.publisherBootId(), + fencingToken, source.applicationVersion(), source.configCompatibilityVersion(), source.createdAt(), + source.sourceGeneration(), source.files(), source.manifestHash()); + ConfigGeneration published = new ConfigGeneration(manifest, candidate.files()); + values.put(next, published); + active = published; + return CompletableFuture.completedFuture(published); + } + + @Override + public CompletionStage recordNodeState( + ReplicaGroupIdentity group, + ReplicaNodeIdentity node, + long appliedGeneration, + ReplicaStatus.State state, + String detail + ) { + return CompletableFuture.completedFuture(null); + } + } +} \ No newline at end of file diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java index 3143d9e..38044f4 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureDefinition.java @@ -1,5 +1,6 @@ package nl.hauntedmc.featureframework.host; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; import nl.hauntedmc.featureframework.api.feature.FeatureRole; import nl.hauntedmc.featureframework.api.feature.FeatureStartupPhase; import nl.hauntedmc.featureframework.api.feature.FeatureScope; @@ -30,6 +31,7 @@ public final class FeatureDefinition private final Function constructor; private final FeatureStartupPhase startupPhase; private final FeatureScope scope; + private final FeaturePlacement placement; private final boolean enabledByDefault; private final Set roles; private final Set requiredFeatures; @@ -51,6 +53,7 @@ private FeatureDefinition(Builder builder) { constructor = Objects.requireNonNull(builder.constructor, "constructor"); startupPhase = builder.startupPhase; scope = Objects.requireNonNull(builder.scope, "scope"); + placement = Objects.requireNonNull(builder.placement, "placement"); enabledByDefault = builder.enabledByDefault; requiredFeatures = immutableText(builder.requiredFeatures, "requiredFeatures"); optionalFeatures = withoutRequired( @@ -98,16 +101,13 @@ public static Builder builder( public Function constructor() { return constructor; } @Override public FeatureStartupPhase startupPhase() { return startupPhase; } @Override public FeatureScope scope() { return scope; } + @Override public FeaturePlacement placement() { return placement; } public boolean enabledByDefault() { return enabledByDefault; } public Set requiredFeatures() { return requiredFeatures; } public Set optionalFeatureDependencies() { return optionalFeatures; } public Set pluginDependencies() { return pluginDependencies; } - @Override - public Set roles() { - return roles; - } - + @Override public Set roles() { return roles; } @Override public Set> requiredCapabilities() { return requiredCapabilities; } @Override public Set> optionalCapabilities() { return optionalCapabilities; } @Override public Set> providedCapabilities() { return providedCapabilities; } @@ -131,7 +131,8 @@ public ResolvedFeatureDefinition descriptor(Set discoveredDependen optionalFeatures, pluginDependencies, requiredResourceExtensions, - optionalResourceExtensions + optionalResourceExtensions, + placement ); } @@ -143,6 +144,7 @@ public static final class Builder { private final Function constructor; private FeatureStartupPhase startupPhase = FeatureStartupPhase.CORE; private FeatureScope scope = FeatureScope.NODE; + private FeaturePlacement placement = FeaturePlacement.ALL_NODES; private boolean enabledByDefault; private final Set roles = new LinkedHashSet<>(); private final Set requiredFeatures = new LinkedHashSet<>(); @@ -169,9 +171,6 @@ private Builder( this.constructor = constructor; } - /** - * Sets a readable startup phase for an otherwise independent feature. - */ public Builder startupPhase(FeatureStartupPhase value) { startupPhase = Objects.requireNonNull(value, "value"); return this; @@ -182,74 +181,25 @@ public Builder scope(FeatureScope value) { return this; } - public Builder enabledByDefault() { - enabledByDefault = true; - return this; - } - - public Builder roles(FeatureRole... values) { - addAll(roles, values, "roles"); - return this; - } - - public Builder requiresFeatures(String... values) { - addAll(requiredFeatures, values, "requiredFeatures"); - return this; - } - - public Builder optionallyUsesFeatures(String... values) { - addAll(optionalFeatures, values, "optionalFeatures"); - return this; - } - - public Builder requiresPlugins(String... values) { - addAll(pluginDependencies, values, "pluginDependencies"); - return this; - } - - public Builder requiresCapabilities(Class... values) { - addAll(requiredCapabilities, values, "requiredCapabilities"); - return this; - } - - public Builder optionallyUsesCapabilities(Class... values) { - addAll(optionalCapabilities, values, "optionalCapabilities"); + public Builder placement(FeaturePlacement value) { + placement = Objects.requireNonNull(value, "value"); return this; } - public Builder providesCapabilities(Class... values) { - addAll(providedCapabilities, values, "providedCapabilities"); - return this; - } - - public Builder requiresInternalServices(Class... values) { - addAll(requiredInternalServices, values, "requiredInternalServices"); - return this; - } - - public Builder optionallyUsesInternalServices(Class... values) { - addAll(optionalInternalServices, values, "optionalInternalServices"); - return this; - } - - public Builder providesInternalServices(Class... values) { - addAll(providedInternalServices, values, "providedInternalServices"); - return this; - } - - public Builder requiresResourceExtensions(Class... values) { - addAll(requiredResourceExtensions, values, "requiredResourceExtensions"); - return this; - } - - public Builder optionallyUsesResourceExtensions(Class... values) { - addAll(optionalResourceExtensions, values, "optionalResourceExtensions"); - return this; - } - - public FeatureDefinition build() { - return new FeatureDefinition<>(this); - } + public Builder enabledByDefault() { enabledByDefault = true; return this; } + public Builder roles(FeatureRole... values) { addAll(roles, values, "roles"); return this; } + public Builder requiresFeatures(String... values) { addAll(requiredFeatures, values, "requiredFeatures"); return this; } + public Builder optionallyUsesFeatures(String... values) { addAll(optionalFeatures, values, "optionalFeatures"); return this; } + public Builder requiresPlugins(String... values) { addAll(pluginDependencies, values, "pluginDependencies"); return this; } + public Builder requiresCapabilities(Class... values) { addAll(requiredCapabilities, values, "requiredCapabilities"); return this; } + public Builder optionallyUsesCapabilities(Class... values) { addAll(optionalCapabilities, values, "optionalCapabilities"); return this; } + public Builder providesCapabilities(Class... values) { addAll(providedCapabilities, values, "providedCapabilities"); return this; } + public Builder requiresInternalServices(Class... values) { addAll(requiredInternalServices, values, "requiredInternalServices"); return this; } + public Builder optionallyUsesInternalServices(Class... values) { addAll(optionalInternalServices, values, "optionalInternalServices"); return this; } + public Builder providesInternalServices(Class... values) { addAll(providedInternalServices, values, "providedInternalServices"); return this; } + public Builder requiresResourceExtensions(Class... values) { addAll(requiredResourceExtensions, values, "requiredResourceExtensions"); return this; } + public Builder optionallyUsesResourceExtensions(Class... values) { addAll(optionalResourceExtensions, values, "optionalResourceExtensions"); return this; } + public FeatureDefinition build() { return new FeatureDefinition<>(this); } } private static Set effectiveRoles( @@ -261,9 +211,7 @@ private static Set effectiveRoles( EnumSet result = declared.isEmpty() ? EnumSet.noneOf(FeatureRole.class) : EnumSet.copyOf(declared); if (!providedCapabilities.isEmpty()) result.add(FeatureRole.CAPABILITY_PROVIDER); - if (!requiredCapabilities.isEmpty() || !optionalCapabilities.isEmpty()) { - result.add(FeatureRole.CAPABILITY_CONSUMER); - } + if (!requiredCapabilities.isEmpty() || !optionalCapabilities.isEmpty()) result.add(FeatureRole.CAPABILITY_CONSUMER); return result.isEmpty() ? Set.of() : Collections.unmodifiableSet(result); } @@ -278,9 +226,7 @@ private static Set immutableText(Set values, String field) { private static Set> immutableTypes(Set> values, String field) { LinkedHashSet> normalized = new LinkedHashSet<>(); - for (Class value : Objects.requireNonNull(values, field)) { - normalized.add(Objects.requireNonNull(value, field + " entry")); - } + for (Class value : Objects.requireNonNull(values, field)) normalized.add(Objects.requireNonNull(value, field + " entry")); return Collections.unmodifiableSet(normalized); } @@ -298,10 +244,7 @@ private static Set> withoutRequiredTypes(Set> optional, Set> first, Set> second, String firstName, String secondName) { for (Class type : first) { - if (second.contains(type)) { - throw new IllegalArgumentException(type.getName() + " cannot be both " - + firstName + " and " + secondName); - } + if (second.contains(type)) throw new IllegalArgumentException(type.getName() + " cannot be both " + firstName + " and " + secondName); } } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java index df7102a..69c1166 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java @@ -2,6 +2,7 @@ import nl.hauntedmc.featureframework.api.FeatureFrameworkApi; import nl.hauntedmc.featureframework.api.RuntimeState; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope; @@ -43,15 +44,7 @@ import java.util.function.Function; import java.util.function.Predicate; -/** - * Complete platform-neutral host for a collection of managed features. - * - *

The host owns operation serialization and runtime state transitions. Discovery and metadata - * queries are delegated to {@link FeatureInventory}, while live feature instance mechanics are - * delegated to {@link FeatureInstanceController}. Paper and Velocity adapters only assemble their - * platform resource scopes and call {@link #start()} and {@link #stop()} from the corresponding - * plugin lifecycle.

- */ +/** Complete platform-neutral host for a collection of managed features. */ final class FeatureHost, C extends FeatureHostContext> implements FeatureFrameworkApi, AutoCloseable { @@ -81,16 +74,12 @@ private FeatureHost(Builder builder) { Predicate pluginAvailable = Objects.requireNonNull(builder.pluginAvailable, "pluginAvailable"); afterGraphMutation = Objects.requireNonNull(builder.afterGraphMutation, "afterGraphMutation"); clearScopes = Objects.requireNonNull(builder.clearScopes, "clearScopes"); - reloadHostResources = builder.reloadHostResources == null - ? configuration::reloadConfig - : builder.reloadHostResources; + reloadHostResources = builder.reloadHostResources == null ? configuration::reloadConfig : builder.reloadHostResources; logger = Objects.requireNonNull(builder.logger, "logger"); observations = new FeatureFrameworkObservations(builder.observer); resetStorage = new FeatureFileResetStorage(configuration.files(), logger); - inventory = new FeatureInventory<>( - capabilityNamespace, runtime, configuration, collection, pluginAvailable, logger); - controller = new FeatureInstanceController<>( - inventory, runtime, configuration, contextFactory, logger, observations); + inventory = new FeatureInventory<>(capabilityNamespace, runtime, configuration, collection, pluginAvailable, logger); + controller = new FeatureInstanceController<>(inventory, runtime, configuration, contextFactory, logger, observations); } public static , C extends FeatureHostContext> @@ -105,21 +94,15 @@ Builder builder( return new Builder<>(hostName, version, capabilityNamespace, runtime, configuration, collection); } - /** Discovers and starts the configured feature graph. */ - public void start() { - runtime.lifecycle().runExclusive(this::startLocked); - } + void activationPolicy(FeatureActivationPolicy policy) { controller.activationPolicy(policy); } + + public void start() { runtime.lifecycle().runExclusive(this::startLocked); } private synchronized void startLocked() { - observations.observe( - FeatureFrameworkOperationKind.HOST_START, - () -> { - startLockedUnobserved(); - return null; - }, - ignored -> FeatureFrameworkOperationOutcome.SUCCESS, - ignored -> null - ); + observations.observe(FeatureFrameworkOperationKind.HOST_START, () -> { + startLockedUnobserved(); + return null; + }, ignored -> FeatureFrameworkOperationOutcome.SUCCESS, ignored -> null); } private void startLockedUnobserved() { @@ -140,9 +123,7 @@ private void startLockedUnobserved() { private void initializeFeatures() { FeatureLoadOrderResolver.Result order = inventory.loadOrder(inventory.registry().getAvailableFeatures().keySet()); - if (!order.skippedFeatures().isEmpty()) { - logger.error("Skipping invalid feature graph entries: " + order.skippedFeatures()); - } + if (!order.skippedFeatures().isEmpty()) logger.error("Skipping invalid feature graph entries: " + order.skippedFeatures()); for (String featureName : order.loadOrder()) controller.loadFeature(featureName); afterGraphMutation.run(); } @@ -150,95 +131,59 @@ private void initializeFeatures() { public FeatureEnableResponse enable(FeatureId id) { FeatureId featureId = Objects.requireNonNull(id, "id"); return runtime.lifecycle().callExclusive(() -> observations.observe( - FeatureFrameworkOperationKind.FEATURE_ENABLE, - featureId, - () -> enableLockedId(featureId.value()), - FeatureHost::enableOutcome, - ignored -> null - )); + FeatureFrameworkOperationKind.FEATURE_ENABLE, featureId, + () -> enableLockedId(featureId.value()), FeatureHost::enableOutcome, ignored -> null)); } private FeatureEnableResponse enableLockedId(String featureName) { return FeatureOperationCoordinator.enable( - featureName, - inventory::resolveFeatureKey, + featureName, inventory::resolveFeatureKey, key -> inventory.registry().getAvailableFeature(key) != null, - inventory.registry()::isFeatureLoaded, - inventory::diagnoseDependencies, - configuration::isFeatureEnabled, - this::persistEnabled, - controller::loadFeature, - afterGraphMutation - ); + inventory.registry()::isFeatureLoaded, inventory::diagnoseDependencies, + configuration::isFeatureEnabled, this::persistEnabled, controller::loadFeature, afterGraphMutation); } public FeatureDisableResponse disable(FeatureId id) { FeatureId featureId = Objects.requireNonNull(id, "id"); return runtime.lifecycle().callExclusive(() -> observations.observe( - FeatureFrameworkOperationKind.FEATURE_DISABLE, - featureId, - () -> disableFeatureLocked(featureId.value()), - FeatureHost::disableOutcome, - ignored -> null - )); + FeatureFrameworkOperationKind.FEATURE_DISABLE, featureId, + () -> disableFeatureLocked(featureId.value()), FeatureHost::disableOutcome, ignored -> null)); } private FeatureDisableResponse disableFeatureLocked(String featureName) { return FeatureOperationCoordinator.disable( - featureName, - inventory::resolveFeatureKey, - inventory.registry()::isFeatureLoaded, - controller::dependentFeatures, - this::disableFeatureLocked, - controller::stopAndRemove, + featureName, inventory::resolveFeatureKey, inventory.registry()::isFeatureLoaded, + controller::dependentFeatures, this::disableFeatureLocked, controller::stopAndRemove, key -> persistEnabled(key, false), - (key, failure) -> controller.completeDisable(key, failure, "shutdown"), - afterGraphMutation - ); + (key, failure) -> controller.completeDisable(key, failure, "shutdown"), afterGraphMutation); } public FeatureSoftReloadResponse softReload(FeatureId id) { FeatureId featureId = Objects.requireNonNull(id, "id"); return runtime.lifecycle().callExclusive(() -> observations.observe( - FeatureFrameworkOperationKind.FEATURE_SOFT_RELOAD, - featureId, - () -> FeatureOperationCoordinator.softReload( - featureId.value(), - inventory::resolveFeatureKey, + FeatureFrameworkOperationKind.FEATURE_SOFT_RELOAD, featureId, + () -> FeatureOperationCoordinator.softReload(featureId.value(), inventory::resolveFeatureKey, inventory.registry()::isFeatureLoaded, key -> { F feature = controller.loadedFeature(key); feature.context().prepare(feature); return feature.applyConfiguration(); - }, - this::recreateLocked - ), - FeatureHost::softReloadOutcome, - ignored -> null - )); + }, this::recreateLocked), + FeatureHost::softReloadOutcome, ignored -> null)); } public FeatureReloadResponse recreate(FeatureId id) { FeatureId featureId = Objects.requireNonNull(id, "id"); return runtime.lifecycle().callExclusive(() -> observations.observe( - FeatureFrameworkOperationKind.FEATURE_RECREATE, - featureId, - () -> recreateLocked(featureId.value()), - FeatureHost::reloadOutcome, - ignored -> null - )); + FeatureFrameworkOperationKind.FEATURE_RECREATE, featureId, + () -> recreateLocked(featureId.value()), FeatureHost::reloadOutcome, ignored -> null)); } - /** Reloads host configuration and transactionally reconciles every configured feature. */ public FeatureGraphReloadResult reloadGraph() { return runtime.lifecycle().callExclusive(() -> observations.observe( - FeatureFrameworkOperationKind.GRAPH_RELOAD, - this::reloadLocked, - result -> result.success() - ? FeatureFrameworkOperationOutcome.SUCCESS - : FeatureFrameworkOperationOutcome.FAILURE, - result -> result.failure().orElse(null) - )); + FeatureFrameworkOperationKind.GRAPH_RELOAD, this::reloadLocked, + result -> result.success() ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE, + result -> result.failure().orElse(null))); } public FeatureFileResetPreview previewFileReset(FeatureId id, FeatureFileResetRequest request) { @@ -249,23 +194,17 @@ public FeatureFileResetPreview previewFileReset(FeatureId id, FeatureFileResetRe private FeatureFileResetPreview previewFileResetLocked(String requested, FeatureFileResetRequest request) { String key = inventory.resolveFeatureKey(requested); - if (key == null) { - return new FeatureFileResetPreview(false, "", request, List.of(), false, false, Set.of(), - "Feature not found"); - } + if (key == null) return new FeatureFileResetPreview(false, "", request, List.of(), false, false, Set.of(), "Feature not found"); try { boolean loaded = inventory.registry().isFeatureLoaded(key); - Set dependents = loaded - ? new java.util.LinkedHashSet<>(controller.buildReloadOrder(key)) - : Set.of(); + Set dependents = loaded ? new java.util.LinkedHashSet<>(controller.buildReloadOrder(key)) : Set.of(); if (loaded) dependents.remove(key); boolean enabled = runtime.mutableFeatureCatalog().find(FeatureId.of(key)) .map(snapshot -> snapshot.configuredEnabled()).orElse(inventory.enabledDefault(key)); return new FeatureFileResetPreview(true, key, request, resetStorage.targets(key, request), enabled, loaded, dependents, ""); } catch (Throwable failure) { - return new FeatureFileResetPreview(false, key, request, List.of(), false, false, Set.of(), - failure.getMessage()); + return new FeatureFileResetPreview(false, key, request, List.of(), false, false, Set.of(), failure.getMessage()); } } @@ -273,12 +212,9 @@ public FeatureFileResetResponse resetFiles(FeatureId id, FeatureFileResetRequest FeatureId featureId = Objects.requireNonNull(id, "id"); Objects.requireNonNull(request, "request"); return runtime.lifecycle().callExclusive(() -> observations.observe( - FeatureFrameworkOperationKind.FILE_RESET, - featureId, - () -> resetFilesLocked(featureId.value(), request), - FeatureHost::resetOutcome, - response -> response.failure().orElse(null) - )); + FeatureFrameworkOperationKind.FILE_RESET, featureId, + () -> resetFilesLocked(featureId.value(), request), FeatureHost::resetOutcome, + response -> response.failure().orElse(null))); } boolean reloadFeatureLocalization(FeatureId id, Consumer reload) { @@ -327,8 +263,7 @@ private FeatureFileResetResponse resetFilesLocked(String requested, FeatureFileR if (stopFailure != null) { boolean restored = controller.startReloadGraph(order, states); afterGraphMutation.run(); - return resetResponse(restored ? FeatureFileResetResult.QUIESCE_FAILED - : FeatureFileResetResult.ROLLBACK_FAILED, + return resetResponse(restored ? FeatureFileResetResult.QUIESCE_FAILED : FeatureFileResetResult.ROLLBACK_FAILED, key, request, false, restored ? FeatureResetRuntimeOutcome.RESTORED : FeatureResetRuntimeOutcome.DEGRADED, restored ? FeatureResetRollbackOutcome.SUCCEEDED : FeatureResetRollbackOutcome.FAILED, @@ -347,13 +282,12 @@ private FeatureFileResetResponse resetFilesLocked(String requested, FeatureFileR dependents, List.of(), null, unsafe); } catch (Throwable failure) { boolean restored = restoreGraphAfterPreMutationFailure(wasLoaded, order, states); - return resetResponse(restored ? FeatureFileResetResult.BACKUP_FAILED - : FeatureFileResetResult.ROLLBACK_FAILED, + return resetResponse(restored ? FeatureFileResetResult.BACKUP_FAILED : FeatureFileResetResult.ROLLBACK_FAILED, key, request, false, restored ? (wasLoaded ? FeatureResetRuntimeOutcome.RESTORED : FeatureResetRuntimeOutcome.UNCHANGED) : FeatureResetRuntimeOutcome.DEGRADED, - wasLoaded ? (restored ? FeatureResetRollbackOutcome.SUCCEEDED - : FeatureResetRollbackOutcome.FAILED) : FeatureResetRollbackOutcome.NOT_REQUIRED, + wasLoaded ? (restored ? FeatureResetRollbackOutcome.SUCCEEDED : FeatureResetRollbackOutcome.FAILED) + : FeatureResetRollbackOutcome.NOT_REQUIRED, dependents, List.of(), null, failure); } @@ -364,16 +298,12 @@ private FeatureFileResetResponse resetFilesLocked(String requested, FeatureFileR boolean stagedMalformedPrerequisites = resetStorage.stageMalformedPrerequisites(backup); boolean regeneratedFromSnapshot = controller.regenerateDefaults(key, request); fullyPrepared = controller.prepareFeatureStorage(key); - if (!regeneratedFromSnapshot && !fullyPrepared) { - throw new IllegalStateException("Feature defaults could not be regenerated"); - } + if (!regeneratedFromSnapshot && !fullyPrepared) throw new IllegalStateException("Feature defaults could not be regenerated"); if (stagedMalformedPrerequisites) { resetStorage.restorePrerequisites(backup); fullyPrepared = controller.prepareFeatureStorage(key); } - if (wasLoaded && !fullyPrepared) { - throw new IllegalStateException("Regenerated files could not prepare the active feature"); - } + if (wasLoaded && !fullyPrepared) throw new IllegalStateException("Regenerated files could not prepare the active feature"); if (request instanceof FeatureFileResetRequest.Config) persistEnabled(key, configuredEnabled); } catch (Throwable failure) { return rollbackReset(key, request, backup, wasLoaded, order, states, dependents, @@ -388,18 +318,12 @@ private FeatureFileResetResponse resetFilesLocked(String requested, FeatureFileR new IllegalStateException("Replacement feature graph did not start")); } runtimeOutcome = FeatureResetRuntimeOutcome.ACTIVE; - } else if (!fullyPrepared) { - runtimeOutcome = FeatureResetRuntimeOutcome.INACTIVE; - } else if (!configuredEnabled) { - runtimeOutcome = FeatureResetRuntimeOutcome.DISABLED; - } else { - runtimeOutcome = controller.loadFeature(key) - ? FeatureResetRuntimeOutcome.ACTIVE : FeatureResetRuntimeOutcome.INACTIVE; - } + } else if (!fullyPrepared) runtimeOutcome = FeatureResetRuntimeOutcome.INACTIVE; + else if (!configuredEnabled) runtimeOutcome = FeatureResetRuntimeOutcome.DISABLED; + else runtimeOutcome = controller.loadFeature(key) ? FeatureResetRuntimeOutcome.ACTIVE : FeatureResetRuntimeOutcome.INACTIVE; - try { - resetStorage.commit(backup); - } catch (Throwable failure) { + try { resetStorage.commit(backup); } + catch (Throwable failure) { return rollbackReset(key, request, backup, wasLoaded, order, states, dependents, deletedOverrides, FeatureFileResetResult.BACKUP_FAILED, failure); } @@ -410,20 +334,12 @@ private FeatureFileResetResponse resetFilesLocked(String requested, FeatureFileR } private FeatureFileResetResponse rollbackReset( - String key, - FeatureFileResetRequest request, - FeatureFileResetStorage.Backup backup, - boolean wasLoaded, - List order, - Map> states, - Set dependents, - List deletedOverrides, - FeatureFileResetResult originalResult, - Throwable failure + String key, FeatureFileResetRequest request, FeatureFileResetStorage.Backup backup, + boolean wasLoaded, List order, Map> states, + Set dependents, List deletedOverrides, FeatureFileResetResult originalResult, Throwable failure ) { - if (wasLoaded) { - controller.stopReloadGraph(order); - } else if (inventory.registry().isFeatureLoaded(key)) { + if (wasLoaded) controller.stopReloadGraph(order); + else if (inventory.registry().isFeatureLoaded(key)) { Throwable cleanupFailure = controller.stopAndRemove(key); controller.completeDisable(key, cleanupFailure, "reset-rollback-shutdown"); if (cleanupFailure != null) failure.addSuppressed(cleanupFailure); @@ -450,27 +366,16 @@ private FeatureFileResetResponse rollbackReset( } private boolean restoreGraphAfterPreMutationFailure( - boolean wasLoaded, - List order, - Map> states - ) { + boolean wasLoaded, List order, Map> states) { boolean restored = !wasLoaded || controller.startReloadGraph(order, states); if (wasLoaded) afterGraphMutation.run(); return restored; } private static FeatureFileResetResponse resetResponse( - FeatureFileResetResult result, - String feature, - FeatureFileResetRequest request, - boolean committed, - FeatureResetRuntimeOutcome runtimeOutcome, - FeatureResetRollbackOutcome rollbackOutcome, - Set dependents, - List deletedOverrides, - String backupId, - Throwable failure - ) { + FeatureFileResetResult result, String feature, FeatureFileResetRequest request, boolean committed, + FeatureResetRuntimeOutcome runtimeOutcome, FeatureResetRollbackOutcome rollbackOutcome, + Set dependents, List deletedOverrides, String backupId, Throwable failure) { return new FeatureFileResetResponse(result, feature, request, committed, runtimeOutcome, rollbackOutcome, dependents, deletedOverrides, Optional.ofNullable(backupId), Optional.ofNullable(failure)); } @@ -478,16 +383,10 @@ private static FeatureFileResetResponse resetResponse( private FeatureGraphReloadResult reloadLocked() { runtime.markReloading(); FeatureGraphReloadResult result = FeatureGraphReloader.reload( - reloadHostResources, - () -> { }, - inventory.registry()::getLoadedFeatureNames, - inventory.registry()::isFeatureLoaded, - configuration::isFeatureEnabled, - this::disableFeatureLocked, - this::recreateLocked, - () -> inventory.registry().getAvailableFeatures().keySet(), - this::enableLockedId - ); + reloadHostResources, () -> { }, inventory.registry()::getLoadedFeatureNames, + inventory.registry()::isFeatureLoaded, configuration::isFeatureEnabled, + this::disableFeatureLocked, this::recreateLocked, + () -> inventory.registry().getAvailableFeatures().keySet(), this::enableLockedId); if (result.success()) runtime.markReady(); else runtime.markDegraded(); return result; @@ -499,10 +398,7 @@ private FeatureReloadResponse recreateLocked(String featureName) { return response; } - /** Stops every feature in reverse lifecycle sequence and marks the runtime stopped. */ - public void stop() { - runtime.lifecycle().runExclusive(this::stopLocked); - } + public void stop() { runtime.lifecycle().runExclusive(this::stopLocked); } private synchronized void stopLocked() { FeatureFrameworkObservations.Operation observation = observations.start(FeatureFrameworkOperationKind.HOST_STOP); @@ -516,16 +412,12 @@ private synchronized void stopLocked() { Throwable failure = unloadAll(); runtime.markStopped(failure); if (failure != null) logger.error(hostName + " shutdown completed with failures.", failure); - observation.complete( - failure == null ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE, - failure - ); + observation.complete(failure == null ? FeatureFrameworkOperationOutcome.SUCCESS + : FeatureFrameworkOperationOutcome.FAILURE, failure); } catch (Throwable failure) { observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure); throwUnchecked(failure); - } finally { - scope.close(); - } + } finally { scope.close(); } } private Throwable unloadAll() { @@ -548,17 +440,9 @@ public Optional resolve(String inputName) { return key == null ? Optional.empty() : Optional.of(FeatureId.of(key)); } - public boolean isLoaded(FeatureId id) { - return inventory.registry().isFeatureLoaded(Objects.requireNonNull(id, "id").value()); - } - - public Optional findLoaded(FeatureId id) { - return Optional.ofNullable(inventory.registry().getLoadedFeature(Objects.requireNonNull(id, "id").value())); - } - - public List loadedFeatures() { - return List.copyOf(inventory.registry().getLoadedFeatures()); - } + public boolean isLoaded(FeatureId id) { return inventory.registry().isFeatureLoaded(Objects.requireNonNull(id, "id").value()); } + public Optional findLoaded(FeatureId id) { return Optional.ofNullable(inventory.registry().getLoadedFeature(Objects.requireNonNull(id, "id").value())); } + public List loadedFeatures() { return List.copyOf(inventory.registry().getLoadedFeatures()); } private void persistEnabled(String key, boolean enabled) { configuration.setFeatureEnabled(key, enabled); @@ -570,15 +454,9 @@ private void persistEnabled(String key, boolean enabled) { @Override public CompletionStage whenReady() { return runtime.whenReady(); } @Override public CapabilityRegistry capabilities() { return runtime.capabilities(); } @Override public FeatureCatalog features() { return runtime.featureCatalog(); } + @Override public void close() { stop(); } - @Override - public void close() { - stop(); - } - - /** Builder exposing the few host-specific callbacks that platform adapters must supply. */ - public static final class Builder< - V, F extends LifecycleFeature, C extends FeatureHostContext> { + public static final class Builder, C extends FeatureHostContext> { private final String hostName; private final V version; private final String capabilityNamespace; @@ -593,14 +471,9 @@ public static final class Builder< private FrameworkLogger logger = FrameworkLogger.noop(); private FeatureFrameworkObserver observer = FeatureFrameworkObserver.noop(); - private Builder( - String hostName, - V version, - String capabilityNamespace, - FeatureRuntime runtime, - FeatureConfigurationRoot configuration, - FeatureCollection collection - ) { + private Builder(String hostName, V version, String capabilityNamespace, + FeatureRuntime runtime, + FeatureConfigurationRoot configuration, FeatureCollection collection) { this.hostName = hostName; this.version = version; this.capabilityNamespace = capabilityNamespace; @@ -610,55 +483,25 @@ private Builder( } public Builder contextFactory(Function, C> value) { - contextFactory = Objects.requireNonNull(value, "contextFactory"); - return this; - } - - public Builder pluginAvailable(Predicate value) { - pluginAvailable = Objects.requireNonNull(value, "pluginAvailable"); - return this; - } - - public Builder afterGraphMutation(Runnable value) { - afterGraphMutation = Objects.requireNonNull(value, "afterGraphMutation"); - return this; - } - - public Builder clearScopes(Runnable value) { - clearScopes = Objects.requireNonNull(value, "clearScopes"); - return this; - } - - public Builder reloadHostResources(Runnable value) { - reloadHostResources = Objects.requireNonNull(value, "reloadHostResources"); - return this; - } - - public Builder logger(FrameworkLogger value) { - logger = Objects.requireNonNull(value, "logger"); - return this; - } - - public Builder observer(FeatureFrameworkObserver value) { - observer = Objects.requireNonNull(value, "observer"); - return this; - } - - public FeatureHost build() { - return new FeatureHost<>(this); + contextFactory = Objects.requireNonNull(value, "contextFactory"); return this; } + public Builder pluginAvailable(Predicate value) { pluginAvailable = Objects.requireNonNull(value, "pluginAvailable"); return this; } + public Builder afterGraphMutation(Runnable value) { afterGraphMutation = Objects.requireNonNull(value, "afterGraphMutation"); return this; } + public Builder clearScopes(Runnable value) { clearScopes = Objects.requireNonNull(value, "clearScopes"); return this; } + public Builder reloadHostResources(Runnable value) { reloadHostResources = Objects.requireNonNull(value, "reloadHostResources"); return this; } + public Builder logger(FrameworkLogger value) { logger = Objects.requireNonNull(value, "logger"); return this; } + public Builder observer(FeatureFrameworkObserver value) { observer = Objects.requireNonNull(value, "observer"); return this; } + public FeatureHost build() { return new FeatureHost<>(this); } } private static FeatureFrameworkOperationOutcome enableOutcome(FeatureEnableResponse response) { return switch (response.result()) { case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; case ALREADY_LOADED -> FeatureFrameworkOperationOutcome.NO_CHANGE; - case NOT_FOUND, MISSING_PLUGIN_DEPENDENCY, MISSING_FEATURE_DEPENDENCY -> - FeatureFrameworkOperationOutcome.SKIPPED; + case NOT_FOUND, MISSING_PLUGIN_DEPENDENCY, MISSING_FEATURE_DEPENDENCY -> FeatureFrameworkOperationOutcome.SKIPPED; case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; }; } - private static FeatureFrameworkOperationOutcome disableOutcome(FeatureDisableResponse response) { return switch (response.result()) { case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; @@ -666,7 +509,6 @@ private static FeatureFrameworkOperationOutcome disableOutcome(FeatureDisableRes case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; }; } - private static FeatureFrameworkOperationOutcome reloadOutcome(FeatureReloadResponse response) { return switch (response.result()) { case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; @@ -674,7 +516,6 @@ private static FeatureFrameworkOperationOutcome reloadOutcome(FeatureReloadRespo case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; }; } - private static FeatureFrameworkOperationOutcome softReloadOutcome(FeatureSoftReloadResponse response) { return switch (response.result()) { case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; @@ -682,13 +523,11 @@ private static FeatureFrameworkOperationOutcome softReloadOutcome(FeatureSoftRel case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; }; } - private static FeatureFrameworkOperationOutcome resetOutcome(FeatureFileResetResponse response) { return switch (response.result()) { case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; case NOT_FOUND, HOST_UNAVAILABLE, UNSAFE_TARGET -> FeatureFrameworkOperationOutcome.SKIPPED; - case QUIESCE_FAILED, BACKUP_FAILED, REGENERATION_FAILED, RESTART_FAILED, ROLLBACK_FAILED -> - FeatureFrameworkOperationOutcome.FAILURE; + case QUIESCE_FAILED, BACKUP_FAILED, REGENERATION_FAILED, RESTART_FAILED, ROLLBACK_FAILED -> FeatureFrameworkOperationOutcome.FAILURE; }; } @@ -697,16 +536,12 @@ private static String requireText(String value, String field) { if (clean.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); return clean; } - private static Throwable appendFailure(Throwable current, Throwable additional) { if (additional == null) return current; if (current == null) return additional; current.addSuppressed(additional); return current; } - @SuppressWarnings("unchecked") - private static void throwUnchecked(Throwable failure) throws E { - throw (E) failure; - } + private static void throwUnchecked(Throwable failure) throws E { throw (E) failure; } } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java index 326bd86..3977d2e 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java @@ -1,6 +1,7 @@ package nl.hauntedmc.featureframework.host; import nl.hauntedmc.featureframework.api.RuntimeState; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; @@ -20,6 +21,7 @@ import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse; import nl.hauntedmc.featureframework.runtime.FeatureRuntime; import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationPolicy; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; import java.util.List; @@ -29,12 +31,7 @@ import java.util.function.Function; import java.util.function.Predicate; -/** - * Shared composition engine for platform feature hosts. - * - *

Paper and Velocity supply only native factories and platform hooks. Scope caching, context - * creation, host construction, configuration reload wiring and graph ownership live here once.

- */ +/** Shared composition engine for platform feature hosts. */ public final class FeatureHostComposition< V, F extends LifecycleFeature, @@ -45,76 +42,48 @@ public final class FeatureHostComposition< R extends FeatureLifecycleResources> implements AutoCloseable { private final FeatureScopeFactory scopes; + private final FeatureConfigurationRoot configuration; private final FeatureHost host; public FeatureHostComposition( - String hostName, - V version, - String capabilityNamespace, + String hostName, V version, String capabilityNamespace, FeatureRuntime runtime, - FeatureConfigurationRoot configuration, - FeatureCollection features, + FeatureConfigurationRoot configuration, FeatureCollection features, Function configFactory, Function localizationFactory, Function loggerFactory, Function, ? extends R> resourcesFactory, FeatureScopeFactory.ContextAssembler contextAssembler, - Predicate pluginAvailable, - Runnable afterGraphMutation, - Runnable reloadLocalization, - Runnable afterHostResourcesReload, - FrameworkLogger logger + Predicate pluginAvailable, Runnable afterGraphMutation, Runnable reloadLocalization, + Runnable afterHostResourcesReload, FrameworkLogger logger ) { - this( - hostName, - version, - capabilityNamespace, - runtime, - configuration, - features, - configFactory, - localizationFactory, - loggerFactory, - resourcesFactory, - contextAssembler, - pluginAvailable, - afterGraphMutation, - reloadLocalization, - afterHostResourcesReload, - logger, - FeatureFrameworkObserver.noop() - ); + this(hostName, version, capabilityNamespace, runtime, configuration, features, configFactory, + localizationFactory, loggerFactory, resourcesFactory, contextAssembler, pluginAvailable, + afterGraphMutation, reloadLocalization, afterHostResourcesReload, logger, + FeatureFrameworkObserver.noop()); } public FeatureHostComposition( - String hostName, - V version, - String capabilityNamespace, + String hostName, V version, String capabilityNamespace, FeatureRuntime runtime, - FeatureConfigurationRoot configuration, - FeatureCollection features, + FeatureConfigurationRoot configuration, FeatureCollection features, Function configFactory, Function localizationFactory, Function loggerFactory, Function, ? extends R> resourcesFactory, FeatureScopeFactory.ContextAssembler contextAssembler, - Predicate pluginAvailable, - Runnable afterGraphMutation, - Runnable reloadLocalization, - Runnable afterHostResourcesReload, - FrameworkLogger logger, - FeatureFrameworkObserver observer + Predicate pluginAvailable, Runnable afterGraphMutation, Runnable reloadLocalization, + Runnable afterHostResourcesReload, FrameworkLogger logger, FeatureFrameworkObserver observer ) { Objects.requireNonNull(runtime, "runtime"); - Objects.requireNonNull(configuration, "configuration"); - scopes = new FeatureScopeFactory<>( - configFactory, localizationFactory, loggerFactory, resourcesFactory, contextAssembler); + this.configuration = Objects.requireNonNull(configuration, "configuration"); + scopes = new FeatureScopeFactory<>(configFactory, localizationFactory, loggerFactory, resourcesFactory, contextAssembler); Runnable reloadResources = () -> { - configuration.reloadConfig(); + this.configuration.reloadConfig(); Objects.requireNonNull(reloadLocalization, "reloadLocalization").run(); Objects.requireNonNull(afterHostResourcesReload, "afterHostResourcesReload").run(); }; - host = FeatureHost.builder(hostName, version, capabilityNamespace, runtime, configuration, features) + host = FeatureHost.builder(hostName, version, capabilityNamespace, runtime, this.configuration, features) .contextFactory(scopes::createContext) .pluginAvailable(Objects.requireNonNull(pluginAvailable, "pluginAvailable")) .afterGraphMutation(Objects.requireNonNull(afterGraphMutation, "afterGraphMutation")) @@ -125,6 +94,19 @@ public FeatureHostComposition( .build(); } + /** Installs replica lifecycle and write policies after host construction but before host start. */ + public void installReplicaPolicies( + FeatureActivationPolicy activationPolicy, + ConfigMutationPolicy mutationPolicy + ) { + host.activationPolicy(Objects.requireNonNull(activationPolicy, "activationPolicy")); + configuration.files().installMutationPolicy(Objects.requireNonNull(mutationPolicy, "mutationPolicy")); + } + + /** Reconciles the running graph after authority or authoritative configuration changes. */ + public boolean reconcileReplicaGraph() { return host.reloadGraph().success(); } + + public void activationPolicy(FeatureActivationPolicy policy) { host.activationPolicy(policy); } public LOC localization(String featureName) { return scopes.localization(featureName); } public CFG config(String featureName) { return scopes.config(featureName); } public LOG logger(String featureName) { return scopes.logger(featureName); } @@ -160,4 +142,4 @@ public boolean reloadFeatureLocalization(FeatureId id) { public CapabilityRegistry capabilities() { return host.capabilities(); } public FeatureCatalog features() { return host.features(); } @Override public void close() { stop(); } -} +} \ No newline at end of file diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java index c68c426..a1f3b6e 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java @@ -1,7 +1,12 @@ package nl.hauntedmc.featureframework.host; +import nl.hauntedmc.featureframework.api.feature.ActivationDecision; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPhase; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.feature.FeatureState; +import nl.hauntedmc.featureframework.api.feature.FeatureSuppression; +import nl.hauntedmc.featureframework.api.feature.FeatureSuppressionReason; import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope; import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind; import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome; @@ -30,6 +35,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -51,6 +57,7 @@ final class FeatureInstanceController, C extends F private final Map defaults = new LinkedHashMap<>(); private final FeatureDependencyManager dependencyManager; private final FeatureFrameworkObservations observations; + private volatile FeatureActivationPolicy activationPolicy = FeatureActivationPolicy.allowAll(); FeatureInstanceController( FeatureInventory inventory, @@ -79,6 +86,10 @@ final class FeatureInstanceController, C extends F ); } + void activationPolicy(FeatureActivationPolicy policy) { + activationPolicy = Objects.requireNonNull(policy, "policy"); + } + void prepareFeatureStorage() { for (ResolvedFeatureDefinition descriptor : registry.getAvailableFeatures().values()) { prepareFeatureStorage(descriptor.registryName()); @@ -89,16 +100,24 @@ boolean prepareFeatureStorage(String featureName) { String key = inventory.resolveFeatureKey(featureName); ResolvedFeatureDefinition descriptor = key == null ? null : registry.getAvailableFeature(key); if (descriptor == null) return false; + FeatureId featureId = FeatureId.of(key); boolean enabled = configuration.isFeatureEnabled(key); - runtime.mutableFeatureCatalog().setConfiguredEnabled(FeatureId.of(key), enabled); + runtime.mutableFeatureCatalog().setConfiguredEnabled(featureId, enabled); if (!enabled || !inventory.missingPluginDependencies(key).isEmpty()) { preparationFailures.remove(key); inventory.clearStorageFailure(key); - if (!registry.isFeatureLoaded(key)) { - runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.DISABLED); - } + if (!registry.isFeatureLoaded(key)) runtime.mutableFeatureCatalog().transition(featureId, FeatureState.DISABLED); + return true; + } + + ActivationDecision preparation = evaluate(descriptor, FeatureActivationPhase.PREPARATION); + if (!preparation.allowed()) { + preparationFailures.remove(key); + inventory.clearStorageFailure(key); + if (!registry.isFeatureLoaded(key)) runtime.mutableFeatureCatalog().suppress(featureId, preparation.suppression().orElseThrow()); return true; } + C context = null; try { context = contextFactory.apply(descriptor); @@ -107,20 +126,17 @@ boolean prepareFeatureStorage(String featureName) { context.prepare(feature); preparationFailures.remove(key); inventory.clearStorageFailure(key); - if (!registry.isFeatureLoaded(key)) { - runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.DISABLED); - } + if (!registry.isFeatureLoaded(key)) runtime.mutableFeatureCatalog().transition(featureId, FeatureState.DISABLED); return true; } catch (Throwable failure) { preparationFailures.add(key); - runtime.mutableFeatureCatalog().fail(FeatureId.of(key), "preparation", failure); + runtime.mutableFeatureCatalog().fail(featureId, "preparation", failure); logger.error("Failed to prepare feature '" + key + "'.", failure); return false; } finally { if (context != null) { - try { - context.cleanup(); - } catch (Throwable cleanupFailure) { + try { context.cleanup(); } + catch (Throwable cleanupFailure) { logger.warn("Failed to clean preparation scope for '" + key + "'.", cleanupFailure); } } @@ -140,17 +156,11 @@ boolean regenerateDefaults(String featureName, FeatureFileResetRequest request) return true; } - boolean loadFeature(String featureName) { - return loadFeature(featureName, null); - } + boolean loadFeature(String featureName) { return loadFeature(featureName, null); } - List dependentFeatures(String featureName) { - return dependencyManager.getDependentFeatures(featureName); - } + List dependentFeatures(String featureName) { return dependencyManager.getDependentFeatures(featureName); } - F loadedFeature(String key) { - return registry.getLoadedFeature(key); - } + F loadedFeature(String key) { return registry.getLoadedFeature(key); } Throwable stopAndRemove(String key) { F feature = registry.getLoadedFeature(key); @@ -167,11 +177,8 @@ Throwable stopAndRemove(String key) { } void completeDisable(String key, Throwable failure, String phase) { - if (failure == null) { - runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.DISABLED); - } else { - runtime.mutableFeatureCatalog().fail(FeatureId.of(key), phase, failure); - } + if (failure == null) runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.DISABLED); + else runtime.mutableFeatureCatalog().fail(FeatureId.of(key), phase, failure); } FeatureReloadResponse reloadFeature(String featureName) { @@ -180,13 +187,36 @@ FeatureReloadResponse reloadFeature(String featureName) { return new FeatureReloadResponse(FeatureReloadResult.NOT_LOADED, featureName, Set.of()); } + ResolvedFeatureDefinition descriptor = registry.getAvailableFeature(key); + ActivationDecision decision = descriptor == null ? ActivationDecision.allow() + : evaluate(descriptor, FeatureActivationPhase.ACTIVATION); + if (!decision.allowed()) { + List order = buildReloadOrder(key); + Throwable stopFailure = stopReloadGraph(order); + if (stopFailure != null) { + runtime.mutableFeatureCatalog().fail(FeatureId.of(key), "suppression-shutdown", stopFailure); + return new FeatureReloadResponse(FeatureReloadResult.FAILED, key, Set.copyOf(order)); + } + FeatureSuppression rootSuppression = decision.suppression().orElseThrow(); + for (String affected : order) { + ResolvedFeatureDefinition affectedDescriptor = registry.getAvailableFeature(affected); + ActivationDecision affectedDecision = affectedDescriptor == null ? ActivationDecision.allow() + : evaluate(affectedDescriptor, FeatureActivationPhase.ACTIVATION); + FeatureSuppression suppression = affected.equals(key) + ? rootSuppression + : affectedDecision.suppression().orElseGet(() -> new FeatureSuppression( + FeatureSuppressionReason.DEPENDENCY_SUPPRESSED, + "Required feature '" + key + "' is suppressed")); + runtime.mutableFeatureCatalog().suppress(FeatureId.of(affected), suppression); + } + logger.info("Suppressed feature graph rooted at '" + key + "' before resource reconstruction."); + java.util.LinkedHashSet dependents = new java.util.LinkedHashSet<>(order); + dependents.remove(key); + return new FeatureReloadResponse(FeatureReloadResult.SUCCESS, key, Set.copyOf(dependents)); + } + FeatureGraphReloadTransaction.Result transaction = FeatureGraphReloadTransaction.execute( - key, - () -> buildReloadOrder(key), - this::captureReloadStates, - this::stopReloadGraph, - this::startReloadGraph - ); + key, () -> buildReloadOrder(key), this::captureReloadStates, this::stopReloadGraph, this::startReloadGraph); if (transaction.success()) { logger.info("Reloaded feature graph rooted at '" + key + "': " + transaction.reloadOrder()); return new FeatureReloadResponse(FeatureReloadResult.SUCCESS, key, transaction.reloadedDependents()); @@ -205,9 +235,7 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { FeatureId featureId = FeatureId.of(key); FeatureFrameworkObservations.Operation observation = observations.start( - FeatureFrameworkOperationKind.FEATURE_LOAD, - featureId - ); + FeatureFrameworkOperationKind.FEATURE_LOAD, featureId); FeatureFrameworkObservationScope scope = observation.openScope(); try { if (registry.isFeatureLoaded(key) || preparationFailures.contains(key) || inventory.hasStorageFailure(key)) { @@ -222,8 +250,26 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { boolean enabled = configuration.isFeatureEnabled(key); runtime.mutableFeatureCatalog().setConfiguredEnabled(featureId, enabled); - if (!enabled || !inventory.missingPluginDependencies(key).isEmpty() - || !dependencyManager.areDependenciesMet(key)) { + if (!enabled || !inventory.missingPluginDependencies(key).isEmpty()) { + observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null); + return false; + } + + if (!dependencyManager.areDependenciesMet(key)) { + Set suppressed = suppressedDependencies(descriptor); + if (!suppressed.isEmpty()) { + runtime.mutableFeatureCatalog().setUnavailableDependencies(featureId, suppressed); + runtime.mutableFeatureCatalog().suppress(featureId, new FeatureSuppression( + FeatureSuppressionReason.DEPENDENCY_SUPPRESSED, + "Required feature dependency is suppressed: " + suppressed)); + } + observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null); + return false; + } + + ActivationDecision activation = evaluate(descriptor, FeatureActivationPhase.ACTIVATION); + if (!activation.allowed()) { + runtime.mutableFeatureCatalog().suppress(featureId, activation.suppression().orElseThrow()); observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null); return false; } @@ -255,10 +301,8 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { FeatureHostContext::cleanup, () -> registry.deregisterLoadedFeature(key) ); - observation.complete( - loaded ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE, - startupFailure == null ? null : startupFailure[0] - ); + observation.complete(loaded ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE, + startupFailure == null ? null : startupFailure[0]); return loaded; } catch (Throwable failure) { observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure); @@ -268,15 +312,30 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { } } + private ActivationDecision evaluate(ResolvedFeatureDefinition descriptor, FeatureActivationPhase phase) { + var metadata = runtime.mutableFeatureCatalog().find(FeatureId.of(descriptor.registryName())) + .map(snapshot -> snapshot.metadata()) + .orElseThrow(() -> new IllegalStateException("Missing public feature metadata: " + descriptor.registryName())); + return Objects.requireNonNull(activationPolicy.evaluate(metadata, phase), "activation policy decision"); + } + + private Set suppressedDependencies(ResolvedFeatureDefinition descriptor) { + Set result = new LinkedHashSet<>(); + for (String dependency : descriptor.featureDependencies()) { + FeatureId id = FeatureId.of(dependency); + runtime.mutableFeatureCatalog().find(id) + .filter(snapshot -> snapshot.state() == FeatureState.SUPPRESSED) + .ifPresent(snapshot -> result.add(id)); + } + return Set.copyOf(result); + } + List buildReloadOrder(String root) { Set affected = FeatureGraphLifecycle.dependentClosure( root, dependencyManager::getDependentFeatures, registry::isFeatureLoaded); List order = inventory.loadOrder(registry.getAvailableFeatures().keySet()).loadOrder().stream() - .filter(affected::contains) - .toList(); - if (order.size() != affected.size()) { - throw new IllegalStateException("Reload graph contains a dependency cycle: " + affected); - } + .filter(affected::contains).toList(); + if (order.size() != affected.size()) throw new IllegalStateException("Reload graph contains a dependency cycle: " + affected); return order; } @@ -340,9 +399,7 @@ private static Object copyValue(Object value) { } @SuppressWarnings("unchecked") - private static T throwUnchecked(Throwable failure) throws E { - throw (E) failure; - } + private static T throwUnchecked(Throwable failure) throws E { throw (E) failure; } private record FeatureDefaults(ConfigMap config, MessageMap messages) { } } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDefinition.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDefinition.java index 8fa653f..f49c1a9 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDefinition.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDefinition.java @@ -1,5 +1,6 @@ package nl.hauntedmc.featureframework.loader; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; import nl.hauntedmc.featureframework.api.feature.FeatureRole; import nl.hauntedmc.featureframework.api.feature.FeatureStartupPhase; import nl.hauntedmc.featureframework.api.feature.FeatureScope; @@ -15,6 +16,8 @@ public interface FeatureManifestDefinition requiredFeatureDependencies); Set> requiredCapabilities(); diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDiscovery.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDiscovery.java index 509cb7b..0d40c47 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDiscovery.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/FeatureManifestDiscovery.java @@ -1,6 +1,7 @@ package nl.hauntedmc.featureframework.loader; import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; import java.util.ArrayList; import java.util.Collection; @@ -16,8 +17,7 @@ /** Validates and materializes an explicit feature inventory without knowing any concrete feature. */ public final class FeatureManifestDiscovery { - private FeatureManifestDiscovery() { - } + private FeatureManifestDiscovery() { } public static , E extends FeatureManifestDefinition> Result discover( Collection manifest, @@ -41,31 +41,38 @@ private FeatureManifestDiscovery() { List conflicts = new ArrayList<>(); Map> byNormalizedKey = new LinkedHashMap<>(); for (E definition : definitions) { - Set dependencies = resolveDependencies( - definition, capabilityProviders, internalProviders, bootstrap); - D descriptor = Objects.requireNonNull( - definition.descriptor(dependencies), "definition descriptor"); + Set dependencies = resolveDependencies(definition, capabilityProviders, internalProviders, bootstrap); + D descriptor = Objects.requireNonNull(definition.descriptor(dependencies), "definition descriptor"); String normalized = descriptor.registryName().toLowerCase(Locale.ROOT); Discovered previous = byNormalizedKey.get(normalized); if (previous != null) { - conflicts.add(new Conflict( - descriptor.registryName(), - descriptor.implementationType(), - previous.descriptor().implementationType() - )); + conflicts.add(new Conflict(descriptor.registryName(), descriptor.implementationType(), + previous.descriptor().implementationType())); continue; } Discovered item = new Discovered<>( - descriptor, - definition, - publicDescriptor(descriptor, definition, namespace) - ); + descriptor, definition, publicDescriptor(descriptor, definition, namespace)); byNormalizedKey.put(normalized, item); discovered.add(item); } + validatePlacementDependencies(byNormalizedKey); return new Result<>(List.copyOf(discovered), List.copyOf(conflicts)); } + private static , E extends FeatureManifestDefinition> + void validatePlacementDependencies(Map> discovered) { + for (Discovered item : discovered.values()) { + if (item.definition().placement() != FeaturePlacement.ALL_NODES) continue; + for (String dependency : item.descriptor().featureDependencies()) { + Discovered provider = discovered.get(dependency.toLowerCase(Locale.ROOT)); + if (provider != null && provider.definition().placement() == FeaturePlacement.GROUP_LEADER_ONLY) { + throw new IllegalStateException("ALL_NODES feature " + item.definition().featureName() + + " cannot require GROUP_LEADER_ONLY feature " + provider.definition().featureName()); + } + } + } + } + private static , E extends FeatureManifestDefinition> Map, E> uniqueProviders( Collection definitions, @@ -94,13 +101,11 @@ Map, E> uniqueProviders( for (E definition : definitions) { LinkedHashSet> referenced = new LinkedHashSet<>(internal ? definition.requiredInternalServices() : definition.requiredCapabilities()); - referenced.addAll(internal - ? definition.optionalInternalServices() : definition.optionalCapabilities()); + referenced.addAll(internal ? definition.optionalInternalServices() : definition.optionalCapabilities()); for (Class type : referenced) { if (!providers.containsKey(type) && !bootstrap.contains(type)) { throw new IllegalStateException("Feature " + definition.featureName() + " references " - + (internal ? "internal service" : "capability") - + " without a provider: " + type.getName()); + + (internal ? "internal service" : "capability") + " without a provider: " + type.getName()); } } } @@ -141,25 +146,15 @@ nl.hauntedmc.featureframework.api.feature.FeatureMetadata publicDescriptor( String namespace ) { Set dependencies = descriptor.featureDependencies().stream() - .map(FeatureId::of) - .collect(Collectors.toUnmodifiableSet()); + .map(FeatureId::of).collect(Collectors.toUnmodifiableSet()); Set capabilities = definition.providedCapabilities().stream() - .map(type -> capabilityId(namespace, type)) - .collect(Collectors.toUnmodifiableSet()); + .map(type -> capabilityId(namespace, type)).collect(Collectors.toUnmodifiableSet()); Set resources = definition.requiredResourceExtensions().stream() - .map(Class::getName) - .collect(Collectors.toUnmodifiableSet()); + .map(Class::getName).collect(Collectors.toUnmodifiableSet()); return new nl.hauntedmc.featureframework.api.feature.FeatureMetadata( - FeatureId.of(descriptor.registryName()), - descriptor.featureName(), - descriptor.featureVersion(), - dependencies, - descriptor.pluginDependencies(), - resources, - capabilities, - definition.roles(), - definition.scope() - ); + FeatureId.of(descriptor.registryName()), descriptor.featureName(), descriptor.featureVersion(), + dependencies, descriptor.pluginDependencies(), resources, capabilities, definition.roles(), + definition.scope(), definition.placement()); } private static String capabilityId(String namespace, Class capability) { @@ -178,15 +173,12 @@ public record Discovered, E extends Fe D descriptor, E definition, nl.hauntedmc.featureframework.api.feature.FeatureMetadata publicDescriptor - ) { - } + ) { } - public record Conflict(String registryName, Class rejectedType, Class existingType) { - } + public record Conflict(String registryName, Class rejectedType, Class existingType) { } public record Result, E extends FeatureManifestDefinition>( List> discovered, List conflicts - ) { - } + ) { } } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/ResolvedFeatureDefinition.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/ResolvedFeatureDefinition.java index f06806c..302c5ab 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/ResolvedFeatureDefinition.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/loader/ResolvedFeatureDefinition.java @@ -1,5 +1,6 @@ package nl.hauntedmc.featureframework.loader; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; import nl.hauntedmc.featureframework.feature.Feature; import java.util.Collections; @@ -8,18 +9,7 @@ import java.util.Set; import java.util.function.Function; -/** - * Immutable, reflection-free construction descriptor used by the host runtime. - * - *

Unlike the implementation-free public catalog metadata in - * {@link nl.hauntedmc.featureframework.api.feature.FeatureMetadata}, this descriptor carries the - * concrete implementation type, construction callback, and platform/plugin dependency declarations - * required to create and order live feature instances. Application composition should normally use - * {@code FeatureDefinition} rather than constructing this type directly.

- * - * @param feature implementation base type - * @param construction context type - */ +/** Immutable, reflection-free construction descriptor used by the host runtime. */ public class ResolvedFeatureDefinition { private final String registryName; private final String featureName; @@ -31,6 +21,7 @@ public class ResolvedFeatureDefinition { private final Set pluginDependencies; private final Set> requiredResourceExtensions; private final Set> optionalResourceExtensions; + private final FeaturePlacement placement; public ResolvedFeatureDefinition( String registryName, @@ -41,18 +32,8 @@ public ResolvedFeatureDefinition( Set featureDependencies, Set pluginDependencies ) { - this( - registryName, - featureName, - featureVersion, - implementationType, - constructor, - featureDependencies, - Set.of(), - pluginDependencies, - Set.of(), - Set.of() - ); + this(registryName, featureName, featureVersion, implementationType, constructor, featureDependencies, + Set.of(), pluginDependencies, Set.of(), Set.of(), FeaturePlacement.ALL_NODES); } public ResolvedFeatureDefinition( @@ -65,8 +46,8 @@ public ResolvedFeatureDefinition( Set optionalFeatureDependencies, Set pluginDependencies ) { - this(registryName, featureName, featureVersion, implementationType, constructor, - featureDependencies, optionalFeatureDependencies, pluginDependencies, Set.of(), Set.of()); + this(registryName, featureName, featureVersion, implementationType, constructor, featureDependencies, + optionalFeatureDependencies, pluginDependencies, Set.of(), Set.of(), FeaturePlacement.ALL_NODES); } public ResolvedFeatureDefinition( @@ -80,6 +61,24 @@ public ResolvedFeatureDefinition( Set pluginDependencies, Set> requiredResourceExtensions, Set> optionalResourceExtensions + ) { + this(registryName, featureName, featureVersion, implementationType, constructor, featureDependencies, + optionalFeatureDependencies, pluginDependencies, requiredResourceExtensions, + optionalResourceExtensions, FeaturePlacement.ALL_NODES); + } + + public ResolvedFeatureDefinition( + String registryName, + String featureName, + String featureVersion, + Class implementationType, + Function constructor, + Set featureDependencies, + Set optionalFeatureDependencies, + Set pluginDependencies, + Set> requiredResourceExtensions, + Set> optionalResourceExtensions, + FeaturePlacement placement ) { this.registryName = requireText(registryName, "registryName"); this.featureName = requireText(featureName, "featureName"); @@ -88,65 +87,38 @@ public ResolvedFeatureDefinition( this.constructor = Objects.requireNonNull(constructor, "constructor"); this.featureDependencies = normalizeDependencies(featureDependencies, registryName); this.optionalFeatureDependencies = withoutRequiredDependencies( - normalizeDependencies(optionalFeatureDependencies, registryName), - this.featureDependencies - ); + normalizeDependencies(optionalFeatureDependencies, registryName), this.featureDependencies); this.pluginDependencies = normalizeDependencies(pluginDependencies, null); this.requiredResourceExtensions = immutableTypes(requiredResourceExtensions); LinkedHashSet> optionalResources = new LinkedHashSet<>(immutableTypes(optionalResourceExtensions)); optionalResources.removeAll(this.requiredResourceExtensions); this.optionalResourceExtensions = Collections.unmodifiableSet(optionalResources); + this.placement = placement == null ? FeaturePlacement.ALL_NODES : placement; } - public String registryName() { - return registryName; - } - - public String featureName() { - return featureName; - } - - public String featureVersion() { - return featureVersion; - } - - public Class implementationType() { - return implementationType; - } - - public Set featureDependencies() { - return featureDependencies; - } - - public Set optionalFeatureDependencies() { - return optionalFeatureDependencies; - } - - public Set pluginDependencies() { - return pluginDependencies; - } - + public String registryName() { return registryName; } + public String featureName() { return featureName; } + public String featureVersion() { return featureVersion; } + public Class implementationType() { return implementationType; } + public Set featureDependencies() { return featureDependencies; } + public Set optionalFeatureDependencies() { return optionalFeatureDependencies; } + public Set pluginDependencies() { return pluginDependencies; } public Set> requiredResourceExtensions() { return requiredResourceExtensions; } public Set> optionalResourceExtensions() { return optionalResourceExtensions; } + public FeaturePlacement placement() { return placement; } public F create(C context) { F feature = constructor.apply(Objects.requireNonNull(context, "context")); - if (feature == null) { - throw new IllegalStateException("Feature constructor returned null: " + implementationType.getName()); - } + if (feature == null) throw new IllegalStateException("Feature constructor returned null: " + implementationType.getName()); if (!implementationType.isInstance(feature)) { - throw new IllegalStateException( - "Feature constructor returned " + feature.getClass().getName() - + " instead of " + implementationType.getName() - ); + throw new IllegalStateException("Feature constructor returned " + feature.getClass().getName() + + " instead of " + implementationType.getName()); } return feature; } private static Set withoutRequiredDependencies(Set optional, Set required) { - if (optional.isEmpty() || required.isEmpty()) { - return optional; - } + if (optional.isEmpty() || required.isEmpty()) return optional; LinkedHashSet result = new LinkedHashSet<>(optional); result.removeIf(candidate -> required.stream().anyMatch(candidate::equalsIgnoreCase)); return result.isEmpty() ? Set.of() : Collections.unmodifiableSet(result); @@ -154,23 +126,17 @@ private static Set withoutRequiredDependencies(Set optional, Set private static String requireText(String value, String fieldName) { String clean = Objects.requireNonNull(value, fieldName).trim(); - if (clean.isEmpty()) { - throw new IllegalArgumentException(fieldName + " must not be blank"); - } + if (clean.isEmpty()) throw new IllegalArgumentException(fieldName + " must not be blank"); return clean; } private static Set normalizeDependencies(Set dependencies, String selfDependencyName) { - if (dependencies == null || dependencies.isEmpty()) { - return Set.of(); - } + if (dependencies == null || dependencies.isEmpty()) return Set.of(); LinkedHashSet normalized = new LinkedHashSet<>(); for (String dependency : dependencies) { String clean = requireText(dependency, "dependency"); boolean duplicate = normalized.stream().anyMatch(clean::equalsIgnoreCase); - if (!duplicate && (selfDependencyName == null || !clean.equalsIgnoreCase(selfDependencyName))) { - normalized.add(clean); - } + if (!duplicate && (selfDependencyName == null || !clean.equalsIgnoreCase(selfDependencyName))) normalized.add(clean); } return normalized.isEmpty() ? Set.of() : Collections.unmodifiableSet(normalized); } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/DefaultFeatureCatalog.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/DefaultFeatureCatalog.java index 75334c2..97dd08f 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/DefaultFeatureCatalog.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/service/DefaultFeatureCatalog.java @@ -1,10 +1,21 @@ package nl.hauntedmc.featureframework.service; -import nl.hauntedmc.featureframework.api.feature.*; +import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; +import nl.hauntedmc.featureframework.api.feature.FeatureCatalogListener; +import nl.hauntedmc.featureframework.api.feature.FeatureFailure; +import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.feature.FeatureMetadata; +import nl.hauntedmc.featureframework.api.feature.FeatureSnapshot; +import nl.hauntedmc.featureframework.api.feature.FeatureState; +import nl.hauntedmc.featureframework.api.feature.FeatureSuppression; import java.time.Clock; import java.time.Instant; -import java.util.*; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -13,28 +24,31 @@ public final class DefaultFeatureCatalog implements FeatureCatalog { private static final int MAX_FAILURE_MESSAGE_LENGTH = 160; - private record Entry(FeatureMetadata metadata, boolean configuredEnabled, FeatureState state, - Optional failure, Optional failureDetail, - Set unavailableDependencies, Instant lastTransitionAt, - Optional lastSuccessfulActivationAt, long generation) { - } + private record Entry( + FeatureMetadata metadata, + boolean configuredEnabled, + FeatureState state, + Optional suppression, + Optional failure, + Optional failureDetail, + Set unavailableDependencies, + Instant lastTransitionAt, + Optional lastSuccessfulActivationAt, + long generation + ) { } private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); private final Clock clock; private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); - public DefaultFeatureCatalog() { - this(Clock.systemUTC()); - } + public DefaultFeatureCatalog() { this(Clock.systemUTC()); } - DefaultFeatureCatalog(Clock clock) { - this.clock = Objects.requireNonNull(clock, "clock"); - } + DefaultFeatureCatalog(Clock clock) { this.clock = Objects.requireNonNull(clock, "clock"); } public void register(FeatureMetadata metadata) { Objects.requireNonNull(metadata, "metadata"); Instant now = clock.instant(); - Entry entry = new Entry(metadata, false, FeatureState.DISABLED, Optional.empty(), + Entry entry = new Entry(metadata, false, FeatureState.DISABLED, Optional.empty(), Optional.empty(), Optional.empty(), Set.of(), now, Optional.empty(), 0L); entries.put(metadata.id(), entry); notifyChanged(entry); @@ -44,16 +58,12 @@ public void setConfiguredEnabled(FeatureId id, boolean enabled) { Entry[] previous = new Entry[1]; Entry next = entries.compute(requireKnown(id), (ignored, entry) -> { previous[0] = requireEntry(id, entry); - if (entry.configuredEnabled() == enabled) { - return entry; - } - return new Entry(entry.metadata(), enabled, entry.state(), entry.failure(), entry.failureDetail(), - entry.unavailableDependencies(), entry.lastTransitionAt(), entry.lastSuccessfulActivationAt(), - entry.generation() + 1); + if (entry.configuredEnabled() == enabled) return entry; + return new Entry(entry.metadata(), enabled, entry.state(), entry.suppression(), entry.failure(), + entry.failureDetail(), entry.unavailableDependencies(), entry.lastTransitionAt(), + entry.lastSuccessfulActivationAt(), entry.generation() + 1); }); - if (next != previous[0]) { - notifyChanged(next); - } + if (next != previous[0]) notifyChanged(next); } /** Updates unavailable feature prerequisites without conflating them with disabled configuration. */ @@ -62,36 +72,34 @@ public void setUnavailableDependencies(FeatureId id, Set unavailableD Entry[] previous = new Entry[1]; Entry next = entries.compute(requireKnown(id), (ignored, entry) -> { previous[0] = requireEntry(id, entry); - if (entry.unavailableDependencies().equals(normalized)) { - return entry; - } - return new Entry(entry.metadata(), entry.configuredEnabled(), entry.state(), entry.failure(), - entry.failureDetail(), normalized, entry.lastTransitionAt(), entry.lastSuccessfulActivationAt(), - entry.generation() + 1); + if (entry.unavailableDependencies().equals(normalized)) return entry; + return new Entry(entry.metadata(), entry.configuredEnabled(), entry.state(), entry.suppression(), + entry.failure(), entry.failureDetail(), normalized, entry.lastTransitionAt(), + entry.lastSuccessfulActivationAt(), entry.generation() + 1); }); - if (next != previous[0]) { - notifyChanged(next); - } + if (next != previous[0]) notifyChanged(next); } public void transition(FeatureId id, FeatureState state) { - transition(id, state, Optional.empty()); + transition(id, state, Optional.empty(), Optional.empty(), Optional.empty()); } - public void fail(FeatureId id, Throwable failure) { - fail(id, "lifecycle", failure); + /** Moves an enabled feature into a deliberate, non-failure suppression state. */ + public void suppress(FeatureId id, FeatureSuppression suppression) { + transition(id, FeatureState.SUPPRESSED, + Optional.of(Objects.requireNonNull(suppression, "suppression")), Optional.empty(), Optional.empty()); } + public void fail(FeatureId id, Throwable failure) { fail(id, "lifecycle", failure); } + /** Records a stable lifecycle phase so API consumers can distinguish startup from cleanup failures. */ public void fail(FeatureId id, String phase, Throwable failure) { Objects.requireNonNull(phase, "phase"); Objects.requireNonNull(failure, "failure"); String message = failure.getMessage(); String safe = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; - safe = safe.length() > MAX_FAILURE_MESSAGE_LENGTH - ? safe.substring(0, MAX_FAILURE_MESSAGE_LENGTH) - : safe; - transition(id, FeatureState.FAILED, Optional.of(safe), + safe = safe.length() > MAX_FAILURE_MESSAGE_LENGTH ? safe.substring(0, MAX_FAILURE_MESSAGE_LENGTH) : safe; + transition(id, FeatureState.FAILED, Optional.empty(), Optional.of(safe), Optional.of(new FeatureFailure(normalizePhase(phase), failure.getClass().getSimpleName(), Optional.of(safe)))); } @@ -117,71 +125,72 @@ public AutoCloseable subscribe(FeatureCatalogListener listener) { return () -> listeners.remove(listener); } - private void transition(FeatureId id, FeatureState state, Optional failure) { - transition(id, state, failure, Optional.empty()); - } - - private void transition(FeatureId id, FeatureState state, Optional failure, - Optional failureDetail) { + private void transition( + FeatureId id, + FeatureState state, + Optional suppression, + Optional failure, + Optional failureDetail + ) { Objects.requireNonNull(id, "id"); Objects.requireNonNull(state, "state"); + if (state == FeatureState.SUPPRESSED && suppression.isEmpty()) { + throw new IllegalArgumentException("SUPPRESSED transition requires suppression detail"); + } Entry next = entries.compute(id, (ignored, current) -> { - if (current == null) { - throw new IllegalArgumentException("Unknown feature: " + id); - } + if (current == null) throw new IllegalArgumentException("Unknown feature: " + id); if (!isAllowed(current.state(), state)) { throw new IllegalStateException("Invalid feature state transition: " + current.state() + " -> " + state); } Instant now = clock.instant(); - Optional activated = state == FeatureState.ACTIVE ? Optional.of(now) : current.lastSuccessfulActivationAt(); - return new Entry(current.metadata(), current.configuredEnabled(), state, failure, failureDetail, + Optional activated = state == FeatureState.ACTIVE + ? Optional.of(now) : current.lastSuccessfulActivationAt(); + return new Entry(current.metadata(), current.configuredEnabled(), state, + state == FeatureState.SUPPRESSED ? suppression : Optional.empty(), + state == FeatureState.FAILED ? failure : Optional.empty(), + state == FeatureState.FAILED ? failureDetail : Optional.empty(), current.unavailableDependencies(), now, activated, current.generation() + 1); }); notifyChanged(next); } private static FeatureSnapshot snapshot(Entry entry, Instant observedAt) { - return new FeatureSnapshot(entry.metadata(), entry.configuredEnabled(), entry.state(), entry.failure(), - entry.failureDetail(), entry.unavailableDependencies(), entry.lastTransitionAt(), + return new FeatureSnapshot(entry.metadata(), entry.configuredEnabled(), entry.state(), entry.suppression(), + entry.failure(), entry.failureDetail(), entry.unavailableDependencies(), entry.lastTransitionAt(), entry.lastSuccessfulActivationAt(), entry.generation(), observedAt); } private void notifyChanged(Entry entry) { FeatureSnapshot snapshot = snapshot(entry, clock.instant()); listeners.forEach(listener -> { - try { - listener.stateChanged(snapshot); - } catch (RuntimeException ignored) { - // Listener isolation is required for lifecycle progress. - } + try { listener.stateChanged(snapshot); } + catch (RuntimeException ignored) { /* Listener isolation is required for lifecycle progress. */ } }); } private static String normalizePhase(String phase) { String normalized = phase.trim(); - if (normalized.isEmpty()) { - throw new IllegalArgumentException("phase must not be blank"); - } + if (normalized.isEmpty()) throw new IllegalArgumentException("phase must not be blank"); return normalized; } private static FeatureId requireKnown(FeatureId id) { return Objects.requireNonNull(id, "id"); } private static Entry requireEntry(FeatureId id, Entry entry) { - if (entry == null) { - throw new IllegalArgumentException("Unknown feature: " + id); - } + if (entry == null) throw new IllegalArgumentException("Unknown feature: " + id); return entry; } private static boolean isAllowed(FeatureState from, FeatureState to) { if (from == to) return true; return switch (from) { - case DISABLED -> to == FeatureState.STARTING || to == FeatureState.FAILED; - case STARTING -> to == FeatureState.ACTIVE || to == FeatureState.FAILED || to == FeatureState.STOPPING; - case ACTIVE -> to == FeatureState.STOPPING || to == FeatureState.FAILED; - case STOPPING -> to == FeatureState.DISABLED || to == FeatureState.FAILED; - case FAILED -> to == FeatureState.STARTING || to == FeatureState.DISABLED; + case DISABLED -> to == FeatureState.STARTING || to == FeatureState.SUPPRESSED || to == FeatureState.FAILED; + case SUPPRESSED -> to == FeatureState.STARTING || to == FeatureState.DISABLED || to == FeatureState.FAILED; + case STARTING -> to == FeatureState.ACTIVE || to == FeatureState.SUPPRESSED + || to == FeatureState.FAILED || to == FeatureState.STOPPING; + case ACTIVE -> to == FeatureState.STOPPING || to == FeatureState.SUPPRESSED || to == FeatureState.FAILED; + case STOPPING -> to == FeatureState.DISABLED || to == FeatureState.SUPPRESSED || to == FeatureState.FAILED; + case FAILED -> to == FeatureState.STARTING || to == FeatureState.SUPPRESSED || to == FeatureState.DISABLED; }; } } diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeaturePlacementSuppressionTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeaturePlacementSuppressionTest.java new file mode 100644 index 0000000..de5b777 --- /dev/null +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeaturePlacementSuppressionTest.java @@ -0,0 +1,89 @@ +package nl.hauntedmc.featureframework.host; + +import nl.hauntedmc.featureframework.api.RuntimeState; +import nl.hauntedmc.featureframework.api.feature.ActivationDecision; +import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; +import nl.hauntedmc.featureframework.api.feature.FeatureState; +import nl.hauntedmc.featureframework.api.feature.FeatureSuppressionReason; +import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration; +import nl.hauntedmc.featureframework.feature.LifecycleFeature; +import nl.hauntedmc.featureframework.runtime.FeatureRuntime; +import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMap; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigService; +import nl.hauntedmc.featureframework.toolkit.io.localization.MessageMap; +import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FeaturePlacementSuppressionTest { + @TempDir + Path temporaryDirectory; + + @Test + void suppressedPlacementNeverConstructsContextFeatureOrResources() { + FrameworkLogger logger = FrameworkLogger.noop(); + ConfigService files = new ConfigService(temporaryDirectory, logger, getClass().getClassLoader()); + DefaultFeatureConfiguration configuration = new DefaultFeatureConfiguration(files, logger); + DefaultCapabilityRegistry capabilities = new DefaultCapabilityRegistry( + getClass().getPackageName(), getClass().getClassLoader()); + FeatureRuntime runtime = + new FeatureRuntime<>("suppressed-host", capabilities); + AtomicInteger contextConstructions = new AtomicInteger(); + AtomicInteger featureConstructions = new AtomicInteger(); + + FeatureDefinition definition = + FeatureDefinition.builder( + "Ingress", "1.0.0", SuppressedFeature.class, + context -> { + featureConstructions.incrementAndGet(); + return new SuppressedFeature(context); + }) + .placement(FeaturePlacement.GROUP_LEADER_ONLY) + .enabledByDefault() + .build(); + + FeatureHost host = FeatureHost.builder( + "suppressed-host", "1.0.0", "test", runtime, configuration, + FeatureCollection.of(definition)) + .contextFactory(descriptor -> { + contextConstructions.incrementAndGet(); + throw new AssertionError("suppressed feature requested a runtime context/resource scope"); + }) + .logger(logger) + .build(); + host.activationPolicy((metadata, phase) -> ActivationDecision.suppress( + FeatureSuppressionReason.GROUP_LEADER_ONLY, + "Follower is not eligible for leader-only placement")); + + host.start(); + + assertEquals(RuntimeState.READY, host.state()); + assertEquals(0, contextConstructions.get()); + assertEquals(0, featureConstructions.get()); + var snapshot = host.features().find(FeatureId.of("Ingress")).orElseThrow(); + assertEquals(FeatureState.SUPPRESSED, snapshot.state()); + assertEquals(FeatureSuppressionReason.GROUP_LEADER_ONLY, + snapshot.suppression().orElseThrow().reason()); + host.stop(); + } + + private interface SuppressedContext extends FeatureHostContext { } + + private static final class SuppressedFeature extends LifecycleFeature { + private SuppressedFeature(SuppressedContext context) { + super(context); + } + + @Override public ConfigMap defaultConfig() { return new ConfigMap(); } + @Override public MessageMap defaultMessages() { return new MessageMap(); } + @Override public void initialize() { } + @Override public void disable() { } + } +} \ No newline at end of file diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/loader/FeaturePlacementValidationTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/loader/FeaturePlacementValidationTest.java new file mode 100644 index 0000000..264cfbf --- /dev/null +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/loader/FeaturePlacementValidationTest.java @@ -0,0 +1,94 @@ +package nl.hauntedmc.featureframework.loader; + +import nl.hauntedmc.featureframework.api.feature.FeaturePlacement; +import nl.hauntedmc.featureframework.api.feature.FeatureStartupPhase; +import nl.hauntedmc.featureframework.api.feature.FeatureScope; +import nl.hauntedmc.featureframework.feature.Feature; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMap; +import nl.hauntedmc.featureframework.toolkit.io.localization.MessageMap; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class FeaturePlacementValidationTest { + @Test + void allNodesCannotRequireLeaderOnlyFeature() { + Definition leader = definition("ingress", FeaturePlacement.GROUP_LEADER_ONLY, Set.of(), Set.of()); + Definition consumer = definition("commands", FeaturePlacement.ALL_NODES, Set.of("ingress"), Set.of()); + + assertThrows(IllegalStateException.class, + () -> FeatureManifestDiscovery.discover(List.of(leader, consumer), Set.of(), "demo")); + } + + @Test + void leaderOnlyMayRequireAllNodesOrLeaderOnlyFeatures() { + Definition shared = definition("shared", FeaturePlacement.ALL_NODES, Set.of(), Set.of()); + Definition ingress = definition("ingress", FeaturePlacement.GROUP_LEADER_ONLY, Set.of(), Set.of()); + Definition singleton = definition( + "singleton", FeaturePlacement.GROUP_LEADER_ONLY, Set.of("shared", "ingress"), Set.of()); + + assertDoesNotThrow(() -> FeatureManifestDiscovery.discover( + List.of(shared, ingress, singleton), Set.of(), "demo")); + } + + @Test + void optionalCrossPlacementDependencyDoesNotInvalidateAllNodesFeature() { + Definition leader = definition("ingress", FeaturePlacement.GROUP_LEADER_ONLY, Set.of(), Set.of()); + Definition consumer = definition( + "commands", FeaturePlacement.ALL_NODES, Set.of(), Set.of("ingress")); + + assertDoesNotThrow(() -> FeatureManifestDiscovery.discover( + List.of(leader, consumer), Set.of(), "demo")); + } + + private static Definition definition( + String name, + FeaturePlacement placement, + Set required, + Set optional + ) { + return new Definition(name, placement, required, optional); + } + + private record Definition( + String featureName, + FeaturePlacement placement, + Set required, + Set optional + ) implements FeatureManifestDefinition> { + @Override public FeatureStartupPhase startupPhase() { return FeatureStartupPhase.CORE; } + @Override public FeatureScope scope() { return FeatureScope.NODE; } + + @Override + public ResolvedFeatureDefinition descriptor(Set discovered) { + LinkedHashSet dependencies = new LinkedHashSet<>(required); + dependencies.addAll(discovered); + return new ResolvedFeatureDefinition<>( + featureName, featureName, "1", TestFeature.class, ignored -> new TestFeature(), + dependencies, optional, Set.of(), Set.of(), Set.of(), placement); + } + + @Override public Set> requiredCapabilities() { return Set.of(); } + @Override public Set> optionalCapabilities() { return Set.of(); } + @Override public Set> providedCapabilities() { return Set.of(); } + @Override public Set> requiredInternalServices() { return Set.of(); } + @Override public Set> optionalInternalServices() { return Set.of(); } + @Override public Set> providedInternalServices() { return Set.of(); } + } + + private static final class TestFeature implements Feature { + @Override public String name() { return "test"; } + @Override public String version() { return "1"; } + @Override public List dependencies() { return List.of(); } + @Override public List pluginDependencies() { return List.of(); } + @Override public ConfigMap defaultConfig() { return new ConfigMap(); } + @Override public MessageMap defaultMessages() { return new MessageMap(); } + @Override public void initialize() { } + @Override public void disable() { } + } +} diff --git a/featureframework-paper/pom.xml b/featureframework-paper/pom.xml index 02c108f..359c6f3 100644 --- a/featureframework-paper/pom.xml +++ b/featureframework-paper/pom.xml @@ -11,9 +11,10 @@ Paper-specific adapters and reusable UI/runtime infrastructure. ${project.groupId}featureframework-core${project.version} + ${project.groupId}featureframework-cluster${project.version} io.papermc.paperpaper-api${paper.version}provided com.mojangbrigadier${brigadier.version}provided org.junit.jupiterjunit-jupiter${junit.version}test org.mockitomockito-core${mockito.version}test - + \ No newline at end of file diff --git a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java index c658393..817e4e5 100644 --- a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java +++ b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java @@ -2,10 +2,12 @@ import nl.hauntedmc.featureframework.api.FeatureFrameworkApi; import nl.hauntedmc.featureframework.api.RuntimeState; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; import nl.hauntedmc.featureframework.api.service.CapabilityRegistry; +import nl.hauntedmc.featureframework.cluster.ReplicaHostControl; import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration; import nl.hauntedmc.featureframework.config.FeatureConfigHandler; import nl.hauntedmc.featureframework.host.FeatureCollection; @@ -32,6 +34,7 @@ import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; import nl.hauntedmc.featureframework.service.InternalServiceRegistry; import nl.hauntedmc.featureframework.service.Registration; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationPolicy; import nl.hauntedmc.featureframework.toolkit.io.config.ConfigService; import nl.hauntedmc.featureframework.toolkit.io.localization.Language; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; @@ -50,7 +53,8 @@ import java.util.function.Function; /** Complete, dependency-clean Paper composition root. */ -public final class PaperFeatureHost

implements FeatureFrameworkApi, AutoCloseable { +public final class PaperFeatureHost

+ implements FeatureFrameworkApi, ReplicaHostControl, AutoCloseable { private final FeatureRuntime runtime; private final FeatureHostComposition, PaperFeatureContext

, FeatureConfigHandler, PaperLocalization, FeatureLogger, PaperFeatureResources> composition; @@ -125,6 +129,16 @@ public static

Builder builder( return builder(plugin, plugin.getPluginMeta().getVersion(), apiRoot, features); } + @Override + public void installReplicaPolicies( + FeatureActivationPolicy activationPolicy, + ConfigMutationPolicy mutationPolicy + ) { + composition.installReplicaPolicies(activationPolicy, mutationPolicy); + } + + @Override public boolean reconcileReplicaGraph() { return composition.reconcileReplicaGraph(); } + public void start() { composition.start(); } public void stop() { composition.stop(); } public boolean isLoaded(FeatureId id) { return composition.isLoaded(id); } @@ -268,4 +282,4 @@ private static String text(String value, String field) { if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); return normalized; } -} +} \ No newline at end of file diff --git a/featureframework-processor/src/main/java/nl/hauntedmc/featureframework/processor/FeatureCatalogProcessor.java b/featureframework-processor/src/main/java/nl/hauntedmc/featureframework/processor/FeatureCatalogProcessor.java index 6f5227c..3986156 100644 --- a/featureframework-processor/src/main/java/nl/hauntedmc/featureframework/processor/FeatureCatalogProcessor.java +++ b/featureframework-processor/src/main/java/nl/hauntedmc/featureframework/processor/FeatureCatalogProcessor.java @@ -104,6 +104,7 @@ private List entries(RoundEnvironment environment, Config config) { version, FeatureStartupPhase.valueOf(enumName(values, "startupPhase")), enumName(values, "scope"), + enumName(values, "placement"), bool(values, "enabledByDefault"), enumNames(values, "roles"), texts(values, "requiresFeatures"), @@ -130,9 +131,7 @@ private boolean declaredConcreteFeatures(RoundEnvironment environment, Config co || type.getKind() != ElementKind.CLASS || type.getModifiers().contains(Modifier.ABSTRACT) || !inPackage(type, config.featurePackage()) - || !types().isAssignable(types().erasure(type.asType()), types().erasure(baseType))) { - continue; - } + || !types().isAssignable(types().erasure(type.asType()), types().erasure(baseType))) continue; if (!hasAnnotation(type, DECLARATION)) { valid &= invalid(type, "Concrete feature " + type.getQualifiedName() + " extending " + baseType + " must declare @FeatureDeclaration"); @@ -192,14 +191,28 @@ private boolean validate(List entries, Config config) { for (Entry entry : entries) { valid &= references(entries, entry, entry.requiredFeatures(), "required feature"); valid &= references(entries, entry, entry.optionalFeatures(), "optional feature"); + valid &= validDirectPlacement(entries, entry); valid &= providers(entry, entry.requiresCapabilities(), capabilities, config.bootstrapCapabilities(), "capability"); valid &= providers(entry, entry.requiresInternalServices(), internalServices, Set.of(), "internal service"); } return valid; } + private boolean validDirectPlacement(Collection entries, Entry owner) { + if (!"ALL_NODES".equals(owner.placement())) return true; + boolean valid = true; + for (String required : owner.requiredFeatures()) { + Entry dependency = entries.stream().filter(entry -> entry.name().equalsIgnoreCase(required)).findFirst().orElse(null); + if (dependency != null && "GROUP_LEADER_ONLY".equals(dependency.placement())) { + valid &= invalid(owner.element(), "ALL_NODES feature cannot require GROUP_LEADER_ONLY feature: " + dependency.name()); + } + } + return valid; + } + private boolean references(Collection entries, Entry owner, List references, String kind) { - Set known = entries.stream().map(entry -> entry.name().toLowerCase(Locale.ROOT)).collect(java.util.stream.Collectors.toSet()); + Set known = entries.stream().map(entry -> entry.name().toLowerCase(Locale.ROOT)) + .collect(java.util.stream.Collectors.toSet()); boolean valid = true; for (String reference : references) { if (reference.equalsIgnoreCase(owner.name())) valid &= invalid(owner.element(), "Feature cannot declare itself as " + kind); @@ -214,9 +227,7 @@ private boolean providers(Entry owner, List references, Map available = new HashSet<>(providers.keySet()); bootstrap.forEach(type -> available.add(key(type))); for (TypeMirror reference : references) { - if (!available.contains(key(reference))) { - valid &= invalid(owner.element(), "No provider declared for " + kind + " " + reference); - } + if (!available.contains(key(reference))) valid &= invalid(owner.element(), "No provider declared for " + kind + " " + reference); } return valid; } @@ -279,13 +290,14 @@ private void generate(TypeElement host, Config config, List entries) thro + "java.util.function.Function<" + contextType + ", ? extends " + featureType + "> constructor, " + "nl.hauntedmc.featureframework.api.feature.FeatureStartupPhase startupPhase, " + "nl.hauntedmc.featureframework.api.feature.FeatureScope scope, " + + "nl.hauntedmc.featureframework.api.feature.FeaturePlacement placement, " + "boolean enabledByDefault, nl.hauntedmc.featureframework.api.feature.FeatureRole[] roles, String[] requiredFeatures, " + "String[] optionalFeatures, String[] plugins, Class[] requiredResources, Class[] optionalResources, " - + "Class[] requiredCapabilities, Class[] optionalCapabilities, " - + "Class[] providedCapabilities, Class[] requiredServices, Class[] optionalServices, Class[] providedServices) {\n"); + + "Class[] requiredCapabilities, Class[] optionalCapabilities, Class[] providedCapabilities, " + + "Class[] requiredServices, Class[] optionalServices, Class[] providedServices) {\n"); writer.write(" var builder = nl.hauntedmc.featureframework.host.FeatureDefinition.<" + featureType + ", " + contextType + ">builder(name, version, type, constructor).startupPhase(startupPhase)" - + ".scope(scope)" + + ".scope(scope).placement(placement)" + ".roles(roles).requiresFeatures(requiredFeatures).optionallyUsesFeatures(optionalFeatures).requiresPlugins(plugins)" + ".requiresResourceExtensions(requiredResources).optionallyUsesResourceExtensions(optionalResources)" + ".requiresCapabilities(requiredCapabilities).optionallyUsesCapabilities(optionalCapabilities).providesCapabilities(providedCapabilities)" @@ -299,6 +311,7 @@ private String definition(Entry entry) { + ".class, " + entry.element().getQualifiedName() + "::new, " + "nl.hauntedmc.featureframework.api.feature.FeatureStartupPhase." + entry.startupPhase() + ", " + "nl.hauntedmc.featureframework.api.feature.FeatureScope." + entry.scope() + ", " + + "nl.hauntedmc.featureframework.api.feature.FeaturePlacement." + entry.placement() + ", " + entry.enabledByDefault() + ", " + enumArray("FeatureRole", entry.roles()) + ", " + stringArray(entry.requiredFeatures()) + ", " + stringArray(entry.optionalFeatures()) + ", " + stringArray(entry.plugins()) + ", " + typeArray(entry.requiredResourceExtensions()) + ", " + typeArray(entry.optionalResourceExtensions()) + ", " @@ -335,8 +348,7 @@ private Map values(Element element, String annotationType) { } private boolean hasAnnotation(Element element, String annotationType) { - return element.getAnnotationMirrors().stream() - .anyMatch(mirror -> mirror.getAnnotationType().toString().equals(annotationType)); + return element.getAnnotationMirrors().stream().anyMatch(mirror -> mirror.getAnnotationType().toString().equals(annotationType)); } private String text(Map values, String name, Element element) { @@ -348,22 +360,12 @@ private String text(Map values, String name, Element element) { private boolean bool(Map values, String name) { return (Boolean) values.get(name); } private String enumName(Map values, String name) { return ((VariableElement) values.get(name)).getSimpleName().toString(); } - private List enumNames(Map values, String name) { return array(values, name).stream().map(value -> ((VariableElement) value).getSimpleName().toString()).toList(); } - private List texts(Map values, String name) { return array(values, name).stream().map(Objects::toString).map(String::trim).toList(); } - - private TypeMirror type(Map values, String name, Element element) { - Object value = values.get(name); - if (value instanceof TypeMirror mirror) return mirror; - error(element, name + " must be a class literal"); - return null; - } - private List types(Map values, String name) { return array(values, name).stream().map(TypeMirror.class::cast).toList(); } @@ -387,16 +389,14 @@ private boolean inPackage(TypeElement type, String featurePackage) { private Types types() { return processingEnv.getTypeUtils(); } private String quote(String value) { return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } - private record Config(String generatedClassName, String featurePackage, - Set bootstrapCapabilities) {} + private record Config(String generatedClassName, String featurePackage, Set bootstrapCapabilities) { } private record Entry(TypeElement element, String name, String version, FeatureStartupPhase startupPhase, - String scope, boolean enabledByDefault, - List roles, List requiredFeatures, - List optionalFeatures, List plugins, + String scope, String placement, boolean enabledByDefault, + List roles, List requiredFeatures, List optionalFeatures, List plugins, List requiredResourceExtensions, List optionalResourceExtensions, - List requiresCapabilities, - List optionalCapabilities, List providesCapabilities, - List requiresInternalServices, List optionalInternalServices, - List providesInternalServices, TypeMirror constructorContext) {} + List requiresCapabilities, List optionalCapabilities, + List providesCapabilities, List requiresInternalServices, + List optionalInternalServices, List providesInternalServices, + TypeMirror constructorContext) { } } diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMutationDeniedException.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMutationDeniedException.java new file mode 100644 index 0000000..5781115 --- /dev/null +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMutationDeniedException.java @@ -0,0 +1,21 @@ +package nl.hauntedmc.featureframework.toolkit.io.config; + +import java.nio.file.Path; +import java.util.Objects; + +/** Structured denial raised when the active host policy forbids a configuration mutation. */ +public class ConfigMutationDeniedException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String relativePath; + private final String operation; + + public ConfigMutationDeniedException(Path relativePath, String operation, String message) { + super(message); + this.relativePath = Objects.requireNonNull(relativePath, "relativePath").toString(); + this.operation = Objects.requireNonNull(operation, "operation"); + } + + public Path relativePath() { return Path.of(relativePath); } + public String operation() { return operation; } +} \ No newline at end of file diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMutationPolicy.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMutationPolicy.java new file mode 100644 index 0000000..36d10c2 --- /dev/null +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigMutationPolicy.java @@ -0,0 +1,17 @@ +package nl.hauntedmc.featureframework.toolkit.io.config; + +import java.nio.file.Path; +import java.util.Objects; + +/** Host policy invoked immediately before FeatureFramework mutates a configuration file. */ +@FunctionalInterface +public interface ConfigMutationPolicy { + void checkMutation(Path relativePath, String operation); + + static ConfigMutationPolicy allowAll() { + return (path, operation) -> { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(operation, "operation"); + }; + } +} diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java index 2eef8f5..5c6a0da 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/ConfigService.java @@ -5,9 +5,9 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermission; import java.util.Objects; @@ -21,17 +21,28 @@ public final class ConfigService { private final FrameworkLogger logger; private final ClassLoader resources; private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private volatile ConfigMutationPolicy mutationPolicy; public ConfigService(ToolkitContext context) { this(Objects.requireNonNull(context.getDataDirectory(), "dataDirectory"), context.getToolkitLogger() == null ? FrameworkLogger.noop() : context.getToolkitLogger(), - context.getResourceClassLoader()); + context.getResourceClassLoader(), ConfigMutationPolicy.allowAll()); } public ConfigService(Path dataDir, FrameworkLogger logger, ClassLoader resources) { + this(dataDir, logger, resources, ConfigMutationPolicy.allowAll()); + } + + public ConfigService( + Path dataDir, + FrameworkLogger logger, + ClassLoader resources, + ConfigMutationPolicy mutationPolicy + ) { this.dataDir = Objects.requireNonNull(dataDir, "dataDir").toAbsolutePath().normalize(); this.logger = Objects.requireNonNull(logger, "logger"); this.resources = resources == null ? ConfigService.class.getClassLoader() : resources; + this.mutationPolicy = Objects.requireNonNull(mutationPolicy, "mutationPolicy"); } public ConfigService(Path dataDir, java.util.logging.Logger logger, ClassLoader resources) { @@ -42,12 +53,19 @@ public ConfigService(Path dataDir, org.slf4j.Logger logger, ClassLoader resource this(dataDir, FrameworkLogger.from(logger), resources); } + /** Installs the host mutation policy. Existing cached YAML handles observe the new policy immediately. */ + public void installMutationPolicy(ConfigMutationPolicy policy) { + mutationPolicy = Objects.requireNonNull(policy, "policy"); + } + public YamlFile open(String relativePath, boolean copyDefaultsIfPresent) { - Path absolute = resolve(relativePath); + Path relative = checkedRelative(relativePath); + Path absolute = dataDir.resolve(relative).normalize(); return cache.computeIfAbsent(absolute, path -> { try { Files.createDirectories(path.getParent()); if (Files.notExists(path)) { + checkMutation(relative, copyDefaultsIfPresent ? "create from defaults" : "create"); if (copyDefaultsIfPresent) { try (InputStream input = resources.getResourceAsStream(relativePath)) { if (input != null) { @@ -63,10 +81,8 @@ public YamlFile open(String relativePath, boolean copyDefaultsIfPresent) { logger.info("[FeatureFramework] Created empty file '" + relativePath + "'"); } } - if (!Files.isRegularFile(path)) { - throw new IllegalStateException("Config path is not a regular file: " + path); - } - return new YamlFile(path, logger); + if (!Files.isRegularFile(path)) throw new IllegalStateException("Config path is not a regular file: " + path); + return new YamlFile(path, relative, logger, this::checkMutation); } catch (IOException exception) { throw new IllegalStateException("Failed to open YAML file: " + path, exception); } @@ -83,26 +99,25 @@ public Optional openExisting(String relativePath) { public boolean isCached(String relativePath) { return cache.containsKey(resolve(relativePath)); } public int cachedFileCount() { return cache.size(); } public Set cachedPaths() { return Set.copyOf(cache.keySet()); } - - /** Root directory used by this service. Administrative storage operations must remain below it. */ public Path dataDirectory() { return dataDir; } + public ConfigMutationPolicy mutationPolicy() { return mutationPolicy; } - /** Replaces a YAML file with a valid empty document while keeping cached handles coherent. */ public void replaceWithEmptyDocument(String relativePath) { - Path absolute = resolve(relativePath); + Path relative = checkedRelative(relativePath); + Path absolute = dataDir.resolve(relative).normalize(); YamlFile cached = cache.get(absolute); if (cached != null) { cached.replaceWithEmptyDocument(); return; } + checkMutation(relative, "replace with empty document"); Path temporary = null; try { Files.createDirectories(absolute.getParent()); temporary = Files.createTempFile(absolute.getParent(), "." + absolute.getFileName(), ".tmp"); preservePosixPermissions(absolute, temporary); - try { - Files.move(temporary, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException ignored) { + try { Files.move(temporary, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, absolute, StandardCopyOption.REPLACE_EXISTING); } open(relativePath, false); @@ -112,8 +127,7 @@ public void replaceWithEmptyDocument(String relativePath) { if (temporary != null) { try { Files.deleteIfExists(temporary); } catch (IOException cleanupFailure) { - logger.warn("[FeatureFramework] Could not remove temporary YAML '" + temporary + "'.", - cleanupFailure); + logger.warn("[FeatureFramework] Could not remove temporary YAML '" + temporary + "'.", cleanupFailure); } } } @@ -124,46 +138,38 @@ private static void preservePosixPermissions(Path source, Path target) { try { Set permissions = Files.getPosixFilePermissions(source); Files.setPosixFilePermissions(target, permissions); - } catch (IOException | UnsupportedOperationException ignored) { - // Non-POSIX filesystems keep their native permission behavior. - } + } catch (IOException | UnsupportedOperationException ignored) { } } - /** - * Removes a file and evicts its cached handle. This is intended for optional files that are - * rediscovered on reload; stable main config/message files should be replaced instead. - */ public void deleteOptional(String relativePath) throws IOException { - Path absolute = resolve(relativePath); + Path relative = checkedRelative(relativePath); + checkMutation(relative, "delete optional file"); + Path absolute = dataDir.resolve(relative).normalize(); Files.deleteIfExists(absolute); cache.remove(absolute); } - /** Reloads a cached handle after an external atomic restore, if one exists. */ public void reloadIfCached(String relativePath) { YamlFile cached = cache.get(resolve(relativePath)); if (cached != null) cached.reload(); } - /** Evicts an optional cached handle without changing the filesystem. */ - public void evict(String relativePath) { - cache.remove(resolve(relativePath)); - } + public void evict(String relativePath) { cache.remove(resolve(relativePath)); } + + public Path resolve(String relativePath) { return dataDir.resolve(checkedRelative(relativePath)).normalize(); } - public Path resolve(String relativePath) { + private Path checkedRelative(String relativePath) { Objects.requireNonNull(relativePath, "relativePath"); - if (relativePath.isBlank()) { - throw new IllegalArgumentException("Config path must not be blank"); - } - Path relative = Path.of(relativePath); - if (relative.isAbsolute()) { - throw new IllegalArgumentException("Config path must be relative: " + relativePath); - } + if (relativePath.isBlank()) throw new IllegalArgumentException("Config path must not be blank"); + Path relative = Path.of(relativePath).normalize(); + if (relative.isAbsolute()) throw new IllegalArgumentException("Config path must be relative: " + relativePath); Path absolute = dataDir.resolve(relative).normalize(); - if (!absolute.startsWith(dataDir)) { - throw new IllegalArgumentException("Config path escapes data directory: " + relativePath); - } - return absolute; + if (!absolute.startsWith(dataDir)) throw new IllegalArgumentException("Config path escapes data directory: " + relativePath); + return relative; + } + + private void checkMutation(Path relativePath, String operation) { + mutationPolicy.checkMutation(relativePath, operation); } public ConfigView view(String relativePath, boolean copyDefaultsIfPresent) { @@ -173,4 +179,4 @@ public ConfigView view(String relativePath, boolean copyDefaultsIfPresent) { public ConfigView view(String relativePath, boolean copyDefaultsIfPresent, String basePath) { return new ConfigView(open(relativePath, copyDefaultsIfPresent), basePath); } -} +} \ No newline at end of file diff --git a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java index d7756d3..00ae381 100644 --- a/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java +++ b/featureframework-toolkit/src/main/java/nl/hauntedmc/featureframework/toolkit/io/config/YamlFile.java @@ -21,26 +21,29 @@ /** Owns one YAML file, its last-known-good in-memory tree, and its synchronization boundary. */ public final class YamlFile { private final Path path; + private final Path policyPath; private final FrameworkLogger logger; + private final ConfigMutationPolicy mutationPolicy; private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final YamlConfigurationLoader loader; private volatile CommentedConfigurationNode root; private volatile ConfigLoadException loadFailure; public YamlFile(Path path, FrameworkLogger logger) { + this(path, path.getFileName(), logger, ConfigMutationPolicy.allowAll()); + } + + YamlFile(Path path, Path policyPath, FrameworkLogger logger, ConfigMutationPolicy mutationPolicy) { this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + this.policyPath = Objects.requireNonNull(policyPath, "policyPath"); this.logger = Objects.requireNonNull(logger, "logger"); + this.mutationPolicy = Objects.requireNonNull(mutationPolicy, "mutationPolicy"); this.loader = loaderFor(this.path); reload(); } - public YamlFile(Path path, java.util.logging.Logger logger) { - this(path, FrameworkLogger.from(logger)); - } - - public YamlFile(Path path, org.slf4j.Logger logger) { - this(path, FrameworkLogger.from(logger)); - } + public YamlFile(Path path, java.util.logging.Logger logger) { this(path, FrameworkLogger.from(logger)); } + public YamlFile(Path path, org.slf4j.Logger logger) { this(path, FrameworkLogger.from(logger)); } public Path path() { return path; } public Optional loadFailure() { return Optional.ofNullable(loadFailure); } @@ -57,38 +60,29 @@ public void reload() { ConfigLoadException failure = new ConfigLoadException(path, exception); loadFailure = failure; throw failure; - } finally { - lock.writeLock().unlock(); - } + } finally { lock.writeLock().unlock(); } } public void mutateAndSave(Consumer mutator) { Objects.requireNonNull(mutator, "mutator"); + mutationPolicy.checkMutation(policyPath, "mutate and save"); lock.writeLock().lock(); try { CommentedConfigurationNode candidate = copyRootUnsafe(); mutator.accept(candidate); commitCandidateUnsafe(candidate); - } finally { - lock.writeLock().unlock(); - } + } finally { lock.writeLock().unlock(); } } - /** - * Replaces this file with a valid empty YAML document, even when the latest on-disk document - * could not be loaded. This is intentionally separate from normal mutation: callers use it - * only for explicit recovery operations where discarding the invalid document is the goal. - */ public void replaceWithEmptyDocument() { + mutationPolicy.checkMutation(policyPath, "replace with empty document"); lock.writeLock().lock(); try { CommentedConfigurationNode candidate = CommentedConfigurationNode.root(); saveCandidate(candidate, true); root = candidate; loadFailure = null; - } finally { - lock.writeLock().unlock(); - } + } finally { lock.writeLock().unlock(); } } Object getRaw(String absolutePath) { @@ -96,33 +90,29 @@ Object getRaw(String absolutePath) { try { if (absolutePath == null || absolutePath.isBlank()) return root.get(Object.class); return root.node(splitPath(absolutePath)).get(Object.class); - } catch (Exception ignored) { - return null; - } finally { - lock.readLock().unlock(); - } + } catch (Exception ignored) { return null; } + finally { lock.readLock().unlock(); } } void setRawAndSave(String absolutePath, Object value) { + mutationPolicy.checkMutation(policyPath, "update '" + absolutePath + "'"); lock.writeLock().lock(); try { CommentedConfigurationNode candidate = copyRootUnsafe(); if (absolutePath == null || absolutePath.isBlank()) candidate.set(value); else candidate.node(splitPath(absolutePath)).set(value); commitCandidateUnsafe(candidate); - } catch (ConfigPersistenceException exception) { - throw exception; - } catch (Exception exception) { + } catch (ConfigPersistenceException exception) { throw exception; } + catch (Exception exception) { throw new ConfigPersistenceException(path, "update '" + absolutePath + "' in", exception); - } finally { - lock.writeLock().unlock(); - } + } finally { lock.writeLock().unlock(); } } CommentedConfigurationNode copyRootUnsafe() { return root.copy(); } void commitCandidateUnsafe(CommentedConfigurationNode candidate) { Objects.requireNonNull(candidate, "candidate"); + mutationPolicy.checkMutation(policyPath, "save"); saveCandidate(candidate, false); root = candidate; } @@ -130,8 +120,7 @@ void commitCandidateUnsafe(CommentedConfigurationNode candidate) { private void saveCandidate(CommentedConfigurationNode candidate, boolean allowInvalidReplacement) { ConfigLoadException currentFailure = loadFailure; if (currentFailure != null && !allowInvalidReplacement) { - throw new ConfigPersistenceException(path, - "save while the latest disk version is invalid for", currentFailure); + throw new ConfigPersistenceException(path, "save while the latest disk version is invalid for", currentFailure); } Path temporary = null; try { @@ -139,9 +128,8 @@ private void saveCandidate(CommentedConfigurationNode candidate, boolean allowIn temporary = Files.createTempFile(path.getParent(), "." + path.getFileName(), ".tmp"); loaderFor(temporary).save(candidate); preservePosixPermissions(path, temporary); - try { - Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException ignored) { + try { Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); } } catch (IOException exception) { @@ -151,8 +139,7 @@ private void saveCandidate(CommentedConfigurationNode candidate, boolean allowIn if (temporary != null) { try { Files.deleteIfExists(temporary); } catch (IOException cleanupFailure) { - logger.warn("[FeatureFramework] Could not remove temporary YAML '" + temporary + "'.", - cleanupFailure); + logger.warn("[FeatureFramework] Could not remove temporary YAML '" + temporary + "'.", cleanupFailure); } } } @@ -163,17 +150,12 @@ private static void preservePosixPermissions(Path source, Path target) { try { Set permissions = Files.getPosixFilePermissions(source); Files.setPosixFilePermissions(target, permissions); - } catch (IOException | UnsupportedOperationException ignored) { - // Non-POSIX filesystems keep their native permission behavior. - } + } catch (IOException | UnsupportedOperationException ignored) { } } private static YamlConfigurationLoader loaderFor(Path target) { - return YamlConfigurationLoader.builder() - .path(target) - .nodeStyle(NodeStyle.BLOCK) - .defaultOptions(ConfigurationOptions.defaults()) - .build(); + return YamlConfigurationLoader.builder().path(target).nodeStyle(NodeStyle.BLOCK) + .defaultOptions(ConfigurationOptions.defaults()).build(); } static Object[] splitPath(String dotted) { diff --git a/featureframework-velocity/pom.xml b/featureframework-velocity/pom.xml index b1e6153..b362c3d 100644 --- a/featureframework-velocity/pom.xml +++ b/featureframework-velocity/pom.xml @@ -11,9 +11,10 @@ Velocity-specific adapters and reusable runtime infrastructure. ${project.groupId}featureframework-core${project.version} + ${project.groupId}featureframework-cluster${project.version} com.velocitypoweredvelocity-api${velocity.version}provided com.velocitypoweredvelocity-brigadier1.0.0-SNAPSHOTprovided org.junit.jupiterjunit-jupiter${junit.version}test org.mockitomockito-core${mockito.version}test - + \ No newline at end of file diff --git a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java index 72a9199..710884d 100644 --- a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java +++ b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java @@ -6,10 +6,12 @@ import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import nl.hauntedmc.featureframework.api.FeatureFrameworkApi; import nl.hauntedmc.featureframework.api.RuntimeState; +import nl.hauntedmc.featureframework.api.feature.FeatureActivationPolicy; import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; import nl.hauntedmc.featureframework.api.service.CapabilityRegistry; +import nl.hauntedmc.featureframework.cluster.ReplicaHostControl; import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration; import nl.hauntedmc.featureframework.config.FeatureConfigHandler; import nl.hauntedmc.featureframework.host.FeatureCollection; @@ -28,6 +30,7 @@ import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; import nl.hauntedmc.featureframework.service.InternalServiceRegistry; import nl.hauntedmc.featureframework.service.Registration; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMutationPolicy; import nl.hauntedmc.featureframework.toolkit.io.config.ConfigService; import nl.hauntedmc.featureframework.toolkit.io.localization.Language; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; @@ -49,7 +52,8 @@ import java.util.function.Function; /** Complete, dependency-clean Velocity composition root. */ -public final class VelocityFeatureHost implements FeatureFrameworkApi, AutoCloseable { +public final class VelocityFeatureHost + implements FeatureFrameworkApi, ReplicaHostControl, AutoCloseable { private final FeatureRuntime runtime; private final FeatureHostComposition, VelocityFeatureContext

, FeatureConfigHandler, VelocityLocalization, FeatureLogger, VelocityFeatureResources> composition; @@ -124,6 +128,16 @@ public static

Builder builder( return builder(plugin, proxy, logger, dataDirectory, version, apiRoot, features); } + @Override + public void installReplicaPolicies( + FeatureActivationPolicy activationPolicy, + ConfigMutationPolicy mutationPolicy + ) { + composition.installReplicaPolicies(activationPolicy, mutationPolicy); + } + + @Override public boolean reconcileReplicaGraph() { return composition.reconcileReplicaGraph(); } + public void start() { composition.start(); } public void stop() { composition.stop(); } public boolean isLoaded(FeatureId id) { return composition.isLoaded(id); } @@ -275,4 +289,4 @@ private static String text(String value, String field) { if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); return normalized; } -} +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5a05d2c..6f0d458 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ nl.hauntedmc.platform haunted-library-parent - 1.3.0 + 1.5.0 @@ -46,7 +46,7 @@ scm:git:https://github.com/HauntedMC/FeatureFramework.git scm:git:ssh://git@github.com/HauntedMC/FeatureFramework.git https://github.com/HauntedMC/FeatureFramework - v1.8.0 + v2.0.0 GitHub @@ -74,7 +74,9 @@ featureframework-toolkit featureframework-processor featureframework-core + featureframework-cluster featureframework-dataprovider + featureframework-cluster-dataprovider featureframework-dataregistry featureframework-paper featureframework-paper-toolkit @@ -84,8 +86,8 @@ - 1.8.0 - 2026-08-25T00:00:00Z + 2.0.0 + 2026-09-02T00:00:00Z 26.2.build.65-beta 4.1.0-SNAPSHOT @@ -102,8 +104,8 @@ 1.3.10 2.12.3 5.11.0 - 1.16.0 - 3.4.1 + 1.17.0 + 3.4.2 ${haunted.jpa.version} ${haunted.junit.version} @@ -137,9 +139,7 @@ true true true - - -Xlint:all,-processing - + -Xlint:all,-processing @@ -157,43 +157,21 @@ - - maven-jar-plugin - ${maven.jar.version} - - - maven-source-plugin - ${maven.source.version} - - - maven-javadoc-plugin - ${maven.javadoc.version} - - - maven-shade-plugin - ${maven.shade.version} - - - org.codehaus.mojo - exec-maven-plugin - ${maven.exec.version} - - - maven-deploy-plugin - ${maven.deploy.version} - + maven-jar-plugin${maven.jar.version} + maven-source-plugin${maven.source.version} + maven-javadoc-plugin${maven.javadoc.version} + maven-shade-plugin${maven.shade.version} + org.codehaus.mojoexec-maven-plugin${maven.exec.version} + maven-deploy-plugin${maven.deploy.version} - - + platform-acceptance - - featureframework-platform-acceptance - + featureframework-platform-acceptance release