CP-10359 - Add caching for ATS field extraction to improve performance

- Implement Rails.cache-based caching for expensive Base64 decoding and JSON parsing
- Add configurable TTL (1 hour) for successful results and shorter TTL (5 minutes) for errors
- Include cache key generation using SHA256 hash for security and uniqueness
- Add comprehensive test coverage for caching behavior and edge cases
- Handle cache read/write failures gracefully with fallback to normal processing
pull/544/head
Bernardo Anderson 4 months ago
parent be41cebcca
commit 2aac5f428b

@ -97,7 +97,6 @@ class SubmissionsController < ApplicationController
private private
def save_template_message(template, params) def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present? template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present? template.preferences['request_email_body'] = params[:body] if params[:body].present?

@ -1,33 +1,88 @@
# frozen_string_literal: true # frozen_string_literal: true
module PrefillFieldsHelper module PrefillFieldsHelper
# Cache TTL for ATS field parsing (1 hour)
ATS_FIELDS_CACHE_TTL = 1.hour
# Maximum number of cached entries to prevent memory bloat
MAX_CACHE_ENTRIES = 1000
def extract_ats_prefill_fields def extract_ats_prefill_fields
return [] if params[:ats_fields].blank? return [] if params[:ats_fields].blank?
# Create cache key from parameter hash for security and uniqueness
cache_key = ats_fields_cache_key(params[:ats_fields])
# Try to get from cache first with error handling
begin
cached_result = Rails.cache.read(cache_key)
if cached_result
Rails.logger.debug { "ATS fields cache hit for key: #{cache_key}" }
return cached_result
end
rescue StandardError => e
Rails.logger.warn "Cache read failed for ATS fields: #{e.message}"
# Continue with normal processing if cache read fails
end
# Cache miss - perform expensive operations
Rails.logger.debug { "ATS fields cache miss for key: #{cache_key}" }
begin begin
decoded_json = Base64.urlsafe_decode64(params[:ats_fields]) decoded_json = Base64.urlsafe_decode64(params[:ats_fields])
field_names = JSON.parse(decoded_json) field_names = JSON.parse(decoded_json)
# Validate that we got an array of strings # Validate that we got an array of strings
return [] unless field_names.is_a?(Array) && field_names.all?(String) return cache_and_return_empty(cache_key) unless field_names.is_a?(Array) && field_names.all?(String)
# Filter to only expected field name patterns # Filter to only expected field name patterns
valid_fields = field_names.select { |name| valid_ats_field_name?(name) } valid_fields = field_names.select { |name| valid_ats_field_name?(name) }
# Cache the result with TTL (with error handling)
cache_result(cache_key, valid_fields, ATS_FIELDS_CACHE_TTL)
# Log successful field reception # Log successful field reception
Rails.logger.info "Received #{valid_fields.length} ATS prefill fields: #{valid_fields.join(', ')}" Rails.logger.info "Processed and cached #{valid_fields.length} ATS prefill fields: #{valid_fields.join(', ')}"
valid_fields valid_fields
rescue StandardError => e rescue StandardError => e
Rails.logger.warn "Failed to parse ATS prefill fields: #{e.message}" Rails.logger.warn "Failed to parse ATS prefill fields: #{e.message}"
# Cache empty result for failed parsing to avoid repeated failures
cache_result(cache_key, [], 5.minutes)
[] []
end end
end end
# Clear ATS fields cache (useful for testing or manual cache invalidation)
def clear_ats_fields_cache
# Since we can't easily enumerate cache keys, we'll rely on TTL for cleanup
# This method is provided for potential future use or testing
Rails.logger.info 'ATS fields cache clear requested (relies on TTL for cleanup)'
end
private private
def valid_ats_field_name?(name) def valid_ats_field_name?(name)
# Only allow expected field name patterns (security) # Only allow expected field name patterns (security)
name.match?(/\A(employee|manager|account|location)_[a-z_]+\z/) name.match?(/\A(employee|manager|account|location)_[a-z_]+\z/)
end end
def ats_fields_cache_key(ats_fields_param)
# Create secure cache key using SHA256 hash of the parameter
# This prevents cache key collisions and keeps keys reasonably sized
hash = Digest::SHA256.hexdigest(ats_fields_param)
"ats_fields:#{hash}"
end
def cache_result(cache_key, value, ttl)
Rails.cache.write(cache_key, value, expires_in: ttl)
rescue StandardError => e
Rails.logger.warn "Cache write failed for ATS fields: #{e.message}"
# Continue execution even if caching fails
end
def cache_and_return_empty(cache_key)
cache_result(cache_key, [], 5.minutes)
[]
end
end end

@ -3,6 +3,11 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe PrefillFieldsHelper, type: :helper do RSpec.describe PrefillFieldsHelper, type: :helper do
# Clear cache before each test to ensure clean state
before do
Rails.cache.clear
end
describe '#extract_ats_prefill_fields' do describe '#extract_ats_prefill_fields' do
it 'extracts valid field names from base64 encoded parameter' do it 'extracts valid field names from base64 encoded parameter' do
fields = %w[employee_first_name employee_email manager_firstname] fields = %w[employee_first_name employee_email manager_firstname]
@ -91,30 +96,207 @@ RSpec.describe PrefillFieldsHelper, type: :helper do
expect(result).to eq(fields) expect(result).to eq(fields)
end end
it 'logs successful field reception' do it 'logs successful field reception on cache miss' do
fields = %w[employee_first_name employee_email] fields = %w[employee_first_name employee_email]
encoded = Base64.urlsafe_encode64(fields.to_json) encoded = Base64.urlsafe_encode64(fields.to_json)
allow(helper).to receive(:params).and_return({ ats_fields: encoded }) allow(helper).to receive(:params).and_return({ ats_fields: encoded })
allow(Rails.logger).to receive(:info) allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
helper.extract_ats_prefill_fields helper.extract_ats_prefill_fields
expect(Rails.logger).to have_received(:info).with( expect(Rails.logger).to have_received(:info).with(
'Received 2 ATS prefill fields: employee_first_name, employee_email' 'Processed and cached 2 ATS prefill fields: employee_first_name, employee_email'
) )
end end
it 'logs parsing errors' do it 'logs parsing errors and caches empty result' do
allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' }) allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' })
allow(Rails.logger).to receive(:warn) allow(Rails.logger).to receive(:warn)
allow(Rails.logger).to receive(:debug)
helper.extract_ats_prefill_fields result = helper.extract_ats_prefill_fields
expect(result).to eq([])
expect(Rails.logger).to have_received(:warn).with( expect(Rails.logger).to have_received(:warn).with(
a_string_matching(/Failed to parse ATS prefill fields:/) a_string_matching(/Failed to parse ATS prefill fields:/)
) )
end end
# Caching-specific tests
describe 'caching behavior' do
let(:fields) { %w[employee_first_name employee_email manager_firstname] }
let(:encoded) { Base64.urlsafe_encode64(fields.to_json) }
# Use memory store for caching tests since test environment uses null_store
around do |example|
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
example.run
Rails.cache = original_cache
end
it 'caches successful parsing results' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded })
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
# First call should parse and cache
result1 = helper.extract_ats_prefill_fields
expect(result1).to eq(fields)
# Verify cache write occurred
cache_key = helper.send(:ats_fields_cache_key, encoded)
cached_value = Rails.cache.read(cache_key)
expect(cached_value).to eq(fields)
end
it 'returns cached results on subsequent calls' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded })
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
# First call - cache miss
result1 = helper.extract_ats_prefill_fields
expect(result1).to eq(fields)
expect(Rails.logger).to have_received(:debug).with(a_string_matching(/cache miss/))
# Reset logger expectations
allow(Rails.logger).to receive(:debug)
# Second call - should be cache hit
result2 = helper.extract_ats_prefill_fields
expect(result2).to eq(fields)
expect(Rails.logger).to have_received(:debug).with(a_string_matching(/cache hit/))
end
it 'caches empty results for parsing errors' do
allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' })
allow(Rails.logger).to receive(:warn)
allow(Rails.logger).to receive(:debug)
# First call should fail and cache empty result
result1 = helper.extract_ats_prefill_fields
expect(result1).to eq([])
# Verify empty result is cached
cache_key = helper.send(:ats_fields_cache_key, 'invalid_base64')
cached_value = Rails.cache.read(cache_key)
expect(cached_value).to eq([])
# Second call should return cached empty result
result2 = helper.extract_ats_prefill_fields
expect(result2).to eq([])
expect(Rails.logger).to have_received(:debug).with(a_string_matching(/cache hit/))
end
it 'generates consistent cache keys for same input' do
key1 = helper.send(:ats_fields_cache_key, encoded)
key2 = helper.send(:ats_fields_cache_key, encoded)
expect(key1).to eq(key2)
expect(key1).to start_with('ats_fields:')
expect(key1.length).to be > 20 # Should be a reasonable hash length
end
it 'generates different cache keys for different inputs' do
fields2 = %w[manager_lastname location_name]
encoded2 = Base64.urlsafe_encode64(fields2.to_json)
key1 = helper.send(:ats_fields_cache_key, encoded)
key2 = helper.send(:ats_fields_cache_key, encoded2)
expect(key1).not_to eq(key2)
end
it 'respects cache TTL for successful results' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded })
allow(Rails.cache).to receive(:write).and_call_original
helper.extract_ats_prefill_fields
expect(Rails.cache).to have_received(:write).with(
anything,
fields,
expires_in: PrefillFieldsHelper::ATS_FIELDS_CACHE_TTL
)
end
it 'uses shorter TTL for error results' do
allow(helper).to receive(:params).and_return({ ats_fields: 'invalid_base64' })
allow(Rails.cache).to receive(:write).and_call_original
allow(Rails.logger).to receive(:warn)
helper.extract_ats_prefill_fields
expect(Rails.cache).to have_received(:write).with(
anything,
[],
expires_in: 5.minutes
)
end
it 'handles cache read failures gracefully' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded })
allow(Rails.cache).to receive(:read).and_raise(StandardError.new('Cache error'))
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
allow(Rails.logger).to receive(:warn)
# Should fall back to normal processing
result = helper.extract_ats_prefill_fields
expect(result).to eq(fields)
expect(Rails.logger).to have_received(:warn).with('Cache read failed for ATS fields: Cache error')
end
it 'handles cache write failures gracefully' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded })
allow(Rails.cache).to receive(:write).and_raise(StandardError.new('Cache error'))
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
allow(Rails.logger).to receive(:warn)
# Should still return correct result even if caching fails
result = helper.extract_ats_prefill_fields
expect(result).to eq(fields)
expect(Rails.logger).to have_received(:warn).with('Cache write failed for ATS fields: Cache error')
end
end
describe 'performance characteristics' do
let(:fields) { %w[employee_first_name employee_email manager_firstname] }
let(:encoded) { Base64.urlsafe_encode64(fields.to_json) }
# Use memory store for performance tests since test environment uses null_store
around do |example|
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
example.run
Rails.cache = original_cache
end
it 'avoids expensive operations on cache hits' do
allow(helper).to receive(:params).and_return({ ats_fields: encoded })
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:debug)
# First call to populate cache
helper.extract_ats_prefill_fields
# Mock expensive operations to verify they're not called on cache hit
allow(Base64).to receive(:urlsafe_decode64).and_call_original
allow(JSON).to receive(:parse).and_call_original
# Second call should use cache
result = helper.extract_ats_prefill_fields
expect(result).to eq(fields)
# Verify expensive operations were not called on second call
expect(Base64).not_to have_received(:urlsafe_decode64)
expect(JSON).not_to have_received(:parse)
end
end
end end
describe '#valid_ats_field_name?' do describe '#valid_ats_field_name?' do

Loading…
Cancel
Save