Skip to content

fix(es): Treat a bulk 409 as an idempotent success in the sync writer - #9113

Merged
yurishkuro merged 5 commits into
jaegertracing:mainfrom
Me-Priyank:fix/es-sync-bulk-conflict-idempotent
Jul 31, 2026
Merged

fix(es): Treat a bulk 409 as an idempotent success in the sync writer#9113
yurishkuro merged 5 commits into
jaegertracing:mainfrom
Me-Priyank:fix/es-sync-bulk-conflict-idempotent

Conversation

@Me-Priyank

Copy link
Copy Markdown
Contributor

Which problem is this PR solving?

Description of the changes

  • RFC 0007 §4.7 requires that a 409 returned for a document written with Jaeger's own deterministic _id count as an idempotent success: under op_type: create, which data streams force, a retried span comes back as a version conflict because the byte-identical document is already stored.
  • The async indexer implements this (OnFailure routes 409 to onItemConflict). SyncBulkWriter did not: isTransientStatus lists only 0 / 429 / 5xx, so a 409 fell through to the terminal branch and was classified as a poison pill. Under the default poison_pill_handling: fail that fails the batch, holds the Kafka offset, and head-of-line blocks the partition on every redelivery; under drop it discards already-durable documents and reports them as poison.
  • classify now counts a 409 separately, as neither transient nor terminal, so it contributes to the chunk's durable total and is logged at debug like the async peer.
  • This is safe because op_type: create is only ever sent by the span writer (core/writer.go, the sole non-test site that sets OpType), whose _id is a content hash of the document, so a conflict means the stored document is the one being written. Service, sampling and dependency writes leave OpType empty, so they use index and upsert.
  • classify returns a small bulkOutcome struct rather than a fourth result because revive's function-result-limit caps a function at three return values. That accounts for the terminalout.terminal churn in sendChunk; there is no behaviour change in that rename.

Scope note: this is latent rather than a regression in a shipped path. DataStreamRotation is the only rotation returning WriteOpCreate, and indices.<type>.rotation.data_stream is still rejected at startup, so no deployed pipeline reaches it today. It becomes live when data-stream rotation is wired, so it lands ahead of that work.

How was this change tested?

  • TestSyncBulkWriter_ConflictIsIdempotent (new) is the sync peer of TestBulkIndexerConflictIsIdempotent: a 409 version_conflict_engine_exception on op_type: create returns no error, counts in bulk_index.inserts, logs no error, and logs the conflict at debug.
  • TestSyncBulkWriter_ItemErrorPropagates keeps its original purpose (item-level error propagation, RFC 0007 M2) but now uses a 400 mapper_parsing_exception, since a 409 is no longer a rejection. No existing test was deleted.
  • The live integration assertion in TestElasticsearchStorage_SyncBulkWriter now proves the 409 is idempotent against a real backend (re-creating an existing _id returns no error and adds no duplicate), and a mapping conflict covers terminal propagation, which is the injection RFC 0007 M5 prescribes.
  • This makes the M3 cross-reference in TestElasticsearchStorage_WriteIdempotency accurate: the live 409 now does demonstrate a benign idempotent write, which is what that comment already claimed.
  • sync_bulk.go patch coverage is 100% (123/123 statements). golangci-lint reports 0 issues, gofmt/gofumpt are clean, and go build ./... passes.
  • Not verified locally: the integration assertions need STORAGE=elasticsearch against a live backend, so they are covered here by compilation and go vet only and rely on the ES 7-9 / OS 1-3 CI matrix to exercise them.

Checklist

AI Usage in this PR (choose one)

  • Moderate: AI helped with code generation or debugging specific parts

@Me-Priyank

Copy link
Copy Markdown
Contributor Author

@yurishkuro PTAL

@Me-Priyank
Me-Priyank force-pushed the fix/es-sync-bulk-conflict-idempotent branch from 18c8bf3 to 17752fc Compare July 25, 2026 11:39
RFC 0007 §4.7 requires that a 409 returned for a document written with our
own deterministic `_id` count as an idempotent success: under op_type:
create, which data streams force, a retried span comes back as a version
conflict because the byte-identical document is already stored.

The async indexer implements this (OnFailure routes 409 to onItemConflict).
SyncBulkWriter did not: isTransientStatus lists only 0/429/5xx, so a 409 fell
through to the terminal branch and was classified as a poison pill. Under the
default poison_pill_handling: fail that fails the batch, holds the Kafka
offset, and head-of-line blocks the partition on every redelivery; under drop
it discards already-durable documents and reports them as poison.

classify now counts a 409 separately, as neither transient nor terminal, so it
contributes to the chunk's durable total and is logged at debug like the async
peer. This is safe because op_type: create is only ever sent by the span
writer, whose `_id` is a content hash of the document, so a conflict means the
stored document is the one being written.

classify returns a bulkOutcome struct rather than a fourth result because
revive's function-result-limit caps a function at three return values.

Tests: TestSyncBulkWriter_ConflictIsIdempotent is the sync peer of
TestBulkIndexerConflictIsIdempotent. TestSyncBulkWriter_ItemErrorPropagates
kept its purpose (item-level error propagation, RFC 0007 M2) but now uses a
400 mapper_parsing_exception, since a 409 is no longer a rejection. The live
integration assertion likewise now proves the 409 is idempotent, and a mapping
conflict covers terminal propagation, which is the injection RFC 0007 M5
prescribes. This makes the M3 cross-reference in TestElasticsearchStorage_
WriteIdempotency accurate: the live 409 now does demonstrate a benign
idempotent write.

Part of jaegertracing#8476

Signed-off-by: Me Priyank <pvonlyone29@gmail.com>
@Me-Priyank
Me-Priyank force-pushed the fix/es-sync-bulk-conflict-idempotent branch from 17752fc to 09d6ca0 Compare July 25, 2026 12:12
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.85%. Comparing base (6a13ef0) to head (87253e8).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #9113   +/-   ##
=======================================
  Coverage   97.84%   97.85%           
=======================================
  Files         377      377           
  Lines       19812    19821    +9     
=======================================
+ Hits        19386    19395    +9     
  Misses        289      289           
  Partials      137      137           
Flag Coverage Δ
badger_direct 7.76% <0.00%> (-0.01%) ⬇️
badger_e2e 11.89% <0.00%> (-0.02%) ⬇️
cassandra-4.x-direct-manual 12.71% <0.00%> (-0.02%) ⬇️
cassandra-4.x-e2e-auto 17.98% <0.00%> (-0.03%) ⬇️
cassandra-4.x-e2e-manual 17.99% <0.00%> (-0.01%) ⬇️
cassandra-5.x-direct-manual 12.71% <0.00%> (-0.02%) ⬇️
cassandra-5.x-e2e-auto 17.99% <0.00%> (-0.01%) ⬇️
cassandra-5.x-e2e-manual 17.99% <0.00%> (-0.01%) ⬇️
clickhouse-direct 7.85% <0.00%> (-0.01%) ⬇️
clickhouse-e2e 13.31% <0.00%> (-0.01%) ⬇️
elasticsearch-7.x-direct 21.42% <68.00%> (+0.06%) ⬆️
elasticsearch-8.x-direct 21.43% <68.00%> (+0.05%) ⬆️
elasticsearch-8.x-e2e 19.62% <0.00%> (-0.02%) ⬇️
elasticsearch-9.x-direct 21.43% <68.00%> (+0.05%) ⬆️
elasticsearch-9.x-e2e 19.47% <0.00%> (-0.24%) ⬇️
grpc_direct 6.85% <0.00%> (-0.01%) ⬇️
grpc_e2e 12.42% <0.00%> (-0.01%) ⬇️
kafka-3.x-v2 20.25% <24.00%> (+0.14%) ⬆️
memory_v2 14.52% <0.00%> (-0.07%) ⬇️
opensearch-1.x-direct 21.43% <68.00%> (+0.06%) ⬆️
opensearch-2.x-direct 21.43% <68.00%> (+0.06%) ⬆️
opensearch-2.x-e2e 19.47% <0.00%> (-0.02%) ⬇️
opensearch-3.x-direct 21.43% <68.00%> (+0.06%) ⬆️
opensearch-3.x-e2e 19.47% <0.00%> (-0.02%) ⬇️
query 12.37% <0.00%> (-0.01%) ⬇️
tailsampling-processor 6.83% <0.00%> (-0.01%) ⬇️
unittests 96.57% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@yurishkuro yurishkuro added the changelog:experimental Change to an experimental part of the code label Jul 31, 2026
@github-actions

Copy link
Copy Markdown

CI Summary Report

Metrics Comparison

⚠️ 4 metric change(s) detected (informational)

View changed metrics

For label-level diff details, open the CI run and expand the "Compare metrics and generate summary" step logs.

metrics_snapshot_kafka_v2⬇️ download diff
4 modified

  • otelcol_kafka_exporter_bytes
  • otelcol_kafka_exporter_bytes_uncompressed
  • otelcol_kafka_exporter_messages
  • otelcol_kafka_exporter_records

Code Coverage

✅ Coverage 98.4% (baseline 98.4%)

➡️ View CI run | View publish logs
2026-07-31 21:17:52 UTC

@yurishkuro
yurishkuro merged commit 68f481b into jaegertracing:main Jul 31, 2026
137 of 140 checks passed
@yurishkuro

Copy link
Copy Markdown
Member

🔥

@github-actions github-actions Bot added the waiting-for-author PR is waiting for author to respond to maintainer's comments label Jul 31, 2026
yurishkuro added a commit to ysh-bot/jaeger that referenced this pull request Jul 31, 2026
The connector's primary job is the synchronous storage write; dead-lettering is
an exceptional tap on its output. Naming it jaeger_storage_dead_letter made the
main pipeline read backwards — `exporters: [jaeger_storage_dead_letter]` naively
scans as "export to a dead letter" when it actually writes to storage. Rename the
type (and package) to jaeger_storage_writer so the exporter slot reads as "write
to storage"; the dead-letter role is carried by the output pipeline it feeds
(traces/dead_letter), not the component name.

Also rebased onto main to fold in jaegertracing#9113: classify() now returns the 409-as-
idempotent-conflict tally alongside the []RejectedItem terminal set the connector
needs, so a retried-and-already-durable span is never mis-routed to dead-letter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Yuri Shkuro <github@ysh.us>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/storage changelog:experimental Change to an experimental part of the code storage/elasticsearch waiting-for-author PR is waiting for author to respond to maintainer's comments

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Synchronous ES bulk writer classifies an idempotent-retry 409 as a poison pill, unlike the async writer

2 participants