Skip to content

KNOX-2900: KNOX-2899 followup - reenable service based discovery filter - #1395

Open
bonampak wants to merge 22 commits into
apache:masterfrom
bonampak:KNOX-2900
Open

bonampak wants to merge 22 commits into
apache:masterfrom
bonampak:KNOX-2900

Conversation

@bonampak

@bonampak bonampak commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

KNOX-2900 - Re-enable service-based discovery filter

What changes were proposed in this pull request?

PollingConfigurationAnalyzer changes:

  • Ignore excluded service types in CM config monitor event relevance
  • Ignore excluded role types in scale events
  • Prevent rediscovery of services that remained in an invalid config state (use the same workflow for service configuration data discovery as ClouderaManagerServiceDiscovery)
  • Re-enable per-descriptor service filtering in CM discovery (cluster configuration cache data is merged after each topology discovery)

How was this patch tested?

Unit tests, manual tests on a dev cluster.

Integration Tests

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Test Results

  4 files    4 suites   1m 28s ⏱️
145 tests 145 ✅ 0 💤 0 ❌
169 runs  169 ✅ 0 💤 0 ❌

Results for commit e69cd65.

♻️ This comment has been updated with latest results.

@smolnar82 smolnar82 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.

I found 2 blockers, and the following efficiency issues:

1. every descriptor is parsed twice per polling cycle

File: PollingConfigurationAnalyzer.java (getReferencedServiceTypes() / clusterReferencesExist())

clusterReferencesExist() and getReferencedServiceTypes() each independently loop over ts.getDescriptors() and re-parse every descriptor file via SimpleDescriptorFactory.parse() (around lines 423 and 602). With N descriptors on disk, each polling cycle now parses every file twice per cluster — doubling filesystem reads + JSON parsing — and duplicates near-identical IOException handling.

Fix direction: merge the two into a single pass over ts.getDescriptors() that returns both "does any descriptor reference this (address, cluster)" and "the set of referenced service names", so each descriptor is parsed once.

2. CORE_SETTINGS is re-fetched from CM once per relevant service type

File: PollingConfigurationAnalyzer.java (getCoreSettingsConfig())

getCoreSettingsConfig() issues a fresh readServices(clusterName, summary) plus a readServiceConfig() round-trip every time getCurrentServiceConfiguration() runs — i.e. once per distinct relevant service type per polling cycle. Cluster discovery, by contrast, computes CORE_SETTINGS once for the whole cluster (coreSettingsConfig(...)).

Failure path: a polling cycle with events for three service types (e.g. HDFS, HIVE_ON_TEZ, YARN) makes three separate readServices() + readServiceConfig() calls just to re-derive the same CORE_SETTINGS value, adding avoidable CM API load on every interval.

Fix direction: compute CORE_SETTINGS once per polling pass and reuse it across all services handled in that pass (cache it for the duration of the cycle).

3.: role-type exclusion config is cached for the analyzer's lifetime while service-type exclusion is read fresh

File: PollingConfigurationAnalyzer.java (isExcludedRoleType() / isExcludedServiceType())

isExcludedRoleType() lazily builds and then permanently caches its TypeNameFilter (roleTypeFilter) from gatewayConfig on first use, whereas the adjacent isExcludedServiceType() re-reads gatewayConfig.getClouderaManagerServiceDiscoveryExcludedServiceTypes() fresh on every call.

Failure path: an operator updates gateway.cloudera.manager.service.discovery.excluded.role.types on a running gateway (without restarting the CM configuration monitor thread). isExcludedServiceType honors an equivalent excluded-service-types change immediately, but isExcludedRoleType keeps using the stale filter built at first invocation and silently ignores the update.

Fix direction: pick one policy for both. Either read both config values fresh per call, or resolve both once at construction — but don't split the behavior between the two sibling checks introduced in this PR.

roleConfigs.put(role, configList);
}
currentConfig = new ServiceConfigurationModel(svcConfig, roleConfigs);
final ApiService apiService = new ApiService().name(service).type(serviceType);

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.

To reuse the discovery workflow, getCurrentServiceConfiguration() reconstructs the ApiService / ApiRole / config objects it feeds into ServiceModelFactory.generateServiceModels(). But this reconstruction doesn't populate the same fields that real discovery (ClouderaManagerServiceDiscovery.discoverService) hands to generators. Any ServiceModelGenerator that dereferences a field the reconstruction leaves null throws an NPE inside generateService(). This is not an edge case — it hits the majority of generators:

  • role.getHostRef().getHostname() — used by 34 of 51 generators (OozieServiceModelGenerator, SolrServiceModelGenerator, HBase, Impala, and most others). NPEs if the reconstructed role's hostRef (or its hostname) isn't populated exactly as discovery populates it.
  • service.getClusterRef().getClusterName() — used by YarnUIServiceModelGenerator and JobHistoryUIServiceModelGenerator. NPEs because the synthetic ApiService sets only name / type, no clusterRef.
  • Any other field a given generator's handles() / generateService() reads that the reconstruction omits.

Failure path & blast radius: a start/restart/scale event for almost any discoverable service (YARN, Oozie, Solr, HBase, …) → hasConfigChanged → getCurrentServiceConfiguration → ServiceModelFactory.generateServiceModels → the generator's generateService() → NPE. The NPE is not an ApiException, so the method's own try/catch doesn't catch it; it propagates to monitorClusterConfigurationChanges' outer catch(Exception), aborting the entire polling cycle for all clusters. Because the triggering event is never marked processed, the same NPE recurs every polling interval — PCA is effectively dead for that gateway.

Root cause / fix direction: the reconstruction in getCurrentServiceConfiguration() diverges from how ClouderaManagerServiceDiscovery builds these objects. Rather than hand-rebuilding ApiService / ApiRole from readServiceConfig, PCA should obtain the model inputs through the same code path discovery uses (the shared component introduced here), so every field a generator may read is populated identically. A unit test that runs each registered generator against PCA-reconstructed inputs would have caught this and would guard against regressions as new generators land.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed:

  • 8c06e4d: fixes the clusterRef NPE by passing the real ApiService from the cluster's service list.
  • 0561951: adds the test that runs every registered generator against monitor-shaped inputs.
  • dd30643: adds the RuntimeException guard so a failing generator no longer ends the whole polling cycle, and tightens its test.

For hostRef, the shared ServiceModelFactory from 7cc335a already covered it.

Comment on lines +367 to +371
} else {
// Had a prior config but produces no model now: became invalid / was removed -> re-discover so the
// service is dropped from the affected topologies (and the scoped-replace merge clears its baseline).
log.serviceDisabled(re.getService());
configHasChanged = true;

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 new branch treats getCurrentServiceConfiguration() == null as "the service is now invalid / disabled" and forces re-discovery. But that method also returns null on any ApiException - network blip, auth failure, transient 5xx:

} catch (ApiException e) {
  log.clouderaManagerConfigurationAPIError(e);
}
return currentConfig; // still null on API error

So a null return is ambiguous: it can mean either "config genuinely produced no model" or "couldn't reach CM". hasConfigChanged's else branch (prior config present, currentConfig == null) can't tell them apart and unconditionally sets configHasChanged = true, logs serviceDisabled, and triggers a full re-discovery.

Failure path: CM API has a transient outage while computing the current config for a service that has a valid prior baseline → getCurrentServiceConfiguration catches the ApiException and returns null → hasConfigChanged forces re-discovery → repeats every polling cycle until CM recovers.

Regression: previously an API-error null was a harmless no-op (configHasChanged stayed false). This PR turns a transient CM error into a repeating full-cluster rediscovery storm — the same failure class KNOX-2900 set out to eliminate.

Fix direction: distinguish "no model produced" from "CM unreachable". Options: let the ApiException propagate (or rethrow) instead of collapsing it to null, or return a tri-state / Optional so the caller can skip the change decision on API errors and only treat a genuinely empty-but-successful result as "service invalid".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by

  • 4b0c342 returns a three-state result so an unreachable CM no longer forces a rediscovery
  • 37f71bf adds the tests.

One limitation, which also existed on master: after an error the event is still marked processed,
so a real change that coincides with an outage isn't retried until another event arrives. It is not a regression.
I'd create a follow-up ticket for that.

@bonampak
bonampak force-pushed the KNOX-2900 branch 2 times, most recently from c45a83c to 5da56dd Compare October 3, 2026 20:51
bonampak and others added 17 commits October 16, 2026 11:07
# Conflicts:
#	gateway-discovery-cm/src/main/java/org/apache/knox/gateway/topology/discovery/cm/ServiceModelGeneratorsHolder.java
#	gateway-discovery-cm/src/test/java/org/apache/knox/gateway/topology/discovery/cm/ServiceModelGeneratorsHolderTest.java
…figurationAnalyzer

getReferencedServiceTypes and clusterReferencesExist swallowed descriptor
parse/read errors silently and only caught IOException. A malformed or
unreadable descriptor could silently stop rediscovery or discard a live
cluster's cached configuration.

Both now catch broadly and log the offending file. getReferencedServiceTypes skips the unreadable descriptor and keeps filtering on the descriptors it could read (an unparseable descriptor can't be regenerated until it is fixed, so it can't be the target of a useful rediscovery now).
clusterReferencesExist instead assumes a reference remains when a descriptor can't be read, so an unreadable descriptor never causes the monitored cluster's baseline to be discarded.
…de when determining referenced services, since SimpleDescriptorHandler never regenerates them (using the gateway-site override list, not the descriptor's own read-only field).

Both now catch broadly and log the offending file. getReferencedServiceTypes skips the unreadable descriptor and keeps filtering on the descriptors it could read (an unparseable descriptor can't be regenerated until it is fixed, so it can't be the target of a useful rediscovery now).
clusterReferencesExist instead assumes a reference remains when a descriptor can't be read, so an unreadable descriptor never causes the monitored cluster's baseline to be discarded.
…iService from the existing readServices(...,"summary") call (also fixes the Solr displayName null).
…ation (withModel/noModel/error) so a transient CM error no longer triggers unnecessary re-discovery each polling cycle.
…e honored

isExcludedRoleType cached its TypeNameFilter for the analyzer's lifetime, so an
update to gateway.cloudera.manager.service.discovery.excluded.role.types on a
running gateway was silently ignored - unlike the sibling isExcludedServiceType,
which reads its config fresh. Drop the cached roleTypeFilter and build the filter
per call (getAllRoleTypes() is already an immutable cached set; scale events are
rare). Add a test that flips the config between two scale-event evaluations and
asserts the update is honored.
clusterReferencesExist() and getReferencedServiceTypes() each looped
ts.getDescriptors() and re-parsed every descriptor file, so an event-bearing
cycle parsed every file twice per cluster. Merge them into a single
analyzeDescriptorReferences() pass returning both whether the cluster is still
referenced and the referenced CM service types. Preserve the two deliberate
asymmetries: an unreadable or read-only-override descriptor still counts as a
reference (keep monitoring) but contributes no referenced service names. Merge
the two per-descriptor error log messages into one accordingly.
getCurrentServiceConfiguration re-fetched the cluster service list
(readServices "summary") and the CORE_SETTINGS config on every call - once per
relevant service type per cycle - though both are cluster-wide and identical
for every service examined that cycle. Cache them per cluster in a map cleared
at the start of each polling pass, so they are fetched at most once per cluster
per cycle and reused (mirroring how cluster discovery computes CORE_SETTINGS
once). The cache is populated lazily inside getCurrentServiceConfiguration, so
scale-only cycles make no extra CM calls and no test seam changes are needed.

Add testClusterModelInputsFetchedOncePerClusterAcrossServices, which drives the
real getCurrentServiceConfiguration for two services in one cluster through a
counting API client and asserts the service list is read exactly once.
The tests started the analyzer on a polling thread and blocked on awaitTermination(10s); since run() loops until stop() (called only after await), every test waited the full 10s, making the suite take ~320s.

Extract run()'s per-iteration body into a package-visible runMonitoringCycle()
(readiness gate + monitorClusterConfigurationChanges; gatewayStatusOk becomes a sticky instance field) and have the outcome tests call it directly instead of spawning a thread.
Registering events before the cycle also removes the previous add-after-start race. No production behavior change.

Add one latch-based test that still exercises the real polling thread:
an event arriving after start is picked up on a subsequent poll, and stop() terminates the loop.
…event relevance log

The DEBUG line for activation events printed only the raw inputs of the
relevance decision. Since descriptor-reference and excluded-service-type
filtering was added, a successful Start/Restart can be ignored because the
service type is excluded or no descriptor eligible for re-discovery
references it (e.g. a service used only by a topology listed in
gateway.read.only.override.topologies), and the line could not tell these
apart.

Reformat the message as labeled key=value pairs and add the two filter
results, excludedServiceType and referencedByDescriptor. Compute each filter
once in isStartEvent and use it for both the decision and the log. The
relevance decision itself is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… relevance log

Like the activation event line, the scale event line printed only the raw
inputs of its relevance decision. A role created/deleted event can be
ignored because the service type is excluded, no descriptor eligible for
re-discovery references it, or the role type is excluded from discovery, and
the line could not tell these apart.

Reformat the message as labeled key=value pairs and add the three filter
results, excludedServiceType, referencedByDescriptor and excludedRoleType.
Compute each filter once in isScaleEvent and use it for both the decision
and the log. The relevance decision itself is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ctivation

After "Analyzing current <service> configuration for changes..." the
analyzer logged nothing when the comparison found no difference, so a
restart that needs no re-discovery looked the same as a stalled or failed
analysis. Add a DEBUG message for that outcome, alongside the existing
INFO messages for changed properties and the skip messages for invalid and
unreachable services.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
bonampak and others added 4 commits October 19, 2026 14:06
When the TopologyService is unavailable, the referenced service types were
passed around as null. Make DescriptorReferences hold that state explicitly
instead. Behavior is unchanged.

Also add a test for the unavailable TopologyService case.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… cycle

Cover the CORE_SETTINGS fetch, and check that the cached data is refreshed on
each new polling cycle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Add tests showing that changing the excluded role types or excluded service
types on a running gateway takes effect on the next polling cycle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tion fails

Service model generators are pluggable, so one of them may throw an unexpected
exception. Treat that like a Cloudera Manager failure: log it and skip the
change decision for that service instead of ending the whole polling cycle.

Name the service in the related log messages, and make the all-generators test
fail on any unexpected exception.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@bonampak
bonampak marked this pull request as ready for review October 20, 2026 16:44
@bonampak

Copy link
Copy Markdown
Contributor Author

About the 3 issues above:

  1. Every descriptor is parsed twice per polling cycle
  • 013fa34 fixes it
  • 8a377a9 "Avoid returning null for unknown referenced service types" cleans up the merged result type and adds test coverage for the unavailable TopologyService path.
  1. CORE_SETTINGS is re-fetched from CM once per relevant service type
  • 12a6721 fetches the service list and CORE_SETTINGS once per cluster per cycle.
  • 2dcde95 adds tests for the CORE_SETTINGS fetch and for the refresh on each cycle.
  1. Role-type exclusion config is cached for the analyzer's lifetime while service-type exclusion is read fresh
  • 87436d7 reads the excluded role types fresh on each call like the service types
  • 8f3c820 adds tests that change each setting between two cycles.

This branch has not been deployed

No deployments
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