Skip to content

feat(storage): add per-bucket S3 configuration via storage.s3.buckets - #6523

Open
papaharry wants to merge 22 commits into
quickwit-oss:mainfrom
defensedotcom:main
Open

papaharry wants to merge 22 commits into
quickwit-oss:mainfrom
defensedotcom:main

Conversation

@papaharry

@papaharry papaharry commented Jun 17, 2026 •

Copy link
Copy Markdown

Description

  • Adds a storage.s3.buckets map, keyed by bare bucket name, so a single Quickwit cluster can address multiple S3-compatible backends (separate credentials, region, endpoint, checksum settings, flavor and flags per bucket).
  • Each entry is a full S3StorageConfig (same fields as the primary s3: block, no inheritance from it). Nested buckets are rejected.
  • When an s3://<bucket>/... URI is resolved, an exact bucket-name match uses that entry; any unlisted bucket falls back to the primary s3: block.
  • URIs stay canonical s3://bucket/path. Routing is decided at runtime from each node's own config, so nothing new is persisted in the metastore / index metadata. Every node that touches a bucket (indexers, searchers, janitor, metastore) must declare the same entry.
  • Bucket entries are self-contained: QW_S3_ENDPOINT and QW_S3_FORCE_PATH_STYLE_ACCESS apply to the primary backend only. This replaces the process-wide OnceLock cache in force_path_style_access(), which would otherwise have leaked the first backend's value into every other backend.
  • Caveat: because routing is by bucket name, a bucket name maps to exactly one backend.

Changes:

  • quickwit-config/src/storage_config.rs: buckets: BTreeMap<String, S3StorageConfig> and a non-serialized is_bucket_config flag on S3StorageConfig; bucket_configs() yields each entry with is_bucket_config set; apply_flavor() and redact() recurse into bucket entries; Debug includes buckets; endpoint() / force_path_style_access() skip the env overrides for bucket configs. StorageConfigs::validate() rejects empty keys, keys containing / (e.g. s3://bucket or bucket/prefix), and nested buckets.
  • quickwit-storage/src/object_storage/s3_compatible_storage_resolver.rs: the factory now holds a default S3Backend plus one S3Backend per bucket entry, each with its own lazily built, cached S3Client. backend_for_uri() picks the backend via parse_s3_uri.
  • docs/configuration/storage-config.md: buckets row in the S3 properties table, QW_S3_FORCE_PATH_STYLE_ACCESS env var documented, and a new "Per-bucket S3 configuration" section with a YAML example and notes on env overrides, per-node config, YAML quoting of numeric/boolean bucket names, and bucket-name uniqueness.

How was this PR tested?

Unit tests in quickwit-config (storage_config::tests):

  • test_storage_s3_buckets_serde: YAML deserialization and bucket_configs()
  • test_storage_s3_buckets_reject_nested_buckets
  • test_storage_s3_buckets_reject_unmatchable_bucket_keys: empty, s3://... and .../prefix keys
  • test_storage_s3_buckets_redact: secrets redacted in Debug and redact()
  • test_storage_s3_buckets_apply_flavor: flavor and deprecated disable_checksums expand per bucket
  • test_storage_s3_bucket_config_uses_own_endpoint: env overrides hit the primary backend only

Unit test in quickwit-storage (s3_compatible_storage_resolver::tests):

  • test_s3_factory_routes_by_exact_bucket_name: exact match, bucket-only URI, dotted bucket names, near-miss and unlisted buckets fall back to primary

End-to-end through the REST stack (ingest -> split -> upload -> publish -> search) against an in-cluster SeaweedFS backend and an external OVH backend.

This fork has been running on my staging environment for a couple of weeks and everything is working as expected. The indexes are created and the source config also works as intended.

image

papaharry and others added 4 commits June 8, 2026 13:45
- storage.s3.named.<name> config map for arbitrary S3-compatible
  endpoints (separate creds, region, endpoint, checksum, flags per entry).
- URIs of the form s3+<name>://bucket/path route to the named backend;
  plain s3:// continues to use the primary endpoint.
- Preserve the s3+<name> qualifier through Uri::parse_str normalization
  so the resolver can recover the backend name on deserialization
  (previously the qualifier was stripped and named URIs silently
  resolved to the primary endpoint).
- Tests: URI parse/serde roundtrip, NamedS3StorageConfig serde + redact +
  as_s3_config projection, parse_named_key.
- Docs: list s3+<name>:// in the URI protocols and add a Named S3
  backends section with a YAML example.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(storage): add named S3-compatible backends via s3+<name>:// URIs
@papaharry
papaharry requested a review from a team as a code owner June 17, 2026 08:16

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

https://github.com/quickwit-oss/quickwit/blob/c7b3a9b51aebfb12cf7d40470417b5e9d1a8203c/quickwit-common/src/uri.rs#L305-L309
P1 Badge Fix Uri path slicing for named S3 schemes

When s3+<name> is preserved here, Uri::path() still strips only self.protocol.as_str().len() + 3 bytes (s3://), so s3+alt://bucket/file is treated as the path t://bucket/file. Any caller that uses parent() before resolving, such as quickwit_storage::load_file or object URI sources via dir_and_filename, will produce s3://t://bucket and route to the wrong primary bucket/backend instead of the named backend.


https://github.com/quickwit-oss/quickwit/blob/c7b3a9b51aebfb12cf7d40470417b5e9d1a8203c/quickwit-storage/src/object_storage/s3_compatible_storage_resolver.rs#L83
P1 Badge Keep path-style access per named backend

When different S3 backends require different force_path_style_access values, the first client created wins because create_s3_client() calls S3StorageConfig::force_path_style_access(), which caches the value in a process-wide OnceLock. If a plain s3:// client with the default false is resolved before s3+minio:// with true, this named client is still built with false, so path-style-only endpoints fail despite the named config setting.


https://github.com/quickwit-oss/quickwit/blob/c7b3a9b51aebfb12cf7d40470417b5e9d1a8203c/quickwit-config/src/storage_config.rs#L406
P2 Badge Apply flavor overrides to named S3 backends

When a named backend is configured with flavor: minio, garage, gcs, or digital_ocean, this projection only copies the flavor into a fresh S3StorageConfig; the only code that expands flavors is StorageConfigs::apply_flavors() on the top-level S3 config, and it never visits named. As a result named backends miss the expected region/path-style/checksum/delete overrides and can fail even though the same flavor works on the primary S3 block.


https://github.com/quickwit-oss/quickwit/blob/c7b3a9b51aebfb12cf7d40470417b5e9d1a8203c/quickwit-storage/src/object_storage/s3_compatible_storage_resolver.rs#L83
P1 Badge Bypass the global S3 endpoint for named backends

When QW_S3_ENDPOINT is set for the primary S3 backend, this new named-backend path passes the projected config into create_s3_client(), whose S3StorageConfig::endpoint() always prefers that env var over the struct endpoint. In that environment every s3+<name>:// backend ignores storage.s3.named.<name>.endpoint and connects to the global endpoint, so multi-backend clusters can route named indexes to the wrong service.


https://github.com/quickwit-oss/quickwit/blob/c7b3a9b51aebfb12cf7d40470417b5e9d1a8203c/quickwit-config/src/storage_config.rs#L392-L393
P2 Badge Preserve the multi-delete alias for named S3 configs

Because NamedS3StorageConfig denies unknown fields, this field no longer accepts the disable_multi_object_delete_requests alias that the primary S3StorageConfig accepts. If an existing S3-compatible config using that key is moved under storage.s3.named.<name> as documented as the same S3 fields, node config parsing fails at startup even though the same option works for plain s3://.


https://github.com/quickwit-oss/quickwit/blob/c7b3a9b51aebfb12cf7d40470417b5e9d1a8203c/quickwit-config/src/storage_config.rs#L396-L398
P2 Badge Accept stalled-stream toggles on named S3 configs

Named backends are documented as accepting the same fields as the primary S3 block, but this struct stops after checksum_algorithm and denies unknown fields, so configs that need disable_stalled_stream_protection_upload or disable_stalled_stream_protection_download for a particular S3-compatible endpoint now fail to parse when moved under storage.s3.named.<name>. Even if parsing were allowed, as_s3_config() currently hard-codes those projected flags to false, so the named backend could not disable the protection independently.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@papaharry
papaharry marked this pull request as draft June 17, 2026 08:50
papaharry and others added 2 commits June 17, 2026 09:52
Six issues from the Codex review of the s3+<name> named-backend feature:

- uri: slice path at the real `://` separator and preserve the `s3+<name>`
  scheme in parent()/file_name() (was corrupting paths to `t://bucket`).
- config: env overrides (QW_S3_ENDPOINT, QW_S3_FORCE_PATH_STYLE_ACCESS) now
  apply to the primary backend only; named backends are self-contained.
  Removed the process-wide OnceLock that cached the first client's path-style.
- config: as_s3_config() applies flavor expansion for named backends.
- config: accept the disable_multi_object_delete_requests alias on named.
- config: accept and pass through disable_checksums and the stalled-stream
  protection toggles on named backends (were denied and hard-coded false).

Adds unit tests for URI scheme preservation, field parity, flavor expansion,
and named-backend endpoint isolation. Updates storage-config docs.
fix(storage): address review findings for named S3 backends
@papaharry
papaharry marked this pull request as ready for review June 17, 2026 09:52

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ebf5acfde

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread quickwit/quickwit-common/src/uri.rs Outdated
Comment thread quickwit/quickwit-storage/src/object_storage/s3_compatible_storage_resolver.rs Outdated
papaharry and others added 3 commits June 19, 2026 14:35
…wait

- config: reject URL-incompatible named-backend names at config load; the
  name is embedded in the s3+<name>:// scheme which the metrics/DataFusion
  path parses with url::Url. Restricted to lowercase ASCII alnum and `-`.
- storage: cache each named backend behind its own OnceCell; the mutex is
  held only synchronously to fetch/insert the cell, never across the client
  build, so independent backends initialize concurrently.
…nt-lock

fix(storage): validate named S3 backend names & remove lock-across-await
@guilload

Copy link
Copy Markdown
Member

I'm not fond of embedding the "profile" in the storage which eventually gets persisted in the metastore. Can we consider an alternative design with per bucket profiles:

storage:
  s3:
    region: us-east-1            # base defaults for any unlisted bucket
    profiles:
      logs-bucket-eu:            # the bucket name IS the key
        endpoint: https://s3.eu-west-3.amazonaws.com
        region: eu-west-3
        access_key_id: ${SECONDARY_S3_ACCESS_KEY_ID}
        secret_access_key: ${SECONDARY_S3_SECRET_ACCESS_KEY}
      seaweed-logs:
        endpoint: http://seaweedfs-s3:8333
        force_path_style_access: true

@papaharry

Copy link
Copy Markdown
Author

Thanks for that. Yeah, makes sense and will make it clearer too. I'll update it (and test on my environment) and will tag you in for another review when I think it's ready again if that's ok (will be a few days before I update it and test it)

@guilload

Copy link
Copy Markdown
Member

Sounds good. Thanks for the PR, it's a useful feature.

papaharry and others added 2 commits June 30, 2026 09:09
…> scheme

Replaces the named-backend design (which embedded a config label in the
`s3+<name>://` URI scheme, persisting it into the metastore) with per-bucket
profiles keyed by bucket name under `storage.s3.profiles.<bucket>`. URIs stay
canonical `s3://bucket/path`, so nothing synthetic is persisted: routing is by
bucket name, resolved at runtime from node-local config.

- quickwit-common/uri.rs: revert all s3+<name> scheme handling to upstream;
  the Uri type no longer needs to carry a non-canonical scheme.
- quickwit-config: `named` -> `profiles` (keyed by bucket), `NamedS3StorageConfig`
  -> `S3ProfileConfig`, `is_named_backend` -> `is_profile`. Drop the URL-scheme
  name validation (a bucket name is not a scheme).
- quickwit-storage: resolver extracts the bucket via parse_s3_uri and looks it
  up in `profiles`; match -> that profile's config + per-bucket client cache,
  no match -> primary backend.
- docs: rewrite as "Per-bucket S3 profiles", including the bucket-name
  uniqueness caveat (a bucket name maps to exactly one backend).

Validated end-to-end through the REST stack: ingest -> split -> upload ->
publish -> search against in-cluster SeaweedFS and external OVH backends.

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

refactor(storage): per-bucket S3 profiles instead of s3+<name> scheme
@papaharry papaharry changed the title feat(storage): add named S3-compatible backends via s3+<name>:// URIs feat(storage): add named S3-compatible backends via s3 profiles Jun 30, 2026
@papaharry

Copy link
Copy Markdown
Author

Hi @guilload, the change has been done (example)
I've been running it locally and works as expected. Is this what you were suggesting?
image

@guilload
guilload requested a review from dayaffe August 28, 2026 15:22

@dayaffe dayaffe 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.

Thanks for submitting this PR and for switching to bucket-keyed profiles as requested! I don't see any remaining blockers. Please update the PR description though as it still describes the old s3+:// approach.

LGTM

@papaharry

Copy link
Copy Markdown
Author

Thank you, description updated.

"Profiles" collides with AWS credential profiles; the map keys are
bucket names, which also matches Hadoop's per-bucket S3A config.

- Drop S3ProfileConfig: `buckets` is a map of S3StorageConfig, so
  serde, flavors, redaction, and Debug are shared with the primary
  backend. Nested `buckets` are rejected in StorageConfigs::validate.
- apply_flavor recurses into bucket configs at load time.
- The resolver builds one backend (config + lazy client) per bucket at
  construction, so resolve() has a single code path and needs no mutex.
- Reject `storage.s3.buckets` keys that are empty or contain `/`. Such
  keys can never match a parsed bucket name, so the bucket would
  silently fall back to the primary backend and its credentials.
- Test bucket routing in the S3 factory: exact match, dotted names,
  bucket-only URIs, no prefix matching, and fallback to the primary.
- Make the env-override test set QW_S3_ENDPOINT and
  QW_S3_FORCE_PATH_STYLE_ACCESS, and restore the test that unknown
  fields in a bucket entry are rejected.
- Skip URI parsing when no buckets are configured, and drop the
  primary backend's duplicate copy of the bucket configs.
- Docs: add the `buckets` property and the
  QW_S3_FORCE_PATH_STYLE_ACCESS env variable to the reference tables.
- Load a full node config with `storage.s3.buckets` to cover validation
  and flavor expansion on the real load path, including a quoted
  numeric bucket name and a rejected `s3://` key.
- Check that the nested-buckets error comes from the nested check.
- Docs: QW_S3_ENDPOINT and QW_S3_FORCE_PATH_STYLE_ACCESS apply to the
  primary backend only; every node must declare the same `buckets`
  entries; quote bucket names YAML would parse as non-strings.
@dayaffe

dayaffe commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Hi @papaharry Thanks again! After discussing internally we decided we actually wanted to change profiles back to buckets and reduce some duplication. Have a look at defensedotcom#6 which includes suggested changes. If you merge that it should update here!

dayaffe and others added 3 commits October 6, 2026 18:01
Remove tests already covered elsewhere: the full node-config load test
(validation and flavor expansion are unit tested), the dotted-name and
no-buckets factory tests (covered by the routing test), and the
bucket unknown-field test (bucket entries share S3StorageConfig's
deny_unknown_fields).
Rename storage.s3.profiles to buckets, share S3StorageConfig for bucket entries
# Conflicts:
#	docs/configuration/storage-config.md
@papaharry papaharry changed the title feat(storage): add named S3-compatible backends via s3 profiles feat(storage): add named S3-compatible backends via s3 buckets Oct 6, 2026
@papaharry papaharry changed the title feat(storage): add named S3-compatible backends via s3 buckets feat(storage): add per-bucket S3 configuration via storage.s3.buckets Oct 6, 2026
@papaharry

Copy link
Copy Markdown
Author

Thanks for getting back to me @dayaffe. PR has been updated (along with description and title updates).
Let me know if more changes are required and I'll have a look

@dayaffe

dayaffe commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@codex review

@dayaffe
dayaffe requested a review from guilload October 7, 2026 18:01

@guilload guilload 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.

Approved but address my nits.

// `QW_S3_ENDPOINT` overrides the primary backend only; bucket configs
// are self-contained and use their own configured endpoint.
if !self.is_bucket_config
&& let Ok(endpoint) = env::var("QW_S3_ENDPOINT")

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.

Suggested change
&& let Ok(endpoint) = env::var("QW_S3_ENDPOINT")
&& let Ok(endpoint) = get_bool_from_env("QW_S3_ENDPOINT")

// SAFETY: this test may not be entirely sound if not run with nextest or --test-threads=1
// as this is only a test, and it would be extremely inconvenient to run it in a different
// way, we are keeping it that way
let previous_endpoint = env::var("QW_S3_ENDPOINT").ok();

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.

:/

Let's introduce some test-only methods on S3StorageConfig if need be so we don't have to do that.


/// S3 compatible object storage resolver.
pub struct S3CompatibleObjectStorageFactory {
default_backend: S3Backend,

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.

Suggested change
default_backend: S3Backend,
default_backend: LazyS3Client,

StorageBackend already exists in quickwit-storage


#### Per-bucket S3 configuration

In addition to the primary `s3:` block, you can declare per-bucket overrides under `storage.s3.buckets`. The map key is the bare bucket name (no `s3://` prefix or path; such keys are rejected at startup); when an `s3://<bucket>/...` URI is resolved, an exact match supplies that bucket's own endpoint, credentials, region, and flags. Any bucket not listed falls back to the fields on the primary `s3:` block. URIs stay canonical `s3://` — routing is by bucket name, so nothing extra is persisted in the index metadata.

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.

"bare" is weird and I don't think it's even needed:

"The map key is the bucket name, e.g. my-bucket, not s3://my-bucket or my-bucket/prefix; such keys are rejected at startup."

Comment on lines +128 to +129
"S3 bucket config key `{bucket}` must be a bare bucket name, without `s3://` \
or `/`",

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.

Suggested change
"S3 bucket config key `{bucket}` must be a bare bucket name, without `s3://` \
or `/`",
"invalid S3 bucket config key `{bucket}`: expected a bucket name (e.g. `my-bucket`), \
not a URI or path",

Comment on lines +131 to +134
ensure!(
bucket_config.buckets.is_empty(),
"S3 bucket config `{bucket}` cannot define nested `buckets`",
);

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.

Suggested change
ensure!(
bucket_config.buckets.is_empty(),
"S3 bucket config `{bucket}` cannot define nested `buckets`",
);
ensure!(!bucket.is_empty(), "S3 bucket config key must not be empty");
ensure!(
!bucket.contains('/'),
"invalid S3 bucket config key `{bucket}`: expected a bucket name (e.g. `my-bucket`), \
not a URI or path",
);

@dayaffe dayaffe added area:storage S3, GCS, Azure and local storage type:feature A new capability, such as an endpoint, query type, source, setting or command labels Oct 22, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:storage S3, GCS, Azure and local storage type:feature A new capability, such as an endpoint, query type, source, setting or command

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants