ALEC-301: Fix LLM clustering engine correctness (follow-up to #157) - #168
Conversation
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
|
@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):
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 |
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
|
@cgorantla — addressed the four round-2 findings (commit
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>
|
Pushed 1. HTTP call hoisted out of the correlation graph lock.
Budget totals are now cached behind 2. Default cluster prompt served from the server. New tests: |
|
@cgorantla this is ready for another look. Both optional hardening items from your review are now implemented and pushed (
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>
|
Good catches — both fixed in Concern 1 (results lagging a full tick period). Root cause: the configured cluster frequency was wired straight to Rather than call
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 Concern 2 (budget-blocked tick skips Minor: 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
left a comment
There was a problem hiding this comment.
This needs to run -smoke in branch name. That will run all the smoke tests.
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
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>
…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>
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
enabledflag.readLlmConfigrequiredenabled— the Root Cause Analysis toggle — so an operator with endpoint/model/key set but RCA off gotnullevery tick. Clustering now requires only the shared connection fields; selecting the engine is the signal to use it.TokenBudget) and writes each call's usage into the sharedALEC_LLM_USAGEstore — so clustering both respects the cap and shows up in the usage dashboard.P2 — robustness
MAX_ALARMS(200) — cluster the most-recent alarms rather than overflow the context window.EdgeDaono longer returns (missed delete callback); logic factored into a sharedremoveEdgeInventory().Verification
UsageStore(the engine bundle can't depend on features/llm-suggestions).🤖 Generated with Claude Code
https://claude.ai/code/session_01UB6PGc2rpPbojnqbTHU5ND