Skip to content

feat(remotewriter): mark remote write POSTs with idempotency key and attempt - #471

Merged
vporoshok merged 2 commits into
ppfrom
feat/remote-write-delivery-marks
Aug 12, 2026
Merged

feat(remotewriter): mark remote write POSTs with idempotency key and attempt#471
vporoshok merged 2 commits into
ppfrom
feat/remote-write-delivery-marks

Conversation

@vporoshok

Copy link
Copy Markdown
Collaborator

Why

Replaying a remote write POST is a regular part of the delivery contract here: Iterator.SendMessage
resends undelivered messages of a batch with the very same payload, and the Go transport may replay a
request on its own. Nothing on the wire said so:

  • a receiver could not tell a replay from new data;
  • Retry-Attempt was hardcoded to 0 in protobufWriter.Write, so the attempt number never left the
    process, even though remote.Client.Store already knows how to send it;
  • the failed send log was a bare failed to send protobuf: <err> — no endpoint, no attempt, no size.

What

Delivery marks. Both marks of a single delivery travel in the request context (delivery.go) and
reach the wire as headers:

  • X-Idempotency-Key = <destination>/<headID>/<targetSegmentID>/<messageIndex>, set by
    deliveryRoundTripper. Messages of a batch are numbered, and a non-empty batch always advances
    targetSegmentID, so the key repeats over all the retries of that message.
  • Retry-Attempt, passed to Store as the attempt number of the send loop (0 for the first delivery).

Upstream files are untouched: the round tripper is wrapped around the transport of the client built in
createClient.

Semantics worth knowing. This exact header name also opts the POST into replays made by the Go
transport itself — Request.isReplayable
treats a POST carrying Idempotency-Key or X-Idempotency-Key as replayable, so http.Transport
resends it when a reused connection fails (stale keep-alive, server closing an idle connection, HTTP/2
refused stream). Those replays happen inside a single Client.Do, below our round tripper, and go out
byte for byte identical — same key, same Retry-Attempt. Hence:

  • the idempotency key is the only unit of deduplication, the pair of key and attempt is not unique;
  • Retry-Attempt is a diagnostic of our send loop, not a count of times a payload reached the receiver.

This is documented in the IdempotencyKeyHeader doc comment and in the package README.

New metric. prometheus_remote_storage_connections_total{remote_name,url,state="new"|"reused"}
counts the connections requests go through (httptrace.GotConn). Since the trace fires per transport
attempt, it also exposes the replays above: more than one connection per send means the transport
resent the request. Growth of state="new" under steady traffic means connections do not survive
keep-alive — the usual source of silent duplicates.

Send error log now reports everything needed to locate the message:

failed to send message: server returned HTTP status 503 Service Unavailable: upstream busy; \
url=http://remote.test/api/v1/write idempotency_key=dst/head-42/7/0 attempt=0 \
duration=1.204s bytes=524288 samples=2000

The URL is the redacted destination URL, the same value as in the metric labels.

Limitations

The key is stable within the life of an iterator. After a restart the same segments may be cut into
different batches (targetSegmentID and the number of shards float), so keys of the re-read data may
differ from those of the first run. A content-derived key would fix that and can be a follow-up.

No receiver reads either header yet.

Tests

delivery_test.go, all green under go test -tags stringlabels -race:

  • a replayed message carries the key of its first attempt and the next attempt number
    ({dst/head-42/7/0, 0}, {dst/head-42/7/1, 0}, then {dst/head-42/7/0, 1}), and the failed attempt
    is logged with url / attempt / duration / bytes / samples;
  • protobufWriter passes the attempt from the context to Store, and 0 when there are no marks;
  • end to end through createClient + httptest: both headers arrive on the wire (this also proves the
    wrapper is not lost behind otelhttp);
  • connection counting: the first send reports state="new", the second state="reused".

🤖 Generated with Claude Code

…attempt

Replaying a remote write POST is a regular part of the delivery contract:
the send loop resends undelivered messages of a batch with the very same
payload, and the Go transport may replay a request on its own. Nothing on
the wire said so, which left a receiver unable to tell a replay from new
data, and left the send error log without the facts needed to locate the
failed message.

Every message POST now carries X-Idempotency-Key, built from destination,
head ID, target segment ID and message index, so it repeats over all the
retries of that message. Retry-Attempt is filled in as well - it was
hardcoded to 0, so the attempt number of the send loop never reached the
receiver.

Note that this header name also opts the POST into replays made by
http.Transport (Request.isReplayable), which happen below our round tripper
with identical headers. The idempotency key is therefore the only unit of
deduplication, and Retry-Attempt stays a diagnostic of our own loop. To make
that wire side visible, connections_total{state="new"|"reused"} counts the
connections requests go through via httptrace.

The failed send log now reports the destination URL, the attempt number, the
request duration and the message size in bytes and samples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vporoshok
vporoshok requested a review from u-veles-a August 12, 2026 11:38
@vporoshok vporoshok self-assigned this Aug 12, 2026
@vporoshok vporoshok added this to the v0.8.8 milestone Aug 12, 2026
Comment thread pp/go/storage/remotewriter/writeloop.go Outdated
Comment thread pp/go/storage/remotewriter/iterator.go Outdated
Comment thread pp/go/storage/remotewriter/iterator.go Outdated
Comment thread pp/go/storage/remotewriter/writeloop.go Outdated
Measure the send duration with the injected clock instead of mixing it with
the real one, stop shadowing the message list variable in the send goroutine,
and clean up the createClient doc comment and its error typo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vporoshok
vporoshok enabled auto-merge (squash) August 12, 2026 18:29
@vporoshok
vporoshok merged commit 47a7fd1 into pp Aug 12, 2026
31 of 33 checks passed
@vporoshok
vporoshok deleted the feat/remote-write-delivery-marks branch August 12, 2026 18:50
u-veles-a pushed a commit that referenced this pull request Aug 13, 2026
…attempt (#471)

* feat(remotewriter): mark remote write POSTs with idempotency key and attempt

Replaying a remote write POST is a regular part of the delivery contract:
the send loop resends undelivered messages of a batch with the very same
payload, and the Go transport may replay a request on its own. Nothing on
the wire said so, which left a receiver unable to tell a replay from new
data, and left the send error log without the facts needed to locate the
failed message.

Every message POST now carries X-Idempotency-Key, built from destination,
head ID, target segment ID and message index, so it repeats over all the
retries of that message. Retry-Attempt is filled in as well - it was
hardcoded to 0, so the attempt number of the send loop never reached the
receiver.

Note that this header name also opts the POST into replays made by
http.Transport (Request.isReplayable), which happen below our round tripper
with identical headers. The idempotency key is therefore the only unit of
deduplication, and Retry-Attempt stays a diagnostic of our own loop. To make
that wire side visible, connections_total{state="new"|"reused"} counts the
connections requests go through via httptrace.

The failed send log now reports the destination URL, the attempt number, the
request duration and the message size in bytes and samples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(remotewriter): address review notes on the delivery marks

Measure the send duration with the injected clock instead of mixing it with
the real one, stop shadowing the message list variable in the send goroutine,
and clean up the createClient doc comment and its error typo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vporoshok added a commit that referenced this pull request Aug 14, 2026
)

#471 gave createClient a metrics argument, #472 added a test calling it with
the old signature. #472 branched off before #471 landed, so both were green on
their own and pp ended up with a test package that does not compile.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
u-veles-a pushed a commit that referenced this pull request Aug 14, 2026
)

the old signature. #472 branched off before #471 landed, so both were green on
their own and pp ended up with a test package that does not compile.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants