Add skip_record_validation_for indexer config for sampled backfill validation - #1315
Add skip_record_validation_for indexer config for sampled backfill validation#1315vermatron wants to merge 11 commits into
skip_record_validation_for indexer config for sampled backfill validation#1315Conversation
…validation Adds an indexer config option that skips per-record JSON schema validation for a configurable fraction of records, keyed by GraphQL type. It exists for backfills of trusted, pre-validated data, where the per-record schema walk is a meaningful ingest cost and the datastore mappings provide a coarse backstop. `skip_record_validation_for` maps a type name to a fraction in `[0.0, 1.0]`: `0.0` (or an absent key) validates every record, `1.0` skips every record, and values in between sample. The skip decision is deterministic per event id -- a stable `Zlib.crc32` of `EventID#to_s` buckets each event -- so the same event makes the same decision on retry across indexer pods (`String#hash` is unsuitable: `RUBY_HASH_SEED` is per-process). The event envelope is always validated regardless of the sampling rate. Two supporting pieces: - `RecordPreparer::UnknownTypeError` (a `KeyError` subtype) is raised when a skipped record reaches the preparer with a missing/unknown abstract-type `__typename`. `Factory#build` rescues it and returns a structured `FailedEventError` rather than letting an exception escape, with a message that omits the offending value. - Skipping a safety check is never silent: `Processor` tallies skipped records per batch and logs a single aggregate `RecordValidationSkipped` entry (with per-type counts), mirroring the batch-level `ElasticGraphIndexingLatencies` log. Per-record logging would be untenable at backfill scale.
marcdaniels-toast
left a comment
There was a problem hiding this comment.
@vermatron I'm digging into this PR. Meanwhile can you merge the latest origin/main into it?
Addresses review feedback on block#1315: the `skip_record_validation_for` description implied that a malformed record whose validation was skipped would always fail in isolation. Only a missing or unknown `__typename` on an abstract-type field actually does - that is the one error `Operation::Factory#build` rescues. Other malformations that per-record validation would have caught escape as unhandled exceptions and take their whole batch down. They surface at two layers: - during `Factory#build`, e.g. a value `IndexingPreparers::Integer` cannot coerce, or a missing `id_source` path - during `router.bulk`, from `Update#metadata` - the rollover index suffix and the custom routing key. `to_datastore_bulk` is memoized and lazy, so these are not reachable from `Factory#build`'s rescue at all. Because an exception produces no `batchItemFailures` response, SQS redelivers the entire batch and the malformed record re-poisons it on every retry until `maxReceiveCount` drains it to the DLQ, dragging the well-formed events along each time. The description now says so. Regenerated `config_schema.yaml` via `script/update_config_artifacts`. Actually isolating these failures is a larger change (it needs a seam that covers both layers, plus typed errors so a schema-artifact `KeyError` is not demoted to a per-event data failure) and is left to a follow-up.
…idation-for-config
…onfig' into add-skip-record-validation-for-config
Move the warning about skipped validation failing a whole batch to the front of the setting's description, and drop the duplicate copy that had accumulated at the end. Keeps the unique details (the document id/routing key/rollover suffix failure mode and the dead letter queue consequence) and regenerates config_schema.yaml. Generated with Claude Code
|
This looks good to me. I'll ask @myronmarston to take a look in case there are bigger picture things I didn't think of or catch. |
myronmarston
left a comment
There was a problem hiding this comment.
Nice work @vermatron! I left some suggestions.
| {"WidgetWorkspace" => ["ABC12345678"]} | ||
| ] | ||
| }, | ||
| skip_record_validation_for: { |
There was a problem hiding this comment.
skip_record_validation_for reads a bit odd as a config setting:
- The name doesn't hint that it's a map keyed by event type
- The name doesn't hint that the map values are percents.
- The
_forsuffix, as a preposition, reads oddly without something coming after it. (I do like the_forsuffix on the end of method names when it reads together with the argument, such ascluster_for(record), but it doesn't read the same as a config setting).
Can we call it skip_record_validation_percents_by_type?
- The
x_by_typenaming implies its a map keyed by type skip_record_validation_percentsdescribes what the values are
| "queue will redeliver all of its events, and the malformed record will fail them again on each retry " \ | ||
| "until it is drained to the dead letter queue.", | ||
| type: "object", | ||
| patternProperties: {/^[A-Z]\w*$/.source => {type: "number", minimum: 0, maximum: 1}}, |
There was a problem hiding this comment.
Can we make the max value 100 and treat it as a percent? IMO that's more natural than making it a value between 0 and 1. It can still be a floating point value.
| ] | ||
| }, | ||
| skip_record_validation_for: { | ||
| description: "Note: only a missing or unknown `__typename` on an abstract-type field is guaranteed to " \ |
There was a problem hiding this comment.
It's a bit odd to start the description with the caveat. Can we instead start with a description of what the config option does, and then put the Note: caveat later?
Maybe something like:
A map, keyed by event type, which controls how often records are validated during ingestion. A value of 100 skips all record validation for the named type; a value of 0 (the default) validates all records of the named type.
With large schemas, the record validation consumes a significant chunk of CPU, and skipping validation can optimize your indexing performance. This setting allows you to make a tradeoff based on your needs.
When validation is skipped, ElasticGraph's usual guarantees (e.g. that all valid records in a batch that has invalid records still get successfully indexed) may no longer apply.
| # `Indexer::Config#skip_record_validation_for` lets that validation be skipped -- so this error | ||
| # exists as a typed, catchable signal that callers can convert into a structured failure. | ||
| class UnknownTypeError < ::KeyError | ||
| end |
There was a problem hiding this comment.
Can you move this definition to here?
https://github.com/block/elasticgraph/blob/main/elasticgraph-support/lib/elastic_graph/errors.rb
Then it aligns with how other EG exception classes are defined. Also, please subclass ElasticGraph::Errors::Error as that's intended to be a common supertype of all exceptions raised by EG.
...although I left another suggestion (see the next comment) which I think remotes the need for this type entirely.
| build_all_operations_for(event, record_preparer_factory.for_json_schema_version(selected_json_schema_version)), | ||
| validation_skipped_for: validation_skipped ? graphql_type_name : nil | ||
| ) | ||
| rescue RecordPreparer::UnknownTypeError |
There was a problem hiding this comment.
Curious on your thinking here. There's an unbounded number of ways things can go wrong when the record hasn't been validated. Why is it worth handling the unknown type case but not worth handling any other case?
An alternate approach worth considering:
- When validation is skipped, wrap the entire
BuildResult.success(...)expression in abegin/rescue, and dorescue => exceptionso that you rescue any kind of error. - In the
rescueblock, re-run the validator (so the caller gets a failure indicating what was malformed like they would have if validation was used). - If the validator returns no
failed_result, re-raise the exception--it means something went wrong that's not detectable via record validation and we shouldn't hide the error.
The hope with this approach:
- Don't just handle one specific error you thought of (
UnknownTypeError)--instead handle anything that goes wrong. - Give the caller the same error they would have gotten w/ validation when validation has been skipped and that skipping lead to an exception. The validation error usually pinpoints the problem.
| rate = skip_record_validation_for[type] | ||
| return false if rate.nil? || rate <= 0.0 | ||
| return true if rate >= 1.0 | ||
| ::Zlib.crc32(EventID.from_event(event).to_s).fdiv(2**32) < rate |
There was a problem hiding this comment.
Can we extract a constant for 2**32 so we don't re-compute it each time?
| BuildResult = ::Data.define(:operations, :failed_event_error) do | ||
| # - `validation_skipped_for` names the event's GraphQL type when per-record validation was skipped | ||
| # (via `skip_record_validation_for`), and is nil otherwise. `Processor` aggregates this for observability. | ||
| BuildResult = ::Data.define(:operations, :failed_event_error, :validation_skipped_for) do |
There was a problem hiding this comment.
Similar to my feedback on config.rb: I like _for as a suffix on a method name that takes an argument (e.g. payroll.salary_for(employee)) but I don't think it reads well when there's no argument that follows, as is the case here since it's just a property. Let's come up with a better name.
| record_preparer_factory: record_preparer_factory, | ||
| logger: datastore_core.logger, | ||
| skip_derived_indexing_type_updates: config.skip_derived_indexing_type_updates, | ||
| skip_record_validation_for: config.skip_record_validation_for, |
There was a problem hiding this comment.
I don't love the _for suffix on the attribute here, either.
Add
skip_record_validation_forindexer config for sampled backfill validationWhy
During large backfills of already-validated data, per-record JSON schema validation is wasted work. Every record walks the full schema (regex, enum, min/max, format, abstract-type discriminators) even though the source has already been validated upstream. Today there's no way to trade that cost for throughput.
This adds a config option that skips per-record validation for a chosen fraction of records, per GraphQL type, while keeping a sampled slice validated as a canary so schema drift still surfaces. It's a sibling of the existing
skip_derived_indexing_type_updatesbackfill knob and follows the same shape.Design notes:
skip_record_validation_formaps a type name to a fraction in[0.0, 1.0].0.0(or an absent key) validates everything,1.0skips everything, and values in between sample. The value is the fraction skipped, so0.9skips 90% and validates 10%.The skip decision is a
Zlib.crc32of the event id (type:id@vversion) bucketed into[0.0, 1.0)and compared against the rate. Same event id, same decision, so a retry never flips a record between validated and skipped, even across pods.String#hashwon't do here: its seed is per-process, so two pods would disagree.The event envelope is always validated. Only the per-record schema walk gets sampled.
Skipping isn't silent.
Processorcounts skipped records per batch and logs oneRecordValidationSkippedline with per-type counts, the same way it logsElasticGraphIndexingLatencies. Logging per record would drown a1.0backfill in log lines, so the count is aggregated per batch.Safety net for skipped abstract-type records: once validation is off, a record with a missing or unknown
__typenamecan reachRecordPreparer. It now raises a typedRecordPreparer::UnknownTypeError, andFactory#buildrescues it into aFailedEventError, so that one failure mode degrades a single event instead of the batch. The rescue is narrow (a typed error, not a barerescue KeyError) so it can't hide schema-artifact bugs, and its message drops the offending value to avoid leaking record data. A pre-walk check was considered and dropped: it would re-implementprepare_for_index's recursion over nested fields and drift out of sync with it.That safety net is the only per-record isolation guarantee, and the config documentation says so explicitly rather than implying a broader one. With validation off, other malformed data that the schema walk would have caught can still raise an unhandled error and fail the whole batch: a value
IndexingPreparers::Integercannot coerce, a missingid_sourcepath, or - afterbuildhas already returned success - the rollover index suffix and routing value computed lazily inUpdate#metadataduringrouter.bulk. Since such a batch produces no partial-failure response, the queue redelivers all of its events and the malformed record fails them again on each retry until it drains to the DLQ. These paths are not new code, but this config is what makes them reachable, so the caveat ships with it.The field defaults to
{}, so nothing changes unless you set it. Additive and minor-release-safe.What
Config:
config.rb: newskip_record_validation_forJSON schema property (object, per-type number in[0, 1],additionalProperties: false, default{});convert_valuescoerces rates toFloat. Thedescription:states which failure mode is isolated and which ones can still fail a batch.operation/factory.rb: newskip_validation?(type, event)helper;buildskips record validation when sampled, records the skipped type on the result, and rescuesUnknownTypeError.BuildResultgainsvalidation_skipped_for.processor.rb: aggregateRecordValidationSkippedlog per batch when any record was skipped.record_preparer.rb: newRecordPreparer::UnknownTypeError; the abstract-typefetchraises it with a value-free message.indexer.rb: wireconfig.skip_record_validation_forinto the factory.elasticgraph-localconfig_schema.yaml: the new property added to the shared config validation schema.13 files changed, 393 insertions(+), 18 deletions(-).
Verification
script/run_specs(COVERAGE=1, real Elasticsearch): 5222 examples, 0 failures. Coverage holds at the project's expected level (the one non-100% file,gem_spec.rb, is pre-existing and untouched here).script/type_check(Steep): no type errors.script/lint(Standard Ruby): 890 files, no offenses.script/spellcheck(codespell): clean.bundle exec rake schema_artifacts:check: up to date (runtime config only, no artifact changes).bundle exec rake site:validate: 148 runs, 0 failures.New tests:
config_spec.rb: integer YAML rates coerce toFloat(1to1.0), and out-of-range rates (1.5,-0.1) are rejected at config load.operation/factory_spec.rb: a skipped type builds operations without record validation; non-skipped types still fail on bad records; envelope validation still runs for skipped types; fractional sampling (stubbedZlib.crc32for both branches); retry stability; the derived-index path under skip; and the unknown-__typenamecase returning aFailedEventErrorinstead of raising.processor_spec.rb: a batch with skips logs oneRecordValidationSkippedwith the rightcount/counts_by_type; a batch with no skips logs none.