Workflow automation Groovy scripts cause performance degradation in batch processing

We’re experiencing severe performance degradation in our Oracle CX Cloud 23C workflow automation that processes opportunity updates in batch. The workflow includes custom Groovy scripts that calculate discount approvals, update related quote records, and trigger notification workflows. When processing small batches (10-20 opportunities), performance is acceptable at around 2-3 minutes. However, when our nightly batch job runs with 500+ opportunities, the workflow takes 6-8 hours to complete and sometimes times out entirely.

I’ve profiled the Groovy scripts and found they’re making individual database queries for each opportunity record instead of using batch operations. Each script execution queries the Quote object, Account object, and Product Catalog multiple times:

opportunities.each { opp ->
  def quote = Quote.findByOpportunity(opp.Id)
  def account = Account.findById(opp.AccountId)
  // Process each record individually
}

This results in thousands of database round-trips during batch processing. I need guidance on optimizing these Groovy scripts to use batch API calls, implement database query caching, and improve overall performance profiling. Our batch processing delays are impacting sales operations significantly.

Let me provide a comprehensive optimization solution covering all the key performance areas:

Groovy Script Optimization: Your current script has the classic N+1 query anti-pattern. Here’s the optimized version using bulk loading:

BEFORE (Inefficient - 1500+ queries for 500 opportunities):

opportunities.each { opp ->
  def quote = Quote.findByOpportunity(opp.Id)
  def account = Account.findById(opp.AccountId)
  def products = Product.findByIds(opp.ProductIds)
  // Process each record - 3 queries per opportunity
}

AFTER (Optimized - 3 bulk queries total):

// 1. Extract all IDs first
def oppIds = opportunities.collect { it.Id }
def accountIds = opportunities.collect { it.AccountId }.unique()
def allProductIds = opportunities.collectMany { it.ProductIds ?: [] }.unique()

// 2. Bulk load all related records
def quotesMap = Quote.findAll("OpportunityId IN :oppIds", [oppIds: oppIds])
                     .collectEntries { [it.OpportunityId, it] }
def accountsMap = Account.findByIds(accountIds)
                         .collectEntries { [it.Id, it] }
def productsMap = Product.findByIds(allProductIds)
                         .collectEntries { [it.Id, it] }

// 3. Process with cached lookups (no additional queries)
opportunities.each { opp ->
  def quote = quotesMap[opp.Id]
  def account = accountsMap[opp.AccountId]
  def products = opp.ProductIds.collect { productsMap[it] }
  // Process using cached data
}

This reduces 1500+ queries to just 3 bulk queries, improving performance by 100-500x for large batches.

Batch API Usage: Replace individual save operations with bulk updates:

BEFORE (Inefficient - 500 individual commits):

opportunities.each { opp ->
  opp.DiscountApproved = calculateDiscount(opp)
  opp.save()  // Individual database commit
}

AFTER (Optimized - Single bulk commit):

// Prepare all updates first
def updates = opportunities.collect { opp ->
  opp.DiscountApproved = calculateDiscount(opp)
  return opp
}

// Bulk update in single transaction
BulkDataAPI.updateRecords('Opportunity', updates, [
  batchSize: 200,
  allOrNone: false,
  bypassTriggers: false
])

The Batch API processes records in optimized chunks and uses a single database transaction, reducing commit overhead from 500 operations to 1.

Database Query Caching: Implement multi-level caching for reference data:

class WorkflowCache {
  // Static cache persists across workflow executions
  private static Map<String, Object> staticCache = [:]

  // Instance cache for single execution
  private Map<String, Object> instanceCache = [:]

  def getProduct(productId, useStaticCache = true) {
    def cacheKey = "product_${productId}"

    // Check instance cache first
    if (instanceCache[cacheKey]) {
      return instanceCache[cacheKey]
    }

    // Check static cache for reference data
    if (useStaticCache && staticCache[cacheKey]) {
      instanceCache[cacheKey] = staticCache[cacheKey]
      return staticCache[cacheKey]
    }

    // Query database only if not cached
    def product = Product.findById(productId)
    instanceCache[cacheKey] = product

    if (useStaticCache) {
      staticCache[cacheKey] = product
    }

    return product
  }

  def clearInstanceCache() {
    instanceCache.clear()
  }

  static def clearStaticCache() {
    staticCache.clear()
  }
}

// Usage in workflow
def cache = new WorkflowCache()
opportunities.each { opp ->
  def products = opp.ProductIds.collect { cache.getProduct(it) }
  // Products are cached across opportunities
}

Performance Profiling: Implement comprehensive profiling to identify bottlenecks:

class PerformanceProfiler {
  private Map<String, Long> timings = [:]
  private Map<String, Integer> counts = [:]

  def startTimer(String operation) {
    timings["${operation}_start"] = System.currentTimeMillis()
  }

  def endTimer(String operation) {
    def start = timings["${operation}_start"]
    def duration = System.currentTimeMillis() - start

    timings[operation] = (timings[operation] ?: 0) + duration
    counts[operation] = (counts[operation] ?: 0) + 1

    return duration
  }

  def logResults() {
    logger.info("=== Performance Profile ===")
    timings.each { operation, totalTime ->
      if (!operation.endsWith('_start')) {
        def count = counts[operation]
        def avgTime = totalTime / count
        logger.info("${operation}: ${totalTime}ms total, ${count} calls, ${avgTime}ms avg")
      }
    }
  }
}

// Usage
def profiler = new PerformanceProfiler()

profiler.startTimer('bulk_load_quotes')
def quotesMap = Quote.findAll("OpportunityId IN :oppIds", [oppIds: oppIds])
                     .collectEntries { [it.OpportunityId, it] }
profiler.endTimer('bulk_load_quotes')

profiler.startTimer('process_opportunities')
opportunities.each { opp ->
  profiler.startTimer('calculate_discount')
  def discount = calculateDiscount(opp)
  profiler.endTimer('calculate_discount')

  profiler.startTimer('update_quote')
  updateQuote(quotesMap[opp.Id], discount)
  profiler.endTimer('update_quote')
}
profiler.endTimer('process_opportunities')

profiler.logResults()

Complete Optimized Workflow Script: Here’s the full production-ready implementation:

import oracle.apps.crmCommon.bulkData.BulkDataAPI

class OptimizedOpportunityProcessor {
  def cache = new WorkflowCache()
  def profiler = new PerformanceProfiler()

  def processOpportunities(opportunities) {
    profiler.startTimer('total_processing')

    try {
      // Step 1: Bulk load all related data
      profiler.startTimer('bulk_data_loading')
      def relatedData = loadRelatedData(opportunities)
      profiler.endTimer('bulk_data_loading')

      // Step 2: Process opportunities with cached data
      profiler.startTimer('opportunity_processing')
      def updates = processWithCache(opportunities, relatedData)
      profiler.endTimer('opportunity_processing')

      // Step 3: Bulk save updates
      profiler.startTimer('bulk_save')
      saveInBulk(updates)
      profiler.endTimer('bulk_save')

      profiler.endTimer('total_processing')
      profiler.logResults()

      return [success: true, processed: opportunities.size()]

    } catch (Exception e) {
      logger.error("Processing failed: ${e.message}", e)
      profiler.logResults()  // Log partial results for troubleshooting
      throw e
    }
  }

  private def loadRelatedData(opportunities) {
    def oppIds = opportunities.collect { it.Id }
    def accountIds = opportunities.collect { it.AccountId }.unique()
    def productIds = opportunities.collectMany { it.ProductIds ?: [] }.unique()

    return [
      quotes: Quote.findAll("OpportunityId IN :ids", [ids: oppIds])
                   .collectEntries { [it.OpportunityId, it] },
      accounts: Account.findByIds(accountIds)
                       .collectEntries { [it.Id, it] },
      products: Product.findByIds(productIds)
                       .collectEntries { [it.Id, it] }
    ]
  }

  private def processWithCache(opportunities, relatedData) {
    return opportunities.collect { opp ->
      def quote = relatedData.quotes[opp.Id]
      def account = relatedData.accounts[opp.AccountId]
      def products = opp.ProductIds.collect { relatedData.products[it] }

      // Business logic
      opp.DiscountApproved = calculateDiscount(opp, account, products)
      opp.QuoteStatus = determineQuoteStatus(quote, opp.DiscountApproved)
      opp.ProcessedDate = new Date()

      return opp
    }
  }

  private def saveInBulk(updates) {
    // Split into chunks for optimal performance
    def chunkSize = 200
    updates.collate(chunkSize).each { chunk ->
      BulkDataAPI.updateRecords('Opportunity', chunk, [
        batchSize: chunkSize,
        allOrNone: false
      ])
    }
  }
}

// Execute optimized processor
def processor = new OptimizedOpportunityProcessor()
processor.processOpportunities(opportunities)

Performance Benchmarks: Expected improvements after optimization:

Before optimization (500 opportunities):

  • Total time: 6-8 hours
  • Database queries: ~1,500
  • Database commits: 500
  • Memory usage: Low (but inefficient)

After optimization (500 opportunities):

  • Total time: 5-8 minutes (60-90x faster)
  • Database queries: 3-5 bulk queries
  • Database commits: 3 (chunked)
  • Memory usage: Moderate (efficient bulk processing)

Implementation Steps:

  1. Deploy optimized script to test environment
  2. Run with 50 opportunities - verify results match original
  3. Gradually increase batch size: 100, 250, 500
  4. Monitor performance metrics at each level
  5. Review profiler logs to identify any remaining bottlenecks
  6. Deploy to production with monitoring enabled
  7. Schedule nightly batch job with optimized script

Monitoring and Maintenance: Set up ongoing performance monitoring:

  • Log processing time for each batch execution
  • Alert if processing time exceeds 15 minutes for 500 records
  • Weekly review of profiler logs to identify optimization opportunities
  • Monthly cache hit rate analysis
  • Quarterly review of batch size optimization

This comprehensive optimization addresses all four focus areas: Groovy script efficiency through bulk loading, Batch API usage for updates, database query caching for reference data, and performance profiling for continuous improvement. Your batch processing time should decrease from 6-8 hours to under 10 minutes for 500 opportunities.


This draft is based on general Oracle CX Cloud knowledge. It has not been verified against your specific version and environment. Practitioners: verify the steps and share your experience below.

Your diagnosis is correct - individual queries in loops are the classic N+1 query problem. You need to bulk-load all related records before the loop, then reference them from a cached map. Instead of querying inside the loop, query all quotes and accounts once, store in maps keyed by ID, then lookup from the map during processing. This reduces 1000 queries to just 2-3 bulk queries.

Beyond the N+1 issue, you should be using the Batch API for bulk updates rather than saving records individually. Oracle CX Cloud provides batch insert/update operations that can process hundreds of records in a single transaction. This dramatically reduces commit overhead and database locking contention. Look into the BulkDataAPI service - it’s designed exactly for your use case of processing 500+ records in workflow automation.

Tested this on Oracle CX Sales 23D with 800 opportunities in batch and the bulk query approach dropped our Groovy script execution time from 4 minutes to under 15 seconds.

I’d also recommend implementing a caching layer for frequently accessed reference data like Product Catalog entries. If your script looks up the same products repeatedly across different opportunities, cache those lookups at the workflow execution level:

def productCache = [:]
def getProduct(productId) {
  if (!productCache[productId]) {
    productCache[productId] = Product.findById(productId)
  }
  return productCache[productId]
}

This prevents redundant queries for the same reference data.

Thanks for the suggestions. I’m implementing the bulk loading approach and seeing improvement in my test environment. One question - when using bulk queries to pre-load all quotes and accounts, what’s the recommended batch size? Should I load all 500+ records in one query, or split into smaller chunks?

Oracle CX Cloud bulk queries can handle up to 1000 records efficiently, so 500 opportunities should be fine in a single query. However, if you’re dealing with complex objects that have many fields or related records, consider chunking into batches of 200-300 to avoid memory issues. The Batch API documentation recommends staying under 5MB per bulk operation to maintain optimal performance.

Also implement performance profiling checkpoints in your script to identify which specific operations are taking the most time. Use System.currentTimeMillis() to measure each major step and log the durations.