Skip to content

Commit 6e17704

Browse files
committed
Extract an ingestion adapter seam inside elasticgraph-indexer
Operation::Factory no longer performs JSON-schema validation itself; instead it routes each event to an ingestion adapter, which validates the event and supplies the version-appropriate record preparer. The existing JSON validation logic moves behind the seam as IngestionAdapter::JSONEvents, so behavior is unchanged (the existing validation specs pass as-is). Multiple adapters dispatch by handles_event?, with a single available adapter receiving all events.
1 parent 8070692 commit 6e17704

11 files changed

Lines changed: 413 additions & 117 deletions

File tree

elasticgraph-indexer/lib/elastic_graph/indexer.rb

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ def record_preparer_factory
6363
end
6464
end
6565

66+
# The ingestion adapters available for processing events. For now, only the JSON events
67+
# adapter is available; indexer extension modules will be able to contribute additional
68+
# adapters as alternate ingestion formats are supported.
69+
def ingestion_adapters
70+
@ingestion_adapters ||= begin
71+
require "elastic_graph/indexer/ingestion_adapter/json_events"
72+
[IngestionAdapter::JSONEvents.new(schema_artifacts: schema_artifacts, logger: logger)]
73+
end
74+
end
75+
6676
def processor
6777
@processor ||= begin
6878
require "elastic_graph/indexer/processor"
@@ -82,10 +92,9 @@ def operation_factory
8292
Operation::Factory.new(
8393
schema_artifacts: schema_artifacts,
8494
index_definitions_by_graphql_type: datastore_core.index_definitions_by_graphql_type,
85-
record_preparer_factory: record_preparer_factory,
95+
ingestion_adapters: ingestion_adapters,
8696
logger: datastore_core.logger,
87-
skip_derived_indexing_type_updates: config.skip_derived_indexing_type_updates,
88-
configure_record_validator: nil
97+
skip_derived_indexing_type_updates: config.skip_derived_indexing_type_updates
8998
)
9099
end
91100
end

elasticgraph-indexer/lib/elastic_graph/indexer/event_id.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def to_s
2626

2727
# Steep weirdly expects them here...
2828
# @dynamic initialize, config, datastore_core, schema_artifacts, datastore_router, monotonic_clock
29-
# @dynamic record_preparer_factory, processor, operation_factory, logger
29+
# @dynamic record_preparer_factory, processor, operation_factory, ingestion_adapters, logger
3030
# @dynamic self.from_parsed_yaml
3131
end
3232
end
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
module ElasticGraph
10+
class Indexer
11+
# Namespace for ingestion adapters. An ingestion adapter teaches the indexer how to handle
12+
# events of a particular ingestion format: it validates each event and provides the
13+
# version-appropriate machinery to prepare the event's record for indexing.
14+
module IngestionAdapter
15+
# Defines the ingestion adapter interface. Adapter classes are not required to subclass this,
16+
# but must implement these methods.
17+
class Interface
18+
# @param schema_artifacts [SchemaArtifacts::FromDisk] the schema artifacts
19+
# @param logger [Logger] the ElasticGraph logger
20+
def initialize(schema_artifacts:, logger:)
21+
# must be defined, but nothing to do
22+
end
23+
24+
# Indicates whether this adapter recognizes the given event as one of its own. When multiple
25+
# adapters are available, the indexer routes each event to the first adapter that returns
26+
# `true`. (When exactly one adapter is available, it receives all events.)
27+
#
28+
# @param event [Hash<String, Object>] an ElasticGraph indexing event
29+
# @return [Boolean] whether this adapter handles the event
30+
def handles_event?(event)
31+
# :nocov: -- must return a boolean to satisfy Steep type checking but never called
32+
false
33+
# :nocov:
34+
end
35+
36+
# Validates the given event and resolves the record preparer appropriate for the event's
37+
# schema version.
38+
#
39+
# @param event [Hash<String, Object>] an ElasticGraph indexing event
40+
# @return [ValidationResult] the result of validating the event
41+
def validate_event(event)
42+
# :nocov: -- must return a result to satisfy Steep type checking but never called
43+
ValidationResult.valid(RecordPreparer::Identity)
44+
# :nocov:
45+
end
46+
end
47+
48+
# Describes a validation problem with an event.
49+
#
50+
# @!attribute [r] payload_description
51+
# @return [String] brief description of the part of the event that was invalid
52+
# @!attribute [r] message
53+
# @return [String] detailed validation failure message
54+
Failure = ::Data.define(:payload_description, :message)
55+
56+
# Returned by {Interface#validate_event}. Either `failure` is non-nil (the event was invalid)
57+
# or `record_preparer` is non-nil (the event was valid and its record can be prepared for
58+
# indexing with the given preparer).
59+
#
60+
# @!attribute [r] record_preparer
61+
# @return [Object, nil] preparer for the event's record, when the event is valid
62+
# @!attribute [r] failure
63+
# @return [Failure, nil] description of the validation problem, when the event is invalid
64+
ValidationResult = ::Data.define(:record_preparer, :failure) do
65+
# @implements ValidationResult
66+
67+
# Builds a result for a valid event.
68+
#
69+
# @param record_preparer [Object] preparer for the event's record
70+
# @return [ValidationResult]
71+
def self.valid(record_preparer)
72+
new(record_preparer: record_preparer, failure: nil)
73+
end
74+
75+
# Builds a result for an invalid event.
76+
#
77+
# @param payload_description [String] brief description of the part of the event that was invalid
78+
# @param message [String] detailed validation failure message
79+
# @return [ValidationResult]
80+
def self.invalid(payload_description:, message:)
81+
new(record_preparer: nil, failure: Failure.new(payload_description: payload_description, message: message))
82+
end
83+
end
84+
end
85+
end
86+
end
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
require "elastic_graph/constants"
10+
require "elastic_graph/indexer/event_id"
11+
require "elastic_graph/indexer/ingestion_adapter"
12+
require "elastic_graph/indexer/record_preparer"
13+
require "elastic_graph/support/json_schema/validator_factory"
14+
15+
module ElasticGraph
16+
class Indexer
17+
module IngestionAdapter
18+
# Ingestion adapter for events in ElasticGraph's versioned JSON format: it validates events
19+
# against the JSON schema identified by the event's `json_schema_version`, and prepares
20+
# records using that version's view of the schema.
21+
class JSONEvents
22+
# @param schema_artifacts [SchemaArtifacts::FromDisk] the schema artifacts
23+
# @param logger [Logger] the ElasticGraph logger
24+
# @param configure_record_validator [Proc, nil] optional callback to further configure the record validator
25+
def initialize(schema_artifacts:, logger:, configure_record_validator: nil)
26+
@schema_artifacts = schema_artifacts
27+
@logger = logger
28+
@configure_record_validator = configure_record_validator
29+
@record_preparer_factory = RecordPreparer::Factory.new(schema_artifacts)
30+
end
31+
32+
# (see Interface#handles_event?)
33+
def handles_event?(event)
34+
event.key?(JSON_SCHEMA_VERSION_KEY)
35+
end
36+
37+
# (see Interface#validate_event)
38+
def validate_event(event)
39+
selected_json_schema_version = select_json_schema_version(event) { |failure| return failure }
40+
41+
# Because the `select_json_schema_version` picks the closest-matching json schema version, the incoming
42+
# event might not match the expected json_schema_version value in the json schema (which is a `const` field).
43+
# This is by design, since we're picking a schema based on best-effort, so to avoid that by-design validation error,
44+
# performing the envelope validation on a "patched" version of the event.
45+
event_with_patched_envelope = event.merge({JSON_SCHEMA_VERSION_KEY => selected_json_schema_version})
46+
47+
if (error_message = validator(EVENT_ENVELOPE_JSON_SCHEMA_NAME, selected_json_schema_version).validate_with_error_message(event_with_patched_envelope))
48+
return ValidationResult.invalid(payload_description: "event payload", message: error_message)
49+
end
50+
51+
record = event.fetch("record")
52+
graphql_type_name = event.fetch("type")
53+
54+
if (error_message = validator(graphql_type_name, selected_json_schema_version).validate_with_error_message(record))
55+
return ValidationResult.invalid(payload_description: "#{graphql_type_name} record", message: error_message)
56+
end
57+
58+
ValidationResult.valid(@record_preparer_factory.for_json_schema_version(selected_json_schema_version))
59+
end
60+
61+
private
62+
63+
def select_json_schema_version(event)
64+
available_json_schema_versions = @schema_artifacts.available_json_schema_versions
65+
66+
requested_json_schema_version = event[JSON_SCHEMA_VERSION_KEY]
67+
68+
# First check that a valid value has been requested (a positive integer)
69+
if !event.key?(JSON_SCHEMA_VERSION_KEY)
70+
yield ValidationResult.invalid(payload_description: JSON_SCHEMA_VERSION_KEY, message: "Event lacks a `#{JSON_SCHEMA_VERSION_KEY}`")
71+
elsif !requested_json_schema_version.is_a?(Integer) || requested_json_schema_version < 1
72+
yield ValidationResult.invalid(payload_description: JSON_SCHEMA_VERSION_KEY, message: "#{JSON_SCHEMA_VERSION_KEY} (#{requested_json_schema_version}) must be a positive integer.")
73+
end
74+
75+
# The requested version might not necessarily be available (if the publisher is deployed ahead of the indexer, or an old schema
76+
# version is removed prematurely, or an indexer deployment is rolled back). So the behavior is to always pick the closest-available
77+
# version. If there's an exact match, great. Even if not an exact match, if the incoming event payload conforms to the closest match,
78+
# the event can still be indexed.
79+
#
80+
# This min_by block will take the closest version in the list. If a tie occurs, the first value in the list wins. The desired
81+
# behavior is in the event of a tie (highly unlikely, there shouldn't be a gap in available json schema versions), the higher version
82+
# should be selected. So to get that behavior, the list is sorted in descending order.
83+
#
84+
selected_json_schema_version = available_json_schema_versions.sort.reverse.min_by { |version| (requested_json_schema_version - version).abs }
85+
86+
if selected_json_schema_version != requested_json_schema_version
87+
@logger.info({
88+
"message_type" => "ElasticGraphMissingJSONSchemaVersion",
89+
"message_id" => event["message_id"],
90+
"event_id" => EventID.from_event(event),
91+
"event_type" => event["type"],
92+
"requested_json_schema_version" => requested_json_schema_version,
93+
"selected_json_schema_version" => selected_json_schema_version
94+
})
95+
end
96+
97+
if selected_json_schema_version.nil?
98+
yield ValidationResult.invalid(
99+
payload_description: JSON_SCHEMA_VERSION_KEY,
100+
message: "Failed to select json schema version. Requested version: #{event[JSON_SCHEMA_VERSION_KEY]}. \
101+
Available json schema versions: #{available_json_schema_versions.sort.join(", ")}"
102+
)
103+
end
104+
105+
selected_json_schema_version
106+
end
107+
108+
def validator(type, selected_json_schema_version)
109+
factory = validator_factories_by_version[selected_json_schema_version] # : Support::JSONSchema::ValidatorFactory
110+
factory.validator_for(type)
111+
end
112+
113+
def validator_factories_by_version
114+
@validator_factories_by_version ||= ::Hash.new do |hash, json_schema_version|
115+
factory = Support::JSONSchema::ValidatorFactory.new(
116+
schema: @schema_artifacts.json_schemas_for(json_schema_version),
117+
sanitize_pii: true
118+
)
119+
120+
if (configure_record_validator = @configure_record_validator)
121+
factory = configure_record_validator.call(factory)
122+
end
123+
124+
hash[json_schema_version] = factory
125+
end
126+
end
127+
128+
# :nocov: -- this should not be called. Instead, it exists to guard against wrongly raising an error from this class.
129+
def raise(*args)
130+
super("`raise` was called on `IngestionAdapter::JSONEvents`, but should not. Instead, use " \
131+
"`yield ValidationResult.invalid(...)` so that we can accumulate all invalid events and allow " \
132+
"the valid events to still be processed.")
133+
end
134+
# :nocov:
135+
end
136+
end
137+
end
138+
end

0 commit comments

Comments
 (0)