From 2aac5f428b7456ae1ece8b1e51ef548a2f292389 Mon Sep 17 00:00:00 2001 From: Bernardo Anderson Date: Fri, 8 Aug 2025 11:44:35 -0500 Subject: [PATCH] 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 --- app/controllers/submissions_controller.rb | 1 - app/helpers/prefill_fields_helper.rb | 59 ++++++- spec/helpers/prefill_fields_helper_spec.rb | 190 ++++++++++++++++++++- 3 files changed, 243 insertions(+), 7 deletions(-) diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 3b663bad..181bacd5 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -97,7 +97,6 @@ class SubmissionsController < ApplicationController private - def save_template_message(template, params) template.preferences['request_email_subject'] = params[:subject] if params[:subject].present? template.preferences['request_email_body'] = params[:body] if params[:body].present? diff --git a/app/helpers/prefill_fields_helper.rb b/app/helpers/prefill_fields_helper.rb index f5ba22be..260c3902 100644 --- a/app/helpers/prefill_fields_helper.rb +++ b/app/helpers/prefill_fields_helper.rb @@ -1,33 +1,88 @@ # frozen_string_literal: true 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 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 decoded_json = Base64.urlsafe_decode64(params[:ats_fields]) field_names = JSON.parse(decoded_json) # 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 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 - 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 rescue StandardError => e 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 + # 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 def valid_ats_field_name?(name) # Only allow expected field name patterns (security) name.match?(/\A(employee|manager|account|location)_[a-z_]+\z/) 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 diff --git a/spec/helpers/prefill_fields_helper_spec.rb b/spec/helpers/prefill_fields_helper_spec.rb index 9cd00f9e..1887c9a2 100644 --- a/spec/helpers/prefill_fields_helper_spec.rb +++ b/spec/helpers/prefill_fields_helper_spec.rb @@ -3,6 +3,11 @@ require 'rails_helper' 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 it 'extracts valid field names from base64 encoded parameter' do fields = %w[employee_first_name employee_email manager_firstname] @@ -91,30 +96,207 @@ RSpec.describe PrefillFieldsHelper, type: :helper do expect(result).to eq(fields) end - it 'logs successful field reception' do + it 'logs successful field reception on cache miss' do fields = %w[employee_first_name employee_email] encoded = Base64.urlsafe_encode64(fields.to_json) allow(helper).to receive(:params).and_return({ ats_fields: encoded }) allow(Rails.logger).to receive(:info) + allow(Rails.logger).to receive(:debug) helper.extract_ats_prefill_fields 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 - 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(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( a_string_matching(/Failed to parse ATS prefill fields:/) ) 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 describe '#valid_ats_field_name?' do