Skip to content

SONARJAVA-6896 Improve the serialization format for Spring context caching - #6114

Open
aurelien-coet-sonarsource wants to merge 5 commits into
epic-SONARJAVA-6237from
ac/SONARJAVA-6896
Open

aurelien-coet-sonarsource wants to merge 5 commits into
epic-SONARJAVA-6237from
ac/SONARJAVA-6896

Conversation

@aurelien-coet-sonarsource

@aurelien-coet-sonarsource aurelien-coet-sonarsource commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary by Gitar

  • Caching enhancements:
    • Migrated Spring context cache serialization format from custom delimited strings to structured Gson JSON objects with versioning
    • Added comprehensive validation and robust error handling for cached metadata entries and component scan packages

This will update automatically on new commits.

@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

SONARJAVA-6896

return readFromCache(context, log, cacheKey, SpringContextCacheHelper::deserializePackages);
}

private static JsonObject serializeBeans(List<BeanData> beans) {

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'd highly recommend to use Gson's TypeAdapter mechanism for serialization/deserialization stuff. It allows us using steaming API (JsonReader / JsonWriter). You'll need to create several adapters for the classes to serialize (actually only two I guess: BeanData and InjectionPoint) and use them in serialize / deserialize methods. The main benefit is that each adapter is self-contained (and you can use its field names are local string literals, not top-level constants). E.g.:

class BeanDataAdapter extends TypeAdapter<BeanData> {

    @Override
    public void write(JsonWriter out, BeanData bean) throws IOException {
      out.beginObject();
      out.name("name").value(bean.beanName());
      out.name("type").value(bean.type());
      out.name("package").value(bean.beanPackage());
      // ... etc.
      out.endObject();
    }

    @Override
    public BeanData read(JsonReader in) throws IOException {
      String name = null, type = null, pkg = null;
      // ... locals for all fields
      in.beginObject();
      while (in.hasNext()) {
        switch (in.nextName()) {
          case "name" -> name = in.nextString();
          case "type" -> type = in.nextString();
          case "package" -> pkg = in.nextString();
          // ...
          default -> in.skipValue();
        }
      }
      in.endObject();
      return new BeanData(name, type, pkg, /* ... */);
    }
  }

@asya-vorobeva asya-vorobeva 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.

After switching to using TypeAdapter, it makes sense to add tests for SpringContextCacheHelper. Thus we can probably reduce / simplify caching tests in our GathererTest's.

Comment thread java-frontend/src/main/java/org/sonar/java/utils/JsonUtils.java
@gitar-bot

gitar-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 4 resolved / 4 findings

Migrates Spring context cache serialization from custom delimited strings to structured JSON with Gson and versioning. Round-trip test now asserts typeHierarchy restoration, cache key collision handling is fixed to use full paths instead of just file names, javadoc for readNullableString is corrected, and integration tests validate real parse results through the cache.

✅ 4 resolved
Quality: Round-trip test never asserts typeHierarchy is restored

📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:218-232 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:278-279 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:313-322 📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextCacheHelper.java:170 📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextCacheHelper.java:222
typeHierarchy is one of the ten serialized bean fields and is the only one whose restoration is never asserted: beans_written_to_cache_are_restored_identically compares type, package, primary, profiles, dependingBeans and location, and scanWithoutParsing_restores_profile_beans_and_dependencies_from_cache ignores getTypeToBeanNamesIndex() entirely (grep over the springcontext tests shows the only getNamesForType assertion is isEmpty() for the no-annotation case). Concretely, if deserializeBean read the wrong key or dropped the hierarchy, deserializeStrings would return an empty set, gatherSpringContextData would register the bean under no type, and every type-based bean lookup would silently miss on incremental analyses — yet all tests in this PR would still pass. Add an assertion on the type-to-bean-names index to the round-trip test.

Quality: readNullableString javadoc points at required(), which cannot work

📄 java-frontend/src/main/java/org/sonar/java/utils/JsonUtils.java:88-99 📄 java-frontend/src/main/java/org/sonar/java/utils/JsonUtils.java:124-129 📄 java-frontend/src/main/java/org/sonar/java/utils/JsonUtils.java:154-160 📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDataTypeAdapter.java:104-116
The javadoc of the new readNullableString says absence of the property "is detected by the caller through required(Object, String)", but that is impossible for a nullable value: required(profiles, PROFILES) would reject a bean whose profiles is legitimately JSON null, which is the common case (see the profiles:null round-trip fixtures). Its only caller consequently ignores the documented mechanism and re-implements it with a profilesRead flag plus a third hand-written copy of the "Missing JSON property '...'" literal already present twice in JsonUtils — contrary to the repository's own DRY guideline in CLAUDE.md. Fix the javadoc to describe the flag-based detection and expose the message once as a factory that all three sites use.

Quality: endsWith(fileName) cannot distinguish the two cache keys

📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:197 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/ComponentScanPackageGathererTest.java:162 📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextCacheHelper.java:52-53
These two tests are now the only ones covering the gatherer→helper wiring ("wiring only, the cache format is covered by SpringContextCacheHelperTest"), but endsWith("<FileName>.java") matches any key ending in the file key — both java:spring:bean-definitions:<key> and java:spring:component-scan-packages:<key> satisfy it. If a gatherer were wired to the wrong helper method (e.g. BeanDefinitionGatherer calling writeComponentScanPackagesToCache), both tests would still pass, so the one thing they are meant to verify is not actually asserted. Match on the key prefix instead, which is the discriminating part.

Quality: No test writes a real parse result through the cache anymore

📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextCacheHelperTest.java:82-96 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextCacheHelperTest.java:110-124 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextCacheHelperTest.java:341-355 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:187-199
The removed beans_written_to_cache_are_restored_identically and leaveFile_writes_profile_beans_and_dependencies_to_cache asserted the serialized shape and the restore of BeanData produced by an actual scan; the replacements assert the shape and round trip of hand-built fixtures (simpleComponent(), beanWithDependencies()), while the surviving leaveFile_writes_collected_beans_to_cache only checks that the payload contains the substring "qualifiedFieldDependencies". Nothing therefore verifies that what BeanDefinitionGatherer actually collects (profiles, typeHierarchy, spans from AnalyzerMessage.textSpanFor) serializes into the documented shape and survives a read — a regression in that mapping would only surface indirectly through AmbiguousDependencyCheckTest. Keep one end-to-end case that feeds the bytes captured from a real scan back into SpringContextCacheHelper.readBeanDefinitionsFromCache.

Implementation Status ◻️ 1 of 2 objectives covered
◻️ SONARJAVA-6896 - 1 of 2 objectives covered

This PR improves the Spring context caching serialization format using JSON via Gson type adapters and helper classes, but does not introduce an intermediate abstract class or interface for caching across checks.

Other objectives on this issue, possibly covered elsewhere:

  • ◻️ Create an additional intermediate abstract class or interface for caching across checks
✅ 1 covered here
  • ✅ Improve the serialization format for Spring context caching using a specific format like JSON or Protobuf
Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Counting what did not apply, without listing it.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqube-next

Copy link
Copy Markdown
Contributor

*/
final class BeanDataTypeAdapter extends TypeAdapter<BeanData> {

private static final String NAME = "name";

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.

Let's remove these top-level constants as I've noted in initial comment. It's absolutely acceptable to use string literals as they're local to the adapter. And it's much more simpler to read:

 @Override
    public void write(JsonWriter out, BeanData bean) throws IOException {
      out.beginObject();
      out.name("name").value(bean.beanName());
      // ... etc.
      out.endObject();
    }

    @Override
    public BeanData read(JsonReader in) throws IOException {
      String name = null, type = null, pkg = null;
      // ... locals for all fields
      in.beginObject();
      while (in.hasNext()) {
        switch (in.nextName()) {
          case "name" -> name = in.nextString();
          // ...
          default -> in.skipValue();
        }
      }
      in.endObject();
      return new BeanData(name, type, pkg, /* ... */);
    }

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.

Yes, the string literals are local to the adapters, but they are reused at least 3 times in each class, between the read and write methods, so I would argue that it makes sense to centralize them in static fields, so we only need to modify a single location if we want to change them later (I also suspect Gitar will flag these as code quality issues). WDYT ?

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