Reindexing 220M legacy documents through a failing ingest pipeline
You must reindex 220M documents from legacy-events into events-v2; the original source system is gone, so this is a one-shot migration. An ingest pipeline parse-legacy runs: grok on message, then date with formats: ["dd/MMM/yyyy:HH:mm:ss Z"] targeting @timestamp, then convert on bytes to long, then geoip. GET _nodes/stats/ingest shows the date processor with 8.1M failures and the convert processor with 2.3M. The destination mapping has bytes as long and a keyword called level. About 4% of documents never appear in events-v2, and nobody saw a 4xx - the reindex task simply reported success. A subset of messages are ISO8601, and message already carries a @timestamp copied from the upstream shipper.
1. Why did 4% of documents vanish without an obvious error, and how would you get visibility into which documents failed and why?
Read the worked answer
An ingest pipeline is per-document. When a processor throws and there is no on_failure (and no ignore_failure), that document is rejected - but the transport-level response is still successful. In _reindex the failures surface only in the task's response.failures array; in _bulk the request returns 200 with "errors": true and per-item error entries.
So: check the item-level result, never the HTTP status.
- Inspect the running/finished task:
GET _tasks/<task_id>โresponse.created,response.failures[](each hascause.reason, e.g.date_time_parse_exceptionorfailed to parse field [bytes] of type [long]). - Inspect processor counters:
GET _nodes/stats/ingest?filter_path=**.processors-stats.failedper processor tells you which processor, not which document. - Add a dead-letter path so failures become observable data rather than silence:
Every rejected document is then written to{"parse-legacy": { "processors": [ ... ], "on_failure": [ {"set": {"field": "_index", "value": "failed-events"}}, {"set": {"field": "error.type", "value": "{{{_ingest.on_failure_processor_type}}}"}}, {"set": {"field": "error.message", "value": "{{{_ingest.on_failure_message}}}"}} ] }}failed-eventswith its original_sourceand the failure reason, and can be replayed after the pipeline is fixed.
Because the source is gone, this dead-letter index is now part of your recovery plan, not debug output.
2. Rewrite the date and convert handling so both timestamp formats parse, `bytes` values like "1,024" and "" do not kill the document, and the incoming `@timestamp` is not silently reused.
Read the worked answer
Key realisation: the date processor was targeting @timestamp while the document already had an @timestamp - so successes also overwrote the original value, and failures left a misleading value behind. Parse into a distinct field, then rename deliberately.
{"parse-legacy": {
"processors": [
{"grok": {
"field": "message",
"patterns": ["%{TIMESTAMP_ISO8601:event_time}", "%{HTTPDATE:event_time}"],
"ignore_failure": true}}
,
{"date": {
"field": "event_time",
"target_field": "event_time_parsed",
"formats": ["dd/MMM/yyyy:HH:mm:ss Z", "ISO8601"],
"timezone": "Europe/Berlin",
"locale": "en",
"ignore_failure": true}}
,
{"rename": {
"field": "event_time_parsed",
"target_field": "@timestamp",
"ignore_missing": true}}
,
{"convert": {"field": "bytes", "type": "long", "ignore_missing": true}}
,
{"gsub": {"field": "bytes", "pattern": "[,\\s]", "replacement": "", "ignore_failure": true}}
]
}}
Reasoning points to state explicitly:
formatsis an ordered list - put the most common format first; matching stops at the first success.timezoneonly applies when the pattern has no zone.dd/MMM/yyyy:HH:mm:ss ZcarriesZ, so the parsed offset wins; ISO8601 also wins. Do not assumetimezoneoverrides them.locale: enguardsMMMmonth names - a node with a non-English default locale will otherwise fail on everyJan/Feb.gsubmust run beforeconvert, and theconvertmust tolerate missing/empty values;ignore_failureonconvertalone would drop the whole field.- Prefer
on_failureโ dead-letter index over scatteredignore_failurefor anything you cannot afford to lose;ignore_failurehides the failure, the dead-letter index preserves it.
3. Design the reindex run itself: resumable, idempotent under retry, and not saturating the cluster. What settings do you choose and why?
Read the worked answer
POST _reindex?wait_for_completion=false&slices=auto&requests_per_second=2000
{
"source": {"index": "legacy-events", "size": 2000, "query": {"range": {"event_time": {"lt": "now-1d/d"}}}},
"dest": {"index": "events-v2", "op_type": "create", "pipeline": "parse-legacy"},
"conflicts": "proceed"
}
Reasoning:
op_type: createwith the original_idis what makes the job retriable.indexwould silently overwrite;createturns a replay into a cheap per-item 409 (version_conflict_engine_exception) thatconflicts: proceedrecords inresponse.version_conflicts. You can now kill and restart the job safely.wait_for_completion=false+GET _tasks/<task_id>- a 220M-document job runs for hours; a synchronous client will time out and leave you blind about progress. Pollresponse.created,response.version_conflicts,response.failures.slices: autoparallelises across source shards; more slices than shards adds overhead without throughput. Watch for search rejections.requests_per_second(throttle) - start conservative and raise while watching_nodes/stats/thread_poolfor search/write queue growth and_cat/indicesmerge pressure.- Pause/resume via the task API:
POST _tasks/<task_id>/_cancelthen re-run with the same settings; thecreatesemantics make that safe. An alternative is manualsearch_afterslicing, which gives you a stable resume cursor but more code. - Run the migration into an index with zero replicas, then set
number_of_replicas: 1andrefresh_intervalback afterwards; merge and refresh churn during ingest is otherwise the dominant cost.