From 450e7d21a4c8ee837d7492a481ad8d33a4928a26 Mon Sep 17 00:00:00 2001 From: Hylfrd Date: Mon, 31 Aug 2026 11:31:30 +0800 Subject: [PATCH] feat(feature): add deterministic lifecycle arbitration --- .../lifecycle/FeatureFailsafePolicy.java | 7 + .../lifecycle/FeatureFailsafeSubmission.java | 42 ++ .../feature/lifecycle/FeatureId.java | 19 + .../feature/lifecycle/FeatureLifecycle.java | 540 ++++++++++++++++++ .../lifecycle/FeatureLifecycleResult.java | 23 + .../lifecycle/FeatureLifecycleTarget.java | 23 + .../feature/lifecycle/FeaturePolicy.java | 19 + .../lifecycle/FeatureRegistration.java | 31 + .../feature/lifecycle/FeatureRunIdentity.java | 23 + .../feature/lifecycle/FeatureState.java | 12 + .../feature/lifecycle/FeatureStopCause.java | 11 + .../feature/lifecycle/package-info.java | 2 + .../lifecycle/FeatureLifecycleTest.java | 449 +++++++++++++++ 13 files changed, 1201 insertions(+) create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafePolicy.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafeSubmission.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureId.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycle.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleResult.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTarget.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeaturePolicy.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRegistration.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRunIdentity.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureState.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureStopCause.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/package-info.java create mode 100644 src/test/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTest.java diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafePolicy.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafePolicy.java new file mode 100644 index 00000000..6da6fcd7 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafePolicy.java @@ -0,0 +1,7 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +/** Whether detector evidence is admissible while a feature is active. */ +public enum FeatureFailsafePolicy { + ALLOW_CHECKS, + SUPPRESS_CHECKS +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafeSubmission.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafeSubmission.java new file mode 100644 index 00000000..a567464e --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureFailsafeSubmission.java @@ -0,0 +1,42 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import dev.hylfrd.farmhelper.failsafe.FailsafeArbitrator; +import dev.hylfrd.farmhelper.failsafe.FailsafeCandidate; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Explicit result of applying feature policy before domain failsafe arbitration. */ +public record FeatureFailsafeSubmission( + Status status, + FailsafeCandidate candidate, + List suppressors, + Optional arbitration +) { + public enum Status { + INACTIVE, + SUPPRESSED, + ARBITRATED + } + + public FeatureFailsafeSubmission { + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(candidate, "candidate"); + suppressors = List.copyOf(Objects.requireNonNull(suppressors, "suppressors")); + Objects.requireNonNull(arbitration, "arbitration"); + if (status == Status.SUPPRESSED && suppressors.isEmpty()) { + throw new IllegalArgumentException("suppressed submission requires a suppressor"); + } + if (status != Status.SUPPRESSED && !suppressors.isEmpty()) { + throw new IllegalArgumentException("only a suppressed submission may name suppressors"); + } + if ((status == Status.ARBITRATED) != arbitration.isPresent()) { + throw new IllegalArgumentException("only an arbitrated submission may carry arbitration"); + } + } + + public boolean accepted() { + return arbitration.map(FailsafeArbitrator.Submission::accepted).orElse(false); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureId.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureId.java new file mode 100644 index 00000000..5365406e --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureId.java @@ -0,0 +1,19 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import java.util.Objects; + +/** Stable, human-readable identity for one registered feature. */ +public record FeatureId(String value) { + public FeatureId { + Objects.requireNonNull(value, "value"); + value = value.trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException("feature id must not be blank"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycle.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycle.java new file mode 100644 index 00000000..1261414c --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycle.java @@ -0,0 +1,540 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import dev.hylfrd.farmhelper.failsafe.FailsafeArbitrator; +import dev.hylfrd.farmhelper.failsafe.FailsafeCandidate; +import dev.hylfrd.farmhelper.macro.FeatureSuspension; +import dev.hylfrd.farmhelper.macro.MacroLifecycle; +import dev.hylfrd.farmhelper.macro.MacroPauseCause; +import dev.hylfrd.farmhelper.macro.MacroState; +import dev.hylfrd.farmhelper.macro.MacroTerminalReason; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Ordered, domain-only owner of feature runtime state. + * + *

Every active macro-pausing feature owns one {@link FeatureSuspension}. Detector admission and + * selection must pass through this class so an explicit foreground feature policy can suppress + * failsafes without teaching the arbitrator about features. Cross-object transitions are intended + * for one client thread; synchronization only prevents the manager's own state from tearing.

+ */ +public final class FeatureLifecycle { + private final MacroLifecycle macroLifecycle; + private final FailsafeArbitrator failsafeArbitrator; + private final List registrationOrder; + private final Map entriesById; + private FeatureRunIdentity identity; + private boolean macroPaused; + private Set macroPauseCauses = Set.of(); + + public FeatureLifecycle( + MacroLifecycle macroLifecycle, + FailsafeArbitrator failsafeArbitrator, + FeatureRegistration... registrations + ) { + this(macroLifecycle, failsafeArbitrator, copyVarargs(registrations)); + } + + public FeatureLifecycle( + MacroLifecycle macroLifecycle, + FailsafeArbitrator failsafeArbitrator, + Collection registrations + ) { + this.macroLifecycle = Objects.requireNonNull(macroLifecycle, "macroLifecycle"); + this.failsafeArbitrator = Objects.requireNonNull(failsafeArbitrator, "failsafeArbitrator"); + Objects.requireNonNull(registrations, "registrations"); + + List ordered = new ArrayList<>(registrations.size()); + java.util.LinkedHashMap indexed = new java.util.LinkedHashMap<>(); + Set targets = java.util.Collections.newSetFromMap( + new IdentityHashMap<>()); + for (FeatureRegistration registration : registrations) { + Objects.requireNonNull(registration, "registration"); + if (!targets.add(registration.target())) { + throw new IllegalArgumentException("duplicate feature target: " + registration.id()); + } + Entry entry = new Entry(registration); + if (indexed.putIfAbsent(registration.id(), entry) != null) { + throw new IllegalArgumentException("duplicate feature id: " + registration.id()); + } + ordered.add(entry); + } + registrationOrder = List.copyOf(ordered); + entriesById = Map.copyOf(indexed); + } + + public synchronized List registeredFeatures() { + return registrationOrder.stream().map(Entry::id).toList(); + } + + public synchronized Optional identity() { + return Optional.ofNullable(identity); + } + + public synchronized FeatureState state(FeatureId id) { + return entry(id).state; + } + + public synchronized List activeFeatures() { + return registrationOrder.stream() + .filter(entry -> entry.state.active()) + .map(Entry::id) + .toList(); + } + + public synchronized List macroPausingFeatures() { + return registrationOrder.stream() + .filter(entry -> entry.state.active() && entry.registration.policy().pausesMacro()) + .map(Entry::id) + .toList(); + } + + public synchronized List failsafeSuppressors() { + return suppressors(); + } + + public synchronized boolean canCheckFailsafes() { + return identity != null && suppressors().isEmpty(); + } + + /** + * Binds one exact macro/world run, resets failsafe identity, and snapshots all auto-start + * eligibility before invoking any feature callback. + */ + public synchronized FeatureLifecycleResult macroStarted(long generation, long worldEpoch) { + FeatureRunIdentity next = new FeatureRunIdentity(generation, worldEpoch); + if (identity != null) { + if (identity.equals(next)) { + return FeatureLifecycleResult.unchanged(); + } + throw new IllegalStateException("a feature macro run is already active: " + identity); + } + if (!macroLifecycle.accepts(generation)) { + throw new IllegalArgumentException("macro generation is not active: " + generation); + } + + FailureCollector failures = new FailureCollector(); + List eligible = new ArrayList<>(); + for (Entry entry : registrationOrder) { + if (!entry.registration.policy().startAtMacroStart()) { + continue; + } + try { + if (entry.registration.enabledNow()) { + eligible.add(entry); + } + } catch (RuntimeException | Error failure) { + failures.add(entry.id(), "eligibility", failure); + } + } + if (failures.failed()) { + return failures.result(false); + } + + identity = next; + macroPaused = macroLifecycle.state() == MacroState.PAUSED; + macroPauseCauses = macroPaused ? macroLifecycle.pauseCauses() : Set.of(); + failsafeArbitrator.reset(generation, worldEpoch); + for (Entry entry : eligible) { + FeatureLifecycleResult activation = activate(entry); + failures.merge(activation); + if (activation.failed()) { + break; + } + } + if (failures.failed()) { + stopEntries(Set.of(), FeatureStopCause.START_ROLLBACK, failures, false); + identity = null; + macroPaused = false; + macroPauseCauses = Set.of(); + failsafeArbitrator.cancel(); + return failures.result(false); + } + return failures.result(true); + } + + public synchronized FeatureLifecycleResult macroPaused( + long generation, + Set causes + ) { + Objects.requireNonNull(causes, "causes"); + Set copiedCauses = Set.copyOf(causes); + if (copiedCauses.isEmpty()) { + throw new IllegalArgumentException("a paused macro must have at least one cause"); + } + if (!matches(generation)) { + return FeatureLifecycleResult.unchanged(); + } + macroPauseCauses = copiedCauses; + if (macroPaused) { + return FeatureLifecycleResult.unchanged(); + } + + macroPaused = true; + FailureCollector failures = new FailureCollector(); + boolean changed = false; + for (Entry entry : registrationOrder) { + if (entry.state == FeatureState.RUNNING + && entry.registration.policy().pauseWithMacro()) { + changed = true; + pause(entry, failures); + } + } + return failures.result(changed); + } + + public synchronized FeatureLifecycleResult macroResumed(long generation) { + if (!matches(generation) || !macroPaused) { + return FeatureLifecycleResult.unchanged(); + } + macroPaused = false; + macroPauseCauses = Set.of(); + FailureCollector failures = new FailureCollector(); + boolean changed = false; + for (Entry entry : registrationOrder) { + if (entry.state == FeatureState.PAUSED) { + changed = true; + resume(entry, failures); + } + } + return failures.result(changed); + } + + /** Commits the generation fence before best-effort feature cleanup. */ + public synchronized FeatureLifecycleResult macroStopped( + long generation, + MacroTerminalReason reason + ) { + Objects.requireNonNull(reason, "reason"); + if (!matches(generation)) { + return FeatureLifecycleResult.unchanged(); + } + + FailureCollector failures = new FailureCollector(); + FeatureRunIdentity stoppedIdentity = identity; + identity = null; + macroPaused = false; + macroPauseCauses = Set.of(); + stopEntries( + Set.of(), FeatureStopCause.MACRO_STOPPED, failures, false, stoppedIdentity); + failsafeArbitrator.cancel(); + return failures.result(true); + } + + /** Enables one currently eligible feature; duplicate enable is inert. */ + public synchronized FeatureLifecycleResult enable(FeatureId id) { + Entry entry = entry(id); + requireActiveMacro(); + if (entry.state.active()) { + return FeatureLifecycleResult.unchanged(); + } + try { + if (!entry.registration.enabledNow()) { + return FeatureLifecycleResult.unchanged(); + } + } catch (RuntimeException | Error failure) { + FailureCollector failures = new FailureCollector(); + failures.add(entry.id(), "eligibility", failure); + return failures.result(false); + } + return activate(entry); + } + + public synchronized FeatureLifecycleResult stop(FeatureId id, FeatureStopCause cause) { + Objects.requireNonNull(cause, "cause"); + Entry entry = entry(id); + if (!entry.state.active()) { + return FeatureLifecycleResult.unchanged(); + } + FailureCollector failures = new FailureCollector(); + stop(entry, cause, failures, true); + return failures.result(true); + } + + public synchronized FeatureLifecycleResult stopAll(FeatureStopCause cause) { + return stopAllExcept(Set.of(), cause); + } + + /** Stops active features in registration order while retaining the named parent/peer set. */ + public synchronized FeatureLifecycleResult stopAllExcept( + Set retained, + FeatureStopCause cause + ) { + Objects.requireNonNull(retained, "retained"); + Objects.requireNonNull(cause, "cause"); + Set copied = Set.copyOf(retained); + for (FeatureId id : copied) { + entry(id); + } + FailureCollector failures = new FailureCollector(); + boolean changed = stopEntries(copied, cause, failures, true); + return failures.result(changed); + } + + /** Resets every registration in deterministic order; active features must be stopped first. */ + public synchronized FeatureLifecycleResult resetAll() { + if (!activeFeatures().isEmpty()) { + throw new IllegalStateException("active features must be stopped before reset"); + } + FailureCollector failures = new FailureCollector(); + for (Entry entry : registrationOrder) { + try { + entry.registration.target().reset(); + } catch (RuntimeException | Error failure) { + failures.add(entry.id(), "reset", failure); + } + } + return failures.result(!registrationOrder.isEmpty()); + } + + /** Applies active feature policy before delegating to the identity-bound arbitrator. */ + public synchronized FeatureFailsafeSubmission submitFailsafe(FailsafeCandidate candidate) { + Objects.requireNonNull(candidate, "candidate"); + if (identity == null) { + return new FeatureFailsafeSubmission( + FeatureFailsafeSubmission.Status.INACTIVE, + candidate, + List.of(), + Optional.empty()); + } + List suppressors = suppressors(); + if (!suppressors.isEmpty()) { + return new FeatureFailsafeSubmission( + FeatureFailsafeSubmission.Status.SUPPRESSED, + candidate, + suppressors, + Optional.empty()); + } + return new FeatureFailsafeSubmission( + FeatureFailsafeSubmission.Status.ARBITRATED, + candidate, + List.of(), + Optional.of(failsafeArbitrator.submit(candidate))); + } + + /** A selected candidate is never surfaced while a foreground feature suppresses checks. */ + public synchronized Optional selectFailsafeIfReady() { + return canCheckFailsafes() + ? failsafeArbitrator.selectIfReady() + : Optional.empty(); + } + + private FeatureLifecycleResult activate(Entry entry) { + FailureCollector failures = new FailureCollector(); + FeatureRunIdentity run = requireActiveMacro(); + entry.state = FeatureState.RUNNING; + + if (entry.registration.policy().failsafePolicy() + == FeatureFailsafePolicy.SUPPRESS_CHECKS) { + // Evidence admitted before a foreground transition is no longer current. + failsafeArbitrator.cancel(); + } + try { + if (entry.registration.policy().pausesMacro()) { + entry.suspension = macroLifecycle.suspendForFeature(entry.id().value()); + } + entry.registration.target().enable(run); + } catch (RuntimeException | Error failure) { + failures.add(entry.id(), "enable", failure); + stop(entry, FeatureStopCause.CALLBACK_FAILED, failures, true); + return failures.result(false); + } + + failures.merge(reconcileMacroState()); + return failures.result(true); + } + + private void pause(Entry entry, FailureCollector failures) { + FeatureRunIdentity run = identity; + entry.state = FeatureState.PAUSED; + try { + entry.registration.target().pause(run, macroPauseCauses); + } catch (RuntimeException | Error failure) { + failures.add(entry.id(), "pause", failure); + stop(entry, FeatureStopCause.CALLBACK_FAILED, failures, true); + } + } + + private void resume(Entry entry, FailureCollector failures) { + FeatureRunIdentity run = identity; + entry.state = FeatureState.RUNNING; + try { + entry.registration.target().resume(run); + } catch (RuntimeException | Error failure) { + failures.add(entry.id(), "resume", failure); + stop(entry, FeatureStopCause.CALLBACK_FAILED, failures, true); + } + } + + private void stop( + Entry entry, + FeatureStopCause cause, + FailureCollector failures, + boolean reconcile + ) { + stop(entry, cause, failures, reconcile, identity); + } + + private void stop( + Entry entry, + FeatureStopCause cause, + FailureCollector failures, + boolean reconcile, + FeatureRunIdentity stoppedIdentity + ) { + if (!entry.state.active()) { + return; + } + FeatureSuspension suspension = entry.suspension; + entry.suspension = null; + entry.state = FeatureState.STOPPED; + try { + if (stoppedIdentity != null) { + entry.registration.target().stop(stoppedIdentity, cause); + } + } catch (RuntimeException | Error failure) { + failures.add(entry.id(), "stop", failure); + } finally { + if (suspension != null) { + try { + suspension.close(); + } catch (RuntimeException | Error failure) { + failures.add(entry.id(), "release macro suspension", failure); + } + } + } + if (reconcile) { + failures.merge(reconcileMacroState()); + } + } + + private boolean stopEntries( + Set retained, + FeatureStopCause cause, + FailureCollector failures, + boolean reconcile + ) { + return stopEntries(retained, cause, failures, reconcile, identity); + } + + private boolean stopEntries( + Set retained, + FeatureStopCause cause, + FailureCollector failures, + boolean reconcile, + FeatureRunIdentity stoppedIdentity + ) { + boolean changed = false; + for (Entry entry : registrationOrder) { + if (entry.state.active() && !retained.contains(entry.id())) { + changed = true; + stop(entry, cause, failures, false, stoppedIdentity); + } + } + if (reconcile) { + failures.merge(reconcileMacroState()); + } + return changed; + } + + private FeatureLifecycleResult reconcileMacroState() { + if (identity == null) { + return FeatureLifecycleResult.unchanged(); + } + return switch (macroLifecycle.state()) { + case RUNNING -> macroResumed(identity.macroGeneration()); + case PAUSED -> macroPaused(identity.macroGeneration(), macroLifecycle.pauseCauses()); + case STOPPED -> FeatureLifecycleResult.unchanged(); + }; + } + + private List suppressors() { + return registrationOrder.stream() + .filter(entry -> entry.state.active() + && entry.registration.policy().failsafePolicy() + == FeatureFailsafePolicy.SUPPRESS_CHECKS) + .map(Entry::id) + .toList(); + } + + private boolean matches(long generation) { + return identity != null && identity.matchesMacro(generation); + } + + private FeatureRunIdentity requireActiveMacro() { + if (identity == null) { + throw new IllegalStateException("no feature macro run is active"); + } + if (!macroLifecycle.accepts(identity.macroGeneration())) { + throw new IllegalStateException("feature macro run is no longer accepted: " + identity); + } + return identity; + } + + private Entry entry(FeatureId id) { + Objects.requireNonNull(id, "id"); + Entry entry = entriesById.get(id); + if (entry == null) { + throw new IllegalArgumentException("feature is not registered: " + id); + } + return entry; + } + + private static List copyVarargs(FeatureRegistration[] registrations) { + Objects.requireNonNull(registrations, "registrations"); + return List.of(registrations.clone()); + } + + private static final class Entry { + private final FeatureRegistration registration; + private FeatureState state = FeatureState.STOPPED; + private FeatureSuspension suspension; + + private Entry(FeatureRegistration registration) { + this.registration = registration; + } + + private FeatureId id() { + return registration.id(); + } + } + + private static final class FailureCollector { + private RuntimeException aggregate; + + private void add(FeatureId id, String operation, Throwable failure) { + if (aggregate == null) { + aggregate = new RuntimeException("one or more feature lifecycle callbacks failed"); + } + aggregate.addSuppressed(new RuntimeException( + "feature " + id + " failed during " + operation, failure)); + } + + private void merge(FeatureLifecycleResult result) { + result.failure().ifPresent(failure -> { + if (aggregate == null) { + aggregate = new RuntimeException( + "one or more feature lifecycle callbacks failed"); + } + for (Throwable suppressed : failure.getSuppressed()) { + aggregate.addSuppressed(suppressed); + } + }); + } + + private boolean failed() { + return aggregate != null; + } + + private FeatureLifecycleResult result(boolean changed) { + return new FeatureLifecycleResult(changed, Optional.ofNullable(aggregate)); + } + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleResult.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleResult.java new file mode 100644 index 00000000..2d516d67 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleResult.java @@ -0,0 +1,23 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import java.util.Objects; +import java.util.Optional; + +/** Result of one best-effort lifecycle transition. */ +public record FeatureLifecycleResult(boolean changed, Optional failure) { + public FeatureLifecycleResult { + Objects.requireNonNull(failure, "failure"); + } + + public boolean succeeded() { + return failure.isEmpty(); + } + + public boolean failed() { + return failure.isPresent(); + } + + static FeatureLifecycleResult unchanged() { + return new FeatureLifecycleResult(false, Optional.empty()); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTarget.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTarget.java new file mode 100644 index 00000000..b17050a1 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTarget.java @@ -0,0 +1,23 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import dev.hylfrd.farmhelper.macro.MacroPauseCause; + +import java.util.Set; + +/** Side-effect boundary driven by {@link FeatureLifecycle}; it does not own lifecycle state. */ +public interface FeatureLifecycleTarget { + default void enable(FeatureRunIdentity identity) { + } + + default void pause(FeatureRunIdentity identity, Set causes) { + } + + default void resume(FeatureRunIdentity identity) { + } + + default void stop(FeatureRunIdentity identity, FeatureStopCause cause) { + } + + default void reset() { + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeaturePolicy.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeaturePolicy.java new file mode 100644 index 00000000..01e060a5 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeaturePolicy.java @@ -0,0 +1,19 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import java.util.Objects; + +/** Immutable lifecycle behavior for one feature registration. */ +public record FeaturePolicy( + boolean startAtMacroStart, + boolean pauseWithMacro, + boolean pausesMacro, + FeatureFailsafePolicy failsafePolicy +) { + public FeaturePolicy { + Objects.requireNonNull(failsafePolicy, "failsafePolicy"); + if (pauseWithMacro && pausesMacro) { + throw new IllegalArgumentException( + "a macro-pausing feature must remain active while its suspension is held"); + } + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRegistration.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRegistration.java new file mode 100644 index 00000000..89c60134 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRegistration.java @@ -0,0 +1,31 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import java.util.Objects; +import java.util.function.BooleanSupplier; + +/** One ordered feature registration with an injected, configuration-agnostic eligibility check. */ +public record FeatureRegistration( + FeatureId id, + FeaturePolicy policy, + BooleanSupplier enabled, + FeatureLifecycleTarget target +) { + public FeatureRegistration { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(enabled, "enabled"); + Objects.requireNonNull(target, "target"); + } + + public FeatureRegistration( + FeatureId id, + FeaturePolicy policy, + FeatureLifecycleTarget target + ) { + this(id, policy, () -> true, target); + } + + public boolean enabledNow() { + return enabled.getAsBoolean(); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRunIdentity.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRunIdentity.java new file mode 100644 index 00000000..7baef63c --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureRunIdentity.java @@ -0,0 +1,23 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import dev.hylfrd.farmhelper.failsafe.FailsafeCandidate; + +/** Exact macro/world identity shared by feature and failsafe work in one run. */ +public record FeatureRunIdentity(long macroGeneration, long worldEpoch) { + public FeatureRunIdentity { + if (macroGeneration <= 0L) { + throw new IllegalArgumentException("macroGeneration must be positive"); + } + if (worldEpoch < 0L) { + throw new IllegalArgumentException("worldEpoch must be non-negative"); + } + } + + public boolean matchesMacro(long candidateGeneration) { + return macroGeneration == candidateGeneration; + } + + public FailsafeCandidate.Identity failsafeIdentity() { + return new FailsafeCandidate.Identity(macroGeneration, worldEpoch); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureState.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureState.java new file mode 100644 index 00000000..8eec7d13 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureState.java @@ -0,0 +1,12 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +/** Manager-owned runtime state; configuration eligibility is deliberately separate. */ +public enum FeatureState { + STOPPED, + RUNNING, + PAUSED; + + public boolean active() { + return this != STOPPED; + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureStopCause.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureStopCause.java new file mode 100644 index 00000000..b1376f9b --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureStopCause.java @@ -0,0 +1,11 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +/** Why a manager-owned feature run is being terminated. */ +public enum FeatureStopCause { + REQUESTED, + EXCLUDED, + FAILSAFE, + MACRO_STOPPED, + CALLBACK_FAILED, + START_ROLLBACK +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/package-info.java b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/package-info.java new file mode 100644 index 00000000..86482979 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/lifecycle/package-info.java @@ -0,0 +1,2 @@ +/** Deterministic, domain-only feature registration and lifecycle arbitration. */ +package dev.hylfrd.farmhelper.feature.lifecycle; diff --git a/src/test/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTest.java b/src/test/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTest.java new file mode 100644 index 00000000..37ea95c0 --- /dev/null +++ b/src/test/java/dev/hylfrd/farmhelper/feature/lifecycle/FeatureLifecycleTest.java @@ -0,0 +1,449 @@ +package dev.hylfrd.farmhelper.feature.lifecycle; + +import dev.hylfrd.farmhelper.failsafe.FailsafeArbitrator; +import dev.hylfrd.farmhelper.failsafe.FailsafeCandidate; +import dev.hylfrd.farmhelper.failsafe.FailsafeType; +import dev.hylfrd.farmhelper.macro.MacroLifecycle; +import dev.hylfrd.farmhelper.macro.MacroLifecycleTarget; +import dev.hylfrd.farmhelper.macro.MacroPauseCause; +import dev.hylfrd.farmhelper.macro.MacroState; +import dev.hylfrd.farmhelper.macro.MacroTerminalReason; +import dev.hylfrd.farmhelper.runtime.time.MonotonicClock; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +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 FeatureLifecycleTest { + private static final long WORLD_EPOCH = 17L; + + @Test + void registrationRejectsDuplicateIdentityAndPreservesDeclaredOrder() { + Harness harness = new Harness(); + RecordingFeature shared = new RecordingFeature("shared", harness.events); + FeatureRegistration alpha = registration( + "alpha", policy(false, false, false, false), shared); + FeatureRegistration beta = registration( + "beta", policy(false, false, false, false), + new RecordingFeature("beta", harness.events)); + + FeatureLifecycle lifecycle = harness.lifecycle(alpha, beta); + + assertEquals(List.of(id("alpha"), id("beta")), lifecycle.registeredFeatures()); + assertThrows(UnsupportedOperationException.class, + () -> lifecycle.registeredFeatures().add(id("other"))); + assertThrows(IllegalArgumentException.class, () -> harness.lifecycle( + alpha, + registration("alpha", policy(false, false, false, false), + new RecordingFeature("duplicate", harness.events)))); + assertThrows(IllegalArgumentException.class, () -> harness.lifecycle( + alpha, + registration("other", policy(false, false, false, false), shared))); + assertThrows(IllegalArgumentException.class, + () -> new FeaturePolicy(false, true, true, + FeatureFailsafePolicy.ALLOW_CHECKS)); + assertEquals(id("trimmed"), new FeatureId(" trimmed ")); + assertThrows(IllegalArgumentException.class, () -> new FeatureId(" ")); + } + + @Test + void macroStartSnapshotsEligibilityAndUsesRegistrationOrder() { + Harness harness = new Harness(); + AtomicBoolean secondEnabled = new AtomicBoolean(true); + AtomicBoolean thirdEnabled = new AtomicBoolean(false); + RecordingFeature first = new RecordingFeature("first", harness.events); + RecordingFeature second = new RecordingFeature("second", harness.events); + RecordingFeature third = new RecordingFeature("third", harness.events); + first.onEnable = () -> secondEnabled.set(false); + FeatureLifecycle lifecycle = harness.lifecycle( + registration("first", policy(true, true, false, false), () -> true, first), + registration("second", policy(true, true, false, false), + secondEnabled::get, second), + registration("third", policy(true, true, false, false), + thirdEnabled::get, third)); + + long generation = harness.startMacro(lifecycle); + FeatureLifecycleResult duplicate = lifecycle.macroStarted(generation, WORLD_EPOCH); + + assertTrue(duplicate.succeeded()); + assertFalse(duplicate.changed()); + assertEquals(List.of("first:enable", "second:enable"), harness.events); + assertEquals(List.of(id("first"), id("second")), lifecycle.activeFeatures()); + + harness.macro.stop(MacroTerminalReason.CLIENT_STOP); + + assertEquals(List.of( + "first:enable", "second:enable", + "first:stop:MACRO_STOPPED", "second:stop:MACRO_STOPPED"), harness.events); + assertTrue(lifecycle.identity().isEmpty()); + assertEquals(FeatureState.STOPPED, lifecycle.state(id("first"))); + assertEquals(FeatureState.STOPPED, lifecycle.state(id("second"))); + } + + @Test + void nestedFeatureSuspensionsWaitForEveryFeatureAndOtherMacroCause() { + Harness harness = new Harness(); + FeatureLifecycle lifecycle = harness.lifecycle( + registration("visitors", policy(false, false, true, false), + new RecordingFeature("visitors", harness.events)), + registration("autosell", policy(false, false, true, false), + new RecordingFeature("autosell", harness.events))); + harness.startMacro(lifecycle); + + assertTrue(lifecycle.enable(id("visitors")).succeeded()); + assertTrue(lifecycle.enable(id("autosell")).succeeded()); + assertEquals(MacroState.PAUSED, harness.macro.state()); + assertEquals(List.of(id("visitors"), id("autosell")), + lifecycle.macroPausingFeatures()); + + harness.macro.manualPause(); + lifecycle.stop(id("visitors"), FeatureStopCause.REQUESTED); + lifecycle.stop(id("autosell"), FeatureStopCause.REQUESTED); + + assertEquals(MacroState.PAUSED, harness.macro.state()); + assertEquals(Set.of(MacroPauseCause.MANUAL), harness.macro.pauseCauses()); + harness.macro.manualResume(); + assertEquals(MacroState.RUNNING, harness.macro.state()); + assertEquals(List.of( + "visitors:enable", "autosell:enable", + "visitors:stop:REQUESTED", "autosell:stop:REQUESTED"), harness.events); + } + + @Test + void macroPauseAndResumeAreIdempotentAndFenceStaleGenerations() { + Harness harness = new Harness(); + RecordingFeature tracker = new RecordingFeature("tracker", harness.events); + RecordingFeature foreground = new RecordingFeature("foreground", harness.events); + FeatureLifecycle lifecycle = harness.lifecycle( + registration("tracker", policy(true, true, false, false), tracker), + registration("foreground", policy(true, false, false, false), foreground)); + long firstGeneration = harness.startMacro(lifecycle); + + harness.macro.manualPause(); + FeatureLifecycleResult duplicatePause = lifecycle.macroPaused( + firstGeneration, Set.of(MacroPauseCause.MANUAL)); + assertFalse(duplicatePause.changed()); + assertEquals(FeatureState.PAUSED, lifecycle.state(id("tracker"))); + assertEquals(FeatureState.RUNNING, lifecycle.state(id("foreground"))); + + harness.macro.manualResume(); + assertEquals(FeatureState.RUNNING, lifecycle.state(id("tracker"))); + harness.macro.stop(MacroTerminalReason.WORLD_CHANGE); + long secondGeneration = harness.startMacro(lifecycle); + + assertFalse(lifecycle.macroStopped( + firstGeneration, MacroTerminalReason.DISCONNECT).changed()); + assertEquals(secondGeneration, lifecycle.identity().orElseThrow().macroGeneration()); + assertEquals(List.of( + "tracker:enable", "foreground:enable", "tracker:pause", "tracker:resume", + "tracker:stop:MACRO_STOPPED", "foreground:stop:MACRO_STOPPED", + "tracker:enable", "foreground:enable"), harness.events); + } + + @Test + void pauseFailureStopsOnlyBrokenFeatureAndCannotBreakMacroLeaseAcquisition() { + Harness harness = new Harness(); + RecordingFeature broken = new RecordingFeature("broken", harness.events); + broken.failPause = true; + RecordingFeature pauser = new RecordingFeature("pauser", harness.events); + FeatureLifecycle lifecycle = harness.lifecycle( + registration("broken", policy(true, true, false, false), broken), + registration("pauser", policy(false, false, true, false), pauser)); + harness.startMacro(lifecycle); + + FeatureLifecycleResult activation = lifecycle.enable(id("pauser")); + + assertTrue(activation.succeeded()); + assertEquals(MacroState.PAUSED, harness.macro.state()); + assertEquals(FeatureState.STOPPED, lifecycle.state(id("broken"))); + assertEquals(FeatureState.RUNNING, lifecycle.state(id("pauser"))); + assertTrue(harness.bridge.lastResult.failed()); + + lifecycle.stop(id("pauser"), FeatureStopCause.REQUESTED); + assertEquals(MacroState.RUNNING, harness.macro.state()); + assertEquals(List.of( + "broken:enable", "broken:pause", "broken:stop:CALLBACK_FAILED", + "pauser:enable", "pauser:stop:REQUESTED"), harness.events); + } + + @Test + void failedAutoStartRollsBackEarlierFeaturesAndReleasesMacroSuspension() { + Harness harness = new Harness(); + RecordingFeature pauser = new RecordingFeature("pauser", harness.events); + RecordingFeature broken = new RecordingFeature("broken", harness.events); + broken.failEnable = true; + FeatureLifecycle lifecycle = harness.lifecycle( + registration("pauser", policy(true, false, true, false), pauser), + registration("broken", policy(true, false, false, false), broken)); + + harness.macro.start(); + long generation = harness.macro.generation(); + FeatureLifecycleResult result = lifecycle.macroStarted(generation, WORLD_EPOCH); + + assertTrue(result.failed()); + assertFalse(result.changed()); + assertTrue(lifecycle.identity().isEmpty()); + assertTrue(lifecycle.activeFeatures().isEmpty()); + assertEquals(MacroState.RUNNING, harness.macro.state()); + assertEquals(List.of( + "pauser:enable", "broken:enable", "broken:stop:CALLBACK_FAILED", + "pauser:stop:START_ROLLBACK"), harness.events); + + harness.macro.stop(MacroTerminalReason.EXCEPTION); + } + + @Test + void stopAllContinuesAfterFailureAndReleasesEverySuspension() { + Harness harness = new Harness(); + RecordingFeature first = new RecordingFeature("first", harness.events); + first.failStop = true; + RecordingFeature second = new RecordingFeature("second", harness.events); + FeatureLifecycle lifecycle = harness.lifecycle( + registration("first", policy(false, false, true, false), first), + registration("second", policy(false, false, true, false), second)); + harness.startMacro(lifecycle); + lifecycle.enable(id("first")); + lifecycle.enable(id("second")); + + FeatureLifecycleResult result = lifecycle.stopAll(FeatureStopCause.REQUESTED); + + assertTrue(result.changed()); + assertTrue(result.failed()); + assertTrue(lifecycle.activeFeatures().isEmpty()); + assertEquals(MacroState.RUNNING, harness.macro.state()); + assertEquals(List.of( + "first:enable", "second:enable", + "first:stop:REQUESTED", "second:stop:REQUESTED"), harness.events); + } + + @Test + void stopAllExceptAndResetUseRegistrationOrder() { + Harness harness = new Harness(); + RecordingFeature first = new RecordingFeature("first", harness.events); + RecordingFeature retained = new RecordingFeature("retained", harness.events); + RecordingFeature third = new RecordingFeature("third", harness.events); + FeatureLifecycle lifecycle = harness.lifecycle( + registration("first", policy(false, false, false, false), first), + registration("retained", policy(false, false, false, false), retained), + registration("third", policy(false, false, false, false), third)); + harness.startMacro(lifecycle); + lifecycle.enable(id("first")); + lifecycle.enable(id("retained")); + lifecycle.enable(id("third")); + + lifecycle.stopAllExcept(Set.of(id("retained")), FeatureStopCause.EXCLUDED); + assertEquals(List.of(id("retained")), lifecycle.activeFeatures()); + lifecycle.stop(id("retained"), FeatureStopCause.REQUESTED); + FeatureLifecycleResult reset = lifecycle.resetAll(); + + assertTrue(reset.succeeded()); + assertEquals(List.of( + "first:enable", "retained:enable", "third:enable", + "first:stop:EXCLUDED", "third:stop:EXCLUDED", + "retained:stop:REQUESTED", + "first:reset", "retained:reset", "third:reset"), harness.events); + assertThrows(IllegalArgumentException.class, () -> lifecycle.stopAllExcept( + Set.of(id("missing")), FeatureStopCause.EXCLUDED)); + } + + @Test + void foregroundSuppressionClearsPriorEvidenceAndGatesArbitrator() { + Harness harness = new Harness(); + RecordingFeature foreground = new RecordingFeature("foreground", harness.events); + FeatureLifecycle lifecycle = harness.lifecycle(registration( + "foreground", policy(false, false, false, true), foreground)); + long generation = harness.startMacro(lifecycle); + FailsafeCandidate candidate = new FailsafeCandidate( + FailsafeType.DIRT, generation, WORLD_EPOCH); + + assertTrue(lifecycle.submitFailsafe(candidate).accepted()); + assertTrue(harness.arbitrator.hasPending()); + lifecycle.enable(id("foreground")); + + assertFalse(harness.arbitrator.hasPending()); + assertFalse(lifecycle.canCheckFailsafes()); + FeatureFailsafeSubmission suppressed = lifecycle.submitFailsafe(candidate); + assertEquals(FeatureFailsafeSubmission.Status.SUPPRESSED, suppressed.status()); + assertEquals(List.of(id("foreground")), suppressed.suppressors()); + harness.clock.advanceMillis(2_000L); + assertTrue(lifecycle.selectFailsafeIfReady().isEmpty()); + + lifecycle.stop(id("foreground"), FeatureStopCause.REQUESTED); + assertTrue(lifecycle.canCheckFailsafes()); + assertTrue(lifecycle.submitFailsafe(candidate).accepted()); + harness.clock.advanceMillis(2_000L); + assertEquals(candidate, lifecycle.selectFailsafeIfReady().orElseThrow()); + + FailsafeCandidate stale = new FailsafeCandidate( + FailsafeType.COBWEB, generation, WORLD_EPOCH + 1L); + assertEquals(FailsafeArbitrator.SubmissionStatus.STALE_IDENTITY, + lifecycle.submitFailsafe(stale).arbitration().orElseThrow().status()); + FailsafeCandidate duplicate = new FailsafeCandidate( + FailsafeType.COBWEB, generation, WORLD_EPOCH); + assertEquals(FailsafeArbitrator.SubmissionStatus.ALREADY_TRIGGERED, + lifecycle.submitFailsafe(duplicate).arbitration().orElseThrow().status()); + + harness.macro.stop(MacroTerminalReason.CLIENT_STOP); + assertEquals(FeatureFailsafeSubmission.Status.INACTIVE, + lifecycle.submitFailsafe(candidate).status()); + } + + private static FeatureRegistration registration( + String value, + FeaturePolicy policy, + FeatureLifecycleTarget target + ) { + return registration(value, policy, () -> true, target); + } + + private static FeatureRegistration registration( + String value, + FeaturePolicy policy, + BooleanSupplier enabled, + FeatureLifecycleTarget target + ) { + return new FeatureRegistration(id(value), policy, enabled, target); + } + + private static FeaturePolicy policy( + boolean startAtMacroStart, + boolean pauseWithMacro, + boolean pausesMacro, + boolean suppressFailsafes + ) { + return new FeaturePolicy( + startAtMacroStart, + pauseWithMacro, + pausesMacro, + suppressFailsafes + ? FeatureFailsafePolicy.SUPPRESS_CHECKS + : FeatureFailsafePolicy.ALLOW_CHECKS); + } + + private static FeatureId id(String value) { + return new FeatureId(value); + } + + private static final class Harness { + private final List events = new ArrayList<>(); + private final TestClock clock = new TestClock(); + private final BridgeTarget bridge = new BridgeTarget(); + private final MacroLifecycle macro = new MacroLifecycle(bridge, clock); + private final FailsafeArbitrator arbitrator = FailsafeArbitrator.withDelaySource( + clock, () -> 2_000L); + + private FeatureLifecycle lifecycle(FeatureRegistration... registrations) { + FeatureLifecycle lifecycle = new FeatureLifecycle(macro, arbitrator, registrations); + bridge.lifecycle = lifecycle; + return lifecycle; + } + + private long startMacro(FeatureLifecycle lifecycle) { + macro.start(); + long generation = macro.generation(); + FeatureLifecycleResult result = lifecycle.macroStarted(generation, WORLD_EPOCH); + assertTrue(result.succeeded()); + return generation; + } + } + + private static final class BridgeTarget implements MacroLifecycleTarget { + private FeatureLifecycle lifecycle; + private FeatureLifecycleResult lastResult = FeatureLifecycleResult.unchanged(); + + @Override + public void start(long generation, long nowNanos) { + } + + @Override + public void pause(long generation, long nowNanos, Set causes) { + if (lifecycle != null) { + lastResult = lifecycle.macroPaused(generation, causes); + } + } + + @Override + public void resume(long generation, long nowNanos) { + if (lifecycle != null) { + lastResult = lifecycle.macroResumed(generation); + } + } + + @Override + public void stop(long generation, MacroTerminalReason reason) { + if (lifecycle != null) { + lastResult = lifecycle.macroStopped(generation, reason); + } + } + } + + private static final class RecordingFeature implements FeatureLifecycleTarget { + private final String name; + private final List events; + private Runnable onEnable = () -> { }; + private boolean failEnable; + private boolean failPause; + private boolean failStop; + + private RecordingFeature(String name, List events) { + this.name = name; + this.events = events; + } + + @Override + public void enable(FeatureRunIdentity identity) { + events.add(name + ":enable"); + onEnable.run(); + if (failEnable) { + throw new IllegalStateException(name + " enable failed"); + } + } + + @Override + public void pause(FeatureRunIdentity identity, Set causes) { + events.add(name + ":pause"); + if (failPause) { + throw new IllegalStateException(name + " pause failed"); + } + } + + @Override + public void resume(FeatureRunIdentity identity) { + events.add(name + ":resume"); + } + + @Override + public void stop(FeatureRunIdentity identity, FeatureStopCause cause) { + events.add(name + ":stop:" + cause); + if (failStop) { + throw new IllegalStateException(name + " stop failed"); + } + } + + @Override + public void reset() { + events.add(name + ":reset"); + } + } + + private static final class TestClock implements MonotonicClock { + private long nowNanos; + + @Override + public long nowNanos() { + return nowNanos; + } + + private void advanceMillis(long millis) { + nowNanos += millis * 1_000_000L; + } + } +}