Skip to content

Remove underscores from object metadata keys - #2354

Merged
samantha-earthmover merged 5 commits into
mainfrom
shughes/fix/metadata-header-underscores
Sep 1, 2026
Merged

Remove underscores from object metadata keys#2354
samantha-earthmover merged 5 commits into
mainfrom
shughes/fix/metadata-header-underscores

Conversation

@samantha-earthmover

@samantha-earthmover samantha-earthmover commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What

Renames the five object-metadata keys Icechunk stamps on writes so they contain letters only:

before after
ic_spec_ver icspecver
ic_client icclient
ic_file_type icfiletype
ic_comp_alg iccompalg
icechunk_write_id icechunkwriteid

The old names stay available as *_DEPRECATED constants, for scripts that read repositories written before this change.

Why

nginx sets underscores_in_headers off by default, so an S3 gateway behind nginx silently drops a request header whose name contains an underscore. Icechunk signs its metadata headers, so the gateway then sees a header in SignedHeaders that is not in the request. MinIO-derived gateways answer:

AccessDenied: There were headers present in the request which were not signed

The message points at the wrong thing. Nothing extra arrived, something signed went missing (extractSignedHeaders, cmd/signature-v4-utils.go).

Tigris serves t3.storage.dev from a mixed fleet, so only some nodes are affected. That made it look like a random flake in Arraylake's integration tests: a whole job failed or passed together, across every client version and environment, and only ever on writes, because reads carry no metadata.

66.93.0.25 runs nginx, 66.93.0.22 does not. Same request, same second, 10 runs each:

NODE             SERVER         HEADER                     RESULT
66.93.0.22       Tigris OS      x-amz-meta-probe_key       . . . . . .
66.93.0.22       Tigris OS      x-amz-meta-probekey        . . . . . .
66.93.0.25       nginx          x-amz-meta-probe_key       R R R R R R
66.93.0.25       nginx          x-amz-meta-probekey        . . . . . .
Script that produces that table (needs no credentials)

MinIO-derived code validates SignedHeaders before it looks up the access key, so a fake key still reaches the branch under test.

#!/usr/bin/env bash
#
# Does an S3 gateway drop user-metadata header names that contain "_"?
#
# nginx sets `underscores_in_headers off` by default and discards such a header.
# When that header is also in the SigV4 SignedHeaders list, the gateway sees a
# signed header that is not in the request, and MinIO-derived gateways answer
# "AccessDenied: There were headers present in the request which were not signed".
# The message points at the wrong thing: nothing extra arrived, something signed
# went missing. Icechunk therefore keeps every object-metadata key alphanumeric.
#
# The probe needs no credentials, because MinIO-derived code validates
# SignedHeaders before it looks up the access key.
#
# Usage:
#   scripts/gateway-metadata-header-probe.sh [endpoint] [bucket]
#   REPS=20 scripts/gateway-metadata-header-probe.sh https://t3.storage.dev
#   IPS="1.2.3.4 5.6.7.8" scripts/gateway-metadata-header-probe.sh
#
# DNS hands out one slice of an anycast fleet at a time, so a single run can miss
# the affected nodes. Pass IPS to pin the addresses you want to compare.
#
# Reads "." as "header survived" and "R" as "header dropped".

set -uo pipefail

ENDPOINT=${1:-https://t3.storage.dev}
BUCKET=${2:-icechunk-nonexistent-probe-bucket}
REPS=${REPS:-10}

HOST=${ENDPOINT#https://}
HOST=${HOST#http://}
HOST=${HOST%%/*}

DATE=$(date -u +%Y%m%dT%H%M%SZ)
SCOPE_DATE=${DATE%%T*}
EMPTY_SHA=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
FAKE_SIG=0000000000000000000000000000000000000000000000000000000000000000

probe_once() {
  local ip=$1 header=$2 resolve=()
  [ -n "$ip" ] && resolve=(--resolve "${HOST}:443:${ip}")
  curl -s --max-time 20 "${resolve[@]}" -X PUT "${ENDPOINT}/${BUCKET}/probe.txt" \
    -H "x-amz-date: ${DATE}" \
    -H "x-amz-content-sha256: ${EMPTY_SHA}" \
    -H "${header}: probe" \
    -H "Authorization: AWS4-HMAC-SHA256 Credential=probe/${SCOPE_DATE}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;${header}, Signature=${FAKE_SIG}" \
    --data-binary "" \
  | grep -oE "<Code>[^<]*" | head -1 | cut -d'>' -f2
}

server_of() {
  local ip=$1 resolve=()
  [ -n "$ip" ] && resolve=(--resolve "${HOST}:443:${ip}")
  curl -s --max-time 15 "${resolve[@]}" -o /dev/null -D - "${ENDPOINT}/" \
  | grep -i '^server:' | tr -d '\r' | cut -d' ' -f2- | head -1
}

# One anycast name can front a mixed fleet, so probe each address on its own.
ips=${IPS:-}
if [ -z "$ips" ] && command -v dig >/dev/null 2>&1; then
  ips=$(dig +short "$HOST" A | grep -E '^[0-9.]+$' | sort -u)
fi

printf '%-16s %-14s %-26s %s\n' NODE SERVER HEADER RESULT
for ip in ${ips:-""}; do
  server=$(server_of "$ip")
  for header in x-amz-meta-probe_key x-amz-meta-probekey; do
    row=""
    for _ in $(seq 1 "$REPS"); do
      case "$(probe_once "$ip" "$header")" in
        AccessDenied) row="${row} R" ;;
        InvalidAccessKeyId | SignatureDoesNotMatch) row="${row} ." ;;
        *) row="${row} ?" ;;
      esac
    done
    printf '%-16s %-14s %-26s%s\n' "${ip:-default}" "${server:-?}" "$header" "$row"
  done
done

cat <<'LEGEND'

  .  header survived, the request reached signature or key validation
  R  header dropped, gateway answered "headers present ... which were not signed"
  ?  unexpected response, inspect by hand

A gateway that prints R for the underscore row and . for the plain row mangles
metadata header names. Icechunk avoids it by keeping keys alphanumeric.
LEGEND

Why not hyphens

Azure requires metadata names to be valid C# identifiers, which forbids -. That is why the keys used _ in the first place (design doc 017). Letters and digits are the only characters both back ends accept, so the new names carry no separator at all. Design doc 017 is updated to record both constraints.

Compatibility

The four format keys have no reader in this repo; they are informational. icechunkwriteid is read back, but only to recognise a write the same process just issued, so it is always written and read with the new name. Objects written by older versions keep the old keys and stay readable; they simply read as "not ours" in the readback path, which is already the correct answer for another writer's object.

Testing

cargo test -p icechunk-format -p icechunk-storage passes. A test in each crate asserts the keys stay ASCII alphanumeric so this cannot regress. cargo fmt --check, cargo clippy --all-targets and cargo check --workspace --all-targets are clean.

Related: earth-mover/arraylake#7414.


Draft on purpose. Samantha, per AI_USAGE_POLICY.md the description should be in your own words along with your review attestation, so please rewrite this before marking it ready.

[This is Claude Code on behalf of Samantha Hughes]

samantha-earthmover and others added 2 commits August 31, 2026 15:31
nginx sets `underscores_in_headers off` by default, so an S3 gateway behind
nginx drops a request header whose name contains an underscore. Icechunk signs
its metadata headers, so the gateway then saw a signed header that was absent
from the request and answered:

  AccessDenied: There were headers present in the request which were not signed

The message points at the wrong thing. Nothing extra arrived; something signed
went missing.

Tigris serves t3.storage.dev from a mixed fleet, so this hit only the nginx
nodes and looked like a random flake: whole CI jobs failed or passed together,
across every client version and environment. Reads were never affected, because
only writes carry metadata.

Keys are now alphanumeric, which also satisfies Azure's C# identifier rule that
forbids `-`. A test in each crate locks the invariant, and
scripts/gateway-metadata-header-probe.sh reproduces the gateway behaviour
without credentials.

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

codecov-commenter commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.96%. Comparing base (d0d25e2) to head (ecff41b).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2354   +/-   ##
=======================================
  Coverage   84.95%   84.96%           
=======================================
  Files          88       88           
  Lines       39594    39611   +17     
=======================================
+ Hits        33638    33655   +17     
  Misses       5956     5956           
Flag Coverage Δ
python 90.09% <ø> (ø)
rust 84.37% <100.00%> (+<0.01%) ⬆️

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

🚀 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.

pub const ICECHUNK_COMPRESSION_OFFSET: usize = ICECHUNK_FILE_TYPE_OFFSET + 1;
pub const ICECHUNK_FILE_HEADER_LEN: usize = ICECHUNK_COMPRESSION_OFFSET + 1;

pub const LATEST_ICECHUNK_FORMAT_VERSION_METADATA_KEY: &str = "ic_spec_ver";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we also keep constants for the old names, maybe with suffix _DEPRACATED or something? In case we ever need to write some scripts that use them with old repos

Comment thread Changelog.python.md Outdated
- Deleting a chunk key that cannot exist (coordinates outside the chunk grid, missing node, or a group path) is now a no-op instead of raising, matching zarr-python's stores. Writing a chunk outside the grid is still rejected ([#2312](https://github.com/earth-mover/icechunk/pull/2312)).
- `to_icechunk` no longer passes `synchronizer` and `zarr_version` to xarray's `ZarrStore.open_group`; xarray removed both parameters and passing them made `to_icechunk` fail with a `TypeError` on xarray development versions ([#2312](https://github.com/earth-mover/icechunk/pull/2312)).
- Writing a chunk with length 0 is now rejected instead of being committed. A chunk must decode to the full chunk shape, so no valid chunk is ever zero bytes long, and such a chunk could only fail once it was read back — long after the commit that introduced it. This is how a sparse GeoTIFF's unstored tiles (`offset = 0, byteCount = 0`) used to reach a repository. Applies to inline, virtual and materialized chunks alike, which means Icechunk deliberately rejects an empty write at a chunk key where a plain key-value store would accept it, in the same way it already rejects invalid zarr keys and invalid metadata. To record that a chunk is not stored at all, delete it rather than writing a zero-length one; it then reads back as the array's fill value ([#2328](https://github.com/earth-mover/icechunk/issues/2328)).
- Object metadata keys no longer contain `_`. Icechunk stamps its own metadata on every file it writes, and stamps a write-id on conditional PUTs. nginx sets `underscores_in_headers off` by default, so an S3 gateway fronted by nginx silently dropped those headers; because they were also signed, the gateway then rejected the request with `AccessDenied: There were headers present in the request which were not signed`. The message is misleading, since the problem is a signed header going missing, not an extra one arriving. Tigris serves `t3.storage.dev` from a mixed fleet, so writes failed only on the nginx nodes and looked intermittent. The keys are now `icspecver`, `icclient`, `icfiletype`, `iccompalg` and `icechunkwriteid`; they stay alphanumeric so they also satisfy Azure's C# identifier rule, which forbids `-`. Use `scripts/gateway-metadata-header-probe.sh` to test a gateway ([#2354](https://github.com/earth-mover/icechunk/pull/2354)).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's put less detail, just what happened not why

samantha-earthmover and others added 2 commits August 31, 2026 15:59
Scripts that read repositories written before the rename still need the old
names. Also trims the changelog entry to what changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe diagnoses a third-party gateway and is not part of the library, so it
belongs in the pull request discussion instead.

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

@paraseba paraseba left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's wait on Luiz's +1 too, to have two people thinking if we depend on this anywhere.

@samantha-earthmover
samantha-earthmover marked this pull request as ready for review August 31, 2026 23:05
@li-em
li-em self-requested a review September 1, 2026 12:56

@li-em li-em left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unfortunate to drop _ and - because it is harder to read, but it is the right solution. Thanks!

@samantha-earthmover
samantha-earthmover added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 07c2e55 Sep 1, 2026
39 of 40 checks passed
@samantha-earthmover
samantha-earthmover deleted the shughes/fix/metadata-header-underscores branch September 1, 2026 19:24
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.

4 participants