Skip to content

[ISSUE #7716] Normalize instance information across reporting paths. - #7785

Merged
Aias00 merged 2 commits into
apache:masterfrom
laomo-hub:fix/instance-info-json-7716
Oct 19, 2026
Merged

Aias00 merged 2 commits into
apache:masterfrom
laomo-hub:fix/instance-info-json-7716

Conversation

@laomo-hub

@laomo-hub laomo-hub commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Refs #7716. Scope coordination: #7716 (comment)

HTTP synchronization serializes an already-encoded instance-information string again, so the dashboard's single parse produces a string rather than an object. The ordinary WebSocket case already passes because convertToMap recursively decodes JSON-looking strings; that conversion instead changes nested metadata string values and can throw on malformed optional metadata.

Keep instanceInfo as a string containing JSON object text. Preserve HTTP text and extract WebSocket metadata from the outer JSON tree without recursive conversion. Normalize optional metadata at the shared heartbeat boundary and when reading stored records, accepting one known legacy JSON-string wrapper. Missing, malformed and non-object metadata falls back to {} without preventing heartbeat processing. Do not log metadata payloads or migrate rows on reads.

Malformed WebSocket outer frames are ignored without logging their payload; a subsequent valid report still publishes a heartbeat. Direct tests cover a missing HTTP metadata header and whitespace compaction without key sorting or nested-string decoding.

Validation:

  • Original production code: 5 selected regression cases, 4 failures; ordinary WebSocket control passes. Failing cases cover HTTP re-encoding, WebSocket nested-string mutation, legacy stored reads and legacy heartbeat information.
  • Targeted tests on JDK 17 with the repository wrapper: 135 passed, no failures/errors/skips. Includes real H2 stored-row compatibility, normal/legacy payloads, nested string preservation, malformed/missing information and repeated heartbeat liveness.
  • Admin and its 28-project prerequisite reactor clean verify: all 28 projects succeeded; 2,744 reported tests, 2,742 passed and 2 skipped, no failures/errors. Admin itself reported 2,029 tests with 1 skipped. All 28 Checkstyle and 28 Apache RAT executions passed.
  • git diff --check passes.

Compatibility: the database/API field stays a string; current JSON-object text and one legacy string-wrapped object are accepted. Existing raw rows are not rewritten by read operations. Heartbeat normalization applies on first registration; existing cached instances keep their metadata under the pre-existing refresh behavior until the cache entry is recreated (for example, after an Admin restart). Display reads normalize either representation. Do not repeatedly decode arbitrary string layers or nested object values.

Coverage limits: no live mixed-version Admin/gateway deployment, complete dashboard rendering or local Docker/e2e run. Dashboard's existing one-pass JSON formatting was inspected in source; H2 and method-level regressions are executed locally. Unrelated instance metadata-refresh/type-identity work remains outside this change.

Make sure that:

  • You have read the contribution guidelines.
  • You submit test cases (unit or integration tests) that back your changes.
  • Your local test passed ./mvnw clean install -Dmaven.javadoc.skip=true. Validation covers Admin and its 28-project prerequisite reactor as documented above; a whole-repository clean install was not run for this change.

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

Approving. The two defects are real and each is fixed at its own layer; I traced the whole write/read path to check that the string contract still holds.

Evidence for the two symptoms

HTTP path — HttpSyncDataService:219 sends SystemInfoUtils.getSystemInfo() as a raw header, while HttpLongPollingDataChangedListener:146 reads it with defaultString(..., "") and :152 re-encoded it with toJson(...). The dashboard's single parse therefore produced a String, not an object. Passing the header text through unchanged is the correct fix.

WebSocket path — ShenyuWebsocketClient:426-433 wraps the same payload into Map.of(BOOTSTRAP_INSTANCE_INFO, info) and serializes the wrapper, so the admin receives a JSON object whose value is object text. convertToMap then decoded recursively, which is why {"note":"{\"nested\":true}"} came out mutated and why malformed optional metadata could throw. Extracting the element from the outer tree (isJsonPrimitive() && isString() ? getAsString() : toString()) keeps the outer encoding while leaving nested values alone — that is the right read point.

Write / read convergence

onInstanceInfoReport (InstanceCheckService:196+) builds the DTO and calls handleBeatInfo, so the normalization at handleBeatInfo is on the path into instanceHealthBeatInfo, and syncDB -> InstanceInfoServiceImpl#createOrUpdate persists that normalized value. Reading normalizes again in buildInstanceInfoVO:114, which covers findById, list (also used by fetchInstanceData) and the paged query, and it does so without writing back — the mapper test that asserts the stored row stays double-quoted while findById returns the decoded text is exactly the assertion I wanted to see. normalize decoding at most one legacy layer, and re-entering it being idempotent, is what keeps this from becoming an open-ended decode chain.

Non-blocking

  1. normalize returns element.toString(), so a stored/reported value with whitespace or unusual key order is compacted on the display path. Harmless, but it means normalize is not a string-level identity for pretty-printed input; the tests only feed compact text. A one-line comment on the canonical-form expectation would prevent a future reader from treating it as a no-op.
  2. handleBeatInfo normalizes only in the else branch (first registration); an instance already in instanceHealthBeatInfo keeps the value it was created with and syncDB writes it back unchanged. That is pre-existing behaviour (metadata is never refreshed), but combined with this change it means a long-running admin converges only after a restart. Worth one line in the description, because the issue is about what the dashboard shows.
  3. WebsocketCollector#onMessage now calls GsonUtils.getInstance().fromJson(message, JsonObject.class). A message that contains the marker but is not a JSON object throws JsonSyntaxException out of @OnMessage; master behaved the same via convertToMap, so it is not a regression — but a try/catch that logs and returns would be strictly better for a websocket entry point, and it is one line.
  4. The HTTP path with the header absent is only covered implicitly. The parameterized websocket test covers null/empty, so a direct case on doLongPolling without the header, asserting the event carries "" and the heartbeat still lands, would close it.

CI

gh pr checks reported nothing on f375e0be; the runs were action_required (first-time contributor), so I approved the ci workflow run. The change spans 11 files and rewrites JSON semantics at three boundaries, so I am not recording an approval before pr_build is green on this head — I will flip it as soon as it is.

Aias00
Aias00 previously approved these changes Oct 18, 2026

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

Follow-up: pr_build is green on the current head (3m9s), so the CI evidence I was waiting for now exists. Everything in my previous review stands — flipping to an approval.

@Aias00

Aias00 commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Pls add my WeChat:aias00

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

Approved.

Good normalization work across all instance-info reporting paths. The HTTP long-polling listener no longer double-encodes the header JSON (the bug that wrapped object text in an extra string); the WebSocket collector reads only the outer frame and preserves inner JSON strings instead of re-serializing; and the new InstanceInfoUtils.normalize() canonicalizes legacy/over-encoded rows for display without rewriting stored values. Tests cover legacy compat, malformed-frame resilience, and the nested-string case (verified that double-wrapping collapses to {} as intended).

Minor note: CI checks have not reported on this branch yet; please confirm the workflow passes before merging.

@laomo-hub

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing and approving the latest changes! The 8 workflow runs for the latest commit (78d2d4d) are still awaiting maintainer approval. Could you please approve them to run? I'll follow up on the results, especially the previous HTTP integration test timeout.

@laomo-hub

laomo-hub commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

I ran the HTTP integration tests in my fork against the latest PR commit, 78d2d4d, using Ubuntu 24.04, JDK 17, and Docker.
The run passed: 97 tests passed, 5 were skipped by existing @disabled annotations, and there were no failures or errors. SignPluginVersionTwoTest.testSignWithNotConfiguredPath, which timed out in the earlier run, passed in 0.012 seconds. All 8 tests in that class passed too.

I only added a workflow to run the tests in my fork, following the upstream HTTP setup with the Maven Wrapper. No application code, test cases, or timeout settings were changed.

Run and test reports · Workflow diff

I couldn't reproduce the earlier timeout in this run, though its cause is still unclear. Sharing the results here for reference while the upstream CI awaits approval.

@Aias00

Aias00 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Follow-up on master status, plus one inconsistency to decide on.

Context

Master (de93952d) is red. InstanceCheckServiceTest.testHandleBeatInfoRefreshesInstanceInfoOnExistingIdentity and ...KeepsInstanceInfoWhenBeatCarriesBlank (both added by #7721) fail with expected: <version=1.0> but was: <{}>.

The cause is that those two tests use non-JSON payloads ("version=1.0"), which your testInvalidOptionalInformationDoesNotStopHeartbeats explicitly defines as normalizing to {} via @ValueSource(strings = {"{broken}", "[]", "true", "not-json"}). I have asked the #7721 author to switch those payloads to JSON objects, since the JSON-object contract is the one this PR deliberately established and documented. So no action is needed from you for the red build.

The inconsistency worth fixing

handleBeatInfo treats the same field two different ways depending on the branch:

if (instanceHealthBeatInfo.containsKey(instanceKey)) {
    ...
    if (StringUtils.isNotBlank(instanceBeatInfoDTO.getInstanceInfo())) {
        instanceInfoVO.setInstanceInfo(instanceBeatInfoDTO.getInstanceInfo());   // raw, no normalize
    }
} else {
    ...
    instanceInfoVO.setInstanceInfo(InstanceInfoUtils.normalize(instanceBeatInfoDTO.getInstanceInfo()));  // normalized
}

For a non-JSON value the stored instanceInfo therefore flips across heartbeats: {} on the first beat, then the original text on the second (because the containsKey branch passes it through raw), then it sticks. The value the dashboard sees changes without the reporter changing anything.

There is also a wording mismatch: the other call site documents normalize as "Normalize legacy rows for display without rewriting the stored value", while this call site does rewrite the stored value.

Two coherent options, your call:

  1. Normalize on both branches — one-line change in the containsKey branch, keeps the JSON-object contract uniform end to end. This is my preference because it matches the contract your tests already pin.
  2. Do not normalize on the store path at all — store raw and rely on the display normalization. This matches the "without rewriting the stored value" comment, but then testHeartbeatNormalizesLegacyInformation would need to assert the raw double-encoded form instead.

Either is fine as long as the storage contract is stated in one place. Option 1 is the smaller delta from what is on master today.

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