Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/superpowers/baselines/2026-04-17/BASELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ Ordered by severity. Each item cites the raw artifact it was derived from.
Follow-up split out below.

- **`GraphHealthIndicator` reports `OUT_OF_SERVICE` (503) even when the graph is loaded.** Discovered during the pipeline smoke-test fix. `/actuator/health` body: `{"groups":["liveness","readiness"],"status":"OUT_OF_SERVICE"}`. The server is fully functional (`/api/stats` returns real data) but the health indicator makes `/actuator/health` unusable as a readiness probe for orchestrators (K8s, Compose, CI). Fix in `src/main/java/io/github/randomcodespace/iq/health/GraphHealthIndicator.java`. Low for baseline use; High when we start Dockerizing or targeting K8s.
- **RESOLVED (2026-04-17, branch `phase-a/fix-graph-health`)**: Root cause was *not* in `GraphHealthIndicator` (which correctly returns UP when nodes>0). It was in `ServeCommand`: the CLI blocks on `Thread.currentThread().join()` inside Spring Boot's `CommandLineRunner.run()`, which prevents `ApplicationReadyEvent` from ever firing. Without that event, Spring's default readiness publisher never flips `ReadinessState` from `REFUSING_TRAFFIC` (503 `OUT_OF_SERVICE`) to `ACCEPTING_TRAFFIC` (200 `UP`). Fix: `ServeCommand` now explicitly publishes `AvailabilityChangeEvent` for `LivenessState.CORRECT` + `ReadinessState.ACCEPTING_TRAFFIC` before blocking, via a new `markReady()` method (unit-tested). Verified end-to-end: `health_http` is now 200 on both seeds (petclinic ready 13s, express ready 14s; status "UP"). Follow-up filed: `GraphBootstrapper`'s `@EventListener(ApplicationReadyEvent.class)` is effectively dead code for the same reason — only noticed because enrich always runs before serve in our pipeline, so the bootstrap fallback never actually needs to fire.

- **SpotBugs: 8 HIGH-priority findings (priority=1) + 1,484 at priority=2.** Total 1,492. HIGH findings must be triaged individually (read `raw/spotbugs.xml`). Noise-dominant rules (`NM_METHOD_NAMING_CONVENTION`=730, `SF_SWITCH_NO_DEFAULT`=448) should be filtered via a SpotBugs exclude file so real signal surfaces; real-concern patterns that deserve review now: `NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE` (26), `BC_UNCONFIRMED_CAST` (55), `UL_UNRELEASED_LOCK_EXCEPTION_PATH` (1), `WMI_WRONG_MAP_ITERATOR` (2), `ES_COMPARING_STRINGS_WITH_EQ` (2), `MT_CORRECTNESS` category (1).
- Raw: `raw/spotbugs.xml`, `raw/spotbugs-summary.json`.
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.LivenessState;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
Expand Down Expand Up @@ -56,6 +60,9 @@
@Autowired(required = false)
private GraphStore graphStore;

@Autowired

Check warning on line 63 in src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this field injection and use constructor injection instead.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ2biUVIktBSZ6HUBCi0&open=AZ2biUVIktBSZ6HUBCi0&pullRequest=46
private ApplicationEventPublisher events;

@Override
public Integer call() {
Path root = path.toAbsolutePath().normalize();
Expand Down Expand Up @@ -96,6 +103,16 @@
System.out.println();
CliOutput.info("Press Ctrl+C to stop.");

// Publish availability transitions so /actuator/health reports UP (200).
// This Callable is invoked from a CommandLineRunner that blocks forever
// (the Thread.join below), so Spring's ApplicationReadyEvent — which
// normally drives ReadinessState to ACCEPTING_TRAFFIC — never fires.
// Without this, /actuator/health stays OUT_OF_SERVICE (503) even though
// the server is accepting and serving traffic. See also: known gap on
// GraphBootstrapper's @EventListener(ApplicationReadyEvent.class) which
// is dead for the same reason — out of scope for this fix.
markReady();

try {
Thread.currentThread().join();
} catch (InterruptedException e) {
Expand All @@ -105,6 +122,15 @@
return 0;
}

/**
* Flip availability state to live + accepting traffic. Extracted for
* testability — callers can verify the right events are published.
*/
void markReady() {
AvailabilityChangeEvent.publish(events, this, LivenessState.CORRECT);
AvailabilityChangeEvent.publish(events, this, ReadinessState.ACCEPTING_TRAFFIC);
}

public Path getPath() { return path; }
public int getPort() { return port; }
public String getHost() { return host; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package io.github.randomcodespace.iq.cli;

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.LivenessState;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import picocli.CommandLine;

import java.nio.file.Path;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

class ServeCommandTest {

Expand Down Expand Up @@ -76,4 +85,25 @@ void pathNotSwallowedWhenNoUiPrecedesPath() {
assertEquals(true, cmd.isNoUi());
assertEquals(Path.of("/some/repo"), cmd.getPath());
}

@Test
void markReadyPublishesLivenessThenReadiness() {
// Regression guard for /actuator/health returning 503 OUT_OF_SERVICE:
// serve's CommandLineRunner blocks forever, so Spring never fires
// ApplicationReadyEvent and readiness stays REFUSING_TRAFFIC.
// ServeCommand must publish LivenessState.CORRECT + ReadinessState.ACCEPTING_TRAFFIC
// before blocking so /actuator/health reports UP (200).
var cmd = new ServeCommand();
var mockEvents = Mockito.mock(ApplicationEventPublisher.class);
ReflectionTestUtils.setField(cmd, "events", mockEvents);

cmd.markReady();

var captor = ArgumentCaptor.forClass(AvailabilityChangeEvent.class);
verify(mockEvents, times(2)).publishEvent(captor.capture());
var published = captor.getAllValues();
// Order matters: liveness first (process is alive), then readiness (serving traffic).
assertEquals(LivenessState.CORRECT, published.get(0).getState());
assertEquals(ReadinessState.ACCEPTING_TRAFFIC, published.get(1).getState());
}
}
Loading