Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
394 changes: 394 additions & 0 deletions JACKSON_VERSION_CONFLICT_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,394 @@
# Spring Bean Registration Fix - Complete Analysis

## Problem Summary

After adding the OpenAI Java client dependency, Spring Boot starts successfully but fails to register any user-defined beans (`@Service`, `@Component`, etc.). Only Spring's auto-configuration beans are registered, leading to `NoSuchBeanDefinitionException` when attempting to inject user beans.

## The Real Root Cause: Jackson Version Conflict

The actual issue is a **Jackson version mismatch** between dependencies:

### The Version Conflict

1. **OpenAI Client Requirements**:
- `com.openai:openai-java:4.11.0` depends on Jackson 2.18+
- Jackson 2.18 introduced `OptBoolean` enum as return type for `JsonProperty.isRequired()`
- The client's `jackson-module-kotlin` uses this new API

2. **surf-cloud's Jackson Version**:
- Provides an older version of Jackson (pre-2.18)
- In this version, `JsonProperty.isRequired()` returns `boolean`, not `OptBoolean`

3. **Runtime Failure**:
- At runtime, `jackson-module-kotlin` from OpenAI calls `JsonProperty.isRequired()` expecting `OptBoolean`
- The older Jackson annotations library doesn't have this method signature
- Result: `NoSuchMethodError: 'com.fasterxml.jackson.annotation.OptBoolean com.fasterxml.jackson.annotation.JsonProperty.isRequired()'`

4. **Bean Creation Cascade Failure**:
- Spring tries to create `ChatProcessorRegistry`
- This requires `ValidateChatMessageWithAiProcessor`
- Which depends on `OpenAiService`
- Which uses OpenAI client with incompatible Jackson
- Spring catches the error and skips all these beans
- Result: No user beans are registered

## Evolution of the Fix (What We Learned)

### Attempt 1: Kotlin Metadata Exclusion ❌
**What we tried**: Modified `exclude("kotlin/**")` to preserve Kotlin metadata
**Why it failed**: The issue had nothing to do with Kotlin metadata

### Attempt 2: Exclude Jackson from OpenAI ❌
**What we tried**:
```kotlin
implementation("com.openai:openai-java:4.11.0") {
exclude(group = "com.fasterxml.jackson.core")
exclude(group = "com.fasterxml.jackson.databind")
// ...
}
```
**Why it failed**: OpenAI client needs Jackson at runtime for JSON deserialization. Without it:
- OpenAI client can't parse API responses
- Led to `BeanCreationException` in `ChatProcessorRegistry`

### Attempt 3: Remove Jackson Relocation ❌
**What we tried**: Removed `relocate("com.fasterxml.jackson", "...")` from ShadowJar
**Why it partially worked**:
- Component scanning now succeeds (beans are discovered)
- But runtime fails with `NoSuchMethodError` due to Jackson version mismatch
- This revealed the underlying version conflict

### Attempt 4: Force Jackson Version at Submodule Level ⚠️
**What we tried**:
```kotlin
// In surf-chat-core-common/build.gradle.kts
configurations.all {
resolutionStrategy.eachDependency {
if (requested.group.startsWith("com.fasterxml.jackson")) {
useVersion("2.18.2")
because("OpenAI client requires Jackson 2.18+ for OptBoolean support")
}
}
}
```
**Why it partially worked but wasn't sufficient**:
- Submodule-level resolution strategy only affects dependencies resolved at that level
- `surf-cloud` provides Jackson through a plugin (`withCloudClientPaper()`)
- Plugin-provided dependencies aren't subject to submodule-level resolution strategies
- The error persisted because the shadow JAR still contained the old Jackson version

### Attempt 5: Force Jackson Version at Root Level ✅
**The correct solution**: Force all Jackson modules to use version 2.18.2 at the **root project level**

```kotlin
// In root build.gradle.kts
allprojects {
configurations.configureEach {
resolutionStrategy.eachDependency {
if (requested.group.startsWith("com.fasterxml.jackson")) {
useVersion("2.18.2")
because("OpenAI client requires Jackson 2.18+ for OptBoolean support")
}
}
}
}
```

**Why this works**:
- Root-level `allprojects` block applies to ALL subprojects
- `configurations.configureEach` ensures it applies to all configurations, including those added by plugins
- ALL Jackson modules (core, databind, annotations, module-kotlin) use version 2.18.2
- Plugin-provided dependencies (like surf-cloud's Jackson) are also affected
- OpenAI client gets the Jackson version it expects
- No version conflicts at runtime
- The `OptBoolean` API is available as expected

## Technical Details

### The NoSuchMethodError Explained

```java
// Jackson 2.18+ (what OpenAI expects)
public interface JsonProperty {
OptBoolean isRequired(); // Returns enum
}

// Jackson pre-2.18 (what surf-cloud provides)
public interface JsonProperty {
boolean isRequired(); // Returns primitive
}
```

When jackson-module-kotlin (from OpenAI's dependencies) tries to call:
```kotlin
val required: OptBoolean = annotation.isRequired()
```

If Jackson annotations is pre-2.18, this fails with `NoSuchMethodError` because:
1. JVM looks for method `isRequired()Lcom/fasterxml/jackson/annotation/OptBoolean;`
2. Only finds `isRequired()Z` (boolean primitive)
3. Method signature mismatch → `NoSuchMethodError`

### Why Spring Fails Silently

Spring's bean creation is defensive:

```java
try {
Object bean = createBean(beanDefinition);
registerBean(beanName, bean);
} catch (Throwable ex) {
if (logger.isDebugEnabled()) {
logger.debug("Failed to create bean '" + beanName + "'", ex);
}
// Continue processing other beans
}
```

- `NoSuchMethodError` extends `Error` which extends `Throwable`
- Spring catches it, logs at DEBUG, and continues
- Application context completes initialization
- Missing beans only discovered when code tries to inject them

## The Complete Fix

### 1. Remove Jackson Relocation
```kotlin
// In surf-chat-paper/build.gradle.kts
// REMOVED - was unnecessary and caused issues:
tasks.shadowJar {
relocate("com.fasterxml.jackson", "dev.slne.surf.chat.shadow.jackson")
}
```

### 2. Force Jackson Version at Root Level (Critical!)
```kotlin
// In root build.gradle.kts
allprojects {
configurations.configureEach {
resolutionStrategy.eachDependency {
if (requested.group.startsWith("com.fasterxml.jackson")) {
useVersion("2.18.2")
because("OpenAI client requires Jackson 2.18+ for OptBoolean support")
}
}
}
}
```

**Why root-level is critical**:
- Applies to ALL subprojects, including plugin configurations
- `surf-cloud` provides Jackson through plugin (`withCloudClientPaper()`)
- Submodule-level strategies don't affect plugin-provided dependencies
- Only root-level `allprojects` ensures comprehensive coverage

### 3. Optional: Add at Submodule Level for Redundancy
```kotlin
// In surf-chat-core-common/build.gradle.kts (redundant but harmless)
configurations.all {
resolutionStrategy.eachDependency {
if (requested.group.startsWith("com.fasterxml.jackson")) {
useVersion("2.18.2")
because("OpenAI client requires Jackson 2.18+ for OptBoolean support")
}
}
}
```

This ensures:
- ✅ No Jackson relocation → OpenAI client can find Jackson
- ✅ Root-level enforcement → Plugin dependencies use correct version
- ✅ Consistent Jackson version → No `NoSuchMethodError`
- ✅ OpenAI client works → Can deserialize JSON responses
- ✅ All beans load → Spring component scanning succeeds
- ✅ Runtime stability → No version conflicts

## Key Lessons Learned

### 1. Version Conflicts Can Be Hidden

Dependency version conflicts don't always manifest during compilation or startup. They can appear as:
- `NoSuchMethodError` at runtime (method signature changes)
- `NoClassDefFoundError` (missing classes)
- Subtle behavioral differences (changed semantics)

**Prevention**: Always check transitive dependency versions when adding new dependencies:
```bash
./gradlew :module:dependencies | grep jackson
```

### 2. Package Relocation Has Limits

ShadowJar's `relocate()`:
- ✅ Moves class files to new package
- ✅ Rewrites references in YOUR compiled code
- ❌ Does NOT rewrite references in third-party JARs

**Lesson**: Only relocate when absolutely necessary (actual classpath conflicts). If you do relocate, ensure all dependencies that use the relocated package are compatible.

### 3. Dependency Exclusions Break Libraries

Excluding a transitive dependency seems like a clean solution, but:
- Libraries expect their dependencies to be present
- Runtime behavior depends on those dependencies
- ClassLoader delegation may not find alternative versions

**Lesson**: Don't exclude dependencies unless you're replacing them with compatible versions.

### 4. Plugin-Provided Dependencies Need Root-Level Forcing

**Critical discovery**: Gradle plugins can provide dependencies that bypass submodule resolution strategies.

In this case:
- `surf-cloud` plugin provides Jackson through `withCloudClientPaper()`
- Submodule-level `resolutionStrategy` in `surf-chat-core-common` didn't affect it
- Only root-level `allprojects` block with `configurations.configureEach` works

**Lesson**: When dealing with plugin-provided dependencies, always apply version forcing at the root project level:
```kotlin
allprojects {
configurations.configureEach {
resolutionStrategy.eachDependency {
// Version forcing logic
}
}
}
```

### 5. Spring's Defensive Bean Creation

Spring's component scanning is resilient to errors, which is good for robustness but bad for debugging:
- Errors during bean creation are caught and logged at DEBUG
- Application context completes "successfully"
- Issues only discovered when beans are needed

**Prevention**: Enable DEBUG logging during development:
```yaml
logging:
level:
org.springframework.beans.factory: DEBUG
org.springframework.context.support: DEBUG
```

### 5. Modern Libraries Evolve APIs

Jackson 2.18's change from `boolean` to `OptBoolean` is a breaking change for:
- Libraries that call the method expecting specific return type
- Bytecode-level compatibility (method signature changes)

**Lesson**: Pin dependency versions explicitly and test after upgrades. Use BOMs (Bill of Materials) for version management.

## Debugging Guide

If you encounter similar issues:

### 1. Check for NoSuchMethodError
```
java.lang.NoSuchMethodError: 'ReturnType com.example.Class.method()'
```
This indicates a version conflict where method signatures changed between versions.

### 2. Identify the Conflict
```bash
# List all versions of the problematic dependency
./gradlew :module:dependencies | grep -E "jackson|conflicting-lib"

# Check for version conflicts
./gradlew :module:dependencyInsight --dependency jackson-databind
```

### 3. Enable Spring Debug Logging
```yaml
logging:
level:
org.springframework: DEBUG
```

Look for messages like "Failed to create bean" or "Skipped bean registration"

### 4. Use Version Alignment
```kotlin
configurations.all {
resolutionStrategy.eachDependency {
if (requested.group == "com.fasterxml.jackson") {
useVersion("2.18.2")
}
}
}
```

### 5. Consider BOM Dependencies
```kotlin
dependencies {
implementation(platform("com.fasterxml.jackson:jackson-bom:2.18.2"))
// All Jackson modules will use version from BOM
}
```

## Prevention Strategies

### 1. Use Dependency BOMs
```kotlin
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0"))
implementation(platform("com.fasterxml.jackson:jackson-bom:2.18.2"))
}
```

### 2. Explicit Version Constraints
```kotlin
dependencies {
constraints {
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
}
}
```

### 3. Regular Dependency Audits
```bash
# Check for conflicts
./gradlew dependencies --configuration runtimeClasspath

# Check for version mismatches
./gradlew dependencyInsight --dependency jackson-databind
```

### 4. Integration Testing
Test the actual runtime behavior, not just compilation:
```kotlin
@SpringBootTest
class IntegrationTest {
@Autowired
lateinit var channelService: ChannelService

@Autowired
lateinit var openAiService: OpenAiService

@Test
fun `all beans are registered`() {
assertNotNull(channelService)
assertNotNull(openAiService)
}
}
```

## Summary

**Initial Problem**: Spring doesn't register user beans after adding OpenAI client

**Root Cause**: Jackson version conflict
- OpenAI client requires Jackson 2.18+ (for `OptBoolean` API)
- surf-cloud provides Jackson pre-2.18
- Version mismatch causes `NoSuchMethodError` at runtime
- Spring silently skips beans that fail to create

**Solution**: Force all Jackson dependencies to version 2.18.2 using `resolutionStrategy`

**Key Insight**: Transitive dependency version conflicts can cause silent runtime failures. Always verify version alignment when adding dependencies, especially for libraries like Jackson that are used by many frameworks.

## References

- [Jackson 2.18 Release Notes](https://github.com/FasterXML/jackson/wiki/Jackson-Release-2.18)
- [Gradle Dependency Resolution](https://docs.gradle.org/current/userguide/dependency_resolution.html)
- [Spring Boot Dependency Management](https://docs.spring.io/spring-boot/docs/current/reference/html/dependency-versions.html)
- [ShadowJar Plugin](https://imperceptiblethoughts.com/shadow/)
Loading