Repository navigation
Conversation
- 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
There was a problem hiding this comment.
💡 Codex Review
https://github.com/quickwit-oss/quickwit/blob/c7b3a9b51aebfb12cf7d40470417b5e9d1a8203c/quickwit-common/src/uri.rs#L305-L309
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
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
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
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
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
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".
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
There was a problem hiding this comment.
💡 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".
…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
…te-limit metric, native-tls removal
|
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 |
|
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) |
|
Sounds good. Thanks for the PR, it's a useful feature. |
…> 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
|
Hi @guilload, the change has been done (example) |
dayaffe
left a comment
There was a problem hiding this comment.
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
|
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.
|
Hi @papaharry Thanks again! After discussing internally we decided we actually wanted to change |
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
|
Thanks for getting back to me @dayaffe. PR has been updated (along with description and title updates). |
|
@codex review |
guilload
left a comment
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
| && 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(); |
There was a problem hiding this comment.
:/
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, |
There was a problem hiding this comment.
| 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. |
There was a problem hiding this comment.
"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."
| "S3 bucket config key `{bucket}` must be a bare bucket name, without `s3://` \ | ||
| or `/`", |
There was a problem hiding this comment.
| "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", |
| ensure!( | ||
| bucket_config.buckets.is_empty(), | ||
| "S3 bucket config `{bucket}` cannot define nested `buckets`", | ||
| ); |
There was a problem hiding this comment.
| 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", | |
| ); |

Description
storage.s3.bucketsmap, 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).S3StorageConfig(same fields as the primarys3:block, no inheritance from it). Nestedbucketsare rejected.s3://<bucket>/...URI is resolved, an exact bucket-name match uses that entry; any unlisted bucket falls back to the primarys3:block.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.QW_S3_ENDPOINTandQW_S3_FORCE_PATH_STYLE_ACCESSapply to the primary backend only. This replaces the process-wideOnceLockcache inforce_path_style_access(), which would otherwise have leaked the first backend's value into every other backend.Changes:
quickwit-config/src/storage_config.rs:buckets: BTreeMap<String, S3StorageConfig>and a non-serializedis_bucket_configflag onS3StorageConfig;bucket_configs()yields each entry withis_bucket_configset;apply_flavor()andredact()recurse into bucket entries;Debugincludesbuckets;endpoint()/force_path_style_access()skip the env overrides for bucket configs.StorageConfigs::validate()rejects empty keys, keys containing/(e.g.s3://bucketorbucket/prefix), and nestedbuckets.quickwit-storage/src/object_storage/s3_compatible_storage_resolver.rs: the factory now holds a defaultS3Backendplus oneS3Backendper bucket entry, each with its own lazily built, cachedS3Client.backend_for_uri()picks the backend viaparse_s3_uri.docs/configuration/storage-config.md:bucketsrow in the S3 properties table,QW_S3_FORCE_PATH_STYLE_ACCESSenv 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 andbucket_configs()test_storage_s3_buckets_reject_nested_bucketstest_storage_s3_buckets_reject_unmatchable_bucket_keys: empty,s3://...and.../prefixkeystest_storage_s3_buckets_redact: secrets redacted inDebugandredact()test_storage_s3_buckets_apply_flavor:flavorand deprecateddisable_checksumsexpand per buckettest_storage_s3_bucket_config_uses_own_endpoint: env overrides hit the primary backend onlyUnit 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 primaryEnd-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.