Skip to content

ALEC-301: Fix LLM clustering engine correctness (follow-up to #157) - #168

Merged
joseanesONMS merged 11 commits into
release-3.xfrom
ja/alec-301-llm-engine-fixes
Aug 4, 2026
Merged

ALEC-301: Fix LLM clustering engine correctness (follow-up to #157)#168
joseanesONMS merged 11 commits into
release-3.xfrom
ja/alec-301-llm-engine-fixes

Conversation

@joseanesONMS

Copy link
Copy Markdown
Contributor

Follow-up to #157 — post-merge review of the original ALEC-301 LLM-Based Correlation surfaced six defects; this corrects them. The engine could silently stay inactive under a valid config, bypass the token budget, and lacked the topology it needs.

P1 — correctness

  1. Clustering no longer gated on the RCA enabled flag. readLlmConfig required enabled — the Root Cause Analysis toggle — so an operator with endpoint/model/key set but RCA off got null every tick. Clustering now requires only the shared connection fields; selecting the engine is the signal to use it.
  2. Shared token budget enforced + recorded. Each tick checks UTC daily/monthly usage against the limits (same semantics as the RCA TokenBudget) and writes each call's usage into the shared ALEC_LLM_USAGE store — so clustering both respects the cap and shows up in the usage dashboard.
  3. Topology is sent to the model. The request now serializes device adjacency (from the cluster graph) and tags each alarm with its device, so the model can group by connectivity as the prompt/UI/demo assume.

P2 — robustness

  1. Request bounded at MAX_ALARMS (200) — cluster the most-recent alarms rather than overflow the context window.
  2. Membership dedup — each alarm to at most one cluster (global + within-group), drop sub-2-alarm groups.
  3. Deleted edges reconciled — the periodic topology refresh removes edges ALEC still holds that EdgeDao no longer returns (missed delete callback); logic factored into a shared removeEdgeInventory().

Verification

  • 34 engine/llm + 20 datasource tests green, with new coverage for every fix.
  • Budget/usage records are written directly to the shared KV store in the same shape as the RCA UsageStore (the engine bundle can't depend on features/llm-suggestions).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UB6PGc2rpPbojnqbTHU5ND

Post-merge review of the ALEC-301 LLM clustering engine surfaced six defects;
this corrects them. The engine could silently stay inactive under a valid
config, bypass the shared token budget, and lacked the topology it needs.

engine/llm/LlmClusterEngine:
- [P1] Decouple clustering from the RCA 'enabled' flag. readLlmConfig no
  longer requires 'enabled' (that toggle is Root Cause Analysis); clustering
  requires only the shared endpoint/model/key. Selecting the engine is the
  signal to use it — it no longer sits silently inactive under a valid config.
- [P1] Enforce and record the shared token budget. Before each tick the engine
  checks UTC daily/monthly usage against the configured limits (same semantics
  as the RCA TokenBudget), and records each call's token usage into the shared
  ALEC_LLM_USAGE store, so clustering both respects and reports into the budget.
- [P1] Send topology connectivity. The request now serializes the adjacency
  between alarm-bearing devices (from the cluster graph) and tags each alarm
  with its device, so the model can actually group by topology as advertised.
- [P2] Bound the request. Beyond MAX_ALARMS (200) the engine clusters the most
  recent alarms rather than emitting a prompt guaranteed to overflow the model.
- [P2] Deduplicate alarm memberships. parseResponse now assigns each alarm to
  at most one cluster (global + within-group dedup) and drops sub-2-alarm
  groups, so a repeated ID can't put an alarm in multiple situations or inflate
  a singleton.

datasource/opennms-direct/DirectInventoryDatasource:
- [P2] Reconcile deleted edges. The periodic refresh now removes edges ALEC
  still holds that the EdgeDao poll no longer returns (missed delete callback),
  instead of leaving their inventory forever. Edge-removal logic extracted to a
  shared removeEdgeInventory() used by both the delete callback and the refresh.

Tests: 34 engine/llm + 20 datasource tests green (new coverage for the budget
enforcement, usage recording, topology rendering, membership dedup, >=2
enforcement, and stale-edge reconciliation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB6PGc2rpPbojnqbTHU5ND
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

@cgorantla — ready for review. This is the follow-up to #157 that corrects the six defects your post-merge review found in the ALEC-301 LLM clustering engine (there is no separate ticket — this is part of the original ALEC-301 work).

P1 (correctness):

  1. Clustering no longer gates on the RCA enabled flag — it required only the shared endpoint/model/key, so the engine no longer sits silently inactive under a valid config.
  2. The shared daily/monthly token budget is now enforced and recorded (same UTC-window semantics + record shape as the RCA TokenBudget/UsageStore, written directly to ALEC_LLM_USAGE since the engine bundle can't depend on features/llm-suggestions).
  3. Device topology (adjacency between alarm-bearing vertices) is serialized into the request, so the model can group by connectivity as the prompt/UI/demo assume.

P2 (robustness): request bounded at 200 alarms; alarm memberships deduped (global + within-group) with the ≥2-per-group rule enforced; and the periodic edge-topology refresh now reconciles missed deletions instead of leaving stale inventory.

All CircleCI stages green; 34 engine/llm + 20 datasource tests pass, with new coverage for every fix. I also built this and deployed it to a local OpenNMS — ALEC boots and the fixed engine.llm bundle loads (verified the budget/topology/usage changes are in the running class).

Four defects raised in review of #168:

1. [race] DirectInventoryDatasource.refreshEdgeTopology could delete a live
   edge added concurrently during the poll. Now snapshots the held edge IDs
   BEFORE polling and only removes IDs from that snapshot that the poll no
   longer returns — an edge added mid-poll is never eligible for removal.

2. [perf] LlmClusterEngine.budgetExceeded did an unbounded KV scan every tick,
   inside the graph lock. Now (a) the budget check is hoisted into tick(), out
   of the graph-locked cluster() section, and (b) it reads a cached day/month
   total refreshed by a full scan only on a UTC period rollover or every
   BUDGET_RESCAN_INTERVAL_MS (5 min); clustering's own spend is folded into the
   cache immediately so enforcement stays accurate between rescans.

3. [correctness] EngineRestImpl.configureAndStoreLlm NPE'd on a null
   clusterFrequencyMs (nullable Integer -> long setter) and accepted 0/negative
   (zero-interval tick). Now clamps null/<=0 to LlmEngineFactory's default.
   Clamp (not 400) is deliberate: the constructor replays persisted config
   through this path and swallows the Response, so a bad record must still
   yield a usable engine.

4. [correctness] The UI default clustering prompt instructed the model to emit
   single-alarm situations, which #168's parser now hard-drops — wasting tokens
   and skewing groupings. AccountSettings.vue's DEFAULT_CLUSTER_PROMPT is now
   the engine's exact prompt (omit singletons, min 2 per group). Flagged the
   drift-proof follow-up (serve it from the server like the RCA defaultSystemPrompt).

Tests: added a budget-cache test, two clusterFrequency-clamp tests; all
engine/llm + features/ui + datasource + UI suites green. ui-ext bundle rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UB6PGc2rpPbojnqbTHU5ND
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

@cgorantla — addressed the four round-2 findings (commit cb5450a0):

  1. Edge-reconciliation racerefreshEdgeTopology now snapshots the held edge IDs before the poll and only removes IDs from that snapshot the poll no longer returns. An edge added concurrently during the poll isn't in the snapshot, so it can never be deleted as "stale."
  2. Budget scan under the graph lock — the check is hoisted into tick() (outside the graph-locked cluster()), and it now reads a cached day/month total refreshed by a full scan only on a UTC period rollover or every 5 min; clustering's own spend is folded into the cache immediately so enforcement stays accurate between rescans. (The 30 s blocking HTTP call is still inside the lock — that's the pre-existing ALEC-301: LLM-based clustering engine (phases 3a–3b) + topology refresh #157 issue you noted this PR doesn't tackle; happy to do it here or as a follow-up.)
  3. configureAndStoreLlm null/≤0 frequency — clamps null/≤0 to LlmEngineFactory.DEFAULT_CLUSTER_FREQUENCY_MS. I clamped rather than 400'd on purpose: the constructor replays persisted config through this path and swallows the Response, so a bad record must still yield a usable engine.
  4. UI default promptAccountSettings.vue's DEFAULT_CLUSTER_PROMPT is now the engine's exact prompt (omit singletons, ≥2 per group), so it no longer tells the model to emit singletons the parser discards. Noted the drift-proof follow-up (serve it from the server like the RCA defaultSystemPrompt).

New tests: budget-cache behavior, and null/zero clusterFrequency clamping. All engine/llm + features/ui + datasource + UI suites green; ui-ext bundle rebuilt. CI re-running.

…ompt from server

Two hardening items on the LLM clustering engine:

1. The clustering call no longer blocks the correlation graph lock on a
   30s HTTP round-trip. cluster() now returns the latest resolved groups
   immediately and kicks the OpenAI request onto a dedicated daemon
   executor (single in-flight request guarded by an AtomicBoolean). The
   parse is split into parseGroups() (raw id groups, off-lock, fed by the
   HTTP thread) and resolveClusters() (id groups -> clusters against the
   live alarm map, under the lock). Budget totals are cached behind
   budgetLock with a 5-minute rescan so the budget check no longer scans
   the usage store on every tick.

2. The engine's canonical DEFAULT_CLUSTER_PROMPT is now public and served
   via LlmConfigStatus.getDefaultClusterPrompt(), so the UI's "Reset to
   default" uses the exact text the engine enforces instead of a
   hard-coded copy that could drift. AccountSettings.vue hydrates the
   default from the server; TUser gains defaultClusterPrompt.

Tests: parseGroups/resolveClusters split coverage in LlmClusterEngineTest;
defaultClusterPrompt serving in LlmConfigImplTest. UI bundle rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

Pushed 3746abc6 implementing the two optional hardening items from the review:

1. HTTP call hoisted out of the correlation graph lock. cluster() previously blocked the graph lock for the full ~30s OpenAI round-trip. It now returns the latest resolved groups immediately and dispatches the request on a dedicated daemon executor (llm-cluster-http), with a single in-flight request guarded by an AtomicBoolean so ticks can't pile up. The parse is split:

  • parseGroups(json, om) → raw id groups, runs on the HTTP thread, off-lock.
  • resolveClusters(idGroups, alarmsById) → maps ids to clusters against the live alarm map (applies the ≥2 rule), runs under the lock.

Budget totals are now cached behind budgetLock with a 5-minute rescan, so the budget check no longer scans the usage store on every tick.

2. Default cluster prompt served from the server. LlmClusterEngine.DEFAULT_CLUSTER_PROMPT is now public and exposed via LlmConfigStatus.getDefaultClusterPrompt(). AccountSettings.vue hydrates "Reset to default" from that value instead of a hard-coded copy, so the UI text can't drift from what the engine enforces. TUser gains the defaultClusterPrompt field.

New tests: parseGroupsReturnsRawIdGroupsWithoutAlarmResolution + resolveClustersMapsIdGroupsAgainstCurrentAlarms (engine), statusServesEngineDefaultClusterPrompt (rest). Full suite green (engine/llm 37, features/ui 54, ui 86); UI bundle rebuilt.

@joseanesONMS

Copy link
Copy Markdown
Contributor Author

@cgorantla this is ready for another look. Both optional hardening items from your review are now implemented and pushed (3746abc6):

  1. HTTP call hoisted out of the correlation graph lockcluster() returns the latest resolved groups immediately and dispatches the ~30s OpenAI request on a dedicated daemon executor (single in-flight, guarded by an AtomicBoolean). Parse split into off-lock parseGroups() and under-lock resolveClusters(); budget totals cached with a 5-min rescan.
  2. Default cluster prompt served from the serverLlmClusterEngine.DEFAULT_CLUSTER_PROMPT is now public and exposed via LlmConfigStatus.getDefaultClusterPrompt(); the UI hydrates "Reset to default" from it instead of a hard-coded copy.

All CI green (pre-build, build, build-debian, build-docs, smoke-test-commit). Full test suite passes (engine/llm 37, features/ui 54, ui 86) and I've verified both changes on a live OpenNMS deploy. Details in the comment above.

…ip super.tick() on budget

Addresses two concerns raised on the async clustering design:

1. Correlation latency was tied to the query frequency. Because the
   configured cluster frequency drove tickResolutionMs, both the LLM
   request AND the apply/GC/feedback pass ran only once per frequency —
   so at the 1-hour default a grouping the model returned at 10:00 did
   not become situations until 11:00 (1-2x frequency of effective lag).
   The two cadences are now decoupled: the engine ticks at a fast
   reconcile interval (RECONCILE_INTERVAL_MS, capped at the frequency)
   so a returned grouping is resolved into situations within ~one tick,
   while new LLM requests are throttled to the configured frequency via
   clusterRequestIntervalMs + lastRequestAtMs. No cross-thread tick()
   call, so AbstractClusterEngine's single-tick-thread state assumptions
   are preserved. LlmEngineFactory sets both cadences.

2. A budget-blocked tick skipped super.tick() entirely, freezing alarm
   GC, feedback processing and re-resolution of the existing grouping —
   potentially for a whole month on a monthly cap. tick() now always
   calls super.tick(); the budget gates only the outbound request (in
   maybeStartClusteringRequest). The "budget reached" WARN moved to the
   false->true transition in tick() so it isn't emitted every reconcile
   tick. Config is read once per tick (tickConfig) and reused by
   cluster(), removing the second KV get.

Tests: factory now asserts frequency drives the request interval, not
the reconcile tick, and that the reconcile tick never exceeds the
frequency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

Good catches — both fixed in f7e548bc.

Concern 1 (results lagging a full tick period). Root cause: the configured cluster frequency was wired straight to tickResolutionMs, so it governed both how often we query the LLM and how often onTick applies the grouping / runs GC / processes feedback. At the 1-hour default that meant a grouping returned at 10:00 didn't become situations until 11:00.

Rather than call tick() from the HTTP callback (unsafe — AbstractClusterEngine's tick-state assumes the single driver tick thread, exactly as you noted), I decoupled the two cadences:

  • The engine now ticks at a fast reconcile interval (RECONCILE_INTERVAL_MS = 30s, capped at the frequency for sub-30s configs). Every reconcile tick re-resolves latestGroups against the current alarms and runs GC/feedback — so a freshly-returned grouping becomes situations within ~one reconcile tick.
  • A new LLM request is throttled separately to the configured frequency via clusterRequestIntervalMs + lastRequestAtMs (gate in maybeStartClusteringRequest).

So the expensive query still happens hourly, but effective correlation latency drops from 1–2× frequency to ~one reconcile tick. Everything stays on the driver's single tick thread; the HTTP thread only writes the volatile latestGroups. This felt cleaner than a driver re-tick hook and doesn't touch the driver API — happy to revisit if you'd prefer the documented-lag route instead.

Concern 2 (budget-blocked tick skips super.tick()). Fixed exactly as you suggested: tick() always calls super.tick() now; the budget gates only the outbound request. GC, feedback and re-resolution keep running even when the cap is hit. The "budget reached" WARN moved to the false→true transition so it isn't logged every reconcile tick.

Minor: readLlmConfig() now runs once per tick — the config is read on the tick thread in tick() (tickConfig) and reused by cluster(). (The 5-min budget rescan is unchanged and still off the graph lock.)

Tests updated: the factory now asserts the frequency drives the request interval (not the reconcile tick) and that the reconcile tick never exceeds the frequency. Full suite green (engine/llm 38, features/ui 54).

@cgorantla cgorantla left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@cgorantla cgorantla left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to run -smoke in branch name. That will run all the smoke tests.

@joseanesONMS
joseanesONMS deleted the ja/alec-301-llm-engine-fixes branch July 22, 2026 10:53
@joseanesONMS
joseanesONMS restored the ja/alec-301-llm-engine-fixes branch July 22, 2026 10:54
@joseanesONMS joseanesONMS reopened this Jul 22, 2026
joseanesONMS added a commit that referenced this pull request Jul 22, 2026
smoke-test-full previously ran only on develop/release/tags, so a feature
branch could not validate against the complete smoke suite before merge.
Add a /.*-smoke.*/ branch filter (and exclude those branches from the
lighter smoke-test-commit) so renaming a branch to include -smoke opts it
into the full run. Requested on #168 to gate the LLM clustering changes on
all smoke tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…m-engine-fixes

# Conflicts:
#	features/ui/src/main/resources/ui-ext/alecUiExtension.es.js
#	features/ui/src/main/resources/ui-ext/alecUiExtension.umd.js
#	features/ui/src/main/resources/ui-ext/style.css
#	features/ui/src/test/java/org/opennms/alec/rest/EngineRestImplTest.java
joseanesONMS added a commit that referenced this pull request Aug 4, 2026
smoke-test-full previously ran only on develop/release/tags, so a feature
branch could not validate against the complete smoke suite before merge.
Add a /.*-smoke.*/ branch filter (and exclude those branches from the
lighter smoke-test-commit) so renaming a branch to include -smoke opts it
into the full run. Requested on #168 to gate the LLM clustering changes on
all smoke tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
joseanesONMS and others added 6 commits August 4, 2026 17:07
…CorrrelationTest timeout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
smoke-test-full previously ran only on develop/release/tags, so a feature
branch could not validate against the complete smoke suite before merge.
Add a /.*-smoke.*/ branch filter (and exclude those branches from the
lighter smoke-test-commit) so renaming a branch to include -smoke opts it
into the full run. Requested on #168 to gate the LLM clustering changes on
all smoke tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ries)

The distributed smoke tests stand up a full stack per test (OpenNMS +
Sentinel x2 + Kafka + Zookeeper + Postgres + Grafana/Helm + Selenium/VNC)
plus a 3.2GB Maven heap — ~14GB against the default medium machine's
7.5GB. The OOM killer then aborts container startup, surfacing as
ContainerLaunchException / container-shutdown timeouts. The tell:
IntegratedCorrelationTest (one lightweight container) passes while every
multi-container distributed test fails.

- smoke-test-executor: resource_class: large (4 vCPU / 15GB)
- MAVEN_OPTS: -Xmx3200m -> -Xmx1500m (the test JVM only orchestrates
  containers; give the RAM to the containers)
- surefire rerunFailingTestsCount=2 to absorb residual transient
  container flakiness without masking reproducible failures

Also lets branches with -smoke in the name run the full smoke suite
(previously develop/release/tags only), so this fix can be validated
against the whole distributed suite before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The standalone-sentinel smoke tests (DistributedStandalone*, DistributedUDL)
fail because alec-driver-main can't resolve the OpenNMS Integration API
'health' package: OIA never gets installed in the standalone sentinel. ALEC
is built against OIA 2.0.0 (opennms.api.version) but the pinned 35.0.3 images
ship OIA 1.6.1. Bump to the latest released 36.0.2 to move the test images
toward ALEC's OIA build target.

(Redundant test passes because sentinel-coordination-zookeeper transitively
installs OIA; standalone has no such provider.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DistributedRedundantCorrrelationTest failed deterministically with the DBSCAN
engine: waitForALECToTerminate() timed out after 1 minute while the killed
ALEC's Karaf still answered SSH. Root cause is a race, not a hang — Driver.destroy()
blocks up to a full minute on initThread.join(1 MINUTE) before it cancels the
non-daemon tick timer, so the JVM only exits ~1 minute after .
The 1-minute terminate wait raced that and lost (DBSCAN's init is slightly slower
than the old cluster engine, which is why it surfaced after ALEC-305). Bump the
wait to 3 minutes to comfortably outlast the driver shutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ence proxies

Thread-dump-diagnosed on a live hung OpenNMS: framework shutdown was stuck in
ReferenceRecipe.getService (Aries service damping) via
DirectInventoryDatasource.destroy() -> removeEventListener on a blueprint
<reference> proxy whose backing service was already unregistered. Damping
waits up to 5 MINUTES per call for a replacement service that never comes
during shutdown, stalling the entire bundle-stop cascade - the JVM appears
hung (this is the "hung OpenNMS stop, needs pkill -9" gotcha, and the sentinel
smoke-test terminate timeouts: Karaf keeps answering SSH and the driver keeps
ticking because their bundles never get their turn to stop).

Three sites called reference proxies from blueprint destroy-methods:
- DirectInventoryDatasource.destroy: eventSubscriptionService.removeEventListener
  (thread-dump-proven local hang)
- Driver.destroy: situationDatasource.unregisterHandler x2 (sentinel; explains
  DistributedRedundantCorrrelationTest terminate timeouts - up to 10 min)
- ActiveStandbySituationProcessor.destroy: domainManager.deregister (sentinel
  coordination)

Fix: run each of these best-effort cleanup calls on a bounded daemon thread
(join 5s). When the service is still up the call completes inline-fast, same
as before; when it is gone we abandon it - the provider is being torn down
with its listener/handler registrations anyway, and a daemon thread cannot
keep the JVM alive. Also cancel the driver's non-daemon tick timer FIRST in
destroy() so no new engine work starts during teardown.

The hazard is old code; it began firing deterministically when bundle count/
stop order shifted (extra dbscan bundle after ALEC-305 pointed the redundant
smoke test at DBSCAN). Verified live: with this fix the previously-always-
hanging local OpenNMS stop exits cleanly in 30s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joseanesONMS
joseanesONMS merged commit e3fdaa0 into release-3.x Aug 4, 2026
7 checks passed
@joseanesONMS
joseanesONMS deleted the ja/alec-301-llm-engine-fixes branch August 4, 2026 22:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants