From 3197938d8e8bb66051806f3914ebeafca32ba55f Mon Sep 17 00:00:00 2001
From: jazairi <16103405+jazairi@users.noreply.github.com>
Date: Fri, 21 Nov 2025 15:04:25 -0500
Subject: [PATCH 1/3] Improve 'all' tab pagination to handle edge cases
Why these changes are being introduced:
The zipper merge we implemented naively queries n/2 results from each
API and interleaves them, where n is the per-page value. This works if
both APIs return many results, but it can cause problems in smaller,
unbalanced result sets.
For example, the query term `doc edgerton` returns 50 Primo results and
4 TIMDEX results. Page 1 only shows 14 results (4 TIMDEX and 10 Primo),
and each subsequent page returns only 10 (all Primo).
Relevant ticket(s):
- [USE-179](https://mitlibraries.atlassian.net/browse/USE-179)
How this addresses that need:
This implements more sophisticated logic that first checks the number
of hits returned by each API and passes that, along with the pagination
information, to a Merged Search Paginator class. This service object
develops a 'merge plan', calculates API offsets, and merges the results
for each page.
Queries on the 'all' tab now fetch twice from each API: once to
determine the total number of hits for the Merged Search Paginator
then again to fetch results at the appropriate offset. While hardly
ideal, this was the only option I could figure to avoid losing results.
I limited these extra calls to queries beyond page 1, which is the
only case where they are needed.
Side effects of this change:
* We now clear cache before each search controller test. This was done
to avoid odd test behavior, but I ran the suite 50 times without any
issues, so it might be excessively cautious.
* The search controller continues to grow with this new logic. I tried
to split things into multiple helper methods, so if we want to move
more things to service objects later, it might be easier to do so.
* A failing cassette has been replaced with a mock.
---
app/controllers/search_controller.rb | 146 +++++++---
app/models/merged_search_paginator.rb | 73 +++++
test/controllers/search_controller_test.rb | 279 +++++++++++++++++---
test/models/merged_search_paginator_test.rb | 58 ++++
test/vcr_cassettes/advanced_title_data.yml | 90 -------
5 files changed, 479 insertions(+), 167 deletions(-)
create mode 100644 app/models/merged_search_paginator.rb
create mode 100644 test/models/merged_search_paginator_test.rb
delete mode 100644 test/vcr_cassettes/advanced_title_data.yml
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index c7b1301a..222fefe2 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -88,48 +88,117 @@ def load_timdex_results
end
def load_all_results
- # Fetch results from both APIs in parallel
- primo_data, timdex_data = fetch_all_data
+ current_page = @enhanced_query[:page] || 1
+ per_page = ENV.fetch('RESULTS_PER_PAGE', '20').to_i
+ data = if current_page.to_i == 1
+ fetch_all_tab_first_page(current_page, per_page)
+ else
+ fetch_all_tab_deeper_pages(current_page, per_page)
+ end
- # Combine errors from both APIs
- @errors = combine_errors(primo_data[:errors], timdex_data[:errors])
+ @results = data[:results]
+ @errors = data[:errors]
+ @pagination = data[:pagination]
+ @show_primo_continuation = data[:show_primo_continuation]
+ end
- # Zipper merge results from both APIs
- @results = merge_results(primo_data[:results], timdex_data[:results])
+ def fetch_all_tab_first_page(current_page, per_page)
+ primo_data, timdex_data = parallel_fetch({ offset: 0, per_page: per_page }, { offset: 0, per_page: per_page })
- # Use Analyzer for combined pagination calculation
- @pagination = Analyzer.new(@enhanced_query, timdex_data[:hits], :all,
- primo_data[:hits]).pagination
+ paginator = build_paginator_from_data(primo_data, timdex_data, current_page, per_page)
- # Handle primo continuation for high page numbers
- @show_primo_continuation = primo_data[:show_continuation] || false
+ assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page)
end
- def fetch_all_data
- # Parallel fetching from both APIs
- primo_thread = Thread.new { fetch_primo_data }
- timdex_thread = Thread.new { fetch_timdex_data }
+ def fetch_all_tab_deeper_pages(current_page, per_page)
+ primo_summary, timdex_summary = parallel_fetch({ offset: 0, per_page: 1 }, { offset: 0, per_page: 1 })
+
+ paginator = build_paginator_from_data(primo_summary, timdex_summary, current_page, per_page)
+
+ primo_data, timdex_data = fetch_all_tab_page_chunks(paginator)
+
+ assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page, deeper: true)
+ end
+
+ # Launch parallel fetch threads for Primo and Timdex and return their data
+ def parallel_fetch(primo_opts = {}, timdex_opts = {})
+ primo_thread = Thread.new { fetch_primo_data(**primo_opts) }
+ timdex_thread = Thread.new { fetch_timdex_data(**timdex_opts) }
[primo_thread.value, timdex_thread.value]
end
+ # Build a paginator from raw API response data
+ def build_paginator_from_data(primo_data, timdex_data, current_page, per_page)
+ primo_total = primo_data[:hits] || 0
+ timdex_total = timdex_data[:hits] || 0
+
+ MergedSearchPaginator.new(
+ primo_total: primo_total,
+ timdex_total: timdex_total,
+ current_page: current_page,
+ per_page: per_page
+ )
+ end
+
+ # For deeper pages, compute merge_plan and api_offsets, then conditionally fetch page chunks
+ def fetch_all_tab_page_chunks(paginator)
+ merge_plan = paginator.merge_plan
+ primo_count = merge_plan.count(:primo)
+ timdex_count = merge_plan.count(:timdex)
+ primo_offset, timdex_offset = paginator.api_offsets
+
+ primo_thread = primo_count > 0 ? Thread.new { fetch_primo_data(offset: primo_offset, per_page: primo_count) } : nil
+ timdex_thread = if timdex_count > 0
+ Thread.new do
+ fetch_timdex_data(offset: timdex_offset, per_page: timdex_count)
+ end
+ end
+
+ primo_data = if primo_thread
+ primo_thread.value
+ else
+ { results: [], errors: nil, hits: paginator.primo_total, show_continuation: false }
+ end
+
+ timdex_data = if timdex_thread
+ timdex_thread.value
+ else
+ { results: [], errors: nil, hits: paginator.timdex_total }
+ end
+
+ [primo_data, timdex_data]
+ end
+
+ # Assemble the final result hash from paginator and API data
+ def assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page, deeper: false)
+ primo_total = primo_data[:hits] || 0
+ timdex_total = timdex_data[:hits] || 0
+
+ merged = paginator.merge_results(primo_data[:results] || [], timdex_data[:results] || [])
+ errors = combine_errors(primo_data[:errors], timdex_data[:errors])
+ pagination = Analyzer.new(@enhanced_query, timdex_total, :all, primo_total).pagination
+
+ show_primo_continuation = if deeper
+ page_offset = (current_page - 1) * per_page
+ primo_data[:show_continuation] || (page_offset >= Analyzer::PRIMO_MAX_OFFSET)
+ else
+ primo_data[:show_continuation]
+ end
+
+ { results: merged, errors: errors, pagination: pagination, show_primo_continuation: show_primo_continuation }
+ end
+
def combine_errors(*error_arrays)
all_errors = error_arrays.compact.flatten
all_errors.any? ? all_errors : nil
end
- def merge_results(primo_results, timdex_results)
- (primo_results || []).zip(timdex_results || []).flatten.compact
- end
-
- def fetch_primo_data
+ def fetch_primo_data(offset: nil, per_page: nil)
+ # Default to current page if not provided
current_page = @enhanced_query[:page] || 1
- per_page = if @active_tab == 'all'
- ENV.fetch('RESULTS_PER_PAGE', '20').to_i / 2
- else
- ENV.fetch('RESULTS_PER_PAGE', '20').to_i
- end
- offset = (current_page - 1) * per_page
+ per_page ||= ENV.fetch('RESULTS_PER_PAGE', '20').to_i
+ offset ||= (current_page - 1) * per_page
# Check if we're beyond Primo API limits before making the request.
if offset >= Analyzer::PRIMO_MAX_OFFSET
@@ -151,8 +220,9 @@ def fetch_primo_data
if results.empty?
docs = primo_response['docs'] if primo_response.is_a?(Hash)
if docs.nil? || docs.empty?
- # Only show continuation for pagination scenarios (page > 1), not for searches with no results
- show_continuation = true if current_page > 1
+ # Only show continuation for pagination scenarios (where offset is present), not for
+ # searches with no results
+ show_continuation = true if offset > 0
else
errors = [{ 'message' => 'No more results available at this page number.' }]
end
@@ -164,19 +234,10 @@ def fetch_primo_data
{ results: [], pagination: {}, errors: handle_primo_errors(e), show_continuation: false, hits: 0 }
end
- def fetch_timdex_data
- # For all tab, modify query to use half page size
- if @active_tab == 'all'
- per_page = ENV.fetch('RESULTS_PER_PAGE', '20').to_i / 2
- page = @enhanced_query[:page] || 1
- from_offset = ((page - 1) * per_page).to_s
-
- query_builder = QueryBuilder.new(@enhanced_query)
- query = query_builder.query
- query['from'] = from_offset
- else
- query = QueryBuilder.new(@enhanced_query).query
- end
+ def fetch_timdex_data(offset: nil, per_page: nil)
+ query = QueryBuilder.new(@enhanced_query).query
+ query['from'] = offset.to_s if offset
+ query['size'] = per_page.to_s if per_page
response = query_timdex(query)
errors = extract_errors(response)
@@ -223,7 +284,8 @@ def query_timdex(query)
def query_primo(per_page, offset)
# We generate unique cache keys to avoid naming collisions.
- cache_key = generate_cache_key(@enhanced_query)
+ # Include per_page and offset in the cache key to ensure pagination works correctly.
+ cache_key = generate_cache_key(@enhanced_query.merge(per_page: per_page, offset: offset))
Rails.cache.fetch("#{cache_key}/primo", expires_in: 12.hours) do
primo_search = PrimoSearch.new(@enhanced_query[:tab])
diff --git a/app/models/merged_search_paginator.rb b/app/models/merged_search_paginator.rb
new file mode 100644
index 00000000..030fa77a
--- /dev/null
+++ b/app/models/merged_search_paginator.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+# MergedSearchPaginator encapsulates stateless merged pagination logic for combining two API result sets.
+# It calculates the merge plan, API offsets, and merges the results for a given page.
+class MergedSearchPaginator
+ attr_reader :primo_total, :timdex_total, :current_page, :per_page
+
+ def initialize(primo_total:, timdex_total:, current_page:, per_page:)
+ @primo_total = primo_total
+ @timdex_total = timdex_total
+ @current_page = current_page
+ @per_page = per_page
+ end
+
+ # Returns an array of :primo and :timdex symbols for the merged result order on this page
+ def merge_plan
+ total_results = primo_total + timdex_total
+ start_index = (current_page - 1) * per_page
+ end_index = [start_index + per_page, total_results].min
+ plan = []
+ primo_used = 0
+ timdex_used = 0
+ i = 0
+ while i < end_index
+ if primo_used < primo_total && (timdex_used >= timdex_total || primo_used <= timdex_used)
+ source = :primo
+ primo_used += 1
+ elsif timdex_used < timdex_total
+ source = :timdex
+ timdex_used += 1
+ end
+ plan << source if i >= start_index
+ i += 1
+ end
+ plan
+ end
+
+ # Returns [primo_offset, timdex_offset] for the start of this page
+ def api_offsets
+ start_index = (current_page - 1) * per_page
+ primo_offset = 0
+ timdex_offset = 0
+ i = 0
+ while i < start_index
+ if primo_offset < primo_total && (timdex_offset >= timdex_total || primo_offset <= timdex_offset)
+ primo_offset += 1
+ elsif timdex_offset < timdex_total
+ timdex_offset += 1
+ else
+ break
+ end
+ i += 1
+ end
+ [primo_offset, timdex_offset]
+ end
+
+ # Merges two result arrays according to the merge plan
+ def merge_results(primo_results, timdex_results)
+ merged = []
+ primo_idx = 0
+ timdex_idx = 0
+ merge_plan.each do |source|
+ if source == :primo
+ merged << primo_results[primo_idx] if primo_idx < primo_results.length
+ primo_idx += 1
+ else
+ merged << timdex_results[timdex_idx] if timdex_idx < timdex_results.length
+ timdex_idx += 1
+ end
+ end
+ merged
+ end
+end
diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb
index aa60242a..2aa86d12 100644
--- a/test/controllers/search_controller_test.rb
+++ b/test/controllers/search_controller_test.rb
@@ -1,8 +1,13 @@
require 'test_helper'
class SearchControllerTest < ActionDispatch::IntegrationTest
+ # Clearing cache before each test to prevent any cache-related flakiness from threading.
+ setup do
+ Rails.cache.clear
+ end
+
def mock_primo_search_success
- # Mock the Primo search components to avoid external API calls
+ # Mock the Primo search components to avoid external API calls (single call)
sample_doc = {
api: 'primo',
title: 'Sample Primo Document Title',
@@ -24,6 +29,29 @@ def mock_primo_search_success
NormalizePrimoResults.expects(:new).returns(mock_normalizer)
end
+ def mock_primo_search_all_tab
+ # Mock the Primo search components for the all tab (multiple calls)
+ sample_doc = {
+ api: 'primo',
+ title: 'Sample Primo Document Title',
+ format: 'Article',
+ year: '2025',
+ creators: [
+ { value: 'Foo Barston', link: nil },
+ { value: 'Baz Quxley', link: nil }
+ ],
+ links: [{ 'kind' => 'full record', 'url' => 'https://example.com/record' }]
+ }
+
+ mock_primo = mock('primo_search')
+ mock_primo.expects(:search).returns({ 'docs' => [sample_doc], 'info' => { 'total' => 1 } }).at_least_once
+ PrimoSearch.expects(:new).returns(mock_primo).at_least_once
+
+ mock_normalizer = mock('normalizer')
+ mock_normalizer.expects(:normalize).returns([sample_doc]).at_least_once
+ NormalizePrimoResults.expects(:new).returns(mock_normalizer).at_least_once
+ end
+
def mock_primo_search_with_hits(total_hits)
sample_docs = (1..10).map do |i|
{
@@ -48,7 +76,7 @@ def mock_primo_search_with_hits(total_hits)
end
def mock_timdex_search_success
- # Mock the TIMDEX GraphQL client to avoid external API calls
+ # Mock the TIMDEX GraphQL client to avoid external API calls (single call)
sample_result = {
'api' => 'timdex',
'title' => 'Sample TIMDEX Document Title',
@@ -88,7 +116,51 @@ def mock_timdex_search_success
})
mock_response.stubs(:data).returns(mock_data)
- TimdexBase::Client.expects(:query).returns(mock_response)
+ TimdexBase::Client.expects(:query).returns(mock_response).at_least_once
+ end
+
+ def mock_timdex_search_all_tab
+ # Mock the TIMDEX GraphQL client for the all tab (multiple calls)
+ sample_result = {
+ 'api' => 'timdex',
+ 'title' => 'Sample TIMDEX Document Title',
+ 'timdexRecordId' => 'sample-record-123',
+ 'contentType' => [{ 'value' => 'Article' }],
+ 'dates' => [{ 'kind' => 'Publication date', 'value' => '2023' }],
+ 'contributors' => [{ 'value' => 'Foo Barston', 'kind' => 'Creator' }],
+ 'highlight' => [
+ {
+ 'matchedField' => 'summary',
+ 'matchedPhrases' => ['sample document']
+ }
+ ],
+ 'sourceLink' => 'https://example.com/record'
+ }
+
+ mock_response = mock('timdex_response')
+ mock_errors = mock('timdex_errors')
+ mock_errors.stubs(:details).returns({})
+ mock_errors.stubs(:to_h).returns({})
+ mock_response.stubs(:errors).returns(mock_errors)
+
+ mock_data = mock('timdex_data')
+ mock_search = mock('timdex_search')
+ mock_search.stubs(:to_h).returns({
+ 'hits' => 1,
+ 'aggregations' => {},
+ 'records' => [sample_result]
+ })
+ mock_data.stubs(:search).returns(mock_search)
+ mock_data.stubs(:to_h).returns({
+ 'search' => {
+ 'hits' => 1,
+ 'aggregations' => {},
+ 'records' => [sample_result]
+ }
+ })
+ mock_response.stubs(:data).returns(mock_data)
+
+ TimdexBase::Client.expects(:query).returns(mock_response).at_least_once
end
def mock_timdex_search_with_hits(total_hits)
@@ -126,13 +198,13 @@ def mock_timdex_search_with_hits(total_hits)
})
mock_response.stubs(:data).returns(mock_data)
- TimdexBase::Client.expects(:query).returns(mock_response)
+ TimdexBase::Client.expects(:query).returns(mock_response).at_least_once
# Mock the results normalization
normalized_results = sample_results.map { |result| result.merge({ source: 'TIMDEX' }) }
mock_normalizer = mock('normalizer')
- mock_normalizer.expects(:normalize).returns(normalized_results)
- NormalizeTimdexResults.expects(:new).returns(mock_normalizer)
+ mock_normalizer.expects(:normalize).returns(normalized_results).at_least_once
+ NormalizeTimdexResults.expects(:new).returns(mock_normalizer).at_least_once
end
test 'index shows basic search form by default' do
@@ -353,16 +425,50 @@ def mock_timdex_search_with_hits(total_hits)
end
test 'highlights partial is not rendered for results with no relevant highlights' do
- VCR.use_cassette('advanced title data',
- allow_playback_repeats: true,
- match_requests_on: %i[method uri body]) do
- get '/results?title=data&advanced=true'
- assert_response :success
+ # Stub TIMDEX response for this test to avoid VCR cassette mismatches.
+ sample_result = {
+ 'api' => 'timdex',
+ 'title' => 'Sample TIMDEX Document Title',
+ 'timdexRecordId' => 'sample-record-123',
+ 'contentType' => [{ 'value' => 'Article' }],
+ 'dates' => [{ 'kind' => 'Publication date', 'value' => '2023' }],
+ 'contributors' => [{ 'value' => 'Foo Barston', 'kind' => 'Creator' }],
+ 'highlight' => [],
+ 'sourceLink' => 'https://example.com/record'
+ }
- # We shouldn't see any highlighted terms because all of the matches will be on title, which is included in
- # SearchHelper#displayed_fields
- assert_select '#results .result-highlights ul li', { count: 0 }
- end
+ mock_response = mock('timdex_response')
+ mock_errors = mock('timdex_errors')
+ mock_errors.stubs(:details).returns({})
+ mock_errors.stubs(:to_h).returns({})
+ mock_response.stubs(:errors).returns(mock_errors)
+
+ mock_data = mock('timdex_data')
+ mock_search = mock('timdex_search')
+ mock_search.stubs(:to_h).returns({
+ 'hits' => 1,
+ 'aggregations' => {},
+ 'records' => [sample_result]
+ })
+ mock_data.stubs(:search).returns(mock_search)
+ mock_data.stubs(:to_h).returns({
+ 'search' => {
+ 'hits' => 1,
+ 'aggregations' => {},
+ 'records' => [sample_result]
+ }
+ })
+ mock_response.stubs(:data).returns(mock_data)
+
+ TimdexBase::Client.expects(:query).returns(mock_response).at_least_once
+
+ # Use the TIMDEX tab route to exercise highlighting behavior without running advanced search/VCR
+ get '/results?q=data&tab=timdex'
+ assert_response :success
+
+ # We shouldn't see any highlighted terms because all of the matches will be on title, which is included in
+ # SearchHelper#displayed_fields
+ assert_select '#results .result-highlights ul li', { count: 0 }
end
test 'searches with zero results are handled gracefully' do
@@ -646,8 +752,8 @@ def source_filter_count(controller)
# Tab functionality tests for USE
test 'results defaults to all tab when no tab parameter provided' do
# Mock both APIs since 'all' tab calls both
- mock_primo_search_success
- mock_timdex_search_success
+ mock_primo_search_all_tab
+ mock_timdex_search_all_tab
get '/results?q=test'
assert_response :success
@@ -799,7 +905,7 @@ def source_filter_count(controller)
})
mock_response.stubs(:data).returns(mock_data)
- TimdexBase::Client.expects(:query).returns(mock_response)
+ TimdexBase::Client.expects(:query).returns(mock_response).at_least_once
get '/results?q=nonexistentterm&tab=timdex'
assert_response :success
@@ -809,8 +915,8 @@ def source_filter_count(controller)
end
test 'all tab displays results from both TIMDEX and Primo' do
- mock_primo_search_success
- mock_timdex_search_success
+ mock_primo_search_all_tab
+ mock_timdex_search_all_tab
get '/results?q=test&tab=all'
assert_response :success
@@ -823,7 +929,7 @@ def source_filter_count(controller)
test 'all tab handles API errors gracefully' do
# Mock Primo to fail
PrimoSearch.expects(:new).raises(StandardError.new('Primo API Error'))
- mock_timdex_search_success
+ mock_timdex_search_all_tab
get '/results?q=test&tab=all'
assert_response :success
@@ -831,7 +937,7 @@ def source_filter_count(controller)
end
test 'all tab is default when no tab specified' do
- mock_primo_search_success
+ mock_primo_search_all_tab
mock_timdex_search_success
get '/results?q=test'
@@ -842,8 +948,8 @@ def source_filter_count(controller)
end
test 'all tab shows as active in navigation' do
- mock_primo_search_success
- mock_timdex_search_success
+ mock_primo_search_all_tab
+ mock_timdex_search_all_tab
get '/results?q=test&tab=all'
assert_response :success
@@ -852,16 +958,24 @@ def source_filter_count(controller)
end
test 'all tab shows primo continuation when page exceeds API offset limit' do
- mock_timdex_search_success
-
- # Mock Primo API to return empty results for high page number (beyond offset limit)
+ sample_doc = {
+ api: 'primo',
+ title: 'Sample Primo Document Title',
+ format: 'Article',
+ year: '2025',
+ creators: [
+ { value: 'Foo Barston', link: nil },
+ { value: 'Baz Quxley', link: nil }
+ ],
+ links: [{ 'kind' => 'full record', 'url' => 'https://example.com/record' }]
+ }
mock_primo = mock('primo_search')
- mock_primo.expects(:search).returns({ 'docs' => [], 'info' => { 'total' => 1000 } })
- PrimoSearch.expects(:new).returns(mock_primo)
-
+ mock_primo.expects(:search).returns({ 'docs' => [sample_doc], 'info' => { 'total' => 1 } }).at_least_once
+ PrimoSearch.expects(:new).returns(mock_primo).at_least_once
mock_normalizer = mock('normalizer')
- mock_normalizer.expects(:normalize).returns([])
- NormalizePrimoResults.expects(:new).returns(mock_normalizer)
+ mock_normalizer.expects(:normalize).returns([sample_doc]).at_least_once
+ NormalizePrimoResults.expects(:new).returns(mock_normalizer).at_least_once
+ mock_timdex_search_success
get '/results?q=test&tab=all&page=49'
assert_response :success
@@ -873,7 +987,24 @@ def source_filter_count(controller)
end
test 'all tab pagination displays combined hit counts' do
- mock_primo_search_with_hits(500)
+ sample_docs = (1..10).map do |i|
+ {
+ title: "Sample Primo Document Title \\#{i}",
+ format: 'Article',
+ year: '2025',
+ creators: [{ value: "Author \\#{i}", link: nil }],
+ links: [{ 'kind' => 'full record', 'url' => "https://example.com/record\\#{i}" }]
+ }
+ end
+ mock_primo = mock('primo_search')
+ mock_primo.expects(:search).returns({
+ 'docs' => sample_docs,
+ 'info' => { 'total' => 500 }
+ }).at_least_once
+ PrimoSearch.expects(:new).returns(mock_primo).at_least_once
+ mock_normalizer = mock('normalizer')
+ mock_normalizer.expects(:normalize).returns(sample_docs).at_least_once
+ NormalizePrimoResults.expects(:new).returns(mock_normalizer).at_least_once
mock_timdex_search_with_hits(300)
get '/results?q=test&tab=all'
@@ -885,7 +1016,24 @@ def source_filter_count(controller)
end
test 'all tab pagination includes next page link when more results available' do
- mock_primo_search_with_hits(500)
+ sample_docs = (1..10).map do |i|
+ {
+ title: "Sample Primo Document Title \\#{i}",
+ format: 'Article',
+ year: '2025',
+ creators: [{ value: "Author \\#{i}", link: nil }],
+ links: [{ 'kind' => 'full record', 'url' => "https://example.com/record\\#{i}" }]
+ }
+ end
+ mock_primo = mock('primo_search')
+ mock_primo.expects(:search).returns({
+ 'docs' => sample_docs,
+ 'info' => { 'total' => 500 }
+ }).at_least_once
+ PrimoSearch.expects(:new).returns(mock_primo).at_least_once
+ mock_normalizer = mock('normalizer')
+ mock_normalizer.expects(:normalize).returns(sample_docs).at_least_once
+ NormalizePrimoResults.expects(:new).returns(mock_normalizer).at_least_once
mock_timdex_search_with_hits(300)
get '/results?q=test&tab=all'
@@ -896,7 +1044,24 @@ def source_filter_count(controller)
end
test 'all tab pagination on page 2 includes previous page link' do
- mock_primo_search_with_hits(500)
+ sample_docs = (1..10).map do |i|
+ {
+ title: "Sample Primo Document Title \\#{i}",
+ format: 'Article',
+ year: '2025',
+ creators: [{ value: "Author \\#{i}", link: nil }],
+ links: [{ 'kind' => 'full record', 'url' => "https://example.com/record\\#{i}" }]
+ }
+ end
+ mock_primo = mock('primo_search')
+ mock_primo.expects(:search).returns({
+ 'docs' => sample_docs,
+ 'info' => { 'total' => 500 }
+ }).at_least_once
+ PrimoSearch.expects(:new).returns(mock_primo).at_least_once
+ mock_normalizer = mock('normalizer')
+ mock_normalizer.expects(:normalize).returns(sample_docs).at_least_once
+ NormalizePrimoResults.expects(:new).returns(mock_normalizer).at_least_once
mock_timdex_search_with_hits(300)
get '/results?q=test&tab=all&page=2'
@@ -908,4 +1073,48 @@ def source_filter_count(controller)
# Should show current range (21-40 for page 2)
assert_select '.pagination-container .current', text: /21 - 40 of 800/
end
+
+ test 'merge_results handles unbalanced API responses correctly' do
+ # Test case 1: Primo has fewer results than TIMDEX
+ paginator = MergedSearchPaginator.new(primo_total: 3, timdex_total: 5, current_page: 1, per_page: 8)
+ primo_results = %w[P1 P2 P3]
+ timdex_results = %w[T1 T2 T3 T4 T5]
+ merged = paginator.merge_results(primo_results, timdex_results)
+ expected = %w[P1 T1 P2 T2 P3 T3 T4 T5]
+ assert_equal expected, merged
+
+ # Test case 2: TIMDEX has fewer results than Primo
+ paginator = MergedSearchPaginator.new(primo_total: 5, timdex_total: 3, current_page: 1, per_page: 8)
+ primo_results = %w[P1 P2 P3 P4 P5]
+ timdex_results = %w[T1 T2 T3]
+ merged = paginator.merge_results(primo_results, timdex_results)
+ expected = %w[P1 T1 P2 T2 P3 T3 P4 P5]
+ assert_equal expected, merged
+
+ # Test case 3: Results exceed per_page limit (default 20)
+ paginator = MergedSearchPaginator.new(primo_total: 15, timdex_total: 15, current_page: 1, per_page: 20)
+ primo_results = (1..15).map { |i| "P#{i}" }
+ timdex_results = (1..15).map { |i| "T#{i}" }
+ merged = paginator.merge_results(primo_results, timdex_results)
+ assert_equal 20, merged.length
+ assert_equal 'P1', merged[0]
+ assert_equal 'T1', merged[1]
+ assert_equal 'P2', merged[2]
+ assert_equal 'T2', merged[3]
+
+ # Test case 4: One array is empty
+ paginator = MergedSearchPaginator.new(primo_total: 0, timdex_total: 3, current_page: 1, per_page: 3)
+ primo_results = []
+ timdex_results = %w[T1 T2 T3]
+ merged = paginator.merge_results(primo_results, timdex_results)
+ assert_equal %w[T1 T2 T3], merged
+
+ # Test case 5: more than 10 results from a single source can display when appropriate
+ paginator = MergedSearchPaginator.new(primo_total: 7, timdex_total: 11, current_page: 1, per_page: 18)
+ primo_results = (1..7).map { |i| "P#{i}" }
+ timdex_results = (1..11).map { |i| "T#{i}" }
+ merged = paginator.merge_results(primo_results, timdex_results)
+ expected = %w[P1 T1 P2 T2 P3 T3 P4 T4 P5 T5 P6 T6 P7 T7 T8 T9 T10 T11]
+ assert_equal expected, merged
+ end
end
diff --git a/test/models/merged_search_paginator_test.rb b/test/models/merged_search_paginator_test.rb
new file mode 100644
index 00000000..8627a5e7
--- /dev/null
+++ b/test/models/merged_search_paginator_test.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class MergedSearchPaginatorTest < ActiveSupport::TestCase
+ test 'merge_plan handles balanced results' do
+ paginator = MergedSearchPaginator.new(primo_total: 3, timdex_total: 3, current_page: 1, per_page: 6)
+ assert_equal(%i[primo timdex primo timdex primo timdex], paginator.merge_plan)
+ end
+
+ test 'merge_plan handles unbalanced results' do
+ paginator = MergedSearchPaginator.new(primo_total: 6, timdex_total: 2, current_page: 1, per_page: 8)
+ assert_equal(%i[primo timdex primo timdex primo primo primo primo], paginator.merge_plan)
+ end
+
+ test 'api_offsets are calculated as expected' do
+ paginator = MergedSearchPaginator.new(primo_total: 10, timdex_total: 10, current_page: 2, per_page: 5)
+ assert_equal([3, 2], paginator.api_offsets)
+ end
+
+ test 'merge_results handles even results' do
+ paginator = MergedSearchPaginator.new(primo_total: 2, timdex_total: 2, current_page: 1, per_page: 4)
+ primo = %w[P1 P2]
+ timdex = %w[T1 T2]
+ assert_equal(%w[P1 T1 P2 T2], paginator.merge_results(primo, timdex))
+ end
+
+ test 'merge_results with shorter array' do
+ paginator = MergedSearchPaginator.new(primo_total: 3, timdex_total: 1, current_page: 1, per_page: 4)
+ primo = %w[P1 P2 P3]
+ timdex = %w[T1]
+ assert_equal(%w[P1 T1 P2 P3], paginator.merge_results(primo, timdex))
+ end
+
+ test 'api_offsets breaks when start_index exceeds totals' do
+ # Use very small totals and request a page far beyond available results to exercise the break
+ paginator = MergedSearchPaginator.new(primo_total: 1, timdex_total: 1, current_page: 5, per_page: 20)
+ primo_offset, timdex_offset = paginator.api_offsets
+
+ # Offsets should stop at the available totals (1 each)
+ assert_equal 1, primo_offset
+ assert_equal 1, timdex_offset
+ end
+
+ test 'merge_plan returns all primo when timdex is empty' do
+ paginator = MergedSearchPaginator.new(primo_total: 2, timdex_total: 0, current_page: 1, per_page: 5)
+ plan = paginator.merge_plan
+
+ assert_equal %i[primo primo], plan
+ end
+
+ test 'merge_plan returns all timdex when primo is empty' do
+ paginator = MergedSearchPaginator.new(primo_total: 0, timdex_total: 2, current_page: 1, per_page: 5)
+ plan = paginator.merge_plan
+
+ assert_equal %i[timdex timdex], plan
+ end
+end
diff --git a/test/vcr_cassettes/advanced_title_data.yml b/test/vcr_cassettes/advanced_title_data.yml
deleted file mode 100644
index 846c4e8b..00000000
--- a/test/vcr_cassettes/advanced_title_data.yml
+++ /dev/null
@@ -1,90 +0,0 @@
----
-http_interactions:
-- request:
- method: post
- uri: https://FAKE_TIMDEX_HOST/graphql
- body:
- encoding: UTF-8
- string: '{"query":"query TimdexSearch__BaseQuery($q: String, $citation: String,
- $contributors: String, $fundingInformation: String, $identifiers: String,
- $locations: String, $subjects: String, $title: String, $index: String, $from:
- String, $booleanType: String, $accessToFilesFilter: [String!], $contentTypeFilter:
- [String!], $contributorsFilter: [String!], $formatFilter: [String!], $languagesFilter:
- [String!], $literaryFormFilter: String, $placesFilter: [String!], $sourceFilter:
- [String!], $subjectsFilter: [String!]) {\n search(searchterm: $q, citation:
- $citation, contributors: $contributors, fundingInformation: $fundingInformation,
- identifiers: $identifiers, locations: $locations, subjects: $subjects, title:
- $title, index: $index, from: $from, booleanType: $booleanType, accessToFilesFilter:
- $accessToFilesFilter, contentTypeFilter: $contentTypeFilter, contributorsFilter:
- $contributorsFilter, formatFilter: $formatFilter, languagesFilter: $languagesFilter,
- literaryFormFilter: $literaryFormFilter, placesFilter: $placesFilter, sourceFilter:
- $sourceFilter, subjectsFilter: $subjectsFilter) {\n hits\n records {\n timdexRecordId\n title\n source\n contentType\n contributors
- {\n kind\n value\n }\n publicationInformation\n dates
- {\n kind\n value\n }\n links {\n kind\n restrictions\n text\n url\n }\n notes
- {\n kind\n value\n }\n highlight {\n matchedField\n matchedPhrases\n }\n provider\n rights
- {\n kind\n description\n uri\n }\n sourceLink\n summary\n }\n aggregations
- {\n accessToFiles {\n key\n docCount\n }\n contentType
- {\n key\n docCount\n }\n contributors {\n key\n docCount\n }\n format
- {\n key\n docCount\n }\n languages {\n key\n docCount\n }\n literaryForm
- {\n key\n docCount\n }\n places {\n key\n docCount\n }\n source
- {\n key\n docCount\n }\n subjects {\n key\n docCount\n }\n }\n }\n}","variables":{"from":"0","title":"data","booleanType":"AND","index":"FAKE_TIMDEX_INDEX"},"operationName":"TimdexSearch__BaseQuery"}'
- headers:
- Accept-Encoding:
- - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
- Accept:
- - application/json
- User-Agent:
- - MIT Libraries Client
- Content-Type:
- - application/json
- response:
- status:
- code: 200
- message: OK
- headers:
- Server:
- - Cowboy
- Date:
- - Thu, 25 Apr 2024 20:57:17 GMT
- Report-To:
- - '{"group":"heroku-nel","max_age":3600,"endpoints":[{"url":"https://nel.heroku.com/reports?ts=1714078637&sid=67ff5de4-ad2b-4112-9289-cf96be89efed&s=Oe%2BY3GtI7ZglEtcdCIpU4KA2AQDyWWWXZ%2BJu0RXMXp0%3D"}]}'
- Reporting-Endpoints:
- - heroku-nel=https://nel.heroku.com/reports?ts=1714078637&sid=67ff5de4-ad2b-4112-9289-cf96be89efed&s=Oe%2BY3GtI7ZglEtcdCIpU4KA2AQDyWWWXZ%2BJu0RXMXp0%3D
- Nel:
- - '{"report_to":"heroku-nel","max_age":3600,"success_fraction":0.005,"failure_fraction":0.05,"response_headers":["Via"]}'
- Connection:
- - keep-alive
- X-Frame-Options:
- - SAMEORIGIN
- X-Xss-Protection:
- - '0'
- X-Content-Type-Options:
- - nosniff
- X-Permitted-Cross-Domain-Policies:
- - none
- Referrer-Policy:
- - strict-origin-when-cross-origin
- Content-Type:
- - application/json; charset=utf-8
- Vary:
- - Accept, Origin
- Etag:
- - W/"cea195da477c7f17058ba8ea7172e175"
- Cache-Control:
- - max-age=0, private, must-revalidate
- X-Request-Id:
- - 9b9ae3f1-d1cc-4e08-b449-6505a46abce8
- X-Runtime:
- - '0.367373'
- Strict-Transport-Security:
- - max-age=63072000; includeSubDomains
- Content-Length:
- - '42683'
- Via:
- - 1.1 vegur
- body:
- encoding: ASCII-8BIT
- string: !binary |-
- eyJkYXRhIjp7InNlYXJjaCI6eyJoaXRzIjoxMDAwMCwicmVjb3JkcyI6W3sidGltZGV4UmVjb3JkSWQiOiJhbG1hOjk5MDAwMjg2MDQwMDEwNjc2MSIsInRpdGxlIjoiRGF0YSBkYXRhIiwiY29udGVudFR5cGUiOlsiTGFuZ3VhZ2UgbWF0ZXJpYWwiXSwiY29udHJpYnV0b3JzIjpbeyJraW5kIjoiTm90IHNwZWNpZmllZCIsInZhbHVlIjoiRGVlcCBTZWEgRHJpbGxpbmcgUHJvamVjdC4gSW5mb3JtYXRpb24gSGFuZGxpbmcgR3JvdXAifV0sInB1YmxpY2F0aW9uSW5mb3JtYXRpb24iOm51bGwsImRhdGVzIjpbeyJraW5kIjoiUHVibGljYXRpb24gZGF0ZSIsInZhbHVlIjoiMTk3NiJ9XSwibGlua3MiOm51bGwsIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiSW5mb3JtYXRpb24gSGFuZGxpbmcgR3JvdXAsIERlZXAgU2VhIERyaWxsaW5nIFByb2plY3QiXX0seyJraW5kIjoiR2VuZXJhbCBOb3RlIiwidmFsdWUiOlsiVGl0bGUgZnJvbSBjYXB0aW9uIl19LHsia2luZCI6IkdlbmVyYWwgTm90ZSIsInZhbHVlIjpbIkRlc2NyaXB0aW9uIGJhc2VkIG9uOiAjMTIgKE5vdi4gMTk3OCkiXX0seyJraW5kIjoiTnVtYmVyaW5nIFBlY3VsaWFyaXRpZXMgTm90ZSIsInZhbHVlIjpbIlNvbWUgbnVtYmVycyBhcmUgcmV2aXNlZCBlZGl0aW9uIl19XSwiaGlnaGxpZ2h0IjpbeyJtYXRjaGVkRmllbGQiOiJ0aXRsZSIsIm1hdGNoZWRQaHJhc2VzIjpbIlx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2UiXX1dLCJwcm92aWRlciI6bnVsbCwicmlnaHRzIjpudWxsLCJzb3VyY2VMaW5rIjoiaHR0cHM6Ly9taXQucHJpbW8uZXhsaWJyaXNncm91cC5jb20vZGlzY292ZXJ5L2Z1bGxkaXNwbGF5P3ZpZD0wMU1JVF9JTlNUOk1JVFx1MDAyNmRvY2lkPWFsbWE5OTAwMDI4NjA0MDAxMDY3NjEiLCJzdW1tYXJ5IjpbIkEgc2VyaWVzIG9mIGJ1bGxldGlucywgZWFjaCB3aXRoIGEgZGlzdGluY3RpdmUgdGl0bGUsIGRlc2NyaWJpbmcgdGhlIHZhcmlvdXMgZGF0YSBwcm9jZXNzaW5nIGFjdGl2aXRpZXMgb2YgdGhlIERlZXAgU2VhIERyaWxsaW5nIFByb2plY3QgYW5kIHRoZSBJbmZvcm1hdGlvbiBIYW5kbGluZyBHcm91cC4iXX0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkzNTE0NzEzNzMwNjc2MSIsInRpdGxlIjoiQmlnIGRhdGEsIG9wZW4gZGF0YSBhbmQgZGF0YSBkZXZlbG9wbWVudCIsImNvbnRlbnRUeXBlIjpbIkxhbmd1YWdlIG1hdGVyaWFsIl0sImNvbnRyaWJ1dG9ycyI6W3sia2luZCI6ImF1dGhvciIsInZhbHVlIjoiTW9uaW5vLCBKZWFuLUxvdWlzIn0seyJraW5kIjoiYXV0aG9yIiwidmFsdWUiOiJTZWRrYW91aSwgU29yYXlhIn1dLCJwdWJsaWNhdGlvbkluZm9ybWF0aW9uIjpudWxsLCJkYXRlcyI6W3sia2luZCI6IlB1YmxpY2F0aW9uIGRhdGUiLCJ2YWx1ZSI6IjIwMTYifV0sImxpbmtzIjpbeyJraW5kIjoiRGlnaXRhbCBvYmplY3QgVVJMIiwicmVzdHJpY3Rpb25zIjpudWxsLCJ0ZXh0IjoiTydSZWlsbHkgT25saW5lIExlYXJuaW5nOiBBY2FkZW1pYy9QdWJsaWMgTGlicmFyeSBFZGl0aW9uIiwidXJsIjoiaHR0cHM6Ly9uYTA2LmFsbWEuZXhsaWJyaXNncm91cC5jb20vdmlldy91cmVzb2x2ZXIvMDFNSVRfSU5TVC9vcGVudXJsP3UuaWdub3JlX2RhdGVfY292ZXJhZ2U9dHJ1ZVx1MDAyNnBvcnRmb2xpb19waWQ9NTM1NjI1ODM5OTAwMDY3NjFcdTAwMjZGb3JjZV9kaXJlY3Q9dHJ1ZSJ9LHsia2luZCI6IkRpZ2l0YWwgb2JqZWN0IFVSTCIsInJlc3RyaWN0aW9ucyI6bnVsbCwidGV4dCI6IldpbGV5IE9ubGluZSBMaWJyYXJ5IFVCQ00gYWxsIE9ubGluZSBCb29rcyIsInVybCI6Imh0dHBzOi8vbmEwNi5hbG1hLmV4bGlicmlzZ3JvdXAuY29tL3ZpZXcvdXJlc29sdmVyLzAxTUlUX0lOU1Qvb3BlbnVybD91Lmlnbm9yZV9kYXRlX2NvdmVyYWdlPXRydWVcdTAwMjZwb3J0Zm9saW9fcGlkPTUzNjI5NzM3MzQwMDA2NzYxXHUwMDI2Rm9yY2VfZGlyZWN0PXRydWUifV0sIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiSmVhbi1Mb3VpcyBNb25pbm8sIFNvcmF5YSBTZWRrYW91aSJdfSx7ImtpbmQiOiJHZW5lcmFsIE5vdGUiLCJ2YWx1ZSI6WyJEZXNjcmlwdGlvbiBiYXNlZCB1cG9uIHByaW50IHZlcnNpb24gb2YgcmVjb3JkIl19LHsia2luZCI6IkJpYmxpb2dyYXBoeSBOb3RlIiwidmFsdWUiOlsiSW5jbHVkZXMgYmlibGlvZ3JhcGhpY2FsIHJlZmVyZW5jZXMgYW5kIGluZGV4Il19LHsia2luZCI6IlNvdXJjZSBvZiBEZXNjcmlwdGlvbiBOb3RlIiwidmFsdWUiOlsiRGVzY3JpcHRpb24gYmFzZWQgb24gcHJpbnQgdmVyc2lvbiByZWNvcmQiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiQmlnIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSwgb3BlbiBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2UgYW5kIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSBkZXZlbG9wbWVudCJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MzUxNDcxMzczMDY3NjEiLCJzdW1tYXJ5IjpbIlRoZSB3b3JsZCBoYXMgYmVjb21lIGRpZ2l0YWwgYW5kIHRlY2hub2xvZ2ljYWwgYWR2YW5jZXMgaGF2ZSBtdWx0aXBsaWVkIGNpcmN1aXRzIHdpdGggYWNjZXNzIHRvIGRhdGEsIHRoZWlyIHByb2Nlc3NpbmcgYW5kIHRoZWlyIGRpZmZ1c2lvbi4gTmV3IHRlY2hub2xvZ2llcyBoYXZlIG5vdyByZWFjaGVkIGEgY2VydGFpbiBtYXR1cml0eS4gRGF0YSBhcmUgYXZhaWxhYmxlIHRvIGV2ZXJ5b25lLCBhbnl3aGVyZSBvbiB0aGUgcGxhbmV0LiBUaGUgbnVtYmVyIG9mIEludGVybmV0IHVzZXJzIGluIDIwMTQgd2FzIDIuOSBiaWxsaW9uIG9yIDQxJSBvZiB0aGUgd29ybGQgcG9wdWxhdGlvbi4gVGhlIG5lZWQgZm9yIGtub3dsZWRnZSBpcyBiZWNvbWluZyBhcHBhcmVudCBpbiBvcmRlciB0byB1bmRlcnN0YW5kIHRoaXMgbXVsdGl0dWRlIG9mIGRhdGEuIFdlIG11c3QgZWR1Y2F0ZSwgaW5mb3JtIGFuZCB0cmFpbiB0aGUgbWFzc2VzLiBUaGUgZGV2ZWxvcG1lbnQgb2YgcmVsYXRlZCB0ZWNobm9sb2dpZXMsIHN1Y2ggYXMgdGhlIGFkdmVudCBvZiB0aGUgSW50ZXJuZXQsIHNvY2lhbCBuZXR3b3JrcywgXCJjbG91ZC1jb21wdXRpbmdcIiAoZGlnaXRhbCBmYWN0b3JpZXMpLCBoYXMgaW5jcmVhc2VkIHRoZSBhdmFpbGFibGUgdm9sdW1lcyBvZiBkYXRhLiBDdXJyZW50bHksIGVhY2ggaW5kaXZpZHVhbCBjcmVhdGVzLCBjb25zdW1lcywgdXNlcyBkaWdpdGFsIGluZm9ybWF0aW9uOiBtb3JlIHRoYW4gMy40IG1pbGxpb24gZS1tYWlscyBhcmUgc2VudCB3b3JsZHdpZGUgZXZlcnkgc2Vjb25kLCBvciAxMDcsMDAwIGJpbGxpb24gYW5udWFsbHkgd2l0aCAxNCw2MDAgZS1tYWlscyBwZXIgeWVhciBwZXIgcGVyc29uLCBidXQgbW9yZSB0aGFuIDcwJSBhcmUgc3BhbS4gQmlsbGlvbnMgb2YgcGllY2VzIG9mIGNvbnRlbnQgYXJlIHNoYXJlZCBvbiBzb2NpYWwgbmV0d29ya3Mgc3VjaCBhcyBGYWNlYm9vaywgbW9yZSB0aGFuIDIuNDYgbWlsbGlvbiBldmVyeSBtaW51dGUuIFdlIHNwZW5kIG1vcmUgdGhhbiA0LjggaG91cnMgYSBkYXkgb24gdGhlIEludGVybmV0IHVzaW5nIGEgY29tcHV0ZXIsIGFuZCAyLjEgaG91cnMgdXNpbmcgYSBtb2JpbGUuIERhdGEsIHRoaXMgbmV3IGV0aGVyZWFsIG1hbm5hIGZyb20gaGVhdmVuLCBpcyBwcm9kdWNlZCBpbiByZWFsIHRpbWUuIEl0IGNvbWVzIGluIGEgY29udGludW91cyBzdHJlYW0gZnJvbSBhIG11bHRpdHVkZSBvZiBzb3VyY2VzIHdoaWNoIGFyZSBnZW5lcmFsbHkgaGV0ZXJvZ2VuZW91cy4gVGhpcyBhY2N1bXVsYXRpb24gb2YgZGF0YSBvZiBhbGwgdHlwZXMgKGF1ZGlvLCB2aWRlbywgZmlsZXMsIHBob3RvcywgZXRjLikgZ2VuZXJhdGVzIG5ldyBhY3Rpdml0aWVzLCB0aGUgYWltIG9mIHdoaWNoIGlzIHRvIGFuYWx5emUgdGhpcyBlbm9ybW91cyBtYXNzIG9mIGluZm9ybWF0aW9uLiBJdCBpcyB0aGVuIG5lY2Vzc2FyeSB0byBhZGFwdCBhbmQgdHJ5IG5ldyBhcHByb2FjaGVzLCBuZXcgbWV0aG9kcywgbmV3IGtub3dsZWRnZSBhbmQgbmV3IHdheXMgb2Ygd29ya2luZywgcmVzdWx0aW5nIGluIG5ldyBwcm9wZXJ0aWVzIGFuZCBuZXcgY2hhbGxlbmdlcyBzaW5jZSBTRU8gbG9naWMgbXVzdCBiZSBjcmVhdGVkIGFuZCBpbXBsZW1lbnRlZC4gQXQgY29tcGFueSBsZXZlbCwgdGhpcyBtYXNzIG9mIGRhdGEgaXMgZGlmZmljdWx0IHRvIG1hbmFnZS4gSXRzIGludGVycHJldGF0aW9uIGlzIHByaW1hcmlseSBhIGNoYWxsZW5nZS4gVGhpcyBpbXBhY3RzIHRob3NlIHdobyBhcmUgdGhlcmUgdG8gXCJtYW5pcHVsYXRlXCIgdGhlIG1hc3MgYW5kIHJlcXVpcmVzIGEgc3BlY2lmaWMgaW5mcmFzdHJ1Y3R1cmUgZm9yIGNyZWF0aW9uLCBzdG9yYWdlLCBwcm9jZXNzaW5nLCBhbmFseXNpcyBhbmQgcmVjb3ZlcnkuIFRoZSBiaWdnZXN0IGNoYWxsZW5nZSBsaWVzIGluIFwidGhlIHZhbHVpbmcgb2YgZGF0YVwiIGF2YWlsYWJsZSBpbiBxdWFudGl0eSwgZGl2ZXJzaXR5IGFuZCBhY2Nlc3Mgc3BlZWQuIl19LHsidGltZGV4UmVjb3JkSWQiOiJhbG1hOjk5MzUyNDI3NTIwMDY3NjEiLCJ0aXRsZSI6IlN0cmF0YSBEYXRhIFN1cGVyc3RyZWFtIFNlcmllczogRGF0YSBXYXJlaG91c2VzLCBEYXRhIExha2VzLCBhbmQgRGF0YSBMYWtlaG91c2VzIiwiY29udGVudFR5cGUiOlsiUHJvamVjdGVkIG1lZGl1bSJdLCJjb250cmlidXRvcnMiOm51bGwsInB1YmxpY2F0aW9uSW5mb3JtYXRpb24iOm51bGwsImRhdGVzIjpbeyJraW5kIjoiUHVibGljYXRpb24gZGF0ZSIsInZhbHVlIjoiMjAyMSJ9XSwibGlua3MiOlt7ImtpbmQiOiJEaWdpdGFsIG9iamVjdCBVUkwiLCJyZXN0cmljdGlvbnMiOm51bGwsInRleHQiOiJPJ1JlaWxseSBPbmxpbmUgTGVhcm5pbmc6IEFjYWRlbWljL1B1YmxpYyBMaWJyYXJ5IEVkaXRpb24iLCJ1cmwiOiJodHRwczovL25hMDYuYWxtYS5leGxpYnJpc2dyb3VwLmNvbS92aWV3L3VyZXNvbHZlci8wMU1JVF9JTlNUL29wZW51cmw/dS5pZ25vcmVfZGF0ZV9jb3ZlcmFnZT10cnVlXHUwMDI2cG9ydGZvbGlvX3BpZD01MzY0NjI1NTc1MDAwNjc2MVx1MDAyNkZvcmNlX2RpcmVjdD10cnVlIn1dLCJub3RlcyI6bnVsbCwiaGlnaGxpZ2h0IjpbeyJtYXRjaGVkRmllbGQiOiJ0aXRsZSIsIm1hdGNoZWRQaHJhc2VzIjpbIlN0cmF0YSBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VEYXRhXHUwMDNjL3NwYW5cdTAwM2UgU3VwZXJzdHJlYW0gU2VyaWVzOiBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VEYXRhXHUwMDNjL3NwYW5cdTAwM2UgV2FyZWhvdXNlcywgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlRGF0YVx1MDAzYy9zcGFuXHUwMDNlIExha2VzLCBhbmQgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlRGF0YVx1MDAzYy9zcGFuXHUwMDNlIExha2Vob3VzZXMiXX1dLCJwcm92aWRlciI6bnVsbCwicmlnaHRzIjpudWxsLCJzb3VyY2VMaW5rIjoiaHR0cHM6Ly9taXQucHJpbW8uZXhsaWJyaXNncm91cC5jb20vZGlzY292ZXJ5L2Z1bGxkaXNwbGF5P3ZpZD0wMU1JVF9JTlNUOk1JVFx1MDAyNmRvY2lkPWFsbWE5OTM1MjQyNzUyMDA2NzYxIiwic3VtbWFyeSI6bnVsbH0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkwMDM0OTkzNDMwMTA2NzYxIiwidGl0bGUiOiJUaGUgRGF0YSBSZXZvbHV0aW9uIDogQmlnIERhdGEsIE9wZW4gRGF0YSwgRGF0YSBJbmZyYXN0cnVjdHVyZXMgXHUwMDI2IFRoZWlyIENvbnNlcXVlbmNlcyIsImNvbnRlbnRUeXBlIjpbIkxhbmd1YWdlIG1hdGVyaWFsIl0sImNvbnRyaWJ1dG9ycyI6W3sia2luZCI6Ik5vdCBzcGVjaWZpZWQiLCJ2YWx1ZSI6IktpdGNoaW4sIFJvYiJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDE0In1dLCJsaW5rcyI6W3sia2luZCI6IkRpZ2l0YWwgb2JqZWN0IFVSTCIsInJlc3RyaWN0aW9ucyI6bnVsbCwidGV4dCI6IkVCU0NPaG9zdCBFYm9va3MiLCJ1cmwiOiJodHRwOi8vc2VhcmNoLmVic2NvaG9zdC5jb20vbG9naW4uYXNweD9kaXJlY3Q9dHJ1ZVx1MDAyNnNjb3BlPXNpdGVcdTAwMjZkYj1ubGVia1x1MDAyNmRiPW5sYWJrXHUwMDI2QU49ODAxNTk0In0seyJraW5kIjoiRUJTQ09ob3N0IiwicmVzdHJpY3Rpb25zIjpudWxsLCJ0ZXh0IjpudWxsLCJ1cmwiOiJodHRwOi8vc2VhcmNoLmVic2NvaG9zdC5jb20vbG9naW4uYXNweD9kaXJlY3Q9dHJ1ZVx1MDAyNnNjb3BlPXNpdGVcdTAwMjZkYj1ubGVia1x1MDAyNmRiPW5sYWJrXHUwMDI2QU49ODAxNTk0In1dLCJub3RlcyI6W3sia2luZCI6IlRpdGxlIFN0YXRlbWVudCBvZiBSZXNwb25zaWJpbGl0eSIsInZhbHVlIjpbIlJvYiBLaXRjaGluIl19LHsia2luZCI6IkJpYmxpb2dyYXBoeSBOb3RlIiwidmFsdWUiOlsiSW5jbHVkZXMgYmlibGlvZ3JhcGhpY2FsIHJlZmVyZW5jZXMgYW5kIGluZGV4Il19LHsia2luZCI6IlNvdXJjZSBvZiBEZXNjcmlwdGlvbiBOb3RlIiwidmFsdWUiOlsiUHJpbnQgdmVyc2lvbiByZWNvcmQiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiVGhlIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBSZXZvbHV0aW9uIDogQmlnIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSwgT3BlbiBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VEYXRhXHUwMDNjL3NwYW5cdTAwM2UsIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBJbmZyYXN0cnVjdHVyZXMgXHUwMDI2IFRoZWlyIENvbnNlcXVlbmNlcyJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MDAzNDk5MzQzMDEwNjc2MSIsInN1bW1hcnkiOlsiQSBzZW1pbmFsIHRleHQsIHdyaXR0ZW4gYnkgb25lIG9mIHRoZSB3b3JsZCdzIGxlYWRpbmcgZXhwZXJ0cyBpbiB0aGUgZmllbGQuIEluIGNvbnRyYXN0IHRvIHRoZSBoeXBlIGFuZCBodWJyaXMgb2YgbXVjaCBtZWRpYSBhbmQgYnVzaW5lc3MgY292ZXJhZ2UsIGl0IHByb3ZpZGVzIGEgc3lub3B0aWMgYW5kIHRydWx5IGNyaXRpY2FsIGFuYWx5c2lzIG9mICdiaWcgZGF0YScsICdvcGVuIGRhdGEnIGFuZCB0aGUgZW1lcmdpbmcgZGF0YSBsYW5kc2NhcGUuIl19LHsidGltZGV4UmVjb3JkSWQiOiJhbG1hOjk5MDAyMjk3MDY3MDEwNjc2MSIsInRpdGxlIjoiVGhlIGRhdGEgcmV2b2x1dGlvbiA6IGJpZyBkYXRhLCBvcGVuIGRhdGEsIGRhdGEgaW5mcmFzdHJ1Y3R1cmVzIFx1MDAyNiB0aGVpciBjb25zZXF1ZW5jZXMiLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJhdXRob3IiLCJ2YWx1ZSI6IktpdGNoaW4sIFJvYiJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDE0In1dLCJsaW5rcyI6bnVsbCwibm90ZXMiOlt7ImtpbmQiOiJUaXRsZSBTdGF0ZW1lbnQgb2YgUmVzcG9uc2liaWxpdHkiLCJ2YWx1ZSI6WyJSb2IgS2l0Y2hpbiJdfSx7ImtpbmQiOiJCaWJsaW9ncmFwaHkgTm90ZSIsInZhbHVlIjpbIkluY2x1ZGVzIGJpYmxpb2dyYXBoaWNhbCByZWZlcmVuY2VzIChwYWdlcyAxOTMtMjE0KSBhbmQgaW5kZXgiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiVGhlIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSByZXZvbHV0aW9uIDogYmlnIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSwgb3BlbiBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2UsIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSBpbmZyYXN0cnVjdHVyZXMgXHUwMDI2IHRoZWlyIGNvbnNlcXVlbmNlcyJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MDAyMjk3MDY3MDEwNjc2MSIsInN1bW1hcnkiOlsiXCJUcmFkaXRpb25hbGx5LCBkYXRhIGhhcyBiZWVuIGEgc2NhcmNlIGNvbW1vZGl0eSB3aGljaCwgZ2l2ZW4gaXRzIHZhbHVlLCBoYXMgYmVlbiBlaXRoZXIgamVhbG91c2x5IGd1YXJkZWQgb3IgZXhwZW5zaXZlbHkgdHJhZGVkLiBJbiByZWNlbnQgeWVhcnMsIHRlY2hub2xvZ2ljYWwgZGV2ZWxvcG1lbnRzIGFuZCBwb2xpdGljYWwgbG9iYnlpbmcgaGF2ZSB0dXJuZWQgdGhpcyBwb3NpdGlvbiBvbiBpdHMgaGVhZC4gRGF0YSBub3cgZmxvdyBhcyBhIGRlZXAgYW5kIHdpZGUgdG9ycmVudCwgYXJlIGxvdyBpbiBjb3N0IGFuZCBzdXBwb3J0ZWQgYnkgcm9idXN0IGluZnJhc3RydWN0dXJlcywgYW5kIGFyZSBpbmNyZWFzaW5nbHkgb3BlbiBhbmQgYWNjZXNzaWJsZS4gQSBkYXRhIHJldm9sdXRpb24gaXMgdW5kZXJ3YXksIG9uZSB0aGF0IGlzIGFscmVhZHkgcmVzaGFwaW5nIGhvdyBrbm93bGVkZ2UgaXMgcHJvZHVjZWQsIGJ1c2luZXNzIGNvbmR1Y3RlZCwgYW5kIGdvdmVybmFuY2UgZW5hY3RlZCwgYXMgd2VsbCBhcyByYWlzaW5nIG1hbnkgcXVlc3Rpb25zIGNvbmNlcm5pbmcgc3VydmVpbGxhbmNlLCBwcml2YWN5LCBzZWN1cml0eSwgcHJvZmlsaW5nLCBzb2NpYWwgc29ydGluZywgYW5kIGludGVsbGVjdHVhbCBwcm9wZXJ0eSByaWdodHMuIEluIGNvbnRyYXN0IHRvIHRoZSBoeXBlIGFuZCBodWJyaXMgb2YgbXVjaCBtZWRpYSBhbmQgYnVzaW5lc3MgY292ZXJhZ2UsIFRoZSBEYXRhIFJldm9sdXRpb24gcHJvdmlkZXMgYSBzeW5vcHRpYyBhbmQgY3JpdGljYWwgYW5hbHlzaXMgb2YgdGhlIGVtZXJnaW5nIGRhdGEgbGFuZHNjYXBlLlwiLS1FeGNlcnB0ZWQgZnJvbSBwdWJsaXNoZXIncyBkZXNjcmlwdGlvbi4iXX0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkzNTA2ODAwNzYwNjc2MSIsInRpdGxlIjoiRGF0YSBhcmNoaXRlY3R1cmUgOiBhIHByaW1lciBmb3IgdGhlIGRhdGEgc2NpZW50aXN0IDogYmlnIGRhdGEsIGRhdGEgd2FyZWhvdXNlIGFuZCBkYXRhIHZhdWx0IiwiY29udGVudFR5cGUiOlsiTGFuZ3VhZ2UgbWF0ZXJpYWwiXSwiY29udHJpYnV0b3JzIjpbeyJraW5kIjoiYXV0aG9yIiwidmFsdWUiOiJJbm1vbiwgVy4gSCJ9LHsia2luZCI6ImF1dGhvciIsInZhbHVlIjoiTGluc3RlZHQsIERhbiJ9LHsia2luZCI6ImVkaXRvciIsInZhbHVlIjoiRWxsaW90LCBTdGV2ZW4ifSx7ImtpbmQiOiJkZXNpZ25lciIsInZhbHVlIjoiUm9nZXJzLCBNYXJrIn1dLCJwdWJsaWNhdGlvbkluZm9ybWF0aW9uIjpbIk1vcmdhbiBLYXVmbWFubjsgMjAxNTsgQW1zdGVyZGFtLCBOZXRoZXJsYW5kcyIsIsKpMjAxNSJdLCJkYXRlcyI6W3sia2luZCI6IlB1YmxpY2F0aW9uIGRhdGUiLCJ2YWx1ZSI6IjIwMTUifV0sImxpbmtzIjpbeyJraW5kIjoiRGlnaXRhbCBvYmplY3QgVVJMIiwicmVzdHJpY3Rpb25zIjpudWxsLCJ0ZXh0IjoiTydSZWlsbHkgT25saW5lIExlYXJuaW5nOiBBY2FkZW1pYy9QdWJsaWMgTGlicmFyeSBFZGl0aW9uIiwidXJsIjoiaHR0cHM6Ly9uYTA2LmFsbWEuZXhsaWJyaXNncm91cC5jb20vdmlldy91cmVzb2x2ZXIvMDFNSVRfSU5TVC9vcGVudXJsP3UuaWdub3JlX2RhdGVfY292ZXJhZ2U9dHJ1ZVx1MDAyNnBvcnRmb2xpb19waWQ9NTM1NDU1NzYzMjAwMDY3NjFcdTAwMjZGb3JjZV9kaXJlY3Q9dHJ1ZSJ9LHsia2luZCI6IkRpZ2l0YWwgb2JqZWN0IFVSTCIsInJlc3RyaWN0aW9ucyI6bnVsbCwidGV4dCI6IkVsc2V2aWVyIFNjaWVuY2VEaXJlY3QgQm9va3MgQ29tcGxldGUiLCJ1cmwiOiJodHRwczovL25hMDYuYWxtYS5leGxpYnJpc2dyb3VwLmNvbS92aWV3L3VyZXNvbHZlci8wMU1JVF9JTlNUL29wZW51cmw/dS5pZ25vcmVfZGF0ZV9jb3ZlcmFnZT10cnVlXHUwMDI2cG9ydGZvbGlvX3BpZD01MzU0NTU3NjMxMDAwNjc2MVx1MDAyNkZvcmNlX2RpcmVjdD10cnVlIn1dLCJub3RlcyI6W3sia2luZCI6IlRpdGxlIFN0YXRlbWVudCBvZiBSZXNwb25zaWJpbGl0eSIsInZhbHVlIjpbIlcuIEguIElubW9uLCBEYW4gTGluc3RlZHQgOyBTdGV2ZW4gRWxsaW90LCBleGVjdXRpdmUgZWRpdG9yIDsgTWFyayBSb2dlcnMsIGRlc2lnbmVyIl19LHsia2luZCI6IkdlbmVyYWwgTm90ZSIsInZhbHVlIjpbIkluY2x1ZGVzIGluZGV4Il19LHsia2luZCI6IlNvdXJjZSBvZiBEZXNjcmlwdGlvbiBOb3RlIiwidmFsdWUiOlsiRGVzY3JpcHRpb24gYmFzZWQgb24gcHJpbnQgdmVyc2lvbiByZWNvcmQiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlRGF0YVx1MDAzYy9zcGFuXHUwMDNlIGFyY2hpdGVjdHVyZSA6IGEgcHJpbWVyIGZvciB0aGUgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIHNjaWVudGlzdCA6IGJpZyBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2UsIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSB3YXJlaG91c2UgYW5kIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSB2YXVsdCJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MzUwNjgwMDc2MDY3NjEiLCJzdW1tYXJ5IjpbIlRvZGF5LCB0aGUgd29ybGQgaXMgdHJ5aW5nIHRvIGNyZWF0ZSBhbmQgZWR1Y2F0ZSBkYXRhIHNjaWVudGlzdHMgYmVjYXVzZSBvZiB0aGUgcGhlbm9tZW5vbiBvZiBCaWcgRGF0YS4gQW5kIGV2ZXJ5b25lIGlzIGxvb2tpbmcgZGVlcGx5IGludG8gdGhpcyB0ZWNobm9sb2d5LiBCdXQgbm8gb25lIGlzIGxvb2tpbmcgYXQgdGhlIGxhcmdlciBhcmNoaXRlY3R1cmFsIHBpY3R1cmUgb2YgaG93IEJpZyBEYXRhIG5lZWRzIHRvIGZpdCB3aXRoaW4gdGhlIGV4aXN0aW5nIHN5c3RlbXMgKGRhdGEgd2FyZWhvdXNpbmcgc3lzdGVtcykuIFRha2luZyBhIGxvb2sgYXQgdGhlIGxhcmdlciBwaWN0dXJlIGludG8gd2hpY2ggQmlnIERhdGEgZml0cyBnaXZlcyB0aGUgZGF0YSBzY2llbnRpc3QgdGhlIG5lY2Vzc2FyeSBjb250ZXh0IGZvciBob3cgcGllY2VzIG9mIHRoZSBwdXp6bGUgc2hvdWxkIGZpdCB0b2dldGhlci4gTW9zdCByZWZlcmVuY2VzIG9uIEJpZyBEYXRhIGxvb2sgYXQgb25seSBvbmUgdGlueSBwYXJ0IG9mIGEgbXVjaCBsYXJnZXIgd2hvbGUuIFVudGlsIGRhdGEgZ2F0aGVyZWQgY2FuIGJlIHB1dCBpbnRvIGFuIGV4aXN0aW5nIGZyYW1ld29yayBvciBhIl19LHsidGltZGV4UmVjb3JkSWQiOiJhbG1hOjk5MzUxMTQ0NTI5MDY3NjEiLCJ0aXRsZSI6IkphdmEgZGF0YSBhbmFseXNpcyA6IGRhdGEgbWluaW5nLCBiaWcgZGF0YSBhbmFseXNpcywgTm9TUUwsIGFuZCBkYXRhIHZpc3VhbGl6YXRpb24iLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJhdXRob3IiLCJ2YWx1ZSI6Ikh1YmJhcmQsIEpvaG4gUiJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDE3In1dLCJsaW5rcyI6W3sia2luZCI6IkRpZ2l0YWwgb2JqZWN0IFVSTCIsInJlc3RyaWN0aW9ucyI6bnVsbCwidGV4dCI6Ik8nUmVpbGx5IE9ubGluZSBMZWFybmluZzogQWNhZGVtaWMvUHVibGljIExpYnJhcnkgRWRpdGlvbiIsInVybCI6Imh0dHBzOi8vbmEwNi5hbG1hLmV4bGlicmlzZ3JvdXAuY29tL3ZpZXcvdXJlc29sdmVyLzAxTUlUX0lOU1Qvb3BlbnVybD91Lmlnbm9yZV9kYXRlX2NvdmVyYWdlPXRydWVcdTAwMjZwb3J0Zm9saW9fcGlkPTUzNTU1NjE3MTYwMDA2NzYxXHUwMDI2Rm9yY2VfZGlyZWN0PXRydWUifV0sIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiSm9obiBSLiBIdWJiYXJkIl19LHsia2luZCI6IkdlbmVyYWwgTm90ZSIsInZhbHVlIjpbIkluY2x1ZGVzIGluZGV4Il19LHsia2luZCI6IlNvdXJjZSBvZiBEZXNjcmlwdGlvbiBOb3RlIiwidmFsdWUiOlsiRGVzY3JpcHRpb24gYmFzZWQgb24gb25saW5lIHJlc291cmNlOyB0aXRsZSBmcm9tIFBERiB0aXRsZSBwYWdlIChlYnJhcnksIHZpZXdlZCBPY3RvYmVyIDE4LCAyMDE3KSJdfV0sImhpZ2hsaWdodCI6W3sibWF0Y2hlZEZpZWxkIjoidGl0bGUiLCJtYXRjaGVkUGhyYXNlcyI6WyJKYXZhIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSBhbmFseXNpcyA6IFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSBtaW5pbmcsIGJpZyBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2UgYW5hbHlzaXMsIE5vU1FMLCBhbmQgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIHZpc3VhbGl6YXRpb24iXX1dLCJwcm92aWRlciI6bnVsbCwicmlnaHRzIjpudWxsLCJzb3VyY2VMaW5rIjoiaHR0cHM6Ly9taXQucHJpbW8uZXhsaWJyaXNncm91cC5jb20vZGlzY292ZXJ5L2Z1bGxkaXNwbGF5P3ZpZD0wMU1JVF9JTlNUOk1JVFx1MDAyNmRvY2lkPWFsbWE5OTM1MTE0NDUyOTA2NzYxIiwic3VtbWFyeSI6WyJHZXQgdGhlIG1vc3Qgb3V0IG9mIHRoZSBwb3B1bGFyIEphdmEgbGlicmFyaWVzIGFuZCB0b29scyB0byBwZXJmb3JtIGVmZmljaWVudCBkYXRhIGFuYWx5c2lzIEFib3V0IFRoaXMgQm9vayBHZXQgeW91ciBiYXNpY3MgcmlnaHQgZm9yIGRhdGEgYW5hbHlzaXMgd2l0aCBKYXZhIGFuZCBtYWtlIHNlbnNlIG9mIHlvdXIgZGF0YSB0aHJvdWdoIGVmZmVjdGl2ZSB2aXN1YWxpemF0aW9ucy4gVXNlIHZhcmlvdXMgSmF2YSBBUElzIGFuZCB0b29scyBzdWNoIGFzIFJhcGlkbWluZXIgYW5kIFdFS0EgZm9yIGVmZmVjdGl2ZSBkYXRhIGFuYWx5c2lzIGFuZCBtYWNoaW5lIGxlYXJuaW5nLiBUaGlzIGlzIHlvdXIgY29tcGFuaW9uIHRvIHVuZGVyc3RhbmRpbmcgYW5kIGltcGxlbWVudGluZyBhIHNvbGlkIGRhdGEgYW5hbHlzaXMgc29sdXRpb24gdXNpbmcgSmF2YSBXaG8gVGhpcyBCb29rIElzIEZvciBJZiB5b3UgYXJlIGEgc3R1ZGVudCBvciBKYXZhIGRldmVsb3BlciBvciBhIGJ1ZGRpbmcgZGF0YSBzY2llbnRpc3Qgd2hvIHdpc2hlcyB0byBsZWFybiB0aGUgZnVuZGFtZW50YWxzIG9mIGRhdGEgYW5hbHlzaXMgYW5kIGxlYXJuIHRvIHBlcmZvcm0gZGF0YSBhbmFseXNpcyB3aXRoIEphdmEsIHRoaXMgYm9vayBpcyBmb3IgeW91LiBTb21lIGZhbWlsaWFyaXR5IHdpdGggZWxlbWVudGFyeSBzdGF0aXN0aWNzIGFuZCByZWxhdGlvbmFsIGRhdGFiYXNlcyB3aWxsIGJlIGhlbHBmdWwgYnV0IGlzIG5vdCBtYW5kYXRvcnksIHRvIGdldCB0aGUgbW9zdCBvdXQgb2YgdGhpcyBib29rLiBBIGZpcm0gdW5kZXJzdGFuZGluZyBvZiBKYXZhIGlzIHJlcXVpcmVkLiBXaGF0IFlvdSBXaWxsIExlYXJuIERldmVsb3AgSmF2YSBwcm9ncmFtcyB0aGF0IGFuYWx5emUgZGF0YSBzZXRzIG9mIG5lYXJseSBhbnkgc2l6ZSwgaW5jbHVkaW5nIHRleHQgSW1wbGVtZW50IGltcG9ydGFudCBtYWNoaW5lIGxlYXJuaW5nIGFsZ29yaXRobXMgc3VjaCBhcyByZWdyZXNzaW9uLCBjbGFzc2lmaWNhdGlvbiwgYW5kIGNsdXN0ZXJpbmcgSW50ZXJmYWNlIHdpdGggYW5kIGFwcGx5IHN0YW5kYXJkIG9wZW4gc291cmNlIEphdmEgbGlicmFyaWVzIGFuZCBBUElzIHRvIGFuYWx5emUgYW5kIHZpc3VhbGl6ZSBkYXRhIFByb2Nlc3MgZGF0YSBmcm9tIGJvdGggcmVsYXRpb25hbCBhbmQgbm9uLXJlbGF0aW9uYWwgZGF0YWJhc2VzIGFuZCBmcm9tIHRpbWUtc2VyaWVzIGRhdGEgRW1wbG95IEphdmEgdG9vbHMgdG8gdmlzdWFsaXplIGRhdGEgaW4gdmFyaW91cyBmb3JtcyBVbmRlcnN0YW5kIG11bHRpbWVkaWEgZGF0YSBhbmFseXNpcyBhbGdvcml0aG1zIGFuZCBpbXBsZW1lbnQgdGhlbSBpbiBKYXZhLiBJbiBEZXRhaWwgRGF0YSBhbmFseXNpcyBpcyBhIHByb2Nlc3Mgb2YgaW5zcGVjdGluZywgY2xlYW5zaW5nLCB0cmFuc2Zvcm1pbmcsIGFuZCBtb2RlbGluZyBkYXRhIHdpdGggdGhlIGFpbSBvZiBkaXNjb3ZlcmluZyB1c2VmdWwgaW5mb3JtYXRpb24uIEphdmEgaXMgb25lIG9mIHRoZSBtb3N0IHBvcHVsYXIgbGFuZ3VhZ2VzIHRvIHBlcmZvcm0geW91ciBkYXRhIGFuYWx5c2lzIHRhc2tzLiBUaGlzIGJvb2sgd2lsbCBoZWxwIHlvdSBsZWFybiB0aGUgdG9vbHMgYW5kIHRlY2huaXF1ZXMgaW4gSmF2YSB0byBjb25kdWN0IGRhdGEgYW5hbHlzaXMgd2l0aG91dCBhbnkgaGFzc2xlLiBBZnRlciBnZXR0aW5nIGEgcXVpY2sgb3ZlcnZpZXcgb2Ygd2hhdCBkYXRhIHNjaWVuY2UgaXMgYW5kIHRoZSBzdGVwcyBpbnZvbHZlZCBpbiB0aGUgcHJvY2VzcywgeW91J2xsIGxlYXJuIHRoZSBzdGF0aXN0aWNhbCBkYXRhIGFuYWx5c2lzIHRlY2huaXF1ZXMgYW5kIGltcGxlbWVudCB0aGVtIHVzaW5nIHRoZSBwb3B1bGFyIEphdmEgQVBJcyBhbmQgbGlicmFyaWVzLiBUaHJvdWdoIHByYWN0aWNhbCBleGFtcGxlcywgeW91IHdpbGwgYWxzbyBsZWFybiB0aGUgbWFjaGluZSBsZWFybmluZyBjb25jZXB0cyBzdWNoIGFzIGNsYXNzaWZpY2F0aW9uIGFuZCByZWdyZXNzaW9uLiBJbiB0aGUgcHJvY2VzcywgeW91J2xsIGZhbWlsaWFyaXplIHlvdXJzZWxmIHdpdGggdG9vbHMgc3VjaCBhcyBSYXBpZG1pbmVyIGFuZCBXRUtBIGFuZCBzZWUgaG93IHRoZXNlIEphdmEtYmFzZWQgdG9vbHMgY2FuIGJlIHVzZWQgZWZmZWN0aXZlbHkgZm9yIGFuYWx5c2lzLiBZb3Ugd2lsbCBhbHNvIGxlYXJuIGhvdyB0byBhbmFseXplIHRleHQgYW5kIG90aGVyIHR5cGVzIG9mIG11bHRpbWVkaWEuIExlYXJuIHRvIHdvcmsgd2l0aCByZWxhdGlvbmFsLCBOb1NRTCwgYW5kIHRpbWUtc2VyaWVzIGRhdGEuIFRoaXMgYm9vayB3aWxsIGFsc28gc2hvdyB5b3UgaG93IHlvdSBjYW4gdXRpbGl6ZSBkaWZmZXJlbnQgSmF2YS1iYXNlZCBsaWJyYXJpZXMgdG8gY3JlYXRlIGluc2lnaHRmdWwgYW5kIGVhc3kgdG8gdW5kZXJzdGFuZCBwbG90cyBhbmQgZ3JhcGhzLiBCeSB0aGUgZW5kIG9mIHRoaXMgYm9vaywgeW91IHdpbGwgaGF2ZSBhIHNvbGlkIHVuZGVyc3RhbmRpbmcgb2YuLi4iXX0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkwMDA0NjAzNjQwMTA2NzYxIiwidGl0bGUiOiJEYXRhIHdpdGggc2VtYW50aWNzIDogZGF0YSBtb2RlbHMgYW5kIGRhdGEgbWFuYWdlbWVudCIsImNvbnRlbnRUeXBlIjpbIkxhbmd1YWdlIG1hdGVyaWFsIl0sImNvbnRyaWJ1dG9ycyI6W3sia2luZCI6Ik5vdCBzcGVjaWZpZWQiLCJ2YWx1ZSI6IlRob21wc29uLCBKLiBQYXRyaWNrIn1dLCJwdWJsaWNhdGlvbkluZm9ybWF0aW9uIjpudWxsLCJkYXRlcyI6W3sia2luZCI6IlB1YmxpY2F0aW9uIGRhdGUiLCJ2YWx1ZSI6IjE5ODkifV0sImxpbmtzIjpudWxsLCJub3RlcyI6W3sia2luZCI6IlRpdGxlIFN0YXRlbWVudCBvZiBSZXNwb25zaWJpbGl0eSIsInZhbHVlIjpbIkouIFBhdHJpY2sgVGhvbXBzb24iXX0seyJraW5kIjoiR2VuZXJhbCBOb3RlIiwidmFsdWUiOlsiSW5jbHVkZXMgaW5kZXgiXX0seyJraW5kIjoiQmlibGlvZ3JhcGh5IE5vdGUiLCJ2YWx1ZSI6WyJCaWJsaW9ncmFwaHk6IHAuIDQ2NS00NjgiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlRGF0YVx1MDAzYy9zcGFuXHUwMDNlIHdpdGggc2VtYW50aWNzIDogXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIG1vZGVscyBhbmQgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIG1hbmFnZW1lbnQiXX1dLCJwcm92aWRlciI6bnVsbCwicmlnaHRzIjpudWxsLCJzb3VyY2VMaW5rIjoiaHR0cHM6Ly9taXQucHJpbW8uZXhsaWJyaXNncm91cC5jb20vZGlzY292ZXJ5L2Z1bGxkaXNwbGF5P3ZpZD0wMU1JVF9JTlNUOk1JVFx1MDAyNmRvY2lkPWFsbWE5OTAwMDQ2MDM2NDAxMDY3NjEiLCJzdW1tYXJ5IjpudWxsfSx7InRpbWRleFJlY29yZElkIjoiYXNwYWNlOnJlcG9zaXRvcmllcy0yLXJlc291cmNlcy0xMjczIiwidGl0bGUiOiJcIkRhdGEgRHJpdmVuXCIgRmlsbSBJbnRlcnZpZXdzIENvbGxlY3Rpb24iLCJjb250ZW50VHlwZSI6WyJBcmNoaXZhbCBtYXRlcmlhbHMiXSwiY29udHJpYnV0b3JzIjpbeyJraW5kIjoic291cmNlIiwidmFsdWUiOiJaZXJuaWtlLCBLYXRlIn0seyJraW5kIjoic291cmNlIiwidmFsdWUiOiJTdHViYmUsIEpvQW5uZSJ9LHsia2luZCI6InNvdXJjZSIsInZhbHVlIjoiU2l2ZSwgSGF6ZWwgTC4ifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6IlNjaHdldHRtYW5uLCBTYXJhaCJ9LHsia2luZCI6InNvdXJjZSIsInZhbHVlIjoiUm95ZGVuLCBMZWlnaCBIYW5keSJ9LHsia2luZCI6InNvdXJjZSIsInZhbHVlIjoiTWFsYW5vdHRlLVJpenpvbGksIFBhb2xhLCAxOTQ2LSJ9LHsia2luZCI6InNvdXJjZSIsInZhbHVlIjoiUGFyZHVlLCBNYXJ5IExvdSJ9LHsia2luZCI6InNvdXJjZSIsInZhbHVlIjoiT3JyLVdlYXZlciwgVGVycnkgTC4ifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6Ik1jTnV0dCwgTWFyY2lhIEtlbXBlciwgMTk1Mi0ifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6IkxlaG1hbm4sIFJ1dGgifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6IkhvcGtpbnMsIE5hbmN5IChOYW5jeSBILikifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6IkdpYnNvbiwgTG9ybmEgSi4ifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6IkNoaXNob2xtLCBTYWxsaWUgVy4ifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6IkNleWVyLCBTeWx2aWEgVGVyZXNzZSJ9LHsia2luZCI6InNvdXJjZSIsInZhbHVlIjoiQmhhdGlhLCBTYW5nZWV0YSwgMTk2OC0ifSx7ImtpbmQiOiJzb3VyY2UiLCJ2YWx1ZSI6IkJhaWx5biwgTG90dGUifSx7ImtpbmQiOiJDcmVhdG9yIiwidmFsdWUiOiJXaWNrZWQgRGVsaWNhdGUgRmlsbXMifSx7ImtpbmQiOiJDcmVhdG9yIiwidmFsdWUiOiJNYXNzYWNodXNldHRzIEluc3RpdHV0ZSBvZiBUZWNobm9sb2d5LiBNSVQgUHJlc3MifV0sInB1YmxpY2F0aW9uSW5mb3JtYXRpb24iOm51bGwsImRhdGVzIjpbeyJraW5kIjoiY3JlYXRpb24iLCJ2YWx1ZSI6IjIwMTgtMDgtMjgifV0sImxpbmtzIjpudWxsLCJub3RlcyI6W3sia2luZCI6Ikhpc3RvcmljYWwgTm90ZSIsInZhbHVlIjpbIkEgU3R1ZHkgb24gdGhlIFN0YXR1cyBvZiBXb21lbiBGYWN1bHR5IGluIHRoZSBTY2hvb2wgb2YgU2NpZW5jZSBhdCBNSVQ6IEhvdyBhIENvbW1pdHRlZSBvbiBXb21lbiBGYWN1bHR5IGNhbWUgdG8gYmUgZXN0YWJsaXNoZWQgYnkgdGhlIERlYW4gb2YgdGhlIFNjaG9vbCBvZiBTY2llbmNlLCB3aGF0IHRoZSBDb21taXR0ZWUgYW5kIHRoZSBEZWFuIGxlYXJuZWQgYW5kIGFjY29tcGxpc2hlZCwgYW5kIHJlY29tbWVuZGF0aW9ucyBmb3IgdGhlIGZ1dHVyZS4gTUlUIEZhY3VsdHkgTmV3c2xldHRlciAsIE1hcmNoIDE5OTkuIiwiSW4gMTk5NSB0aGUgRGVhbiBvZiBTY2llbmNlIGVzdGFibGlzaGVkIGEgQ29tbWl0dGVlIHRvIGFuYWx5emUgdGhlIHN0YXR1cyBvZiB3b21lbiBmYWN1bHR5IGluIHRoZSBzaXggZGVwYXJ0bWVudHMgaW4gdGhlIFNjaG9vbCBvZiBTY2llbmNlIGF0IHRoZSBNYXNzY2h1c2V0dHMgSW5zdGl0dXRlIG9mIFRlY2hub2xvZ3kgKE1JVCkuIFRoZSBDb21taXR0ZWUgc3VibWl0dGVkIGEgcmVwb3J0IG9mIGl0cyBmaW5kaW5ncyBpbiBBdWd1c3QsIDE5OTYgYW5kIGFtZW5kZWQgcmVwb3J0cyBpbiAxOTk3IGFuZCAxOTk4LiBUaGUgQ29tbWl0dGVlIGRpc2NvdmVyZWQgdGhhdCBqdW5pb3Igd29tZW4gZmFjdWx0eSBmZWx0IHdlbGwgc3VwcG9ydGVkIHdpdGhpbiB0aGVpciBkZXBhcnRtZW50cy4gSW4gY29udHJhc3QgdG8ganVuaW9yIHdvbWVuLCBtYW55IHRlbnVyZWQgd29tZW4gZmFjdWx0eSBmZWx0IG1hcmdpbmFsaXplZCBhbmQgZXhjbHVkZWQgZnJvbSBhIHNpZ25pZmljYW50IHJvbGUgaW4gdGhlaXIgZGVwYXJ0bWVudHMuIE1hcmdpbmFsaXphdGlvbiBpbmNyZWFzZWQgYXMgd29tZW4gcHJvZ3Jlc3NlZCB0aHJvdWdoIHRoZWlyIGNhcmVlcnMgYXQgTUlULiIsIlZpZXcgdGhlIE1hcmNoIDE5OTkgTUlUIEZhY3VsdHkgTmV3c2xldHRlciBmb3IgbW9yZSBpbmZvcm1hdGlvbiBvbiB0aGlzIHJlcG9ydC4iXX0seyJraW5kIjoiU2NvcGUgYW5kIENvbnRlbnRzIiwidmFsdWUiOlsiVGhpcyBjb2xsZWN0aW9uIGNvbnNpc3RzIG9mIHZpZGVvIGludGVydmlld3MgYW5kIHRyYW5zY3JpcHRzIHdpdGggMTcgZmVtYWxlIE1hc3NjaHVzZXR0cyBJbnN0aXR1dGUgb2YgVGVjaG5vbG9neSBmYWN1bHR5IG1lbWJlcnMgYW5kIHRoZSBzaG9ydCBkb2N1bWVudGFyeSB1c2luZyB0aGUgaW50ZXJ2aWV3cywgXCJUaGUgVXByaXNpbmdcIi4gVGhlIGludGVydmlld3MgZm9jdXMgb24gd29tZW4gZmFjdWx0eSBpbiBzY2llbmNlIGFuZCBlbmdpbmVlcmluZyBhdCBNSVQsIGFuZCBtb3JlIGJyb2FkbHkgZ2VuZGVyIGVxdWl0eSBpc3N1ZXMgaW4gU1RFTSBmaWVsZHMuIFNwZWNpZmljYWxseSByZWZlcmVuY2luZyBldmVudHMgZGlzY3Vzc2VkIHRoZSAxOTk5IHJlcG9ydCwgU3R1ZHkgb24gdGhlIFN0YXR1cyBvZiBXb21lbiBGYWN1bHR5IGluIFNjaWVuY2UgYXQgTUlULiBUaGUgaW50ZXJ2aWV3cyB3ZXJlIHByb2R1Y2VkIGJ5IFdpY2tlZCBEZWxpY2F0ZSBGaWxtcyBpbiBjb25qdW5jdGlvbiB3aXRoIHRoZSBNSVQgUHJlc3MgYW5kIE1JVCBMaWJyYXJpZXMuIEludGVydmlld3MgbWF5IGJlIHVzZWQgaW4gYSBmdXR1cmUgZmlsbSwgRGF0YSBEcml2ZW4uIEEgZG9jdW1lbnRhcnkgd2FzIG1hZGUgYnkgV2lja2VkIERlbGljYXRlIEZpbG1zIGNhbGxlZCBcIlBpY3R1cmUgQSBTY2llbnRpc3RcIiB3aGljaCBmZWF0dXJlZCBzb21lIG9mIHRoZSBmb290YWdlLiJdfV0sImhpZ2hsaWdodCI6W3sibWF0Y2hlZEZpZWxkIjoidGl0bGUiLCJtYXRjaGVkUGhyYXNlcyI6WyJcIlx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBEcml2ZW5cIiBGaWxtIEludGVydmlld3MgQ29sbGVjdGlvbiJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOlt7ImtpbmQiOiJDb25kaXRpb25zIEdvdmVybmluZyBBY2Nlc3MiLCJkZXNjcmlwdGlvbiI6Ik1vc3Qgb2YgdGhlIGNvbGxlY3Rpb24gaXMgb3BlbiBmb3IgcmVhZGluZyByb29tIGFjY2VzcyBvbmx5IHBlciB0aGUgZG9ub3IgYWdyZWVtZW50LiBJbnRlcnZpZXdzIHdpdGggTmFuY3kgSG9wa2lucyBhcmUgZnVsbHkgcmVzdHJpY3RlZC4gU2VlIGFjY2VzcyBub3RlcyBmb3IgaW5kaXZpZHVhbCBpdGVtcyBmb3IgbW9yZSBkZXRhaWxzLiIsInVyaSI6bnVsbH0seyJraW5kIjoiQ29uZGl0aW9ucyBHb3Zlcm5pbmcgVXNlIiwiZGVzY3JpcHRpb24iOiJBY2Nlc3MgdG8gY29sbGVjdGlvbnMgaW4gdGhlIERlcGFydG1lbnQgb2YgRGlzdGluY3RpdmUgQ29sbGVjdGlvbnMgaXMgbm90IGF1dGhvcml6YXRpb24gdG8gcHVibGlzaC4gUGxlYXNlIHNlZSB0aGUgTUlUIExpYnJhcmllcyBQZXJtaXNzaW9ucyBQb2xpY3kgZm9yIHBlcm1pc3Npb24gaW5mb3JtYXRpb24uIENvcHlyaWdodCBvZiBzb21lIGl0ZW1zIGluIHRoaXMgY29sbGVjdGlvbiBtYXkgYmUgaGVsZCBieSByZXNwZWN0aXZlIGNyZWF0b3JzLCBub3QgYnkgdGhlIGRvbm9yIG9mIHRoZSBjb2xsZWN0aW9uIG9yIE1JVC4iLCJ1cmkiOm51bGx9XSwic291cmNlTGluayI6Imh0dHBzOi8vYXJjaGl2ZXNzcGFjZS5taXQuZWR1L3JlcG9zaXRvcmllcy8yL3Jlc291cmNlcy8xMjczIiwic3VtbWFyeSI6bnVsbH0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkzNTQyODkxMTAwNjc2MSIsInRpdGxlIjoiVGhlIGRhdGEgcmV2b2x1dGlvbiA6IGEgY3JpdGljYWwgYW5hbHlzaXMgb2YgYmlnIGRhdGEsIG9wZW4gZGF0YSBcdTAwMjYgZGF0YSBpbmZyYXN0cnVjdHVyZXMiLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJhdXRob3IiLCJ2YWx1ZSI6IktpdGNoaW4sIFJvYiJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDIyIn1dLCJsaW5rcyI6bnVsbCwibm90ZXMiOlt7ImtpbmQiOiJUaXRsZSBTdGF0ZW1lbnQgb2YgUmVzcG9uc2liaWxpdHkiLCJ2YWx1ZSI6WyJSb2IgS2l0Y2hpbiJdfSx7ImtpbmQiOiJCaWJsaW9ncmFwaHkgTm90ZSIsInZhbHVlIjpbIkluY2x1ZGVzIGJpYmxpb2dyYXBoaWNhbCByZWZlcmVuY2VzIChwYWdlcyAzMDktMzQ1KSBhbmQgaW5kZXgiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiVGhlIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSByZXZvbHV0aW9uIDogYSBjcml0aWNhbCBhbmFseXNpcyBvZiBiaWcgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlLCBvcGVuIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSBcdTAwMjYgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIGluZnJhc3RydWN0dXJlcyJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MzU0Mjg5MTEwMDY3NjEiLCJzdW1tYXJ5IjpudWxsfSx7InRpbWRleFJlY29yZElkIjoiYWxtYTo5OTM1MTgxMjQ1NTA2NzYxIiwidGl0bGUiOiJJbnRlbGxpZ2VudCBkYXRhIGFuYWx5c2lzIDogZnJvbSBkYXRhIGdhdGhlcmluZyB0byBkYXRhIGNvbXByZWhlbnNpb24iLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJlZGl0b3IiLCJ2YWx1ZSI6Ikd1cHRhLCBEZWVwYWsifV0sInB1YmxpY2F0aW9uSW5mb3JtYXRpb24iOm51bGwsImRhdGVzIjpbeyJraW5kIjoiUHVibGljYXRpb24gZGF0ZSIsInZhbHVlIjoiMjAyMCJ9XSwibGlua3MiOlt7ImtpbmQiOiJEaWdpdGFsIG9iamVjdCBVUkwiLCJyZXN0cmljdGlvbnMiOm51bGwsInRleHQiOiJPJ1JlaWxseSBPbmxpbmUgTGVhcm5pbmc6IEFjYWRlbWljL1B1YmxpYyBMaWJyYXJ5IEVkaXRpb24iLCJ1cmwiOiJodHRwczovL25hMDYuYWxtYS5leGxpYnJpc2dyb3VwLmNvbS92aWV3L3VyZXNvbHZlci8wMU1JVF9JTlNUL29wZW51cmw/dS5pZ25vcmVfZGF0ZV9jb3ZlcmFnZT10cnVlXHUwMDI2cG9ydGZvbGlvX3BpZD01MzYzNTAzNzIzMDAwNjc2MVx1MDAyNkZvcmNlX2RpcmVjdD10cnVlIn0seyJraW5kIjoiRGlnaXRhbCBvYmplY3QgVVJMIiwicmVzdHJpY3Rpb25zIjpudWxsLCJ0ZXh0IjoiV2lsZXkgT25saW5lIExpYnJhcnkiLCJ1cmwiOiJodHRwczovL25hMDYuYWxtYS5leGxpYnJpc2dyb3VwLmNvbS92aWV3L3VyZXNvbHZlci8wMU1JVF9JTlNUL29wZW51cmw/dS5pZ25vcmVfZGF0ZV9jb3ZlcmFnZT10cnVlXHUwMDI2cG9ydGZvbGlvX3BpZD01MzY0MDk4ODc4MDAwNjc2MVx1MDAyNkZvcmNlX2RpcmVjdD10cnVlIn1dLCJub3RlcyI6W3sia2luZCI6IlRpdGxlIFN0YXRlbWVudCBvZiBSZXNwb25zaWJpbGl0eSIsInZhbHVlIjpbImVkaXRlZCBieSBEZWVwYWsgR3VwdGEgW2FuZCB0aHJlZSBvdGhlcnNdIl19LHsia2luZCI6IkJpYmxpb2dyYXBoeSBOb3RlIiwidmFsdWUiOlsiSW5jbHVkZXMgYmlibGlvZ3JhcGhpY2FsIHJlZmVyZW5jZXMgYW5kIGluZGV4Il19LHsia2luZCI6IlNvdXJjZSBvZiBEZXNjcmlwdGlvbiBOb3RlIiwidmFsdWUiOlsiRGVzY3JpcHRpb24gYmFzZWQgb24gcHJpbnQgdmVyc2lvbiByZWNvcmQiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiSW50ZWxsaWdlbnQgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIGFuYWx5c2lzIDogZnJvbSBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2UgZ2F0aGVyaW5nIHRvIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSBjb21wcmVoZW5zaW9uIl19XSwicHJvdmlkZXIiOm51bGwsInJpZ2h0cyI6bnVsbCwic291cmNlTGluayI6Imh0dHBzOi8vbWl0LnByaW1vLmV4bGlicmlzZ3JvdXAuY29tL2Rpc2NvdmVyeS9mdWxsZGlzcGxheT92aWQ9MDFNSVRfSU5TVDpNSVRcdTAwMjZkb2NpZD1hbG1hOTkzNTE4MTI0NTUwNjc2MSIsInN1bW1hcnkiOlsiXCJUaGUgbmV3IHRvb2wgZm9yIGFuYWx5c2VzIGlzID9JbnRlbGxpZ2VudCBEYXRhIEFuYWx5c2lzIChJREEpPy4gSURBIGNhbiBiZSBkZWZpbmVkIGFzIHRoZSB1c2Ugb2Ygc3BlY2lhbGl6ZWQgc3RhdGlzdGljYWwsIHBhdHRlcm4gcmVjb2duaXRpb24sIG1hY2hpbmUgbGVhcm5pbmcsIGRhdGEgYWJzdHJhY3Rpb24sIGFuZCB2aXN1YWxpemF0aW9uIHRvb2xzIGZvciBhbmFseXNpcyBvZiBkYXRhIGFuZCBkaXNjb3Zlcnkgb2YgbWVjaGFuaXNtcyB0aGF0IGNyZWF0ZWQgdGhlIGRhdGEuIFN1Y2ggZGF0YSBhcmUgdHlwaWNhbGx5IGNvbXBsZXgsIG1lYW5pbmcgdGhhdCB0aGV5IGFyZSBjaGFyYWN0ZXJpemVkIGJ5IG1hbnkgcmVjb3JkcywgbWFueSB2YXJpYWJsZXMsIHN1YnRsZSBpbnRlcmFjdGlvbnMgYmV0d2VlbiB2YXJpYWJsZXMsIG9yIGEgY29tYmluYXRpb24gb2YgYWxsIHRocmVlLiBFbmdpbmVlcmluZywgY29tcHV0aW5nIHNjaWVuY2VzLCBkYXRhYmFzZSBzY2llbmNlLCBtYWNoaW5lIGxlYXJuaW5nLCBhbmQgZXZlbiBhcnRpZmljaWFsIGludGVsbGlnZW5jZSBhcmUgYnJpbmdpbmcgdGhlaXIgcG93ZXJzIHRvIHRoaXMgbmV3bHkgYm9ybiBkYXRhIGFuYWx5c2lzIGRpc2NpcGxpbmUuIFRoZSBtYWluIGlkZWEgdW5kZXJseWluZyB0aGUgY29uY2VwdCBvZiBJbnRlbGxpZ2VudCBEYXRhIEFuYWx5c2lzIGlzIGV4dHJhY3Rpbmcga25vd2xlZGdlIGZyb20gYSB2ZXJ5IGxhcmdlIGFtb3VudCBvZiBkYXRhLCB3aXRoIGEgdmVyeSBsYXJnZSBhbW91bnQgb2YgdmFyaWFibGVzOyBkYXRhIHRoYXQgcmVwcmVzZW50cyB2ZXJ5IGNvbXBsZXgsIG5vbi1saW5lYXIsIHJlYWwtbGlmZSBwcm9ibGVtcy4gTW9yZW92ZXIsIElEQSBjYW4gaGVscCB3aGVuIHN0YXJ0aW5nIGZyb20gdGhlIHJhdyBkYXRhLCBjb3Bpbmcgd2l0aCBwcmVkaWN0aW9uIHRhc2tzIHdpdGhvdXQga25vd2luZyB0aGUgdGhlb3JldGljYWwgZGVzY3JpcHRpb24gb2YgdGhlIHVuZGVybHlpbmcgcHJvY2VzcywgY2xhc3NpZmljYXRpb24gdGFza3Mgb2YgbmV3IGV2ZW50cyBiYXNlZCBvbiBwYXN0IG9uZXMsIG9yIG1vZGVsaW5nIHRoZSBhZm9yZW1lbnRpb25lZCB1bmtub3duIHByb2Nlc3MuIENsYXNzaWZpY2F0aW9uLCBwcmVkaWN0aW9uLCBhbmQgbW9kZWxpbmcgYXJlIHRoZSBjb3JuZXJzdG9uZXMgdGhhdCBJbnRlbGxpZ2VudCBEYXRhIEFuYWx5c2lzIGNhbiBicmluZyB0byB1c1wiLS0iXX0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkwMDA5Mzg0NTcwMTA2NzYxIiwidGl0bGUiOiJlLURhdGEgOiB0dXJuaW5nIGRhdGEgaW50byBpbmZvcm1hdGlvbiB3aXRoIGRhdGEgd2FyZWhvdXNpbmciLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJOb3Qgc3BlY2lmaWVkIiwidmFsdWUiOiJEeWNoZcyBLCBKaWxsIn1dLCJwdWJsaWNhdGlvbkluZm9ybWF0aW9uIjpudWxsLCJkYXRlcyI6W3sia2luZCI6IlB1YmxpY2F0aW9uIGRhdGUiLCJ2YWx1ZSI6IjIwMDAifV0sImxpbmtzIjpudWxsLCJub3RlcyI6W3sia2luZCI6IlRpdGxlIFN0YXRlbWVudCBvZiBSZXNwb25zaWJpbGl0eSIsInZhbHVlIjpbIkppbGwgRHljaGXMgSJdfSx7ImtpbmQiOiJCaWJsaW9ncmFwaHkgTm90ZSIsInZhbHVlIjpbIkluY2x1ZGVzIGJpYmxpb2dyYXBoaWNhbCByZWZlcmVuY2VzIGFuZCBpbmRleCJdfV0sImhpZ2hsaWdodCI6W3sibWF0Y2hlZEZpZWxkIjoidGl0bGUiLCJtYXRjaGVkUGhyYXNlcyI6WyJlLVx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSA6IHR1cm5pbmcgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIGludG8gaW5mb3JtYXRpb24gd2l0aCBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2Ugd2FyZWhvdXNpbmciXX1dLCJwcm92aWRlciI6bnVsbCwicmlnaHRzIjpudWxsLCJzb3VyY2VMaW5rIjoiaHR0cHM6Ly9taXQucHJpbW8uZXhsaWJyaXNncm91cC5jb20vZGlzY292ZXJ5L2Z1bGxkaXNwbGF5P3ZpZD0wMU1JVF9JTlNUOk1JVFx1MDAyNmRvY2lkPWFsbWE5OTAwMDkzODQ1NzAxMDY3NjEiLCJzdW1tYXJ5IjpudWxsfSx7InRpbWRleFJlY29yZElkIjoiYWxtYTo5OTM1MTY2MzE4MTA2NzYxIiwidGl0bGUiOiJEYXRhIHByb3RlY3Rpb24gOiBlbnN1cmluZyBkYXRhIGF2YWlsYWJpbGl0eSIsImNvbnRlbnRUeXBlIjpbIkxhbmd1YWdlIG1hdGVyaWFsIl0sImNvbnRyaWJ1dG9ycyI6W3sia2luZCI6ImF1dGhvciIsInZhbHVlIjoiRGUgR3Vpc2UsIFByZXN0b24ifV0sInB1YmxpY2F0aW9uSW5mb3JtYXRpb24iOm51bGwsImRhdGVzIjpbeyJraW5kIjoiUHVibGljYXRpb24gZGF0ZSIsInZhbHVlIjoiMjAyMCJ9XSwibGlua3MiOlt7ImtpbmQiOiJEaWdpdGFsIG9iamVjdCBVUkwiLCJyZXN0cmljdGlvbnMiOm51bGwsInRleHQiOiJUYXlsb3IgXHUwMDI2IEZyYW5jaXMgRXZpZGVuY2UgQmFzZWQgRWJvb2sgQ29sbGVjdGlvbiIsInVybCI6Imh0dHBzOi8vbmEwNi5hbG1hLmV4bGlicmlzZ3JvdXAuY29tL3ZpZXcvdXJlc29sdmVyLzAxTUlUX0lOU1Qvb3BlbnVybD91Lmlnbm9yZV9kYXRlX2NvdmVyYWdlPXRydWVcdTAwMjZwb3J0Zm9saW9fcGlkPTUzNjU5NTA4ODUwMDA2NzYxXHUwMDI2Rm9yY2VfZGlyZWN0PXRydWUifV0sIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiUHJlc3RvbiBEZSBHdWlzZSJdfSx7ImtpbmQiOiJTb3VyY2Ugb2YgRGVzY3JpcHRpb24gTm90ZSIsInZhbHVlIjpbIkRlc2NyaXB0aW9uIGJhc2VkIG9uIHByaW50IHZlcnNpb24gcmVjb3JkIl19XSwiaGlnaGxpZ2h0IjpbeyJtYXRjaGVkRmllbGQiOiJ0aXRsZSIsIm1hdGNoZWRQaHJhc2VzIjpbIlx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBwcm90ZWN0aW9uIDogZW5zdXJpbmcgXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlZGF0YVx1MDAzYy9zcGFuXHUwMDNlIGF2YWlsYWJpbGl0eSJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MzUxNjYzMTgxMDY3NjEiLCJzdW1tYXJ5IjpbIlwiVGhpcyBib29rIGFybXMgcmVhZGVycyB3aXRoIGluZm9ybWF0aW9uIGZvciBtYWtpbmcgZGVjaXNpb25zIG9uIGhvdyB0byBwcm90ZWN0IGRhdGEgZnJvbSBsb3NzIGluIHRoZSBjbG91ZCwgb24tc2l0ZSwgb3IgYm90aC4gSXQgZXhwbGFpbnMgdGhlIGNoYW5naW5nIGZhY2Ugb2YgZGF0YSByZWNvdmVyeSBhbmQgdGVjaG5pcXVlcyBmb3IgZGVhbGluZyB3aXRoIGJpZyBkYXRhLiBUaGUgc2Vjb25kIGVkaXRpb24gaGFzIG5ldyBjaGFwdGVycyBvbiBldGhpY2FsIGFuZCBsZWdhbCBpc3N1ZXMsIGNvbnZlcmdlbnQgZGF0YSBwcm90ZWN0aW9uLCBhcmNoaXRlY3R1cmUsIHNtYXJ0IGRhdGEgcHJvdGVjdGlvbiwgYW5kIHByb3RlY3Rpb24gYXQgdGhlIGVkZ2UuIEl0IGFsc28gaW5jbHVkZXMgZXhwYW5kZWQgY2hhcHRlcnMgb24gZGF0YSBwcm90ZWN0aW9uIGluIHRoZSBjbG91ZCBhbmQgcHJvdGVjdGluZyBpbmZyYXN0cnVjdHVyZS4gS2V5IEZlYXR1cmVzOiBQcm90ZWN0IGRhdGEgYW5kIHN5c3RlbXMgZnJvbSByYW5zb213YXJlIGFuZCBvdGhlciBjeWJlcnRocmVhdHMgQmVjb21lIGNvbXBsaWFudCB3aXRoIGxlZ2FsIHJlcXVpcmVtZW50cyBmb3IgcHJvdGVjdGluZyBkYXRhIFByb3RlY3QgZGF0YSBpbiB0aGUgY2xvdWQsIG9uLXByZW1pc2VzLCBvciBpbiBtaXhlZCBlbnZpcm9ubWVudHMgVGFja2xlIGRlZHVwbGljYXRpb24gdG8gZW5zdXJlIGRhdGEgaW50ZWdyaXR5IEF1dGhvciBCaW86IFByZXN0b24gZGUgR3Vpc2UgaGFzIGJlZW4gd29ya2luZyB3aXRoIGRhdGEgcmVjb3ZlcnkgcHJvZHVjdHMgZm9yIGhpcyBlbnRpcmUgY2FyZWVyIC0gZGVzaWduaW5nLCBpbXBsZW1lbnRpbmcgYW5kIHN1cHBvcnRpbmcgc29sdXRpb25zIGZvciBnb3Zlcm5tZW50cywgdW5pdmVyc2l0aWVzLCBhbmQgYnVzaW5lc3NlcyByYW5naW5nIGZyb20gU01FcyB0byBGb3J0dW5lIDUwMCBjb21wYW5pZXMuIFRoaXMgYnJvYWQgZXhwb3N1cmUgdG8gaW5kdXN0cnkgdmVydGljYWxzIGFuZCBidXNpbmVzcyBzaXplcyBoYXMgZW5hYmxlZCBQcmVzdG9uIHRvIHVuZGVyc3RhbmQgbm90IG9ubHkgdGhlIHRlY2huaWNhbCByZXF1aXJlbWVudHMgb2YgZGF0YSBwcm90ZWN0aW9uIGFuZCByZWNvdmVyeSwgYnV0IHRoZSBtYW5hZ2VtZW50IGFuZCBwcm9jZWR1cmFsIGFzcGVjdHMgdG9vXCItLSJdfSx7InRpbWRleFJlY29yZElkIjoiYWxtYTo5OTM1NTExMDQ0MDA2NzYxIiwidGl0bGUiOiJEYXRhIHByb3RlY3Rpb24gZW5zdXJpbmcgZGF0YSBhdmFpbGFiaWxpdHkiLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJOb3Qgc3BlY2lmaWVkIiwidmFsdWUiOiJEZSBHdWlzZSwgUHJlc3RvbiJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDIwIn1dLCJsaW5rcyI6W3sia2luZCI6IkRpZ2l0YWwgb2JqZWN0IFVSTCIsInJlc3RyaWN0aW9ucyI6bnVsbCwidGV4dCI6IlRheWxvciBcdTAwMjYgRnJhbmNpcyBlQm9va3MgQ29tcGxldGUiLCJ1cmwiOiJodHRwczovL3d3dy50YXlsb3JmcmFuY2lzLmNvbS9ib29rcy85NzgwMzY3NDYzNDk2In0seyJraW5kIjoiRGlnaXRhbCBvYmplY3QgVVJMIiwicmVzdHJpY3Rpb25zIjpudWxsLCJ0ZXh0IjpudWxsLCJ1cmwiOiJodHRwczovL3d3dy50YXlsb3JmcmFuY2lzLmNvbS9ib29rcy85NzgwMzY3NDYzNDk2In1dLCJub3RlcyI6W3sia2luZCI6IlRpdGxlIFN0YXRlbWVudCBvZiBSZXNwb25zaWJpbGl0eSIsInZhbHVlIjpbImJ5IFByZXN0b24gRGUgR3Vpc2UiXX0seyJraW5kIjoiR2VuZXJhbCBOb3RlIiwidmFsdWUiOlsiNi41IFNlbGYtUmVmbGVjdGlvbiJdfSx7ImtpbmQiOiJTb3VyY2Ugb2YgRGVzY3JpcHRpb24gTm90ZSIsInZhbHVlIjpbIk9DTEMtbGljZW5zZWQgdmVuZG9yIGJpYmxpb2dyYXBoaWMgcmVjb3JkIl19XSwiaGlnaGxpZ2h0IjpbeyJtYXRjaGVkRmllbGQiOiJ0aXRsZSIsIm1hdGNoZWRQaHJhc2VzIjpbIlx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBwcm90ZWN0aW9uIGVuc3VyaW5nIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSBhdmFpbGFiaWxpdHkiXX1dLCJwcm92aWRlciI6bnVsbCwicmlnaHRzIjpudWxsLCJzb3VyY2VMaW5rIjoiaHR0cHM6Ly9taXQucHJpbW8uZXhsaWJyaXNncm91cC5jb20vZGlzY292ZXJ5L2Z1bGxkaXNwbGF5P3ZpZD0wMU1JVF9JTlNUOk1JVFx1MDAyNmRvY2lkPWFsbWE5OTM1NTExMDQ0MDA2NzYxIiwic3VtbWFyeSI6bnVsbH0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkzNTE4MTAxNTYwNjc2MSIsInRpdGxlIjoiRGF0YSBhbmFseXRpY3MgYW5kIGJpZyBkYXRhIiwiY29udGVudFR5cGUiOlsiTGFuZ3VhZ2UgbWF0ZXJpYWwiXSwiY29udHJpYnV0b3JzIjpbeyJraW5kIjoiYXV0aG9yIiwidmFsdWUiOiJTZWRrYW91aSwgU29yYXlhIn1dLCJwdWJsaWNhdGlvbkluZm9ybWF0aW9uIjpbIklTVEUgTHRkL0pvaG4gV2lsZXkgYW5kIFNvbnMgSW5jOyAyMDE4OyBIb2Jva2VuLCBOZXcgSmVyc2V5Il0sImRhdGVzIjpbeyJraW5kIjoiUHVibGljYXRpb24gZGF0ZSIsInZhbHVlIjoiMjAxOCJ9XSwibGlua3MiOlt7ImtpbmQiOiJEaWdpdGFsIG9iamVjdCBVUkwiLCJyZXN0cmljdGlvbnMiOm51bGwsInRleHQiOiJXaWxleSBPbmxpbmUgTGlicmFyeSIsInVybCI6Imh0dHBzOi8vbmEwNi5hbG1hLmV4bGlicmlzZ3JvdXAuY29tL3ZpZXcvdXJlc29sdmVyLzAxTUlUX0lOU1Qvb3BlbnVybD91Lmlnbm9yZV9kYXRlX2NvdmVyYWdlPXRydWVcdTAwMjZwb3J0Zm9saW9fcGlkPTUzNjM2NjEzMjQwMDA2NzYxXHUwMDI2Rm9yY2VfZGlyZWN0PXRydWUifV0sIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiU29yYXlhIFNlZGthb3VpIl19LHsia2luZCI6IkJpYmxpb2dyYXBoeSBOb3RlIiwidmFsdWUiOlsiSW5jbHVkZXMgYmlibGlvZ3JhcGhpY2FsIHJlZmVyZW5jZXMgYW5kIGluZGV4Il19LHsia2luZCI6IlNvdXJjZSBvZiBEZXNjcmlwdGlvbiBOb3RlIiwidmFsdWUiOlsiRGVzY3JpcHRpb24gYmFzZWQgb24gcHJpbnQgdmVyc2lvbiByZWNvcmQiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlRGF0YVx1MDAzYy9zcGFuXHUwMDNlIGFuYWx5dGljcyBhbmQgYmlnIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MzUxODEwMTU2MDY3NjEiLCJzdW1tYXJ5IjpudWxsfSx7InRpbWRleFJlY29yZElkIjoiYWxtYTo5OTM1MDg0MDI0NDA2NzYxIiwidGl0bGUiOiJEYXRhIFByZXByb2Nlc3NpbmcgaW4gRGF0YSBNaW5pbmciLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJhdXRob3IiLCJ2YWx1ZSI6IkdhcmPDrWEsIFNhbHZhZG9yIn0seyJraW5kIjoiYXV0aG9yIiwidmFsdWUiOiJMdWVuZ28sIEp1bGnDoW4ifSx7ImtpbmQiOiJhdXRob3IiLCJ2YWx1ZSI6IkhlcnJlcmEsIEZyYW5jaXNjbyJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDE1In1dLCJsaW5rcyI6W3sia2luZCI6IkRpZ2l0YWwgb2JqZWN0IFVSTCIsInJlc3RyaWN0aW9ucyI6bnVsbCwidGV4dCI6IlNwcmluZ2VyTGluayBCb29rcyBFbmdpbmVlcmluZyIsInVybCI6Imh0dHBzOi8vbmEwNi5hbG1hLmV4bGlicmlzZ3JvdXAuY29tL3ZpZXcvdXJlc29sdmVyLzAxTUlUX0lOU1Qvb3BlbnVybD91Lmlnbm9yZV9kYXRlX2NvdmVyYWdlPXRydWVcdTAwMjZwb3J0Zm9saW9fcGlkPTUzNjIyMzExNjYwMDA2NzYxXHUwMDI2Rm9yY2VfZGlyZWN0PXRydWUifV0sIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiYnkgU2FsdmFkb3IgR2FyY8OtYSwgSnVsacOhbiBMdWVuZ28sIEZyYW5jaXNjbyBIZXJyZXJhIl19LHsia2luZCI6IkdlbmVyYWwgTm90ZSIsInZhbHVlIjpbIkRlc2NyaXB0aW9uIGJhc2VkIHVwb24gcHJpbnQgdmVyc2lvbiBvZiByZWNvcmQiXX0seyJraW5kIjoiQmlibGlvZ3JhcGh5IE5vdGUiLCJ2YWx1ZSI6WyJJbmNsdWRlcyBiaWJsaW9ncmFwaGljYWwgcmVmZXJlbmNlcyBhbmQgaW5kZXgiXX1dLCJoaWdobGlnaHQiOlt7Im1hdGNoZWRGaWVsZCI6InRpdGxlIiwibWF0Y2hlZFBocmFzZXMiOlsiXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlRGF0YVx1MDAzYy9zcGFuXHUwMDNlIFByZXByb2Nlc3NpbmcgaW4gXHUwMDNjc3BhbiBjbGFzcz1cImhpZ2hsaWdodFwiXHUwMDNlRGF0YVx1MDAzYy9zcGFuXHUwMDNlIE1pbmluZyJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MzUwODQwMjQ0MDY3NjEiLCJzdW1tYXJ5IjpbIkRhdGEgUHJlcHJvY2Vzc2luZyBmb3IgRGF0YSBNaW5pbmcgYWRkcmVzc2VzIG9uZSBvZiB0aGUgbW9zdCBpbXBvcnRhbnQgaXNzdWVzIHdpdGhpbiB0aGUgd2VsbC1rbm93biBLbm93bGVkZ2UgRGlzY292ZXJ5IGZyb20gRGF0YSBwcm9jZXNzLiBEYXRhIGRpcmVjdGx5IHRha2VuIGZyb20gdGhlIHNvdXJjZSB3aWxsIGxpa2VseSBoYXZlIGluY29uc2lzdGVuY2llcywgZXJyb3JzIG9yIG1vc3QgaW1wb3J0YW50bHksIGl0IGlzIG5vdCByZWFkeSB0byBiZSBjb25zaWRlcmVkIGZvciBhIGRhdGEgbWluaW5nIHByb2Nlc3MuIEZ1cnRoZXJtb3JlLCB0aGUgaW5jcmVhc2luZyBhbW91bnQgb2YgZGF0YSBpbiByZWNlbnQgc2NpZW5jZSwgaW5kdXN0cnkgYW5kIGJ1c2luZXNzIGFwcGxpY2F0aW9ucywgY2FsbHMgdG8gdGhlIHJlcXVpcmVtZW50IG9mIG1vcmUgY29tcGxleCB0b29scyB0byBhbmFseXplIGl0LiBUaGFua3MgdG8gZGF0YSBwcmVwcm9jZXNzaW5nLCBpdCBpcyBwb3NzaWJsZSB0byBjb252ZXJ0IHRoZSBpbXBvc3NpYmxlIGludG8gcG9zc2libGUsIGFkYXB0aW5nIHRoZSBkYXRhIHRvIGZ1bGZpbGwgdGhlIGlucHV0IGRlbWFuZHMgb2YgZWFjaCBkYXRhIG1pbmluZyBhbGdvcml0aG0uIERhdGEgcHJlcHJvY2Vzc2luZyBpbmNsdWRlcyB0aGUgZGF0YSByZWR1Y3Rpb24gdGVjaG5pcXVlcywgd2hpY2ggYWltIGF0IHJlZHVjaW5nIHRoZSBjb21wbGV4aXR5IG9mIHRoZSBkYXRhLCBkZXRlY3Rpbmcgb3IgcmVtb3ZpbmcgaXJyZWxldmFudCBhbmQgbm9pc3kgZWxlbWVudHMgZnJvbSB0aGUgZGF0YS4gVGhpcyBib29rIGlzIGludGVuZGVkIHRvIHJldmlldyB0aGUgdGFza3MgdGhhdCBmaWxsIHRoZSBnYXAgYmV0d2VlbiB0aGUgZGF0YSBhY3F1aXNpdGlvbiBmcm9tIHRoZSBzb3VyY2UgYW5kIHRoZSBkYXRhIG1pbmluZyBwcm9jZXNzLiBBIGNvbXByZWhlbnNpdmUgbG9vayBmcm9tIGEgcHJhY3RpY2FsIHBvaW50IG9mIHZpZXcsIGluY2x1ZGluZyBiYXNpYyBjb25jZXB0cyBhbmQgc3VydmV5aW5nIHRoZSB0ZWNobmlxdWVzIHByb3Bvc2VkIGluIHRoZSBzcGVjaWFsaXplZCBsaXRlcmF0dXJlLCBpcyBnaXZlbi5FYWNoIGNoYXB0ZXIgaXMgYSBzdGFuZC1hbG9uZSBndWlkZSB0byBhIHBhcnRpY3VsYXIgZGF0YSBwcmVwcm9jZXNzaW5nIHRvcGljLCBmcm9tIGJhc2ljIGNvbmNlcHRzIGFuZCBkZXRhaWxlZCBkZXNjcmlwdGlvbnMgb2YgY2xhc3NpY2FsIGFsZ29yaXRobXMsIHRvIGFuIGluY3Vyc2lvbiBvZiBhbiBleGhhdXN0aXZlIGNhdGFsb2cgb2YgcmVjZW50IGRldmVsb3BtZW50cy4gVGhlIGluLWRlcHRoIHRlY2huaWNhbCBkZXNjcmlwdGlvbnMgbWFrZSB0aGlzIGJvb2sgc3VpdGFibGUgZm9yIHRlY2huaWNhbCBwcm9mZXNzaW9uYWxzLCByZXNlYXJjaGVycywgc2VuaW9yIHVuZGVyZ3JhZHVhdGUgYW5kIGdyYWR1YXRlIHN0dWRlbnRzIGluIGRhdGEgc2NpZW5jZSwgY29tcHV0ZXIgc2NpZW5jZSBhbmQgZW5naW5lZXJpbmcuIl19LHsidGltZGV4UmVjb3JkSWQiOiJhbG1hOjk5MDAyMTI0NjAwMDEwNjc2MSIsInRpdGxlIjoiRGF0YSBtaW5pbmcgYW5kIGRhdGEgd2FyZWhvdXNpbmciLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJOb3Qgc3BlY2lmaWVkIiwidmFsdWUiOiJNb3VyeWEsIFMuIEsifSx7ImtpbmQiOiJOb3Qgc3BlY2lmaWVkIiwidmFsdWUiOiJHdXB0YSwgU2hhbHUifV0sInB1YmxpY2F0aW9uSW5mb3JtYXRpb24iOm51bGwsImRhdGVzIjpbeyJraW5kIjoiUHVibGljYXRpb24gZGF0ZSIsInZhbHVlIjoiMjAxMyJ9XSwibGlua3MiOm51bGwsIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiUy5LLiBNb3VyeWEsIFNoYWx1IEd1cHRhIl19LHsia2luZCI6IkdlbmVyYWwgTm90ZSIsInZhbHVlIjpbIkluY2x1ZGVzIGluZGV4Il19XSwiaGlnaGxpZ2h0IjpbeyJtYXRjaGVkRmllbGQiOiJ0aXRsZSIsIm1hdGNoZWRQaHJhc2VzIjpbIlx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBtaW5pbmcgYW5kIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSB3YXJlaG91c2luZyJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MDAyMTI0NjAwMDEwNjc2MSIsInN1bW1hcnkiOm51bGx9LHsidGltZGV4UmVjb3JkSWQiOiJhbG1hOjk5MDAxMzU0MTk3MDEwNjc2MSIsInRpdGxlIjoiRGF0YSBtaW5pbmcgYW5kIGRhdGEgdmlzdWFsaXphdGlvbiIsImNvbnRlbnRUeXBlIjpbIkxhbmd1YWdlIG1hdGVyaWFsIl0sImNvbnRyaWJ1dG9ycyI6W3sia2luZCI6Ik5vdCBzcGVjaWZpZWQiLCJ2YWx1ZSI6IlJhbywgQy4gUmFkaGFrcmlzaG5hIChDYWx5YW1wdWRpIFJhZGhha3Jpc2huYSkifSx7ImtpbmQiOiJOb3Qgc3BlY2lmaWVkIiwidmFsdWUiOiJXZWdtYW4sIEVkd2FyZCBKIn0seyJraW5kIjoiTm90IHNwZWNpZmllZCIsInZhbHVlIjoiU29sa2EsIEplZmZyZXkgTCJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDA1In1dLCJsaW5rcyI6bnVsbCwibm90ZXMiOlt7ImtpbmQiOiJUaXRsZSBTdGF0ZW1lbnQgb2YgUmVzcG9uc2liaWxpdHkiLCJ2YWx1ZSI6WyJlZGl0ZWQgYnkgQy5SLiBSYW8sIEUuSi4gV2VnbWFuLCBKLkwuIFNvbGthIl19LHsia2luZCI6IkJpYmxpb2dyYXBoeSBOb3RlIiwidmFsdWUiOlsiSW5jbHVkZXMgYmlibGlvZ3JhcGhpY2FsIHJlZmVyZW5jZXMgYW5kIGluZGV4Il19XSwiaGlnaGxpZ2h0IjpbeyJtYXRjaGVkRmllbGQiOiJ0aXRsZSIsIm1hdGNoZWRQaHJhc2VzIjpbIlx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBtaW5pbmcgYW5kIFx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZWRhdGFcdTAwM2Mvc3Bhblx1MDAzZSB2aXN1YWxpemF0aW9uIl19XSwicHJvdmlkZXIiOm51bGwsInJpZ2h0cyI6bnVsbCwic291cmNlTGluayI6Imh0dHBzOi8vbWl0LnByaW1vLmV4bGlicmlzZ3JvdXAuY29tL2Rpc2NvdmVyeS9mdWxsZGlzcGxheT92aWQ9MDFNSVRfSU5TVDpNSVRcdTAwMjZkb2NpZD1hbG1hOTkwMDEzNTQxOTcwMTA2NzYxIiwic3VtbWFyeSI6bnVsbH0seyJ0aW1kZXhSZWNvcmRJZCI6ImFsbWE6OTkzNTA5NTY4MDUwNjc2MSIsInRpdGxlIjoiRGF0YSBtaW5pbmcgYW5kIGRhdGEgdmlzdWFsaXphdGlvbiIsImNvbnRlbnRUeXBlIjpbIkxhbmd1YWdlIG1hdGVyaWFsIl0sImNvbnRyaWJ1dG9ycyI6W3sia2luZCI6Ik5vdCBzcGVjaWZpZWQiLCJ2YWx1ZSI6IlJhbywgQy4gUmFkaGFrcmlzaG5hIChDYWx5YW1wdWRpIFJhZGhha3Jpc2huYSkifSx7ImtpbmQiOiJOb3Qgc3BlY2lmaWVkIiwidmFsdWUiOiJXZWdtYW4sIEVkd2FyZCBKIn0seyJraW5kIjoiTm90IHNwZWNpZmllZCIsInZhbHVlIjoiU29sa2EsIEplZmZyZXkgTCJ9XSwicHVibGljYXRpb25JbmZvcm1hdGlvbiI6bnVsbCwiZGF0ZXMiOlt7ImtpbmQiOiJQdWJsaWNhdGlvbiBkYXRlIiwidmFsdWUiOiIyMDA1In1dLCJsaW5rcyI6W3sia2luZCI6IkRpZ2l0YWwgb2JqZWN0IFVSTCIsInJlc3RyaWN0aW9ucyI6bnVsbCwidGV4dCI6IkVsc2V2aWVyIFNjaWVuY2VEaXJlY3QgQm9va3MgQ29tcGxldGUiLCJ1cmwiOiJodHRwczovL25hMDYuYWxtYS5leGxpYnJpc2dyb3VwLmNvbS92aWV3L3VyZXNvbHZlci8wMU1JVF9JTlNUL29wZW51cmw/dS5pZ25vcmVfZGF0ZV9jb3ZlcmFnZT10cnVlXHUwMDI2cG9ydGZvbGlvX3BpZD01MzU1MTU1OTA5MDAwNjc2MVx1MDAyNkZvcmNlX2RpcmVjdD10cnVlIn1dLCJub3RlcyI6W3sia2luZCI6IlRpdGxlIFN0YXRlbWVudCBvZiBSZXNwb25zaWJpbGl0eSIsInZhbHVlIjpbImVkaXRlZCBieSBDLlIuIFJhbywgRS5KLiBXZWdtYW4sIEouTC4gU29sa2EiXX0seyJraW5kIjoiR2VuZXJhbCBOb3RlIiwidmFsdWUiOlsiRGVzY3JpcHRpb24gYmFzZWQgdXBvbiBwcmludCB2ZXJzaW9uIG9mIHJlY29yZCJdfSx7ImtpbmQiOiJCaWJsaW9ncmFwaHkgTm90ZSIsInZhbHVlIjpbIkluY2x1ZGVzIGJpYmxpb2dyYXBoaWNhbCByZWZlcmVuY2VzIGFuZCBpbmRleCJdfV0sImhpZ2hsaWdodCI6W3sibWF0Y2hlZEZpZWxkIjoidGl0bGUiLCJtYXRjaGVkUGhyYXNlcyI6WyJcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VEYXRhXHUwMDNjL3NwYW5cdTAwM2UgbWluaW5nIGFuZCBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VkYXRhXHUwMDNjL3NwYW5cdTAwM2UgdmlzdWFsaXphdGlvbiJdfV0sInByb3ZpZGVyIjpudWxsLCJyaWdodHMiOm51bGwsInNvdXJjZUxpbmsiOiJodHRwczovL21pdC5wcmltby5leGxpYnJpc2dyb3VwLmNvbS9kaXNjb3ZlcnkvZnVsbGRpc3BsYXk/dmlkPTAxTUlUX0lOU1Q6TUlUXHUwMDI2ZG9jaWQ9YWxtYTk5MzUwOTU2ODA1MDY3NjEiLCJzdW1tYXJ5IjpbIlRoaXMgYm9vayBmb2N1c2VzIG9uIGRlYWxpbmcgd2l0aCBsYXJnZS1zY2FsZSBkYXRhLCBhIGZpZWxkIGNvbW1vbmx5IHJlZmVycmVkIHRvIGFzIGRhdGEgbWluaW5nLiBUaGUgYm9vayBpcyBkaXZpZGVkIGludG8gdGhyZWUgc2VjdGlvbnMuIFRoZSBmaXJzdCBkZWFscyB3aXRoIGFuIGludHJvZHVjdGlvbiB0byBzdGF0aXN0aWNhbCBhc3BlY3RzIG9mIGRhdGEgbWluaW5nIGFuZCBtYWNoaW5lIGxlYXJuaW5nIGFuZCBpbmNsdWRlcyBhcHBsaWNhdGlvbnMgdG8gdGV4dCBhbmFseXNpcywgY29tcHV0ZXIgaW50cnVzaW9uIGRldGVjdGlvbiwgYW5kIGhpZGluZyBvZiBpbmZvcm1hdGlvbiBpbiBkaWdpdGFsIGZpbGVzLiBUaGUgc2Vjb25kIHNlY3Rpb24gZm9jdXNlcyBvbiBhIHZhcmlldHkgb2Ygc3RhdGlzdGljYWwgbWV0aG9kb2xvZ2llcyB0aGF0IGhhdmUgcHJvdmVuIHRvIGJlIGVmZmVjdGl2ZSBpbiBkYXRhIG1pbmluZyBhcHBsaWNhdGlvbnMuIFRoZXNlIGluY2x1ZGUgY2x1c3RlcmluZywgY2xhc3NpZmljYXRpb24sIG11bHRpdmFyaWF0ZSBkZW5zaXR5IGVzdGltYXRpb24sIHRyZWUtYmFzZWQgbWV0aG9kcywgcGF0dGVybiByZWNvZ25pdGlvbiwgbyJdfSx7InRpbWRleFJlY29yZElkIjoiYWxtYTo5OTM1MTQ2MzQzNTA2NzYxIiwidGl0bGUiOiJEYXRhIE1pbmluZyBvbiBNdWx0aW1lZGlhIERhdGEiLCJjb250ZW50VHlwZSI6WyJMYW5ndWFnZSBtYXRlcmlhbCJdLCJjb250cmlidXRvcnMiOlt7ImtpbmQiOiJhdXRob3IiLCJ2YWx1ZSI6IlBlcm5lciwgUGV0cmEifV0sInB1YmxpY2F0aW9uSW5mb3JtYXRpb24iOm51bGwsImRhdGVzIjpbeyJraW5kIjoiUHVibGljYXRpb24gZGF0ZSIsInZhbHVlIjoiMjAwMyJ9XSwibGlua3MiOlt7ImtpbmQiOiJEaWdpdGFsIG9iamVjdCBVUkwiLCJyZXN0cmljdGlvbnMiOm51bGwsInRleHQiOiJTcHJpbmdlckxpbmsgQm9va3MgTGVjdHVyZSBOb3RlcyBJbiBDb21wdXRlciBTY2llbmNlIEFyY2hpdmUiLCJ1cmwiOiJodHRwczovL25hMDYuYWxtYS5leGxpYnJpc2dyb3VwLmNvbS92aWV3L3VyZXNvbHZlci8wMU1JVF9JTlNUL29wZW51cmw/dS5pZ25vcmVfZGF0ZV9jb3ZlcmFnZT10cnVlXHUwMDI2cG9ydGZvbGlvX3BpZD01MzU2MjQwNzQxMDAwNjc2MVx1MDAyNkZvcmNlX2RpcmVjdD10cnVlIn0seyJraW5kIjoiRGlnaXRhbCBvYmplY3QgVVJMIiwicmVzdHJpY3Rpb25zIjpudWxsLCJ0ZXh0IjoiU3ByaW5nZXIgTmF0dXJlIC0gU3ByaW5nZXIgQm9vayBBcmNoaXZlIC0gQ29sbGVjdGlvbiAyMDAwLTIwMDQiLCJ1cmwiOiJodHRwczovL25hMDYuYWxtYS5leGxpYnJpc2dyb3VwLmNvbS92aWV3L3VyZXNvbHZlci8wMU1JVF9JTlNUL29wZW51cmw/dS5pZ25vcmVfZGF0ZV9jb3ZlcmFnZT10cnVlXHUwMDI2cG9ydGZvbGlvX3BpZD01MzU2MjQwNzM5MDAwNjc2MVx1MDAyNkZvcmNlX2RpcmVjdD10cnVlIn0seyJraW5kIjoiRGlnaXRhbCBvYmplY3QgVVJMIiwicmVzdHJpY3Rpb25zIjpudWxsLCJ0ZXh0IjoiU3ByaW5nZXIgTmF0dXJlIC0gU3ByaW5nZXIgTGVjdHVyZSBOb3RlcyBpbiBDb21wdXRlciBTY2llbmNlIGVCb29rcyIsInVybCI6Imh0dHBzOi8vbmEwNi5hbG1hLmV4bGlicmlzZ3JvdXAuY29tL3ZpZXcvdXJlc29sdmVyLzAxTUlUX0lOU1Qvb3BlbnVybD91Lmlnbm9yZV9kYXRlX2NvdmVyYWdlPXRydWVcdTAwMjZwb3J0Zm9saW9fcGlkPTUzNTYyNDA3NDAwMDA2NzYxXHUwMDI2Rm9yY2VfZGlyZWN0PXRydWUifV0sIm5vdGVzIjpbeyJraW5kIjoiVGl0bGUgU3RhdGVtZW50IG9mIFJlc3BvbnNpYmlsaXR5IiwidmFsdWUiOlsiYnkgUGV0cmEgUGVybmVyIl19LHsia2luZCI6IkdlbmVyYWwgTm90ZSIsInZhbHVlIjpbIkJpYmxpb2dyYXBoaWMgTGV2ZWwgTW9kZSBvZiBJc3N1YW5jZTogTW9ub2dyYXBoIl19LHsia2luZCI6IkJpYmxpb2dyYXBoeSBOb3RlIiwidmFsdWUiOlsiSW5jbHVkZXMgYmlibGlvZ3JhcGhpY2FsIHJlZmVyZW5jZXMgYW5kIGluZGV4Il19XSwiaGlnaGxpZ2h0IjpbeyJtYXRjaGVkRmllbGQiOiJ0aXRsZSIsIm1hdGNoZWRQaHJhc2VzIjpbIlx1MDAzY3NwYW4gY2xhc3M9XCJoaWdobGlnaHRcIlx1MDAzZURhdGFcdTAwM2Mvc3Bhblx1MDAzZSBNaW5pbmcgb24gTXVsdGltZWRpYSBcdTAwM2NzcGFuIGNsYXNzPVwiaGlnaGxpZ2h0XCJcdTAwM2VEYXRhXHUwMDNjL3NwYW5cdTAwM2UiXX1dLCJwcm92aWRlciI6bnVsbCwicmlnaHRzIjpudWxsLCJzb3VyY2VMaW5rIjoiaHR0cHM6Ly9taXQucHJpbW8uZXhsaWJyaXNncm91cC5jb20vZGlzY292ZXJ5L2Z1bGxkaXNwbGF5P3ZpZD0wMU1JVF9JTlNUOk1JVFx1MDAyNmRvY2lkPWFsbWE5OTM1MTQ2MzQzNTA2NzYxIiwic3VtbWFyeSI6WyJEZXNwaXRlIGJlaW5nIGEgeW91bmcgZmllbGQgb2YgcmVzZWFyY2ggYW5kIGRldmVsb3BtZW50LCBkYXRhIG1pbmluZyBoYXMgcHJvdmVkIHRvIGJlIGEgc3VjY2Vzc2Z1bCBhcHByb2FjaCB0byBleHRyYWN0aW5nIGtub3dsZWRnZSBmcm9tIGh1Z2UgY29sbGVjdGlvbnMgb2Ygc3RydWN0dXJlZCBkaWdpdGFsIGRhdGEgY29sbGVjdGlvbiBhcyB1c3VhbGx5IHN0b3JlZCBpbiBkYXRhYmFzZXMuIFdoZXJlYXMgZGF0YSBtaW5pbmcgd2FzIGRvbmUgaW4gZWFybHkgZGF5cyBwcmltYXJpbHkgb24gbnVtZXJpY2FsIGRhdGEsIG5vd2FkYXlzIG11bHRpbWVkaWEgYW5kIEludGVybmV0IGFwcGxpY2F0aW9ucyBkcml2ZSB0aGUgbmVlZCB0byBkZXZlbG9wIGRhdGEgbWluaW5nIG1ldGhvZHMgYW5kIHRlY2huaXF1ZXMgdGhhdCBjYW4gd29yayBvbiBhbGwga2luZHMgb2YgZGF0YSBzdWNoIGFzIGRvY3VtZW50cywgaW1hZ2VzLCBhbmQgc2lnbmFscy4gVGhpcyBib29rIGludHJvZHVjZXMgdGhlIGJhc2ljIGNvbmNlcHRzIG9mIG1pbmluZyBtdWx0aW1lZGlhIGRhdGEgYW5kIGRlbW9uc3RyYXRlcyBob3cgdG8gYXBwbHkgdGhlc2UgbWV0aG9kcyBpbiB2YXJpb3VzIGFwcGxpY2F0aW9uIGZpZWxkcy4gSXQgaXMgd3JpdHRlbiBmb3Igc3R1ZGVudHMsIGFtYml0aW9uZWQgcHJvZmVzc2lvbmFscyBmcm9tIGluZHVzdHJ5IGFuZCBtZWRpY2luZSwgYW5kIGZvciBzY2llbnRpc3RzIHdobyB3YW50IHRvIGNvbnRyaWJ1dGUgUlx1MDAyNkQgd29yayB0byB0aGUgZmllbGQgb3IgYXBwbHkgdGhpcyBuZXcgdGVjaG5vbG9neS4iXX1dLCJhZ2dyZWdhdGlvbnMiOnsiYWNjZXNzVG9GaWxlcyI6W3sia2V5IjoidW5rbm93bjogY2hlY2sgd2l0aCBvd25pbmcgaW5zdGl0dXRpb24iLCJkb2NDb3VudCI6MzUyN30seyJrZXkiOiJNSVQgYXV0aGVudGljYXRpb24gcmVxdWlyZWQiLCJkb2NDb3VudCI6NTF9XSwiY29udGVudFR5cGUiOlt7ImtleSI6Imxhbmd1YWdlIG1hdGVyaWFsIiwiZG9jQ291bnQiOjMyNjQ4fSx7ImtleSI6InBvbHlnb24gZGF0YSIsImRvY0NvdW50IjoxNjgwfSx7ImtleSI6ImFydGljbGUiLCJkb2NDb3VudCI6MTQ5Mn0seyJrZXkiOiJ0aGVzaXMiLCJkb2NDb3VudCI6MTM0OX0seyJrZXkiOiJkYXRhc2V0IiwiZG9jQ291bnQiOjEzMjZ9LHsia2V5IjoibWFudXNjcmlwdCBsYW5ndWFnZSBtYXRlcmlhbCIsImRvY0NvdW50IjoxMDc2fSx7ImtleSI6InByb2plY3RlZCBtZWRpdW0iLCJkb2NDb3VudCI6OTk5fSx7ImtleSI6InBvaW50IGRhdGEiLCJkb2NDb3VudCI6OTU0fSx7ImtleSI6InZlY3RvciBkYXRhIiwiZG9jQ291bnQiOjM4M30seyJrZXkiOiJyYXN0ZXIgZGF0YSIsImRvY0NvdW50IjozNzN9XSwiY29udHJpYnV0b3JzIjpbeyJrZXkiOiJnZW9sb2dpY2FsIHN1cnZleSAodS5zLikiLCJkb2NDb3VudCI6MjQwOH0seyJrZXkiOiJtYXNzYWNodXNldHRzIGluc3RpdHV0ZSBvZiB0ZWNobm9sb2d5LiBkZXBhcnRtZW50IG9mIGVsZWN0cmljYWwgZW5naW5lZXJpbmcgYW5kIGNvbXB1dGVyIHNjaWVuY2UiLCJkb2NDb3VudCI6MTA1N30seyJrZXkiOiJuYXRpb25hbCBidXJlYXUgb2YgZWNvbm9taWMgcmVzZWFyY2giLCJkb2NDb3VudCI6ODA1fSx7ImtleSI6InVuaXRlZCBzdGF0ZXMuIGdvdmVybm1lbnQgYWNjb3VudGFiaWxpdHkgb2ZmaWNlIiwiZG9jQ291bnQiOjc4MX0seyJrZXkiOiJlbnZpcm9ubWVudGFsIHN5c3RlbXMgcmVzZWFyY2ggaW5zdGl0dXRlIChyZWRsYW5kcywgY2FsaWYuKSIsImRvY0NvdW50Ijo3Mzh9LHsia2V5IjoiaW5zdGl0dXRlIG9mIGVsZWN0cmljYWwgYW5kIGVsZWN0cm9uaWNzIGVuZ2luZWVycyIsImRvY0NvdW50Ijo2MDR9LHsia2V5IjoiZWFzdCB2aWV3IGNhcnRvZ3JhcGhpYywgaW5jb3Jwb3JhdGVkIiwiZG9jQ291bnQiOjU2MX0seyJrZXkiOiJhc3NvY2lhdGlvbiBmb3IgY29tcHV0aW5nIG1hY2hpbmVyeSIsImRvY0NvdW50Ijo0MzJ9LHsia2V5IjoibWFzc2FjaHVzZXR0cyBpbnN0aXR1dGUgb2YgdGVjaG5vbG9neS4gZGVwYXJ0bWVudCBvZiBlbGVjdHJpY2FsIGVuZ2luZWVyaW5nIGFuZCBjb21wdXRlciBzY2llbmNlLiIsImRvY0NvdW50IjozOTZ9LHsia2V5Ijoib3dlbiwgYW5kcmV3IiwiZG9jQ291bnQiOjM4M31dLCJmb3JtYXQiOlt7ImtleSI6ImVsZWN0cm9uaWMgcmVzb3VyY2UiLCJkb2NDb3VudCI6NDg0OX0seyJrZXkiOiJzaGFwZWZpbGUiLCJkb2NDb3VudCI6MzA1N30seyJrZXkiOiJnZW90aWZmIiwiZG9jQ291bnQiOjM3M30seyJrZXkiOiJnZW9wYWNrYWdlIiwiZG9jQ291bnQiOjc4fSx7ImtleSI6InBkZiIsImRvY0NvdW50IjoxOX0seyJrZXkiOiJqcGVnIiwiZG9jQ291bnQiOjE3fSx7ImtleSI6InRpZmYiLCJkb2NDb3VudCI6MTB9XSwibGFuZ3VhZ2VzIjpbeyJrZXkiOiJlbmdsaXNoIiwiZG9jQ291bnQiOjM3MDI5fSx7ImtleSI6ImVuZyIsImRvY0NvdW50IjoxNjU5fSx7ImtleSI6ImVuX3VzIiwiZG9jQ291bnQiOjE0Mjd9LHsia2V5IjoiZW4iLCJkb2NDb3VudCI6OTE4fSx7ImtleSI6ImluIGVuZ2xpc2giLCJkb2NDb3VudCI6Mzc1fSx7ImtleSI6Im9yaWdpbmFsIGxhbmd1YWdlIGluIGVuZ2xpc2giLCJkb2NDb3VudCI6MTMyfSx7ImtleSI6Imdlcm1hbiIsImRvY0NvdW50Ijo5M30seyJrZXkiOiJmcmVuY2giLCJkb2NDb3VudCI6ODN9LHsia2V5IjoicnVzc2lhbiIsImRvY0NvdW50IjozNX0seyJrZXkiOiJzcGFuaXNoIiwiZG9jQ291bnQiOjMwfV0sImxpdGVyYXJ5Rm9ybSI6W3sia2V5Ijoibm9uZmljdGlvbiIsImRvY0NvdW50IjoyNzMxMX0seyJrZXkiOiJmaWN0aW9uIiwiZG9jQ291bnQiOjQ5NTN9XSwicGxhY2VzIjpbeyJrZXkiOiJlYXJ0aCAocGxhbmV0KSIsImRvY0NvdW50IjozNTV9LHsia2V5IjoiY2hpbmEiLCJkb2NDb3VudCI6MzIxfSx7ImtleSI6InVuaXRlZCBzdGF0ZXMiLCJkb2NDb3VudCI6MjU2fSx7ImtleSI6ImV1cm9wZSIsImRvY0NvdW50IjoxNzl9LHsia2V5IjoicHVlcnRvIHJpY28iLCJkb2NDb3VudCI6MTQzfSx7ImtleSI6ImVjdWFkb3IiLCJkb2NDb3VudCI6MTA3fSx7ImtleSI6InJlcHVibGljIG9mIGVjdWFkb3IiLCJkb2NDb3VudCI6MTA3fSx7ImtleSI6ImNhbmFkYSIsImRvY0NvdW50IjoxMDF9LHsia2V5IjoiaW5kaWEiLCJkb2NDb3VudCI6ODl9LHsia2V5IjoicGFyYWd1YXkiLCJkb2NDb3VudCI6ODd9XSwic291cmNlIjpbeyJrZXkiOiJtaXQgYWxtYSIsImRvY0NvdW50IjozNTMwOH0seyJrZXkiOiJvcGVuZ2VvbWV0YWRhdGEgZ2lzIHJlc291cmNlcyIsImRvY0NvdW50IjozNTI3fSx7ImtleSI6ImRzcGFjZUBtaXQiLCJkb2NDb3VudCI6MzMzM30seyJrZXkiOiJ3b29kcyBob2xlIG9wZW4gYWNjZXNzIHNlcnZlciIsImRvY0NvdW50Ijo3ODl9LHsia2V5IjoiemVub2RvIiwiZG9jQ291bnQiOjY0NX0seyJrZXkiOiJhYmR1bCBsYXRpZiBqYW1lZWwgcG92ZXJ0eSBhY3Rpb24gbGFiIGRhdGF2ZXJzZSIsImRvY0NvdW50Ijo2MH0seyJrZXkiOiJtaXQgZ2lzIHJlc291cmNlcyIsImRvY0NvdW50Ijo1MX0seyJrZXkiOiJyZXNlYXJjaCBkYXRhYmFzZXMiLCJkb2NDb3VudCI6MTV9LHsia2V5IjoibGliZ3VpZGVzIiwiZG9jQ291bnQiOjd9LHsia2V5IjoibWl0IGFyY2hpdmVzc3BhY2UiLCJkb2NDb3VudCI6MX1dLCJzdWJqZWN0cyI6W3sia2V5Ijoic29jaWV0eSIsImRvY0NvdW50Ijo0Mjg0fSx7ImtleSI6ImRhdGFzZXRzIiwiZG9jQ291bnQiOjMyOTB9LHsia2V5IjoiYm91bmRhcmllcyIsImRvY0NvdW50IjoyODQ2fSx7ImtleSI6InVuaXRlZCBzdGF0ZXMiLCJkb2NDb3VudCI6MjY1M30seyJrZXkiOiJkYXRhIG1pbmluZyIsImRvY0NvdW50IjoyMTUxfSx7ImtleSI6ImRhdGFiYXNlIG1hbmFnZW1lbnQiLCJkb2NDb3VudCI6MTk2MH0seyJrZXkiOiJhcnRpZmljaWFsIGludGVsbGlnZW5jZSIsImRvY0NvdW50IjoxOTIxfSx7ImtleSI6ImJpZyBkYXRhIiwiZG9jQ291bnQiOjE1NzN9LHsia2V5IjoiZWNvbm9teSIsImRvY0NvdW50IjoxMDI2fSx7ImtleSI6ImNlbnN1cyIsImRvY0NvdW50Ijo5ODV9XX19fX0=
- recorded_at: Thu, 25 Apr 2024 20:57:18 GMT
-recorded_with: VCR 6.2.0
From 0106e5f94150518bf71cb5bdbd2f310932d43f50 Mon Sep 17 00:00:00 2001
From: jazairi <16103405+jazairi@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:25:04 -0500
Subject: [PATCH 2/3] Refactor all tab logic to service and enable cache
Why these changes are being introduced:
In discussions of the PR in review for USE-179, we determined that the
proposed pagination improvements could be more efficient. We had also
determined that the code changes were difficult to follow, and could
use better documentation.
Relevant ticket(s):
- USE-179
How this addresses that need:
This commit caches the page 1 'summary' API calls, which we use to
gather the hit counts from each API to calculate pagination on deeper
pages. It also abstracts the 'all' tab code to a 'Merged Search Service',
mirroring the design pattern of the Merged Search Paginator, and adds
docstrings to the methods in that service.
Side effects of this change:
We are still making two API calls on deeper pages when the hit totals
are not cached. I could not find a workaround to this while still
supporting nonlinear pagination. However, the vast majority of users
(even bots, presumably) will begin their search at page 1, so hopefully
this is a rare occurrence.
---
app/controllers/search_controller.rb | 106 +---------
app/models/merged_search_service.rb | 235 +++++++++++++++++++++
test/controllers/search_controller_test.rb | 23 ++
test/models/merged_search_service_test.rb | 202 ++++++++++++++++++
4 files changed, 469 insertions(+), 97 deletions(-)
create mode 100644 app/models/merged_search_service.rb
create mode 100644 test/models/merged_search_service_test.rb
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 222fefe2..00a431f7 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -90,11 +90,15 @@ def load_timdex_results
def load_all_results
current_page = @enhanced_query[:page] || 1
per_page = ENV.fetch('RESULTS_PER_PAGE', '20').to_i
- data = if current_page.to_i == 1
- fetch_all_tab_first_page(current_page, per_page)
- else
- fetch_all_tab_deeper_pages(current_page, per_page)
- end
+
+ service = MergedSearchService.new(
+ enhanced_query: @enhanced_query,
+ active_tab: @active_tab,
+ cache: Rails.cache,
+ primo_fetcher: ->(offset:, per_page:, query: nil) { fetch_primo_data(offset: offset, per_page: per_page) },
+ timdex_fetcher: ->(offset:, per_page:, query: nil) { fetch_timdex_data(offset: offset, per_page: per_page) }
+ )
+ data = service.fetch(page: current_page, per_page: per_page)
@results = data[:results]
@errors = data[:errors]
@@ -102,98 +106,6 @@ def load_all_results
@show_primo_continuation = data[:show_primo_continuation]
end
- def fetch_all_tab_first_page(current_page, per_page)
- primo_data, timdex_data = parallel_fetch({ offset: 0, per_page: per_page }, { offset: 0, per_page: per_page })
-
- paginator = build_paginator_from_data(primo_data, timdex_data, current_page, per_page)
-
- assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page)
- end
-
- def fetch_all_tab_deeper_pages(current_page, per_page)
- primo_summary, timdex_summary = parallel_fetch({ offset: 0, per_page: 1 }, { offset: 0, per_page: 1 })
-
- paginator = build_paginator_from_data(primo_summary, timdex_summary, current_page, per_page)
-
- primo_data, timdex_data = fetch_all_tab_page_chunks(paginator)
-
- assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page, deeper: true)
- end
-
- # Launch parallel fetch threads for Primo and Timdex and return their data
- def parallel_fetch(primo_opts = {}, timdex_opts = {})
- primo_thread = Thread.new { fetch_primo_data(**primo_opts) }
- timdex_thread = Thread.new { fetch_timdex_data(**timdex_opts) }
-
- [primo_thread.value, timdex_thread.value]
- end
-
- # Build a paginator from raw API response data
- def build_paginator_from_data(primo_data, timdex_data, current_page, per_page)
- primo_total = primo_data[:hits] || 0
- timdex_total = timdex_data[:hits] || 0
-
- MergedSearchPaginator.new(
- primo_total: primo_total,
- timdex_total: timdex_total,
- current_page: current_page,
- per_page: per_page
- )
- end
-
- # For deeper pages, compute merge_plan and api_offsets, then conditionally fetch page chunks
- def fetch_all_tab_page_chunks(paginator)
- merge_plan = paginator.merge_plan
- primo_count = merge_plan.count(:primo)
- timdex_count = merge_plan.count(:timdex)
- primo_offset, timdex_offset = paginator.api_offsets
-
- primo_thread = primo_count > 0 ? Thread.new { fetch_primo_data(offset: primo_offset, per_page: primo_count) } : nil
- timdex_thread = if timdex_count > 0
- Thread.new do
- fetch_timdex_data(offset: timdex_offset, per_page: timdex_count)
- end
- end
-
- primo_data = if primo_thread
- primo_thread.value
- else
- { results: [], errors: nil, hits: paginator.primo_total, show_continuation: false }
- end
-
- timdex_data = if timdex_thread
- timdex_thread.value
- else
- { results: [], errors: nil, hits: paginator.timdex_total }
- end
-
- [primo_data, timdex_data]
- end
-
- # Assemble the final result hash from paginator and API data
- def assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page, deeper: false)
- primo_total = primo_data[:hits] || 0
- timdex_total = timdex_data[:hits] || 0
-
- merged = paginator.merge_results(primo_data[:results] || [], timdex_data[:results] || [])
- errors = combine_errors(primo_data[:errors], timdex_data[:errors])
- pagination = Analyzer.new(@enhanced_query, timdex_total, :all, primo_total).pagination
-
- show_primo_continuation = if deeper
- page_offset = (current_page - 1) * per_page
- primo_data[:show_continuation] || (page_offset >= Analyzer::PRIMO_MAX_OFFSET)
- else
- primo_data[:show_continuation]
- end
-
- { results: merged, errors: errors, pagination: pagination, show_primo_continuation: show_primo_continuation }
- end
-
- def combine_errors(*error_arrays)
- all_errors = error_arrays.compact.flatten
- all_errors.any? ? all_errors : nil
- end
-
def fetch_primo_data(offset: nil, per_page: nil)
# Default to current page if not provided
current_page = @enhanced_query[:page] || 1
diff --git a/app/models/merged_search_service.rb b/app/models/merged_search_service.rb
new file mode 100644
index 00000000..69c8ec51
--- /dev/null
+++ b/app/models/merged_search_service.rb
@@ -0,0 +1,235 @@
+require 'digest'
+
+# Orchestrates merged "all" tab searches across Primo and TIMDEX.
+#
+# Handles parallel fetches, per-query totals caching, pagination calculation via
+# `MergedSearchPaginator`, and assembly of a controller-friendly response hash.
+class MergedSearchService
+ # Time to live value for cache expiration.
+ TTL = 10.minutes
+
+ # Initialize a new MergedSearchService.
+ #
+ # @param enhanced_query [Hash] query hash produced by `Enhancer`
+ # @param active_tab [String] the currently active tab (e.g. 'all')
+ # @param cache [Object] optional cache store responding to `read`/`write` (defaults to `Rails.cache`)
+ # @param primo_fetcher [#call] optional callable used to fetch Primo results; should accept `offset:, per_page:, query:`
+ # @param timdex_fetcher [#call] optional callable used to fetch TIMDEX results; should accept `offset:, per_page:, query:`
+ def initialize(enhanced_query:, active_tab:, cache: Rails.cache, primo_fetcher: nil, timdex_fetcher: nil)
+ @enhanced_query = enhanced_query
+ @active_tab = active_tab
+ @cache = cache
+ @primo_fetcher = primo_fetcher || method(:default_primo_fetch)
+ @timdex_fetcher = timdex_fetcher || method(:default_timdex_fetch)
+ end
+
+ # Execute merged search orchestration for the requested page.
+ #
+ # @param page [Integer] page number to fetch
+ # @param per_page [Integer] number of results per page
+ # @return [Hash] keys: :results, :errors, :pagination, :show_primo_continuation
+ def fetch(page:, per_page:)
+ current_page = (page || 1).to_i
+ per_page = (per_page || 20).to_i
+
+ if current_page == 1
+ primo_data, timdex_data = parallel_fetch(offset: 0, per_page: per_page)
+
+ totals = { primo: primo_data[:hits].to_i, timdex: timdex_data[:hits].to_i }
+ write_cached_totals(totals)
+
+ paginator = build_paginator_from_totals(totals, current_page, per_page)
+
+ results = assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page)
+
+ return results
+ end
+
+ totals = @cache.read(totals_cache_key)
+
+ unless totals
+ primo_summary, timdex_summary = parallel_fetch(offset: 0, per_page: 1)
+ totals = { primo: primo_summary[:hits].to_i, timdex: timdex_summary[:hits].to_i }
+ write_cached_totals(totals)
+ end
+
+ paginator = build_paginator_from_totals(totals, current_page, per_page)
+ primo_data, timdex_data = fetch_all_tab_page_chunks(paginator)
+
+ assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page, deeper: true)
+ end
+
+ private
+
+ # Generate the cache key used to store per-query totals for this enhanced query/tab.
+ #
+ # @return [String] cache key ending in '/totals'
+ def totals_cache_key
+ base = generate_cache_key(@enhanced_query.merge(tab: @active_tab))
+ "#{base}/totals"
+ end
+
+ # Persist per-query totals to cache(s).
+ #
+ # The method writes to the injected cache (if available) and to
+ # `Rails.cache`. Additional marker keys are written to improve test
+ # discoverability for stores that are probed with `read_matched`.
+ #
+ # @param totals [Hash] { primo: Integer, timdex: Integer }
+ def write_cached_totals(totals)
+ @cache.write(totals_cache_key, totals, expires_in: TTL) if @cache.respond_to?(:write)
+ Rails.cache.write(totals_cache_key, totals, expires_in: TTL)
+ Rails.cache.write("#{totals_cache_key}_marker_totals", totals, expires_in: TTL)
+ merged_key = "merged_search_totals:#{totals_cache_key}"
+ Rails.cache.write(merged_key, totals, expires_in: TTL)
+ end
+
+ # Perform parallel fetches from Primo and TIMDEX using the configured
+ # fetchers. Each fetcher should return the usual response hash including
+ # `:results` and `:hits`.
+ #
+ # WARNING: exceptions raised inside these threads will not automatically
+ # propagate to the caller; callers/tests should account for this.
+ #
+ # @param offset [Integer] api offset to request
+ # @param per_page [Integer] number of items to request
+ # @return [Array] [primo_response, timdex_response]
+ def parallel_fetch(offset:, per_page:)
+ primo = nil
+ timdex = nil
+ threads = []
+ threads << Thread.new { primo = @primo_fetcher.call(offset: offset, per_page: per_page, query: @enhanced_query) }
+ threads << Thread.new { timdex = @timdex_fetcher.call(offset: offset, per_page: per_page, query: @enhanced_query) }
+ threads.each(&:join)
+ [primo, timdex]
+ end
+
+ # Compute API offsets from the paginator and fetch the page-sized chunks
+ # required to assemble the merged page.
+ #
+ # @param paginator [MergedSearchPaginator]
+ # @return [Array] [primo_data, timdex_data]
+ def fetch_all_tab_page_chunks(paginator)
+ merge_plan = paginator.merge_plan
+ primo_count = merge_plan.count(:primo)
+ timdex_count = merge_plan.count(:timdex)
+ primo_offset, timdex_offset = paginator.api_offsets
+
+ primo_thread = if primo_count > 0
+ Thread.new do
+ @primo_fetcher.call(offset: primo_offset, per_page: primo_count, query: @enhanced_query)
+ end
+ end
+ timdex_thread = if timdex_count > 0
+ Thread.new do
+ @timdex_fetcher.call(offset: timdex_offset, per_page: timdex_count, query: @enhanced_query)
+ end
+ end
+
+ primo_data = if primo_thread
+ primo_thread.value
+ else
+ { results: [], errors: nil, hits: paginator.primo_total,
+ show_continuation: false }
+ end
+ timdex_data = timdex_thread ? timdex_thread.value : { results: [], errors: nil, hits: paginator.timdex_total }
+
+ [primo_data, timdex_data]
+ end
+
+ # Assemble the final hash returned to the controller for rendering.
+ #
+ # @param paginator [MergedSearchPaginator]
+ # @param primo_data [Hash] response from Primo fetcher
+ # @param timdex_data [Hash] response from TIMDEX fetcher
+ # @param current_page [Integer]
+ # @param per_page [Integer]
+ # @param deeper [Boolean] whether this was a deeper-page flow
+ # @return [Hash] response with :results, :errors, :pagination, :show_primo_continuation
+ def assemble_all_tab_result(paginator, primo_data, timdex_data, current_page, per_page, deeper: false)
+ primo_total = primo_data[:hits] || 0
+ timdex_total = timdex_data[:hits] || 0
+
+ merged = paginator.merge_results(primo_data[:results] || [], timdex_data[:results] || [])
+ errors = combine_errors(primo_data[:errors], timdex_data[:errors])
+ pagination = Analyzer.new(@enhanced_query, timdex_total, :all, primo_total).pagination
+
+ show_primo_continuation = if deeper
+ page_offset = (current_page - 1) * per_page
+ primo_data[:show_continuation] || (page_offset >= Analyzer::PRIMO_MAX_OFFSET)
+ else
+ primo_data[:show_continuation]
+ end
+
+ { results: merged, errors: errors, pagination: pagination, show_primo_continuation: show_primo_continuation }
+ end
+
+ # Merge multiple error arrays into a single array or nil when empty.
+ #
+ # @return [Array, nil]
+ def combine_errors(*error_arrays)
+ all_errors = error_arrays.compact.flatten
+ all_errors.any? ? all_errors : nil
+ end
+
+ # Build a `MergedSearchPaginator` given cached totals.
+ #
+ # @param totals [Hash] { primo: Integer, timdex: Integer }
+ # @return [MergedSearchPaginator]
+ def build_paginator_from_totals(totals, current_page, per_page)
+ MergedSearchPaginator.new(primo_total: totals[:primo] || 0, timdex_total: totals[:timdex] || 0,
+ current_page: current_page, per_page: per_page)
+ end
+
+ # Default Primo fetcher used when no custom fetcher is injected.
+ #
+ # @param offset [Integer]
+ # @param per_page [Integer]
+ # @param query [Hash]
+ # @return [Hash] response including :results and :hits
+ def default_primo_fetch(offset:, per_page:, query:)
+ if offset && offset >= Analyzer::PRIMO_MAX_OFFSET
+ return { results: [], pagination: {}, errors: nil, show_continuation: true, hits: 0 }
+ end
+
+ per_page ||= ENV.fetch('RESULTS_PER_PAGE', '20').to_i
+ primo_search = PrimoSearch.new
+ raw = primo_search.search(query[:q], per_page, offset)
+ hits = raw.dig('info', 'total') || 0
+ results = NormalizePrimoResults.new(raw, query[:q]).normalize
+ { results: results, pagination: Analyzer.new(query, hits, :primo).pagination, errors: nil,
+ show_continuation: false, hits: hits }
+ rescue StandardError => e
+ { results: [], pagination: {}, errors: [{ 'message' => e.message }], show_continuation: false, hits: 0 }
+ end
+
+ # Default TIMDEX fetcher used when no custom fetcher is injected.
+ #
+ # @param offset [Integer]
+ # @param per_page [Integer]
+ # @param query [Hash]
+ # @return [Hash] response including :results and :hits
+ def default_timdex_fetch(offset:, per_page:, query:)
+ q = QueryBuilder.new(query).query
+ q['from'] = offset.to_s if offset
+ q['size'] = per_page.to_s if per_page
+
+ resp = TimdexBase::Client.query(TimdexSearch::BaseQuery, variables: q)
+ data = resp.data.to_h
+ hits = data.dig('search', 'hits') || 0
+ raw_results = data.dig('search', 'records') || []
+ results = NormalizeTimdexResults.new(raw_results, query[:q]).normalize
+ { results: results, pagination: Analyzer.new(query, hits, :timdex).pagination, errors: nil, hits: hits }
+ rescue StandardError => e
+ { results: [], pagination: {}, errors: [{ 'message' => e.message }], hits: 0 }
+ end
+
+ # Generate a cache key based on the supplied query hash.
+ #
+ # @param query [Hash]
+ # @return [String] MD5 hex digest
+ def generate_cache_key(query)
+ sorted = query.sort_by { |k, _v| k.to_sym }.to_h
+ Digest::MD5.hexdigest(sorted.to_s)
+ end
+end
diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb
index 2aa86d12..2421c3a8 100644
--- a/test/controllers/search_controller_test.rb
+++ b/test/controllers/search_controller_test.rb
@@ -805,6 +805,29 @@ def source_filter_count(controller)
assert_select 'a[href*="tab=website"]', count: 1
end
+ test 'all tab page 1 writes totals to cache' do
+ # This integration-level behavior is covered by unit tests on `MergedSearchService`.
+ # Here we assert the controller delegates to the service.
+ mock_service = mock('merged_service')
+ mock_service.expects(:fetch).returns({ results: [], errors: nil, pagination: {}, show_primo_continuation: false })
+ MergedSearchService.expects(:new).returns(mock_service)
+
+ get '/results?q=test'
+ assert_response :success
+ end
+
+ test 'all tab deeper page reads cached totals and avoids summary calls' do
+ # This behavior is covered in greater depth by `MergedSearchService` unit tests.
+ mock_service = mock('merged_service')
+ mock_service.expects(:fetch).with(page: 2,
+ per_page: 20).returns({ results: [],
+ errors: nil, pagination: {}, show_primo_continuation: false })
+ MergedSearchService.expects(:new).returns(mock_service)
+
+ get '/results?q=test&page=2'
+ assert_response :success
+ end
+
test 'results handles primo search errors gracefully' do
PrimoSearch.expects(:new).raises(StandardError.new('API Error'))
diff --git a/test/models/merged_search_service_test.rb b/test/models/merged_search_service_test.rb
new file mode 100644
index 00000000..9cae280f
--- /dev/null
+++ b/test/models/merged_search_service_test.rb
@@ -0,0 +1,202 @@
+require 'test_helper'
+require 'ostruct'
+
+class MergedSearchServiceTest < ActiveSupport::TestCase
+ test 'page 1 writes totals to cache' do
+ mem_cache = ActiveSupport::Cache::MemoryStore.new
+ query = { q: 'test' }
+
+ primo_fetcher = lambda do |offset:, per_page:, query:|
+ { results: ['foo'], hits: 42, errors: nil, show_continuation: false }
+ end
+
+ timdex_fetcher = lambda do |offset:, per_page:, query:|
+ { results: ['bar'], hits: 37, errors: nil }
+ end
+
+ service = MergedSearchService.new(enhanced_query: query, active_tab: 'all', cache: mem_cache,
+ primo_fetcher: primo_fetcher, timdex_fetcher: timdex_fetcher)
+
+ res = service.fetch(page: 1, per_page: 20)
+ assert_equal 2, res[:results].length
+
+ # Verify cache written
+ key = service.send(:totals_cache_key)
+ cached = mem_cache.read(key)
+ refute_nil cached
+ assert_equal 42, cached[:primo]
+ assert_equal 37, cached[:timdex]
+ end
+
+ test 'deeper page reads cached totals and avoids summary calls' do
+ mem_cache = ActiveSupport::Cache::MemoryStore.new
+ query = { q: 'test' }
+
+ service = MergedSearchService.new(enhanced_query: query, active_tab: 'all', cache: mem_cache)
+
+ # populate cache so service uses it instead of summary calls
+ mem_cache.write(service.send(:totals_cache_key), { primo: 50, timdex: 50 })
+
+ # fetchers that would raise if a summary call (per_page == 1) is attempted
+ primo_fetcher = lambda do |offset:, per_page:, query:|
+ raise 'Summary call made' if per_page == 1
+
+ { results: ['foo'], hits: 50, errors: nil, show_continuation: false }
+ end
+
+ timdex_fetcher = lambda do |offset:, per_page:, query:|
+ raise 'Summary call made' if per_page == 1
+
+ { results: ['bar'], hits: 50, errors: nil }
+ end
+
+ service = MergedSearchService.new(enhanced_query: query, active_tab: 'all', cache: mem_cache,
+ primo_fetcher: primo_fetcher, timdex_fetcher: timdex_fetcher)
+
+ # Should not raise
+ assert_nothing_raised do
+ res = service.fetch(page: 2, per_page: 20)
+ assert res[:results].is_a?(Array)
+ end
+ end
+
+ test 'falls back to summary and writes cache when totals are missing' do
+ mem_cache = ActiveSupport::Cache::MemoryStore.new
+ q = { q: 'test' }
+
+ calls = []
+ primo_fetcher = lambda do |offset:, per_page:, query:|
+ calls << [:primo, offset, per_page]
+ if per_page == 1
+ { results: [], hits: 7, errors: nil, show_continuation: false }
+ else
+ { results: ['foo'], hits: 7, errors: nil, show_continuation: false }
+ end
+ end
+
+ timdex_fetcher = lambda do |offset:, per_page:, query:|
+ calls << [:timdex, offset, per_page]
+ if per_page == 1
+ { results: [], hits: 3, errors: nil }
+ else
+ { results: ['bar'], hits: 3, errors: nil }
+ end
+ end
+
+ svc = MergedSearchService.new(enhanced_query: q, active_tab: 'all', cache: mem_cache, primo_fetcher: primo_fetcher,
+ timdex_fetcher: timdex_fetcher)
+
+ res = svc.fetch(page: 2, per_page: 20)
+
+ # summary calls should have been made with per_page == 1
+ assert_includes calls, [:primo, 0, 1]
+ assert_includes calls, [:timdex, 0, 1]
+
+ # totals cached
+ key = svc.send(:totals_cache_key)
+ totals = mem_cache.read(key)
+ refute_nil totals
+ assert_equal 7, totals[:primo]
+ assert_equal 3, totals[:timdex]
+
+ assert res[:results].is_a?(Array)
+ end
+
+ test 'default_primo_fetch returns continuation when offset exceeds max' do
+ svc = MergedSearchService.new(enhanced_query: { q: 'foo' }, active_tab: 'all', cache: ActiveSupport::Cache::MemoryStore.new)
+ res = svc.send(:default_primo_fetch, offset: Analyzer::PRIMO_MAX_OFFSET, per_page: 20, query: { q: 'foo' })
+ assert_equal true, res[:show_continuation]
+ assert_equal 0, res[:hits]
+ end
+
+ test 'default_primo_fetch handles exceptions gracefully' do
+ svc = MergedSearchService.new(enhanced_query: { q: 'foo' }, active_tab: 'all', cache: ActiveSupport::Cache::MemoryStore.new)
+ PrimoSearch.expects(:new).raises(StandardError.new('boom'))
+ res = svc.send(:default_primo_fetch, offset: 0, per_page: 10, query: { q: 'foo' })
+ assert_equal 0, res[:hits]
+ assert res[:errors].is_a?(Array)
+ end
+
+ test 'default_timdex_fetch handles client errors gracefully' do
+ svc = MergedSearchService.new(enhanced_query: { q: 'foo' }, active_tab: 'all', cache: ActiveSupport::Cache::MemoryStore.new)
+ TimdexBase::Client.expects(:query).raises(StandardError.new('boom'))
+ res = svc.send(:default_timdex_fetch, offset: 0, per_page: 10, query: { q: 'foo' })
+ assert_equal 0, res[:hits]
+ assert res[:errors].is_a?(Array)
+ end
+
+ test 'fetch_all_tab_page_chunks handles zero-count branches' do
+ mem = ActiveSupport::Cache::MemoryStore.new
+ called = []
+ primo_fetcher = lambda { |offset:, per_page:, query:|
+ called << [:primo, offset, per_page]
+ { results: ['P'], hits: 5, errors: nil, show_continuation: false }
+ }
+ timdex_fetcher = lambda { |offset:, per_page:, query:|
+ called << [:timdex, offset, per_page]
+ { results: [], hits: 0, errors: nil }
+ }
+
+ svc = MergedSearchService.new(enhanced_query: { q: 'foo' }, active_tab: 'all', cache: mem,
+ primo_fetcher: primo_fetcher, timdex_fetcher: timdex_fetcher)
+
+ paginator = OpenStruct.new(
+ merge_plan: %i[primo primo],
+ api_offsets: [10, 0],
+ primo_total: 5,
+ timdex_total: 0
+ )
+
+ primo_data, timdex_data = svc.send(:fetch_all_tab_page_chunks, paginator)
+ assert primo_data[:results].is_a?(Array)
+ assert timdex_data[:results].is_a?(Array)
+ assert_equal 0, timdex_data[:hits]
+ end
+
+ test 'combine_errors merges arrays or returns nil' do
+ svc = MergedSearchService.new(enhanced_query: { q: 'foo' }, active_tab: 'all', cache: ActiveSupport::Cache::MemoryStore.new)
+ assert_nil svc.send(:combine_errors, nil, [])
+ merged = svc.send(:combine_errors, [{ 'message' => 'a' }], [{ 'message' => 'b' }])
+ assert_equal 2, merged.length
+ end
+
+ test 'default_primo_fetch returns normalized results on success' do
+ svc = MergedSearchService.new(enhanced_query: { q: 'foo' }, active_tab: 'all', cache: ActiveSupport::Cache::MemoryStore.new)
+ mock_primo = mock('primo_search')
+ mock_primo.expects(:search).returns({ 'info' => { 'total' => 12 }, 'docs' => [] })
+ PrimoSearch.expects(:new).returns(mock_primo)
+
+ mock_normalizer = mock('normalizer')
+ mock_normalizer.expects(:normalize).returns(['normalized'])
+ NormalizePrimoResults.expects(:new).returns(mock_normalizer)
+
+ mock_analyzer = mock('analyzer')
+ mock_analyzer.expects(:pagination).returns({ page: 1 })
+ Analyzer.expects(:new).returns(mock_analyzer)
+
+ res = svc.send(:default_primo_fetch, offset: 0, per_page: 10, query: { q: 'foo' })
+ assert_equal 12, res[:hits]
+ assert_equal ['normalized'], res[:results]
+ assert_equal({ page: 1 }, res[:pagination])
+ end
+
+ test 'default_timdex_fetch returns normalized results on success' do
+ svc = MergedSearchService.new(enhanced_query: { q: 'foo' }, active_tab: 'all', cache: ActiveSupport::Cache::MemoryStore.new)
+ fake_resp = OpenStruct.new(data: OpenStruct.new(to_h: { 'search' => { 'hits' => 5,
+ 'records' => [{ 'id' => 1 }] } }))
+ TimdexBase::Client.stubs(:query).returns(fake_resp)
+
+ mock_normalizer = mock('normalizer')
+ mock_normalizer.expects(:normalize).returns(['t_normalized'])
+ NormalizeTimdexResults.expects(:new).returns(mock_normalizer)
+
+ mock_analyzer = mock('analyzer')
+ mock_analyzer.expects(:pagination).returns({ page: 1 })
+ Analyzer.expects(:new).returns(mock_analyzer)
+
+ res = svc.send(:default_timdex_fetch, offset: 0, per_page: 10, query: { q: 'foo' })
+ assert_equal 5, res[:hits]
+ assert_equal ['t_normalized'], res[:results]
+ assert_equal({ page: 1 }, res[:pagination])
+ end
+end
From c8695b0f5ab771e34de6ba201187a86ea5ad38a7 Mon Sep 17 00:00:00 2001
From: Jeremy Prevost
Date: Thu, 4 Dec 2025 10:27:59 -0500
Subject: [PATCH 3/3] Add additional docs for merged_search_service
---
app/models/merged_search_service.rb | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/app/models/merged_search_service.rb b/app/models/merged_search_service.rb
index 69c8ec51..103c1a3f 100644
--- a/app/models/merged_search_service.rb
+++ b/app/models/merged_search_service.rb
@@ -32,6 +32,10 @@ def fetch(page:, per_page:)
current_page = (page || 1).to_i
per_page = (per_page || 20).to_i
+ # For page 1, we retrieve `per_page` results for each API and then store the totals
+ # We don't always use all of the results that were returned here, but the logic in the subsequent page requests
+ # accounts for that in the offset calculation. We retrieve the full per_page for each API to ensure we always get a
+ # full page 1 unless both APIs have less than per_page combined.
if current_page == 1
primo_data, timdex_data = parallel_fetch(offset: 0, per_page: per_page)
@@ -47,6 +51,8 @@ def fetch(page:, per_page:)
totals = @cache.read(totals_cache_key)
+ # If we don't have a stored totals value for the incoming query, we need to create one. This situation can happen
+ # if a user accesses (shared, bookmarked, refreshed, etc) a non-page 1 query after the cache has expired.
unless totals
primo_summary, timdex_summary = parallel_fetch(offset: 0, per_page: 1)
totals = { primo: primo_summary[:hits].to_i, timdex: timdex_summary[:hits].to_i }