Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/source/ray-observability/reference/system-metrics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ Ray exports a number of system metrics, which provide introspection into the sta
* - `ray_placement_groups`
- `State`
- Current number of placement groups by state. The State label (e.g., PENDING, CREATED, REMOVED) describes the state of the placement group. See `rpc::PlacementGroupTable <https://github.com/ray-project/ray/blob/e85355b9b593742b4f5cb72cab92051980fa73d3/src/ray/protobuf/gcs.proto#L517>`_ for more information.
* - `ray_gcs_redis_request_payload_bytes`
- `Command`, `TableName`
- Application bytes in Redis command arguments sent by the GCS, by command and GCS table. Exported only when the GCS storage backend is Redis. Includes the verb, Redis key, field names, and values; excludes RESP framing, TLS, and TCP/IP overhead. Counted once per logical command, not per retry. `Command` is the uppercase Redis verb truncated to 16 bytes; `TableName` is a GCS table, `NONE`, or `ALL`.
* - `ray_gcs_redis_response_payload_bytes`
- `Command`, `TableName`
- Application bytes in Redis replies received by the GCS, by command and GCS table. Exported only when the GCS storage backend is Redis. Includes bulk/status strings, HSCAN field names, and integer decimal text; excludes nil replies, error replies that are retried, RESP framing, TLS, and TCP/IP overhead.
* - `ray_gcs_redis_command_count_total`
- `Command`, `TableName`
- Number of Redis commands issued by the GCS, by command and GCS table. Exported only when the GCS storage backend is Redis. Counts Redis commands, not `StoreClient` calls: batches count per chunk and table scans per HSCAN round, and a retried command counts once. Divide the byte metrics by this for mean bytes per Redis command. Set `RAY_gcs_redis_payload_metrics_enabled=false` to stop recording all three metrics. The byte metrics omit `_total` under OpenTelemetry because they are non-monotonic sums; legacy OpenCensus also exports them with `_total`.
* - `ray_memory_manager_worker_eviction_total`
- `Type`, `Name`
- The number of tasks and actors killed by the Ray Out of Memory killer (https://docs.ray.io/en/master/ray-core/scheduling/ray-oom-prevention.html) broken down by types (whether it is tasks or actors) and names (name of tasks and actors).
Expand Down
8 changes: 8 additions & 0 deletions src/ray/common/ray_config_def.h
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,14 @@ RAY_CONFIG(int64_t, redis_db_connect_wait_milliseconds, 500)
/// Timeout for synchronous Redis probe commands issued while initializing GCS storage.
RAY_CONFIG(int64_t, redis_db_probe_timeout_milliseconds, 30000)

/// Whether the GCS records per-command Redis payload byte metrics
/// (gcs_redis_request_payload_bytes, gcs_redis_response_payload_bytes,
/// gcs_redis_command_count). Recording costs three additional
/// ray::stats::Metric::Record calls per Redis command, each of which takes a
/// process-global registration mutex. Set to false to restore the pre-change
/// behavior if that contention is measurable in a high-throughput GCS.
RAY_CONFIG(bool, gcs_redis_payload_metrics_enabled, true)

/// Whether GCS namespace cleanup deletes Redis keys with UNLINK instead of DEL.
/// This is disabled by default. With Redis's default lazyfree-lazy-user-del=no,
/// DEL reclaims memory synchronously; Redis operators can configure DEL itself
Expand Down
21 changes: 17 additions & 4 deletions src/ray/gcs/gcs_server.cc
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@
namespace ray {
namespace gcs {

namespace {

// The subset of GcsServerMetrics the Redis store client records into. Returned
// by value: RedisMetrics holds references, and the referents are owned by the
// GcsServerMetrics the server was constructed with, which outlives it.
RedisMetrics MakeRedisMetrics(const GcsServerMetrics &metrics) {
return RedisMetrics{metrics.redis_request_payload_bytes_sum,
metrics.redis_response_payload_bytes_sum,
metrics.redis_command_count_counter};
}

} // namespace

inline std::ostream &operator<<(std::ostream &str, GcsServer::StorageType val) {
switch (val) {
case GcsServer::StorageType::IN_MEMORY:
Expand Down Expand Up @@ -190,8 +203,8 @@ GcsServer::GcsServer(const ray::gcs::GcsServerConfig &config,
clock_);
break;
case StorageType::REDIS_PERSIST: {
auto redis_store_client =
std::make_shared<RedisStoreClient>(io_context, GetRedisClientOptions(), clock_);
auto redis_store_client = std::make_shared<RedisStoreClient>(
io_context, GetRedisClientOptions(), clock_, MakeRedisMetrics(metrics_));
// Health check Redis periodically and crash if it becomes unavailable.
// NOTE: periodical_runner_ must run on the same IO context as the Redis client.
periodical_runner_->RunFnPeriodically(
Expand Down Expand Up @@ -770,8 +783,8 @@ void GcsServer::InitKVManager() {
std::unique_ptr<StoreClient> store_client;
switch (storage_type_) {
case (StorageType::REDIS_PERSIST):
store_client =
std::make_unique<RedisStoreClient>(io_context, GetRedisClientOptions(), clock_);
store_client = std::make_unique<RedisStoreClient>(
io_context, GetRedisClientOptions(), clock_, MakeRedisMetrics(metrics_));
break;
case (StorageType::IN_MEMORY):
store_client = std::make_unique<ObservableStoreClient>(
Expand Down
8 changes: 8 additions & 0 deletions src/ray/gcs/gcs_server_main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ int main(int argc, char *argv[]) {
ray::gcs::GetGcsStorageOperationLatencyInMsHistogramMetric();
auto storage_operation_count_counter =
ray::gcs::GetGcsStorageOperationCountCounterMetric();
auto redis_request_payload_bytes_sum =
ray::gcs::GetGcsRedisRequestPayloadBytesSumMetric();
auto redis_response_payload_bytes_sum =
ray::gcs::GetGcsRedisResponsePayloadBytesSumMetric();
auto redis_command_count_counter = ray::gcs::GetGcsRedisCommandCountCounterMetric();
auto resource_usage_gauge = ray::raylet::GetResourceUsageGaugeMetric();
auto health_check_rpc_latency_ms_histogram =
ray::gcs::GetHealthCheckRpcLatencyMsHistogramMetric();
Expand Down Expand Up @@ -230,6 +235,9 @@ int main(int argc, char *argv[]) {
/*storage_operation_latency_in_ms_histogram=*/
storage_operation_latency_in_ms_histogram,
/*storage_operation_count_counter=*/storage_operation_count_counter,
/*redis_request_payload_bytes_sum=*/redis_request_payload_bytes_sum,
/*redis_response_payload_bytes_sum=*/redis_response_payload_bytes_sum,
/*redis_command_count_counter=*/redis_command_count_counter,
resource_usage_gauge,
scheduler_placement_time_ms_histogram,
health_check_rpc_latency_ms_histogram,
Expand Down
55 changes: 55 additions & 0 deletions src/ray/gcs/metrics.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ struct GcsServerMetrics {
ray::observability::MetricInterface &event_recorder_dropped_events_counter;
ray::observability::MetricInterface &storage_operation_latency_in_ms_histogram;
ray::observability::MetricInterface &storage_operation_count_counter;
ray::observability::MetricInterface &redis_request_payload_bytes_sum;
ray::observability::MetricInterface &redis_response_payload_bytes_sum;
ray::observability::MetricInterface &redis_command_count_counter;
ray::observability::MetricInterface &resource_usage_gauge;
ray::observability::MetricInterface &scheduler_placement_time_ms_histogram;
ray::observability::MetricInterface &health_check_rpc_latency_ms_histogram;
Expand Down Expand Up @@ -168,6 +171,58 @@ inline ray::stats::Count GetGcsStorageOperationCountCounterMetric() {
};
}

// The payload definitions below are normative: they are what the metric means,
// and tests assert exact deltas against them. "Payload" is always the
// application data carried by RESP values, and never the RESP framing
// ("*N\r\n", "$len\r\n", type prefixes, trailing CRLF), TLS records or TCP/IP
// headers.
// GCS values are not compressed anywhere between GcsTable::Put and the socket,
// so compressed bytes do not arise. To reconcile with Redis' own
// total_net_input_bytes, add framing: 3 + digits(nargs) per command plus
// 5 + digits(len) per argument.

inline ray::stats::Sum GetGcsRedisRequestPayloadBytesSumMetric() {
return ray::stats::Sum{
/*name=*/"gcs_redis_request_payload_bytes",
/*description=*/
"Bytes of Redis command arguments sent by the GCS: the sum of the byte "
"lengths of the RESP arguments, including the command verb, the Redis "
"key, hash field names and values. Excludes RESP framing, TLS and TCP/IP "
"overhead; GCS values are not compressed. Counted once per logical "
"command, not per retry.",
/*unit=*/"bytes",
/*tag_keys=*/{"Command", "TableName"},
};
}

inline ray::stats::Sum GetGcsRedisResponsePayloadBytesSumMetric() {
return ray::stats::Sum{
/*name=*/"gcs_redis_response_payload_bytes",
/*description=*/
"Bytes of Redis replies received by the GCS: the sum of the byte lengths "
"of every bulk string and status string in the reply, including the field "
"names returned by HSCAN. Integers count as their decimal text; nil replies "
"contribute zero. Excludes RESP framing, TLS and TCP/IP overhead. A reply "
"that comes back as an error is retried instead of delivered, and "
"contributes nothing at all -- error bytes are never counted here.",
/*unit=*/"bytes",
/*tag_keys=*/{"Command", "TableName"},
};
}

inline ray::stats::Count GetGcsRedisCommandCountCounterMetric() {
return ray::stats::Count{
/*name=*/"gcs_redis_command_count",
/*description=*/
"Number of Redis commands issued by the GCS, broken down by command and "
"table. Batched operations count once per chunk and a table scan counts "
"once per HSCAN round, so this is a count of round trips rather than of "
"StoreClient calls.",
/*unit=*/"",
/*tag_keys=*/{"Command", "TableName"},
};
Comment thread
cursor[bot] marked this conversation as resolved.
}

inline ray::stats::Histogram GetHealthCheckRpcLatencyMsHistogramMetric() {
return ray::stats::Histogram{
/*name=*/"health_check_rpc_latency_ms",
Expand Down
1 change: 1 addition & 0 deletions src/ray/gcs/store_client/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ ray_cc_library(
"//src/ray/util:network_util",
"@boost//:asio",
"@com_google_absl//absl/container:flat_hash_set",
"@com_google_absl//absl/strings",
"@com_google_absl//absl/strings:str_format",
"@com_google_absl//absl/synchronization",
],
Expand Down
111 changes: 102 additions & 9 deletions src/ray/gcs/store_client/redis_context.cc
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
#include "ray/gcs/store_client/redis_context.h"

#include <cerrno>
#include <charconv>
#include <cstddef>
#include <limits>
#include <memory>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

Expand All @@ -30,6 +34,7 @@ extern "C" {
}

// TODO(pcm): Integrate into the C++ tree.
#include "absl/strings/ascii.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_join.h"
#include "absl/strings/str_split.h"
Expand All @@ -40,6 +45,55 @@ namespace ray {

namespace gcs {

size_t ResponsePayloadBytes(const redisReply &reply) {
switch (reply.type) {
case REDIS_REPLY_STRING:
case REDIS_REPLY_STATUS:
// Only reachable for an error nested inside an aggregate reply; a top-level
// error is retried before it gets here. See the header.
case REDIS_REPLY_ERROR:
case REDIS_REPLY_VERB:
case REDIS_REPLY_BIGNUM:
return static_cast<size_t>(reply.len);
case REDIS_REPLY_INTEGER: {
char buffer[std::numeric_limits<long long>::digits10 + 3];
const auto [end, error] =
std::to_chars(buffer, buffer + sizeof(buffer), reply.integer);
RAY_CHECK(error == std::errc{});
return static_cast<size_t>(end - buffer);
}
case REDIS_REPLY_DOUBLE:
// hiredis preserves the original RESP3 decimal representation in `str`.
return static_cast<size_t>(reply.len);
case REDIS_REPLY_BOOL:
// RESP3 encodes booleans as one application byte: `t` or `f`.
return 1;
case REDIS_REPLY_NIL:
return 0;
case REDIS_REPLY_ARRAY:
case REDIS_REPLY_MAP:
case REDIS_REPLY_SET:
case REDIS_REPLY_PUSH: {
// hiredis leaves `element` uninitialized when `elements` is 0, so the loop
// bound is what keeps this from dereferencing garbage.
size_t total = 0;
for (size_t i = 0; i < reply.elements; ++i) {
total += ResponsePayloadBytes(*reply.element[i]);
}
Comment on lines +79 to +82

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.

return total;
}
default:
// Unknown reply types have no payload definition.
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.

}
}

std::string NormalizeRedisCommandLabel(std::string_view verb) {
std::string label(verb.substr(0, kMaxRedisCommandLabelLength));
absl::AsciiStrToUpper(&label);
return label;
}

CallbackReply::CallbackReply(const redisReply &redis_reply)
: reply_type_(redis_reply.type) {
switch (reply_type_) {
Expand Down Expand Up @@ -163,7 +217,9 @@ RedisRequestContext::RedisRequestContext(instrumented_io_context &io_service,
RedisCallback callback,
RedisAsyncContext *context,
std::vector<std::string> args,
ClockInterface &clock)
ClockInterface &clock,
RedisMetrics *metrics,
std::string_view table_label)
: exp_back_off_(RayConfig::instance().redis_retry_base_ms(),
RayConfig::instance().redis_retry_multiplier(),
RayConfig::instance().redis_retry_max_ms()),
Expand All @@ -173,12 +229,32 @@ RedisRequestContext::RedisRequestContext(instrumented_io_context &io_service,
callback_(std::move(callback)),
start_time_(clock.Now()),
redis_cmds_(std::move(args)),
clock_(clock) {
clock_(clock),
metrics_(metrics),
table_label_(table_label) {
RAY_CHECK(!redis_cmds_.empty());
command_label_ = NormalizeRedisCommandLabel(redis_cmds_.front());
argc_.reserve(redis_cmds_.size());
argv_.reserve(redis_cmds_.size());
// This context owns the argument buffers now, so their sizes are safe to read
// here no matter what the caller moved into them on the way in (AsyncPut, for
// one, moves the caller's value into the RedisCommand). Summing in the loop
// that already computes argc_ also means the request side costs nothing
// beyond the additions.
size_t request_payload_bytes = 0;
for (size_t i = 0; i < redis_cmds_.size(); ++i) {
argv_.push_back(redis_cmds_[i].data());
argc_.push_back(redis_cmds_[i].size());
request_payload_bytes += redis_cmds_[i].size();
}
if (metrics_ != nullptr) {
// Once per logical command. Run() is what retries, so a retransmission is
// not counted again -- see the metric description.
metrics_->request_payload_bytes_sum.Record(
static_cast<double>(request_payload_bytes),
{{"Command", command_label_}, {"TableName", table_label_}});
metrics_->command_count_counter.Record(
1, {{"Command", command_label_}, {"TableName", table_label_}});
}
}

Expand All @@ -204,6 +280,15 @@ void RedisRequestContext::RedisResponseFn(redisAsyncContext *async_context,
[request_cxt]() { request_cxt->Run(); },
std::chrono::milliseconds(delay));
} else {
// Measure while hiredis still owns the reply, and before anything is posted
// to the io_service: `request_cxt` is deleted at the end of this branch, so
// every read of it has to happen above the post.
if (request_cxt->metrics_ != nullptr) {
request_cxt->metrics_->response_payload_bytes_sum.Record(
static_cast<double>(ResponsePayloadBytes(*redis_reply)),
{{"Command", request_cxt->command_label_},
{"TableName", request_cxt->table_label_}});
}
auto reply = std::make_shared<CallbackReply>(*redis_reply);
request_cxt->io_service_.post(
[reply, callback = std::move(request_cxt->callback_)]() {
Expand Down Expand Up @@ -244,9 +329,12 @@ void RedisRequestContext::Run() {
return Status::RedisError(REPLY->str); \
}

RedisContext::RedisContext(instrumented_io_context &io_service, ClockInterface &clock)
RedisContext::RedisContext(instrumented_io_context &io_service,
ClockInterface &clock,
std::optional<RedisMetrics> metrics)
: io_service_(io_service),
clock_(clock),
metrics_(std::move(metrics)),
context_(nullptr),
ssl_context_(nullptr),
redis_db_probe_timeout_milliseconds_(
Expand Down Expand Up @@ -832,13 +920,18 @@ std::unique_ptr<CallbackReply> RedisContext::RunArgvSync(
}

void RedisContext::RunArgvAsync(std::vector<std::string> args,
RedisCallback redis_callback) {
RedisCallback redis_callback,
std::string_view table_label) {
RAY_CHECK(redis_async_context_);
auto request_context = new RedisRequestContext(io_service_,
std::move(redis_callback),
redis_async_context_.get(),
std::move(args),
clock_);
RAY_CHECK(!args.empty());
auto request_context =
new RedisRequestContext(io_service_,
std::move(redis_callback),
redis_async_context_.get(),
std::move(args),
clock_,
metrics_.has_value() ? &*metrics_ : nullptr,
table_label);
// RedisRequestContext is thread safe.
request_context->Run();
}
Expand Down
Loading
Loading