Skip to content

feat(io): S3 LIST + footer probe + kvikIO backend, and io-layer fixes - #172

Merged
rapids-bot[bot] merged 2 commits into
NVIDIA:mainfrom
aminaramoon:sirius_io_match_up
Jul 29, 2026
Merged

feat(io): S3 LIST + footer probe + kvikIO backend, and io-layer fixes#172
rapids-bot[bot] merged 2 commits into
NVIDIA:mainfrom
aminaramoon:sirius_io_match_up

Conversation

@aminaramoon

Copy link
Copy Markdown
Contributor

Adds three capabilities to the io datasource layer, fixes several latent defects found while auditing it, and gives the layer its first test coverage.

Bug fixes

  • io_request: copy_async validates its host source before forming the pointer. A null host_buffer, or an out-of-range [src_off, src_off + size), previously produced UB (nullptr + offset) or a wild in-range pointer that the near-null check could not catch. Now returns cudaErrorInvalidValue. The existing asserts compile out in release, so the check has to happen before the pointer is formed.
  • prefetching_cache: mark the request state load-failed on the prefetch_loop cancel/stop path. Only the chunk states were being marked, leaving the request-level state inconsistent on cancellation.
  • Four empty catch blocks were silently swallowing exceptions — the rest_reactor and uring_reactor worker loops, and two in templated_ioctx::shutdown. Restoring the log call sites fills them. CUCASCADE_LOG_* are no-ops today, so this costs nothing at runtime but keeps the diagnostics in place for when logging is wired up.

Behavioral fixes

  • rest_reactor::preferred_prefetching_stage: opportunisticjust_in_time. prefetching_cache branches on exactly this value, so S3 reads were being eagerly prefetched into cache buffers rather than read straight into the caller's buffer. Network round-trips are high-latency; read ahead on demand rather than prefilling the working set.
  • uri_parser: S3 object keys are literal bytes. %, ? and # are ordinary key bytes, matching AWS CLI semantics, so s3://b/a%20b now opens the key a%20b. Previously the key was percent-decoded and split on ?, which opened the wrong object.

New capability

  • S3 ListObjectsV2 — a hand-rolled, fail-closed list parser (no XML dependency); presign_url(extra_canonical_query) so caller query params participate in the signature (required for S3 to accept a presigned LIST); authorize_list on the authorizer interface and both SigV4 implementations; an X-Amz-* injection guard so callers cannot smuggle or override signing params; rest_reactor::list_page; and rest_ioctx::list_objects_paged / list_objects / list_max_matches. Pagination is guarded against non-conforming backends: truncated-without-token, truncated-but-empty, and non-advancing continuation token all throw rather than loop or silently truncate.
  • Parquet footer probeopen_hint plus create_io_object(path, hint); a suffix-range GET that resolves the object size and stashes its trailing bytes in one round-trip; and a stash-hit fast path in rest_reactor::host_read so the footer reads that follow are served from memory instead of costing extra round-trips. Falls back to a plain HEAD on any unusable response (200 full body, 416, missing/unsatisfied Content-Range).
  • Known-size opencreate_io_object(path, known_size), so a size already learned from a LIST response builds the io_object with zero network: no HEAD, no probe.
  • kvikio_context — a local-file fallback backend built directly on kvikio::FileHandle rather than cudf::io::datasource, which keeps the io library cudf-free. Registered as a catch-all that lookup_path defers behind the explicit uring/rest backends, so s3:// never resolves to it and a local file still routes to uring first.
  • kvikio_config — optional-per-field tunables: nthreads, task_size, gds_threshold, bounce_buffer_size, O_DIRECT reads and overread, per-block-device pools, and compat mode. An unset field means "leave kvikIO's own default alone", so the KVIKIO_* environment variables keep working as the outer default and this is an explicit in-process override layered on top. Every field except compat_mode maps to a setter on kvikIO's process-global defaults singleton and is documented as such — last context constructed wins. compat_mode is the exception: it rides the FileHandle constructor, so it scopes to files this ioctx opens and mutates nothing global.

Cleanup

  • metadata_store::get_metadata(cache_key) overload, for callers that know the path but have not built an io_object yet.
  • Dropped object_store_config::s3_use_async_backend — no consumers, and it referenced types that do not exist.
  • Renamed io_context_registry::lookuplookup_path. It is passed a full path, not a bare scheme (the checkers parse the URI / stat the filesystem themselves), and the docs claimed otherwise.

Tests

New cucascade_io_tests target — 732 assertions across 71 cases, covering uri_parser, sigv4, the list parser, both authorizers, static credentials and kvikio_config. The io layer had no tests at all before this.

Full suite: 6,330 assertions / 343 cases, 0 failures.

Binary Result
cucascade_tests 3225 / 89
cucascade_io_tests (new) 732 / 71
cucascade_cudf_tests 2326 / 177
cucascade_topology_discovery_tests 47 / 6

Builds clean with CUCASCADE_WARNINGS_AS_ERRORS=ON; pre-commit run -a passes.

Not verified

The two S3 benchmarks are gated behind CUCASCADE_BUILD_S3_BENCHMARK, which requires aws-sdk-cpp (present only in the s3-bench pixi environment), so they were not compiled. By inspection they use only the single-argument open_datasource / open_io_object overloads — unchanged, since the new behavior was added as overloads rather than signature changes — and nothing switches over io_context_type, so the new kvikio enumerator cannot trip -Werror=switch.

Follow-up

REST perf instrumentation (per-chunk timings, queue wait, TTFB, retry/terminal counters, pool aggregation) is deliberately not part of this PR. The control-plane paths added here leave room for it and it layers on cleanly; the existing S3 benchmarks are its natural consumer.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@aminaramoon
aminaramoon marked this pull request as ready for review July 29, 2026 05:15
[[nodiscard]] std::unique_ptr<datasource> open_datasource(std::shared_ptr<ioctx> io_ctx,
std::string path);

/// As above, forwarding @p hint to the backend's io_object resolution so it can,

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.

we can probably clean up the AI comments after reviews finish. I don't mind the explanations while reviewing.

/// default covers files up to ~1.4 GB in one GET (the common range); raise it
/// for multi-GB single files, lower it for many-tiny-file / low-bandwidth
/// workloads.
std::size_t footer_probe_bytes{512UL << 10}; // 512 KiB

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.

If retrieving a larger payload doesn't increase the latency significantly it woyuld be fine to increase this a bit.

@@ -0,0 +1,139 @@
/*

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.

I think this file should be moved into the testing directory

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.

Or is the idea that other projects are going to want to use this authorizer themselves for testing? If so maybe we can make a new target for things that aren't for production but are meant to be included by users of the library for testing?

// Single-pass unescape of the five predefined XML entities. Unknown sequences
// (e.g. numeric character references, which S3 does not emit for keys) pass
// through verbatim.
std::string xml_unescape(std::string_view s)

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.

It seems risky to handle xml parsing ourselves. Is there a reason we can't just use a library that handles that for us?

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.

I agree, the alternative is boost

aminaramoon and others added 2 commits July 29, 2026 10:44
Adds three capabilities to the io datasource layer, fixes several latent
defects found while auditing it, and gives the layer its first test coverage.

Bug fixes
- io_request: validate copy_async's host source before forming the pointer.
  A null host_buffer or an out-of-range [src_off, src_off+size) previously
  produced UB (nullptr + offset) or a wild in-range pointer that the near-null
  check could not catch. Now returns cudaErrorInvalidValue.
- prefetching_cache: mark the request state load-failed on the prefetch_loop
  cancel/stop path. Only the chunk states were being marked, leaving the
  request-level state inconsistent on cancellation.
- Four empty catch blocks were silently swallowing exceptions: the
  rest_reactor and uring_reactor worker loops, and two in
  templated_ioctx::shutdown. Restoring the log call sites fills them.
  CUCASCADE_LOG_* are no-ops today, so this costs nothing at runtime.

Behavioral fixes
- rest_reactor::preferred_prefetching_stage: opportunistic -> just_in_time.
  prefetching_cache branches on this value, so S3 reads were being eagerly
  prefetched into cache buffers instead of read straight into the caller's
  buffer. Network round-trips are high-latency; read ahead on demand rather
  than prefilling the working set.
- uri_parser: S3 object keys are literal bytes. '%', '?' and '#' are ordinary
  key bytes, matching AWS CLI semantics, so s3://b/a%20b now opens the key
  a%20b. Previously the key was percent-decoded and split on '?', opening the
  wrong object.

New capability
- S3 ListObjectsV2: a hand-rolled fail-closed list parser,
  presign_url(extra_canonical_query) so caller query params participate in the
  signature, authorize_list on the authorizer interface and both SigV4 impls,
  an X-Amz-* injection guard, rest_reactor::list_page, and rest_ioctx's
  list_objects_paged / list_objects / list_max_matches with guards against
  non-conforming backends (truncated-without-token, truncated-empty,
  non-advancing token).
- Parquet footer probe: open_hint plus create_io_object(path, hint), a
  suffix-range GET that resolves object size and stashes the trailing bytes in
  one round-trip, and a stash-hit fast path in rest_reactor::host_read so the
  footer reads that follow are served locally.
- Known-size open: create_io_object(path, known_size), so a size already
  learned from a LIST response skips the HEAD entirely.
- kvikio_context: a local-file fallback backend built directly on
  kvikio::FileHandle rather than cudf::io::datasource, keeping the io library
  cudf-free. Registered as a catch-all that lookup_path defers behind the
  explicit uring/rest backends.
- kvikio_config: optional-per-field tunables (nthreads, task_size,
  gds_threshold, bounce_buffer_size, O_DIRECT reads and overread,
  per-block-device pools, compat mode). Unset means "leave kvikIO's env-var
  default alone", so KVIKIO_* still works as the outer default. Every field
  except compat_mode is process-global once applied and is documented as such;
  compat_mode rides the FileHandle constructor so it stays scoped to this
  ioctx.

Cleanup
- metadata_store: add the get_metadata(cache_key) overload for callers that
  know the path but have not built an io_object yet.
- Drop object_store_config::s3_use_async_backend: no consumers, and it named
  types that do not exist.
- Rename io_context_registry::lookup to lookup_path. It takes a full path, not
  a scheme, and the docs claimed otherwise.

Tests
- New cucascade_io_tests target: 732 assertions across 71 cases covering
  uri_parser, sigv4, the list parser, both authorizers, static credentials and
  kvikio_config. The io layer had no tests before this.

Builds clean with CUCASCADE_WARNINGS_AS_ERRORS=ON; pre-commit passes.

Not verified: the two S3 benchmarks are gated behind
CUCASCADE_BUILD_S3_BENCHMARK, which requires aws-sdk-cpp (present only in the
s3-bench pixi environment), so they were not compiled. They use only the
single-argument open_datasource / open_io_object overloads, which are
unchanged since the new behavior was added as overloads rather than signature
changes, and nothing switches over io_context_type.

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

The stash getter handed out shared_ptr<const std::vector<uint8_t>>, which
leaks the container type into the interface: consumers only ever read through
it (data / size / subspan), but the signature also advertises the allocator,
the growth policy and the whole mutation API.

Replace it with shared_byte_span, an alias for
shared_ptr<const std::span<const uint8_t>> built with shared_ptr's aliasing
constructor. A single make_shared allocates a byte_storage holding both the
buffer and a span over it; the returned pointer refers to the span while the
control block keeps the buffer alive. One allocation, no copy, and ownership
still rides along.

byte_storage is non-copyable and non-movable on purpose: `view` points into
`bytes`, so a copy would deep-copy the buffer and leave the copy's span aimed
at the original's allocation. It is only ever constructed in place.

rest_reactor::host_read needs no change — span and vector both answer data()
and size() through operator->.

Also switch two string-keyed lookups to string_view:

- metadata_store::get_metadata now takes string_view. This required giving the
  underlying map a transparent hash plus std::equal_to<> so C++20
  heterogeneous lookup applies; without both, a string_view parameter would
  just construct a temporary key per call and be strictly worse than
  string const&.
- content_range_total / content_range_start take string_view. Both already
  converted their argument to a string_view on the first line.

Left alone deliberately: make_signer's region and evict_page_cache's path both
need an owning string (the latter for c_str()), so a view would only add a
conversion.

Tests: 12 new cases / 296 assertions covering aliasing lifetime (source vector
and first owner both dropped before reading), buffer stability across owners,
the host_read access pattern, empty and null stashes, and heterogeneous lookup
via string_view, string literal, owning string, and a non-NUL-terminated slice
of a longer buffer.

Verified under ASan + UBSan + leak detection: the full io suite (1029
assertions, 83 cases) runs clean.

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

Copy link
Copy Markdown
Contributor Author

/ok to test 83f4386

1 similar comment
@aminaramoon

Copy link
Copy Markdown
Contributor Author

/ok to test 83f4386

@aminaramoon

Copy link
Copy Markdown
Contributor Author

/merge

@rapids-bot
rapids-bot Bot merged commit 176575f into NVIDIA:main Jul 29, 2026
12 checks passed
rapids-bot Bot pushed a commit that referenced this pull request Aug 3, 2026
## What

Implements the REST perf-instrumentation follow-up left out of #172. Sirius consumes these counters in its S3 tests and benchmark JSON.
Add loopback HTTP range server for REST conformance tests

## Counter behavior

- Retry, terminal-failure, device-stream-sync, and payload-byte counters are always enabled. Payload bytes count HTTP response-body bytes across attempts, including retries.
- `chunk_get`, `queue_wait`, `h2d_observed`, `blocking_host_get`, and `ttfb_ns` are recorded only when `perf_instrumentation` is enabled. The disabled instrumentation path adds no clock reads.
- Despite its retained name, `ttfb_ns` measures GET submission to completion of the first completed GET, not time to the first byte on the wire.
- Counters use relaxed atomics. Snapshots can be read while reactors are running without taking reactor locks, but exact comparisons should be made after outstanding requests finish.
- `rest_perf_snapshot` is source-pin API rather than a stable binary layout. New fields are appended without reordering existing fields.
- cuCascade exposes only the C++ config field. YAML wiring remains the responsibility of the embedding application.

## Validation

- Standalone: `pixi run -e cuda-12-stable build` / `test` — 4/4 ctest targets, `cucascade_io_tests` 103 cases / 1169 assertions green, `[rest][perf]` 20 cases / 149 assertions.
- Consumer-side: the Sirius integration branch builds against this branch, and its full S3 integration suite and S3 benchmark pass with the perf coverage restored (the counters are consumed by its tests and its benchmark JSON).

Authors:
  - Yu (https://github.com/ran-yuan-rui)

Approvers:
  - Amin Aramoon (https://github.com/aminaramoon)

URL: #178
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants