Skip to content

Decompose FioriRecommendationHandler into focused helper classes; bound negative cache #35

@Schmarvinius

Description

@Schmarvinius

Summary

Extract the ~400-line FioriRecommendationHandler into an orchestrator + focused helpers. Replace the unbounded negative-result cache with a bounded Caffeine cache.

Problem

FioriRecommendationHandler (~400 lines) has 10 distinct responsibilities:

  1. Entity eligibility checks (draft-enabled, single row, active entity)
  2. Prediction element discovery (ValueList annotations)
  3. Context column analysis (type filtering)
  4. Context query construction (SELECT with ordering/limiting)
  5. Predict row building (null replacement with [PREDICT])
  6. Synthetic key computation (composite key flattening)
  7. AI inference orchestration (client resolution + invocation)
  8. Prediction value type coercion (String → Integer/BigDecimal/etc.)
  9. Text path resolution (annotation introspection for descriptions)
  10. Final recommendations map assembly

In cds-services, handlers delegate complex logic to focused helper classes (e.g., RemoteODataHandlerUriGenerator, ODataDataUtils).

Additionally, entitiesWithoutPredictions uses ConcurrentHashMap.newKeySet() which grows unboundedly — a memory risk in multi-tenant/extensibility scenarios.

Plan

Step 1: Extract RecommendationContextBuilder

Handles responsibilities 2-6:

class RecommendationContextBuilder {
  private final CdsStructuredType target;
  private final CdsStructuredType rowType;
  private final int contextRowLimit;

  RecommendationContextBuilder(CdsStructuredType target, CdsStructuredType rowType, int limit) { ... }

  List<String> predictionElementNames() { ... }  // from lines 113-125
  List<String> contextColumns() { ... }          // from lines 127-140
  CqnSelect buildContextQuery() { ... }         // from lines 142-176
  CdsData buildPredictRow(CdsData row) { ... }  // from lines 225-236
  String computeSyntheticKey(Map<String, Object> row) { ... } // from lines 238-252
  String indexColumn() { ... }
  boolean syntheticKeyNeeded() { ... }
}

Move SUPPORTED_CONTEXT_TYPES EnumSet into this class.

Step 2: Extract RecommendationResultParser

Handles responsibilities 8-10:

class RecommendationResultParser {
  Map<String, Object> buildRecommendations(
      PersistenceService db, CdsData prediction,
      List<String> predictionElementNames,
      CdsReadEventContext context, CdsStructuredType rowType) { ... }

  // Private helpers:
  // - parseValue(Object, CdsBaseType)
  // - resolveTextPaths(...)
  // - buildFkToAssociationMap(...)
  // - resolveDescriptionsBatch(...)
  // - getTextPath(...)
}

Step 3: Simplify Handler to Orchestrator

FioriRecommendationHandler.afterRead() becomes ~80-100 lines:

@After(entity = "*")
public void afterRead(CdsReadEventContext context, List<CdsData> dataList) {
  // Quick bail-outs (null, cache, size, draft, active) ...

  var builder = new RecommendationContextBuilder(target, rowType, limit);
  if (builder.predictionElementNames().isEmpty()) {
    entitiesWithoutPredictions.put(entityName, Boolean.TRUE);
    return;
  }

  List<CdsData> contextRows = db.run(builder.buildContextQuery()).list();
  if (contextRows.size() < 2) return;

  CdsData predictRow = builder.buildPredictRow(row);
  if (predictRow == null) return;

  List<CdsData> allRows = builder.assembleRows(contextRows, predictRow, row);

  RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId);
  List<CdsData> predictions = client.predict(allRows, builder.predictionElementNames(), builder.indexColumn());
  // ... validate predictions ...

  var parser = new RecommendationResultParser();
  row.put("SAP_Recommendations", parser.buildRecommendations(db, predictions.get(0), missingCols, context, rowType));
}

Step 4: Bound the Negative Cache

// Before
private final Set<String> entitiesWithoutPredictions = ConcurrentHashMap.newKeySet();

// After
private final Cache<String, Boolean> entitiesWithoutPredictions =
    Caffeine.newBuilder().maximumSize(10_000).build();

Update usages:

  • contains()getIfPresent() != null
  • add()put(key, Boolean.TRUE)

Files

  • New: RecommendationContextBuilder.java
  • New: RecommendationResultParser.java
  • Modified: FioriRecommendationHandler.java (reduced to ~80-100 lines)

Verification

All existing tests for the recommendation module should pass unchanged. The refactoring is purely structural (extract method → extract class).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions