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 8 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?
| "schema walk yields meaningful ingest speedups, with a sampled fraction left validated as a canary " \ | ||
| "for schema drift. Leave empty for live-traffic ingestion: the datastore mappings will not catch all " \ | ||
| "the constraints (regex, enum, min/max, format, abstract-type discriminators) that the JSON schema " \ | ||
| "enforces.", |
There was a problem hiding this comment.
The description warns that skipped records won't get rejection for constraints like regex/enum/format, but it doesn't mention that a malformed record can also raise an unhandled exception that fails the whole batch, not just that one record (e.g. IndexingPreparers::Integer#prepare_for_indexing raises Errors::IndexOperationError on a non-coercible value, and that isn't rescued in Factory#build).
Given your statement that "one bad record fails on its own instead of crashing the batch" as a property of this feature, I think it's worth being explicit in the docs that this guarantee only covers the missing/unknown __typename case. Other malformed-data failures can still crash the batch. Something like:
Note: only a missing or unknown
__typenameon an abstract-type field is guaranteed to fail in isolation (as a structured event failure) when validation is skipped. Other malformed data that per-record validation would normally catch (e.g. a value that can't be coerced to its expected type) may raise an unhandled error that fails the entire batch, not just the offending record.
There was a problem hiding this comment.
On actually isolating these rather than documenting them: it needs a seam that covers both layers, plus typed errors so that a genuine schema-artifact bug (eg_meta.fetch("nameInIndex") raises a bare KeyError too) doesn't get quietly demoted to a per-event data failure. I'm happy to build that here if you want it in scope. It's also defensible to treat "skipping validation can cost you a batch" as an accepted, documented consequence of an opt-in backfill knob, which is what the description now says plainly. Which would you prefer?
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
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.