Skip to content

[core][gcs] Export Redis payload byte metrics by command and table - #65554

Open
zzchun wants to merge 6 commits into
ray-project:masterfrom
zzchun:ray-14-gcs-redis-payload-byte-metrics
Open

[core][gcs] Export Redis payload byte metrics by command and table#65554
zzchun wants to merge 6 commits into
ray-project:masterfrom
zzchun:ray-14-gcs-redis-payload-byte-metrics

Conversation

@zzchun

@zzchun zzchun commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Description

GCS Redis command metrics can't tell an operator whether load is many small values or a few very large payloads. Today there is nothing to answer that with:

  • The Redis backend emits no per-operation count at all. gcs_storage_operation_count / gcs_storage_operation_latency_ms are recorded by the ObservableStoreClient decorator, and RedisStoreClient is the one StoreClient implementation never wrapped in it (gcs_server.cc, REDIS_PERSIST branches for both table storage and internal KV — compare the IN_MEMORY / ROCKSDB_PERSIST branches right beside them).
  • The only Redis-specific metric carries no labels. gcs_latency declares a CustomKey tag key but is recorded via Record(double), which passes an empty tag set, so no latency is attributable to a command or a table.

This PR adds three metrics on the GCS Redis client, labeled by a normalized Redis verb and the GCS table name:

Measure Aggregation Exported as
gcs_redis_request_payload_bytes Sum ray_gcs_redis_request_payload_bytes_total
gcs_redis_response_payload_bytes Sum ray_gcs_redis_response_payload_bytes_total
gcs_redis_command_count Count ray_gcs_redis_command_count

Labels on all three: Command, TableName.

rate(ray_gcs_redis_response_payload_bytes_total[5m]) / rate(ray_gcs_redis_command_count[5m]), broken down by TableName, is the query that separates "this table has many small rows" from "something is writing multi-megabyte records" — which is the whole point of the change.

Gated by RAY_gcs_redis_payload_metrics_enabled (default true), read once in the RedisStoreClient constructor.

Related issues

Related to #65552 .

Additional information

The payload definition is published with the metric

A metric described as "data size" isn't actionable, so the definition ships in the metric description, in system-metrics.rst, and is pinned by exact-delta tests:

  • Request bytes — the sum of strlen(argv[i]) over the whole RESP argument vector, including the command verb, the Redis key, and hash field names. Excludes RESP framing, TLS and TCP/IP overhead. GCS values aren't compressed anywhere between GcsTable::Put and the socket.
  • Response bytes — the sum of redisReply::len over every bulk-string and status node, recursing into arrays. Field names returned by HSCAN are included. Integer and nil replies contribute zero. An error reply is retried rather than delivered and contributes nothing.
  • Both counted once per logical command, not per retry.

To reconcile against Redis' own total_net_input_bytes, add framing: 3 + digits(nargs) per command plus 5 + digits(len) per argument.

Contribution checks

Duplicate-work check: searching open PRs for Redis payload metrics found no other implementation; PR #65554 was the only result. This update only resolves its conflicts with current master.

Tests run locally for the conflict resolution:

bazel test //src/ray/gcs/store_client/tests:redis_store_client_test
PASSED

pre-commit run --files src/ray/common/ray_config_def.h src/ray/gcs/store_client/redis_store_client.cc src/ray/gcs/store_client/tests/redis_store_client_test.cc
PASSED

AI assistance was used to resolve the upstream merge conflicts. The human submitter must review every changed line and understand and defend the resolution before requesting maintainer review.

@zzchun
zzchun requested review from a team as code owners August 18, 2026 08:58

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces new system metrics to track Redis request and response payload sizes, as well as command counts, within the GCS storage backend. It includes a configuration option to enable or disable these metrics and adds comprehensive unit tests to verify exact byte tracking and label boundaries. A review comment suggests adding defensive null checks in ResponsePayloadBytes when traversing redisReply elements to prevent potential segmentation faults from malformed replies.

Comment on lines +61 to +64
size_t total = 0;
for (size_t i = 0; i < reply.elements; ++i) {
total += ResponsePayloadBytes(*reply.element[i]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In defensive programming, it is important to handle potential null pointers when dealing with external library structures like redisReply. If reply.elements > 0 but reply.element is nullptr (e.g., due to allocation failure or malformed replies), or if any individual reply.element[i] is nullptr, dereferencing them will cause a segmentation fault. Adding explicit null checks ensures robustness.

    size_t total = 0;
    if (reply.elements > 0 && reply.element != nullptr) {
      for (size_t i = 0; i < reply.elements; ++i) {
        if (reply.element[i] != nullptr) {
          total += ResponsePayloadBytes(*reply.element[i]);
        }
      }
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for looking at this. I dug into it and I'm going to leave it as-is, for two reasons.

The guard wouldn't actually prevent the crash. ResponsePayloadBytes is called from RedisRequestContext::RedisResponseFn immediately before std::make_shared<CallbackReply>(*redis_reply), and CallbackReply::ParseAsStringArrayOrScanArray dereferences the same pointers — element[0], element[1], and array_entry->element[i] — with no null checks (pre-existing, untouched by this PR). On a malformed reply, adding the guard here just relocates the segfault a few lines down while implying a robustness that isn't there.

elements > 0 implies element != nullptr is a hiredis invariant. createArrayObject allocates element with calloc and frees the whole reply if that allocation fails, so a reply delivered to a callback never has non-zero elements with a null element. The surrounding code already relies on this.

The one case that genuinely is unspecified — hiredis leaves element uninitialized when elements == 0 — is handled by the loop bound, and pinned by TestResponsePayloadBytes.EmptyArrayDoesNotDereferenceElements, which deliberately passes a garbage pointer to prove the bound is what stops us.

Happy to add the checks if a maintainer prefers defense-in-depth here, but I'd want to add them to CallbackReply in the same change so the two consumers of these pointers stay consistent.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 0c1e0ca. Configure here.

Comment thread src/ray/gcs/metrics.h
Comment thread src/ray/gcs/store_client/redis_context.h Outdated
GCS Redis observability cannot distinguish many small values from a few very
large payloads. The Redis backend emits no per-operation count at all --
gcs_storage_operation_{count,latency_ms} are recorded by ObservableStoreClient,
and RedisStoreClient is the one StoreClient implementation never wrapped in it
-- and the only Redis-specific metric, gcs_latency, declares a CustomKey tag it
never populates, so nothing is attributable to a command or a table.

Add three metrics labeled by a normalized Redis verb and the GCS table name:

  gcs_redis_request_payload_bytes    (Sum)
  gcs_redis_response_payload_bytes   (Sum)
  gcs_redis_command_count            (Count)

Sum, not Count, for the byte counters: ray::stats::Count maps to OpenCensus
Aggregation::Count(), which discards the recorded value, while the OpenTelemetry
path adds it. A byte counter built on Count would export different quantities
depending on RAY_enable_open_telemetry.

The payload definition ships with the metric and is pinned by tests: the sum of
the byte lengths of the RESP bulk strings -- including the command verb, the
Redis key and hash field names -- excluding RESP framing, TLS and TCP/IP
overhead, counted once per logical command rather than per retry. GCS values are
not compressed. An error reply is retried instead of delivered and contributes
nothing.

Recording happens in RedisRequestContext, the single object every async command
passes through, so coverage is structural rather than per-call-site. Request
bytes are summed in the constructor, after the argument buffers have been moved
in, so AsyncPut's std::move of the caller's value cannot zero the measurement.
Response bytes are summed from the raw redisReply in RedisResponseFn, before
CallbackReply copies and before the io_service post that precedes the context's
deletion. RunArgvAsync's label parameter is deliberately not defaulted, so a new
command site cannot compile without deciding how it is attributed.

Label cardinality is bounded by construction: Command is normalized against a
closed allowlist, and TableName comes from RedisKey::table_name -- never the
rendered key, which embeds the per-cluster storage namespace.

RedisContext owns the metrics by value. RedisScanner copies the shared context
and holds a self-ref for the duration of a scan, so the context routinely
outlives the RedisStoreClient that created it; borrowing the metrics from the
client would be a use-after-free on the next HSCAN reply.

Gated by RAY_gcs_redis_payload_metrics_enabled (default true), read once in the
RedisStoreClient constructor.

Signed-off-by: zzchun <zzchun8@gmail.com>
@zzchun
zzchun force-pushed the ray-14-gcs-redis-payload-byte-metrics branch from 0c1e0ca to a1d4a99 Compare August 18, 2026 09:31
@ray-gardener ray-gardener Bot added docs An issue or change related to documentation core Issues that should be addressed in Ray Core community-contribution Contributed by the community labels Aug 18, 2026
@Sparks0219

Copy link
Copy Markdown
Contributor

@rueian could you PTAL 🙏

@rueian rueian added the go add ONLY when ready to merge, run all tests label Aug 28, 2026
@zzchun
zzchun requested a review from a team as a code owner August 28, 2026 03:16
@rueian

rueian commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Hi @zzchun, please resolve the conflicts.

Signed-off-by: zzchun <zzchun8@gmail.com>
"DEL",
"UNLINK",
"INFO"};
for (const auto &allowed : kAllowlist) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we just use the verb directly but limit its length?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I removed the closed allowlist and now derive the Command label directly from the actual Redis verb in args[0]. The label is normalized to uppercase ASCII and truncated to 16 bytes. Deriving it inside RedisRequestContext also prevents a call site from supplying a label that differs from the command being sent. Production verbs remain code-controlled. Tests cover case normalization and the truncation boundary.

Updated in 6400b8f.

}
default:
// INTEGER, NIL, DOUBLE, BOOL and anything unknown carry no payload bytes.
return 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it correct to use 0 for these types? For integers, shouldn't we count the decimal length?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The decimal representation between the RESP integer prefix and CRLF is application payload, so returning zero undercounted it. Integer replies now count their decimal text length, including a minus sign, using allocation-free std::to_chars.

I also audited the other scalar reply types: doubles use the original text length preserved by hiredis, booleans contribute one byte, and nil remains zero. Tests cover zero, negative values, LLONG_MIN/LLONG_MAX, doubles, booleans, and nil.

Updated in 6400b8f.

Comment thread doc/source/ray-observability/reference/system-metrics.rst Outdated
Comment thread doc/source/ray-observability/reference/system-metrics.rst Outdated
Comment thread doc/source/ray-observability/reference/system-metrics.rst Outdated
…payload-byte-metrics

Signed-off-by: zzchun <zzchun8@gmail.com>
Signed-off-by: zzchun <zzchun8@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community core Issues that should be addressed in Ray Core docs An issue or change related to documentation go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants