mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-04 20:43:04 +00:00
4e16705873
* fix(ecstore): stop pruning at nonempty directories (#7616) * fix(ecstore): stop pruning at nonempty directories * test(ecstore): release pruning fixtures before temp cleanup (cherry picked from commit8f150d1d8e) * fix(heal): preserve retryable batch failures during recovery (#7642) * fix(heal): preserve retryable batch failures during recovery * test(heal): pin prebuilt hooks binaries in ci (cherry picked from commit5cd58319ed) * fix(s3): reject oversize single PUT early and map body errors to 4xx (#7635) * fix(s3): reject oversize single PUT early and map body errors to 4xx A single PutObject above the 5 GiB single-request ceiling was only rejected after the client had streamed 5 GiB into s3s's read-time body budget, and the resulting BodySizeLimitExceeded surfaced from the erasure writer as 500 InternalError. A body whose connection hit EOF before Content-Length bytes arrived (hyper's IncompleteBody) was also a 500. SDKs retry 500s, so one oversize upload was resent from offset 0 five times. - PutObject and UploadPart reject a declared length above MAX_SINGLE_PUT_OBJECT_SIZE with 400 EntityTooLarge before reading the body; the constant moves to rustfs_config so the s3s limit and the admission check share one value. - ApiError maps BodySizeLimitExceeded to EntityTooLarge and a hyper body EOF to IncompleteBody across both io::Error conversions. Fixes #7596. * test(s3): cover UploadPart admission, aws-chunked length, real s3s limit - Poll-counting test body proves PutObject and UploadPart reject a declared size above the ceiling with zero body polls; exact-cap and zero-length parts pass admission. - A STREAMING-* aws-chunked PUT whose framed Content-Length exceeds the cap is admitted when the decoded length is within it and rejected when the decoded length is over it. - The display-based BodySizeLimitExceeded matcher is checked against the real error produced by the pinned s3s Body budget. (cherry picked from commit50b31bc75b) * fix(ecstore): make directory mtime fixture portable (#7623) * fix(ecstore): make directory mtime fixture portable * style(ecstore): format mtime fixture assertion --------- Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: Zhengchao An <anzhengchao@gmail.com> (cherry picked from commitb1cc286cac) * fix(storage): prevent readiness after native migration failures (#7652) * fix(storage): prevent readiness after native migration failures * fix(storage): skip unsupported IAM records before reading * fix(storage): use stable typed migration metadata errors * fix(storage): include migration record in startup errors * test(storage): cover native migration startup failures * test(storage): use array chunks in migration fixture --------- Co-authored-by: RJ Regenold <214054+rjregenold@users.noreply.github.com> Co-authored-by: cxymds <cxymds@gmail.com> (cherry picked from commit0cbc3ffe61) * fix(admin): expose OIDC account display fields (#7654) Expose verified OIDC username and email claims as display-only metadata on self-account responses while preserving the virtual parent as the authorization identity.\n\nKeep rustfs-madmin public response structs unchanged by adding the optional wire fields through private handler response wrappers. (cherry picked from commitf02bc947cd) * fix(replication): correct peer joins and remote-state reporting (#7650) * fix(replication): propagate verified peer deployment identities * fix(replication): report actual remote peer state * fix(replication): defer initial sync until all peers join * test(replication): shut down TLS fixtures cleanly --------- Co-authored-by: houseme <housemecn@gmail.com> (cherry picked from commit853ae63b6a) * fix(s3): bound stalled UploadPart request bodies (#7659) * fix(s3): bound stalled UploadPart request bodies * fix(ci): preserve the S3S footprint ratchet (cherry picked from commit666dfd9f9f) * fix(tables): reject reserved warehouse locations (#7671) Co-authored-by: cxymds <cxymds@gmail.com> (cherry picked from commit414176c47f) * fix(ci): bind nightly lanes to one resolved source (#7688) (cherry picked from commit01d8e4347f) * fix(replication): close the pre-stable convergence gaps from backlog#2367 (#7626) * fix(replication): total-order rule sort and honor V1 top-level Prefix Rule matching had two defects from the pre-GA replication audit (rustfs/backlog#2367 C-1 and C-2): - The actionable-rule sort compared same-destination rules by priority but answered Equal for any other pair, which is not a total order; the standard library sort panics on such comparators once a slice exceeds the insertion-sort threshold, so an object matching more than 20 enabled rules across two or more targets could panic the PUT or DELETE task. Rules now sort by priority descending with destination and id as tie-breakers, and filter_target_arns preserves that order instead of draining a HashSet. - A V1 rule written without a <Filter> carries its prefix at the top level; that field was never read, so <Prefix>logs/</Prefix> matched every object. ReplicationRuleExt::prefix now falls back to it, with a <Filter> keeping precedence. The existing prefix fixtures were built this way and had been asserting nothing. * fix(admin): advertise data-usage and listen capabilities to rc The rc client gated `rc du` and `rc watch` on a pinned contract that matched server versions by the string prefix `1.0.0-rc.`; a server that reports `1.0.0` no longer matches, and the dynamic `advertised` list did not carry either name, so `rc du` against a GA server fails with an unsupported-capability error (rustfs/backlog#2367 E-2). Advertise `admin.data-usage` from the admin route inventory like the IAM entries, and `listen_notification` for the bucket `?events=` extension route the admin router dispatches. The client merges advertised entries ahead of its pinned contract, so no version sniffing is needed. * fix(site-replication): stop notifying the local site on remove and rotate The pending-remove and pending-rotation notification loops skipped the local site by endpoint only, while finalization identifies it by deployment id or endpoint. The reconcile tick resolves the local peer from the node's own listen address (and a handler from the request Host), so `remove --all` dialed the site's registered endpoint, waited out the request timeout against the lifecycle lock it was holding, and answered `Partial: failed to notify 1 peer(s)` for a removal that had succeeded (rustfs/backlog#2367 A-4, backlog#2195 item 3). Both loops now iterate the peers still awaiting notification through one helper that applies the finalization identity. * fix(site-replication): promote and settle IAM retries without a tick of slack Two retry-queue behaviours kept an IAM change from converging for ten to twenty minutes after a peer came back (rustfs/backlog#2367 A-1 and A-3, backlog#2305): - The lightweight 30-second pass filtered its reachability probe to bucket ops, so a backed-off IAM or bucket-metadata snapshot waited for the 600-second tick to notice the peer. It now probes every backed-off class and still replays only bounded bucket ops; promotion is a state flip the heavyweight tick acts on. - Backoffs are multiples of the tick interval, so a failure stamped δ seconds after a tick was 600 − δ old at the next tick and slipped a whole extra interval. The heavyweight drain now evaluates backoff halfway to its next tick. - An IAM entry first created by a non-deletion failure (the add bootstrap's snapshot send, the drain's own replay, an import-iam schedule) was never stamped `deletions_recorded`, so a later recorded deletion could not settle it and it escalated to the marker only `replicate repair` clears. Entries created by this binary now start recorded; a row persisted by an older binary keeps the escalation semantics. * fix(site-replication): reload peer node caches after bucket wiring writes Every S3 bucket-config write ends by asking the other nodes of the cluster to reload the bucket's metadata; the site-replication writers never did. On a multi-node site the node that ran the pairing (or applied a peer's bucket-meta item) rewrote the bucket targets and the derived replication rules on disk, while every other node kept serving its cached copy for up to the 15-minute refresh. A `resync start` routed to such a node reported every freshly wired bucket as `Config not found` and a bucket whose operator target the pairing had replaced as `recorded remote target no longer exists` (rustfs/backlog#2367 A-5, backlog#2195 item 2; functional SITE-105). Add one best-effort reload helper in the site-replication hooks and call it after the bucket setup, versioning, peer bucket-meta apply, removed-peer cleanup, make-with-versioning, and endpoint-refresh writes; the ensure helpers now report whether they wrote so unchanged passes stay silent. The resync manifest and start now read the persisted wiring instead of the node-local cache, matching the target read the start path already did. The new four-node e2e pairs two clusters and starts a resync through a non-coordinator node right after pairing; it also covers an IAM user created on a non-coordinator node converging to the peer site. * test(e2e): cover delete-marker replication from a multi-node source The functional suite reported delete markers created on a 3-node source never reaching the target (rustfs/backlog#2195 item 4, REP-105). The report was a probe defect, but the shape had no coverage: the existing delete-marker e2e runs a single-node source. Pin it against a four-node source replicating to a four-node peer and to a single-node target, with the write and the delete issued through different nodes. * ci(e2e): refresh the distributed selection for the new replication cases Four distributed cases were added (two site-replication, two delete-marker replication). The linux digest is derived from the last CI listing of the lane (34 cases, matching the previous pin) plus the four new names; the darwin digest is the local listing, which selects the same 38 cases. (cherry picked from commitaeaba86d73) * fix(e2e): require a verified server binary for every e2e run (#7687) * fix(ci): share quick checks and lint workflows * fix(ci): install actionlint from its verified release * fix(ci): reject dependencies on required quick checks * feat(test): verify the E2E server build and source identity * test(e2e): register verified Darwin test membership * test(e2e): record verified Linux receipt test membership * test(e2e): record compiled Darwin receipt test membership * test(e2e): record compiled Linux receipt test membership * test(e2e): record Darwin e2e-full membership after merging main * fix(test): route scanner/heal evidence E2E runs through the verified server binary The evidence runners built rustfs with plain cargo and then ran e2e_test directly, which now fails without a run receipt. They build through scripts/e2e_binary.py and run the e2e_test invocations under e2e_binary.py run; the obsolete rustfs.features stamp is removed. * docs(e2e): run server-backed e2e commands through the verified binary wrapper * test(e2e): record Linux e2e-full membership from the branch CI listing (cherry picked from commit2909b1bfe1) * fix(kms): classify KMS/SSE error contracts and SSE-S3 headers (#7697) * fix(sse): classify bare SSE-KMS writes when no KMS is available A `aws:kms` request without a key id, on a bucket without a default key, returned `500 InternalError` whenever no KMS service was running: the "no KMS key available" branch exited with an untyped storage error before the availability classification that the keyed form already received. Route that branch through the same split: `503 ServiceUnavailable` while a configured KMS is stopped, `400 InvalidRequest` when KMS was never configured, and `400 InvalidRequest` naming the missing key id when a running KMS has no default key. `CreateMultipartUpload` shares the path. Adds a unit test for the bare form and an e2e module that stops KMS through the admin API, runs a master-key-only node, and runs a Local KMS without a default key; refreshes the e2e-full selection digests. (cherry picked from commit c3259dadc3d603a9185a5b0ad9f83dfb884e61c8) * fix(sse): keep KMS error classes on the encrypted read path GetObject, CopyObject and UploadPartCopy on an SSE-KMS object whose key no longer exists answered `500 InternalError` ("KMS key not found") while PutObject under the same key already answered `400 KMS.NotFoundException`. The read path carries its classification through ecstore's `EncryptionResolutionErrorKind`, which had no kind for a missing key, a denied KMS grant or a missing backend capability, so all three folded onto `DecryptionFailed` and the S3 layer reported an internal fault. Add `KeyNotFound`, `AccessDenied` and `NotImplemented` kinds, map them on both sides of the boundary, and give an envelope the configured backend cannot unwrap a diagnosable message while keeping its `500`. Unit tests cover the kind round trip and the reader wrapping; a new e2e test deletes a key immediately and checks GET/Copy return 400 with `KMS.NotFoundException` while HEAD stays 200. The e2e-full selection digests are refreshed from the current listing (the previous digests predated the delete-authorization tests) and the e2e `create_default_key` helper is updated to the accepted `EncryptDecrypt` spelling. (cherry picked from commit 2523a9814e97caea318d4ff1a51bef3a4d4445b2) * fix(kms): classify key-management errors on the admin routes `POST /kms/keys`, the legacy `create-key` alias and `generate-data-key` reported every backend refusal as `500`: a blank key name (which each backend failed on differently, the Local backend by writing a key file with an empty stem), a name already taken, an unknown key, a disabled key and a capability the backend lacks. `delete` and the lifecycle routes already classified the same errors. Refuse a blank or whitespace name in `KmsManager::create_key` before any backend sees it, and share one `KmsError` to status mapping across create, delete and generate-data-key (400 for validation and key state, 404 for an unknown key, 409 for a taken name, 501 for a missing capability, 500 only for damaged material). The XML-error routes carry the same status explicitly since s3s derives none for a custom code. The read-only Static backend now reports create, delete and cancel-deletion as `UnsupportedCapability`, matching its rotate and enable/disable answers, so the admin API returns 501 for all of them. (cherry picked from commit e33cac5493c4d9d6662e0d2980b58ba2b24a6d1b) * fix(sse): stop SSE-S3 responses from naming the wrapping KMS key `x-amz-server-side-encryption-aws-kms-key-id` is defined for `aws:kms` objects only, but PutObject, CopyObject, CreateMultipartUpload and GetObject returned it for `AES256` objects too, carrying the KMS key that wraps the SSE-S3 data key (the service default, or the literal `default` on a node without KMS). The write paths copied `kms_key_id` from the encryption material unconditionally, and the single-decrypt GET classification did the same after resolving the key for authorization. Add `EncryptionMaterial::response_kms_key_id`, which yields the id only for SSE-KMS, use it at the four write-response sites, and gate the GET classification the same way. CompleteMultipartUpload and HeadObject already omitted the header. Unit tests pin both directions; a new e2e test covers Put/Get/Head/Copy and CreateMultipartUpload for AES256 with an aws:kms control. The e2e-full selection digests are refreshed from the current listing. (cherry picked from commit 29d793a63352b0b60fd53c565e80fdbede8964bb) * fix(s3): validate PutBucketEncryption rules before storing them A default-encryption rule naming an unknown `SSEAlgorithm` (for example `AES128`), a rule without `ApplyServerSideEncryptionByDefault`, an empty rule list, or a `KMSMasterKeyID` on an `AES256` rule was stored as written: the only algorithm check on the route decided whether to fill in the default KMS key. `GetBucketEncryption` then advertised that configuration while the write path encrypted header-less writes under its `AES256` fallback, so the bucket's declared and actual schemes disagreed. Two comments claimed the route already refused unknown algorithms. Validate the configuration before any of it is applied: `MalformedXML` for a malformed rule set or unknown algorithm, `InvalidArgument` for a key id on a non-KMS rule, and nothing stored on refusal. Correct the two comments to describe when the AES256 fallback is still reachable. Unit tests cover every refusal and the accepted shapes; an e2e test checks the refusals leave the previous configuration in place. The e2e-full selection digests are refreshed from the current listing. (cherry picked from commit 29e4486dce41197ed93f5253cdbabc57d27a4ddb) * test(e2e): refresh e2e-full selection for the combined KMS/SSE fixes * test: align two unit tests with the new KMS and bucket-encryption contracts `scheduled_deletion_carries_a_deadline_and_can_be_cancelled` still expects the state error (`InvalidOperation`) for cancelling a key that is not pending deletion; only the Static backend's mutations moved to `UnsupportedCapability`. The uninitialized-store PutBucketEncryption test now sends a well-formed AES256 rule so it reaches the store lookup instead of the new configuration validation. (cherry picked from commite2e6a2535a) * fix(site-replication): keep an operator's bucket-level target to a peer instead of taking it over (#7709) * fix(site-replication): keep an operator's bucket-level target to a peer instead of taking it over Site replication wired each bucket by looking for an existing replication target "to the same peer" and rewriting the first match in place as its own same-name target. An operator's bucket-level target that happened to point at that site (different target bucket, operator credentials) was the first match whenever it pre-dated the join, and the reconciler repeats the pass every 600s, so the takeover also depended on target order afterwards. The operator's rule then named an ARN no target backed and their bucket replication stopped silently, while the inherited bucket-level reset id made every site resync report the bucket as owned by another resync (rustfs/backlog#2479, rustfs/backlog#2489). Follow MinIO's `getRemoteARN` / `getRemoteARNForPeer` shape instead: - Wiring updates a target in place only under the same ARN, or when it is recognisably the site's own under an older ARN shape (same peer, same-name target bucket, site replication service account). Anything else gets the site target added next to it. - The site resync manifest takes the target the derived `site-repl-<deployment id>` rule names (same-name shape as fallback), so an operator target to the peer neither aborts the bucket as "multiple remote targets matched peer" nor gets resynced into. - Peer removal prunes only targets a pruned derived rule names or the same-name target bucket; operator targets stamped with the peer's deployment id survive together with their rules. Unit tests cover the three predicates. e2e `test_site_replication_keeps_operator_bucket_target_to_peer` runs a bucket-level replication plus `replication-reset` to the future peer, joins the sites, and requires the operator target untouched, both paths delivering, the site resync completing against the site target, and the operator target and rule surviving `replicate remove --all`; without the fix it fails at the join with the operator target gone. The repl-nightly selection digest is refreshed for the new case. * test(site-replication): drop a redundant clone flagged by clippy The reconcile unit test cloned the remote peer into the state map although the binding is not used afterwards; workspace clippy (-D warnings) rejects that as redundant_clone. (cherry picked from commitecdc55fa4b) * fix: enforce S3 permissions for recursive force deletion (#7661) * fix: enforce S3 authorization for recursive deletion * fix: satisfy the s3s footprint guard * fix: restore list versions policy compatibility (#7686) (cherry picked from commit3fd1ce414d) * fix(ci): repair functional defaults and chain regression checks (#7664) * fix(ci): default functional suites to nightly packages * test(ci): follow the fault-tolerance chain handoff (cherry picked from commit509a0fa90c) * fix(ci): align security workflow tests with chain (#7679) (cherry picked from commitd9e47d2813) * test(ecstore): keep tier cleanup tests stable after immediate receipt queueing Release PR #7766 made PUT/CopyObject overwrites queue the tier free-version cleanup receipt immediately, which broke two ecstore tests on release CI. In tier_overwrite_put_and_self_copy_recover_persisted_cleanup_owners the restarted store already runs expiry workers from the second iteration on, so they deleted the remote bytes before the test could assert that the commit leaves them in place; the test now fails the first remote DELETE via set_remove_failure(true) so the cleanup owner stays durable and the later restart still has to rediscover it from xl.meta (the failed remove does not bump remove_count, and the test re-enables removes before the recovery wait). In batch_transitioned_delete_post_commit_failures_roll_back_without_free_version_receipt the convergence loop now treats a transient InsufficientReadQuorum as "not yet converged", because cleanup rewrites xl.meta disk by disk and a racing read can briefly miss quorum (seen in the rio-v2 lane); any other error still panics. * Revert "fix(ci): align security workflow tests with chain (#7679)" This reverts commit4544359f6d. * Revert "fix(ci): repair functional defaults and chain regression checks (#7664)" This reverts commit5ba7ec0291. * fix(ecstore): pass shard integrity to backported ingest-mode test The stalled-reader test backported with #7659 used main's four-argument encode_with_ingest_mode, but release's signature takes an optional IntegrityBuilder, so pass None to keep the test focused on ingest-mode cleanup. --------- Co-authored-by: Henry Guo <marshawcoco@gmail.com> Co-authored-by: 唐小鸭 <tangtang1251@qq.com> Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: RJ Regenold <rregenold@teamraft.com> Co-authored-by: RJ Regenold <214054+rjregenold@users.noreply.github.com> Co-authored-by: cxymds <cxymds@gmail.com> Co-authored-by: GatewayJ <835269233@qq.com> Co-authored-by: Jason Kossis <jkossis@gmail.com>
3027 lines
127 KiB
Rust
3027 lines
127 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use crate::disk::error::Error;
|
|
use crate::disk::error_reduce::{
|
|
OBJECT_OP_IGNORED_ERRS, WriteQuorumFailureSummary, build_write_quorum_failure_summary, reduce_write_quorum_errs,
|
|
};
|
|
use crate::erasure::coding::BitrotWriterWrapper;
|
|
use crate::erasure::coding::Erasure;
|
|
use crate::erasure::coding::erasure::EncodedBlock;
|
|
use crate::io_support::shard_integrity::{IntegrityBuilder, PreparedIntegrity};
|
|
use crate::runtime::sources as runtime_sources;
|
|
use bytes::{Bytes, BytesMut};
|
|
use futures::StreamExt;
|
|
use futures::stream::FuturesUnordered;
|
|
use rustfs_utils::HashAlgorithm;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use std::vec;
|
|
use tokio::io::AsyncRead;
|
|
use tokio::runtime::RuntimeFlavor;
|
|
use tokio::sync::mpsc;
|
|
use tokio::task::{JoinError, JoinHandle};
|
|
use tracing::error;
|
|
|
|
/// Queue-capacity input for encoded blocks awaiting shard writers; it is not a
|
|
/// per-PUT or process-RSS memory limit.
|
|
const ENV_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES: &str = "RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES";
|
|
const ENV_RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS: &str = "RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS";
|
|
const ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST: &str = "RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST";
|
|
const DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES: usize = 32 * 1024 * 1024;
|
|
const DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BLOCKS: usize = 32;
|
|
const DEFAULT_RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS: usize = 4;
|
|
const DEFAULT_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST: bool = false;
|
|
|
|
pub(crate) enum IntegrityEncodeMode {
|
|
Inline(usize),
|
|
SingleBlock(usize),
|
|
Streaming,
|
|
Batched,
|
|
}
|
|
|
|
/// Cached value of `RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES` env var.
|
|
/// Read once at first use via `OnceLock` to avoid per-encode syscall.
|
|
static CACHED_MAX_INFLIGHT_BYTES: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
|
static CACHED_BATCH_BLOCKS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
|
static CACHED_BYTESMUT_INGEST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
|
|
|
#[inline(always)]
|
|
fn stage_timer_if_enabled() -> Option<Instant> {
|
|
rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now)
|
|
}
|
|
|
|
#[inline(always)]
|
|
fn record_internal_stage_if_enabled(stage: &'static str, started_at: Option<Instant>) {
|
|
if let Some(started_at) = started_at {
|
|
rustfs_io_metrics::record_stage_duration(stage, started_at.elapsed().as_secs_f64() * 1000.0);
|
|
}
|
|
}
|
|
|
|
fn encode_channel_capacity(expanded_block_bytes: usize, max_inflight_bytes: usize) -> usize {
|
|
if expanded_block_bytes == 0 {
|
|
return 1;
|
|
}
|
|
|
|
max_inflight_bytes
|
|
.saturating_div(expanded_block_bytes)
|
|
.clamp(1, DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BLOCKS)
|
|
}
|
|
|
|
fn encode_batch_block_count() -> usize {
|
|
*CACHED_BATCH_BLOCKS.get_or_init(|| {
|
|
rustfs_utils::get_env_usize(ENV_RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS, DEFAULT_RUSTFS_ERASURE_ENCODE_BATCH_BLOCKS)
|
|
.clamp(1, DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BLOCKS)
|
|
})
|
|
}
|
|
|
|
fn erasure_encode_max_inflight_bytes() -> usize {
|
|
*CACHED_MAX_INFLIGHT_BYTES.get_or_init(|| {
|
|
rustfs_utils::get_env_usize(
|
|
ENV_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES,
|
|
DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES,
|
|
)
|
|
})
|
|
}
|
|
|
|
fn use_bytesmut_ingest() -> bool {
|
|
*CACHED_BYTESMUT_INGEST.get_or_init(|| {
|
|
rustfs_utils::get_env_bool(ENV_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST, DEFAULT_RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST)
|
|
})
|
|
}
|
|
|
|
fn small_ingest_capacity(erasure: &Erasure, size_hint: usize) -> usize {
|
|
let data_len = size_hint.min(erasure.block_size);
|
|
erasure.encoded_capacity_for_data_len(data_len).min(erasure.block_size)
|
|
}
|
|
|
|
/// Keeps the encoder producer scoped to its parent future. Tokio detaches a
|
|
/// task when its `JoinHandle` is dropped, so the producer must be aborted when
|
|
/// an upload is cancelled before the encode pipeline finishes.
|
|
struct AbortOnDropTask<T>(JoinHandle<T>);
|
|
|
|
impl<T> AbortOnDropTask<T> {
|
|
fn new(task: JoinHandle<T>) -> Self {
|
|
Self(task)
|
|
}
|
|
|
|
async fn abort_and_wait(&mut self) {
|
|
self.0.abort();
|
|
let _ = (&mut self.0).await;
|
|
}
|
|
|
|
async fn join(&mut self) -> Result<T, JoinError> {
|
|
(&mut self.0).await
|
|
}
|
|
}
|
|
|
|
impl<T> Drop for AbortOnDropTask<T> {
|
|
fn drop(&mut self) {
|
|
self.0.abort();
|
|
}
|
|
}
|
|
|
|
/// Read up to `limit` bytes into `buf`'s uninitialized spare capacity, appending after its
|
|
/// current length, and distinguish a clean EOF from a short read.
|
|
///
|
|
/// Mirrors `rustfs_utils::read_full_or_eof` semantics: returns `Ok(None)` when EOF is reached
|
|
/// before any byte is read, `Ok(Some(n))` once at least one byte is read, preserves
|
|
/// `InvalidData` errors (e.g. checksum mismatches) raised after a partial fill, and wraps other
|
|
/// partial-fill errors as `UnexpectedEof`. Unlike `resize` followed by a slice read, the spare
|
|
/// capacity is never zero-filled first, so full-block ingest skips a per-block memset.
|
|
async fn read_full_buf_or_eof<R>(reader: &mut R, buf: &mut BytesMut, limit: usize) -> std::io::Result<Option<usize>>
|
|
where
|
|
R: AsyncRead + Unpin,
|
|
{
|
|
use bytes::BufMut as _;
|
|
use tokio::io::AsyncReadExt as _;
|
|
|
|
debug_assert!(buf.is_empty(), "block ingest buffer must start empty");
|
|
debug_assert!(limit > 0, "limit must be non-zero (block_size is validated upstream)");
|
|
let mut total = 0;
|
|
while total < limit {
|
|
let mut limited = (&mut *buf).limit(limit - total);
|
|
let n = match reader.read_buf(&mut limited).await {
|
|
Ok(n) => n,
|
|
Err(e) => {
|
|
if total == 0 {
|
|
return Err(e);
|
|
}
|
|
// Preserve InvalidData (e.g. checksum mismatch) instead of wrapping it as
|
|
// UnexpectedEof, so proper error handling can occur upstream.
|
|
if e.kind() == std::io::ErrorKind::InvalidData {
|
|
return Err(e);
|
|
}
|
|
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, e));
|
|
}
|
|
};
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
total += n;
|
|
}
|
|
if total == 0 { Ok(None) } else { Ok(Some(total)) }
|
|
}
|
|
|
|
#[allow(dead_code, reason = "byte accounting asserted by this file's tests (backlog#1823)")]
|
|
fn queued_block_bytes(block: &[Bytes]) -> usize {
|
|
block.iter().map(Bytes::len).sum()
|
|
}
|
|
|
|
/// Owns an encoded queue entry's gauge contribution until its consumer takes it.
|
|
struct QueuedInflightBytes {
|
|
bytes: usize,
|
|
}
|
|
|
|
impl QueuedInflightBytes {
|
|
fn new(bytes: usize) -> Self {
|
|
rustfs_io_metrics::add_ec_encode_inflight_bytes(bytes);
|
|
Self { bytes }
|
|
}
|
|
|
|
fn settle(&mut self) {
|
|
let bytes = std::mem::take(&mut self.bytes);
|
|
if bytes != 0 {
|
|
rustfs_io_metrics::remove_ec_encode_inflight_bytes(bytes);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for QueuedInflightBytes {
|
|
fn drop(&mut self) {
|
|
self.settle();
|
|
}
|
|
}
|
|
|
|
/// Couples an encoded block with its queue gauge contribution. Keeping the
|
|
/// guard in the queue entry also covers Tokio sends that complete through a
|
|
/// permit after the receiver has closed.
|
|
struct InflightEntry<T> {
|
|
entry: T,
|
|
accounting: QueuedInflightBytes,
|
|
}
|
|
|
|
impl<T> InflightEntry<T> {
|
|
fn new(entry: T, bytes: usize) -> Self {
|
|
Self {
|
|
entry,
|
|
accounting: QueuedInflightBytes::new(bytes),
|
|
}
|
|
}
|
|
|
|
fn into_inner(mut self) -> T {
|
|
self.accounting.settle();
|
|
self.entry
|
|
}
|
|
}
|
|
|
|
async fn send_queued<T>(
|
|
sender: &mpsc::Sender<InflightEntry<T>>,
|
|
entry: T,
|
|
bytes: usize,
|
|
) -> Result<(), mpsc::error::SendError<InflightEntry<T>>> {
|
|
sender.send(InflightEntry::new(entry, bytes)).await
|
|
}
|
|
|
|
fn queued_batch_bytes(batch: &[EncodedBlock]) -> usize {
|
|
batch.iter().map(EncodedBlock::queued_bytes).sum()
|
|
}
|
|
|
|
fn dominant_error_summary_label(summary: &WriteQuorumFailureSummary) -> &'static str {
|
|
summary.dominant_error_label
|
|
}
|
|
|
|
fn format_write_quorum_failure(summary: &WriteQuorumFailureSummary) -> String {
|
|
format!(
|
|
"erasure write quorum (required={}, achieved={}, failed={}, total={}, offline-disks={}/{}, retryable-failures={}, dominant-error={})",
|
|
summary.required,
|
|
summary.achieved,
|
|
summary.failed,
|
|
summary.total,
|
|
summary.offline_disks,
|
|
summary.total,
|
|
summary.retryable_failures,
|
|
dominant_error_summary_label(summary)
|
|
)
|
|
}
|
|
|
|
fn quorum_dominant_error_metric_label(summary: &WriteQuorumFailureSummary) -> &'static str {
|
|
dominant_error_summary_label(summary)
|
|
}
|
|
|
|
/// Progress deadlines that bound how long shard writers may stall.
|
|
///
|
|
/// A single shard write (or writer shutdown) is a unit of forward progress: for
|
|
/// a remote shard the underlying `HttpWriter` buffers the bytes and hands them
|
|
/// to a background HTTP task, so a peer that accepts the connection but never
|
|
/// drains the body eventually wedges the write once the bounded buffers fill.
|
|
/// Without a deadline that stalled writer is awaited forever and pins an
|
|
/// otherwise-healthy write quorum (rustfs/backlog#1319).
|
|
///
|
|
/// * `stall_timeout` is re-armed on every shard write, so it bounds a stall, not
|
|
/// the total transfer time of a large object — a slow-but-honest writer that
|
|
/// keeps completing shards is never killed.
|
|
/// * `absolute_cap` is an optional administrator backstop: the shard writers for
|
|
/// one object are engaged for at most this long in aggregate. It defends
|
|
/// against a "slow-drip" peer that produces just enough progress to reset the
|
|
/// per-shard timeout on every block while never converging. Disabled by
|
|
/// default so a legitimate large upload over a slow link is not killed on
|
|
/// total time alone.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct WriteProgressPolicy {
|
|
stall_timeout: Option<std::time::Duration>,
|
|
absolute_cap: Option<std::time::Duration>,
|
|
}
|
|
|
|
impl WriteProgressPolicy {
|
|
/// Build a policy, mapping a zero duration to "disabled" for both knobs.
|
|
pub(crate) fn new(stall_timeout: std::time::Duration, absolute_cap: std::time::Duration) -> Self {
|
|
Self {
|
|
stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout),
|
|
absolute_cap: (!absolute_cap.is_zero()).then_some(absolute_cap),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for WriteProgressPolicy {
|
|
fn default() -> Self {
|
|
Self::new(
|
|
crate::disk::disk_store::get_object_disk_write_stall_timeout(),
|
|
crate::disk::disk_store::get_object_disk_write_absolute_cap(),
|
|
)
|
|
}
|
|
}
|
|
|
|
pub(crate) struct MultiWriter<'a> {
|
|
writers: &'a mut [Option<BitrotWriterWrapper>],
|
|
integrity: Option<&'a mut IntegrityBuilder>,
|
|
write_quorum: usize,
|
|
errs: Vec<Option<Error>>,
|
|
policy: WriteProgressPolicy,
|
|
/// Absolute deadline for the whole object, armed lazily on the first
|
|
/// progress-bounded operation when `policy.absolute_cap` is set.
|
|
absolute_deadline: Option<tokio::time::Instant>,
|
|
}
|
|
|
|
impl<'a> MultiWriter<'a> {
|
|
pub fn new(writers: &'a mut [Option<BitrotWriterWrapper>], write_quorum: usize) -> Self {
|
|
Self::with_policy(writers, write_quorum, WriteProgressPolicy::default())
|
|
}
|
|
|
|
pub fn with_policy(writers: &'a mut [Option<BitrotWriterWrapper>], write_quorum: usize, policy: WriteProgressPolicy) -> Self {
|
|
let length = writers.len();
|
|
MultiWriter {
|
|
writers,
|
|
integrity: None,
|
|
write_quorum,
|
|
errs: vec![None; length],
|
|
policy,
|
|
absolute_deadline: None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn take_retryable_internode_write_failure(&mut self) -> Option<Error> {
|
|
self.errs
|
|
.iter_mut()
|
|
.find(|error| error.as_ref().is_some_and(Error::is_retryable_internode_write_failure))
|
|
.and_then(Option::take)
|
|
}
|
|
|
|
/// Effective budget for one shard operation: the smaller of the per-shard
|
|
/// stall timeout and the time remaining until the object's absolute cap.
|
|
/// Returns `None` when neither deadline is configured (wait indefinitely).
|
|
fn next_progress_budget(&mut self) -> Option<std::time::Duration> {
|
|
if let Some(cap) = self.policy.absolute_cap {
|
|
let deadline = *self
|
|
.absolute_deadline
|
|
.get_or_insert_with(|| tokio::time::Instant::now() + cap);
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
match self.policy.stall_timeout {
|
|
Some(stall) => Some(stall.min(remaining)),
|
|
None => Some(remaining),
|
|
}
|
|
} else {
|
|
self.policy.stall_timeout
|
|
}
|
|
}
|
|
|
|
async fn write_shard(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>, shard: &[u8]) {
|
|
match writer_opt {
|
|
Some(writer) => {
|
|
match writer.write(shard).await {
|
|
Ok(n) => {
|
|
if n < shard.len() {
|
|
*err = Some(Error::ShortWrite);
|
|
*writer_opt = None; // Mark as failed
|
|
} else {
|
|
*err = None;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
*err = Some(Error::from(e));
|
|
*writer_opt = None; // Mark as failed so the caller drops this disk before commit
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
*err = Some(Error::DiskNotFound);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn write(&mut self, data: Vec<Bytes>) -> std::io::Result<()> {
|
|
self.write_shards(data.iter().map(Bytes::as_ref)).await
|
|
}
|
|
|
|
async fn write_block(&mut self, block: &EncodedBlock) -> std::io::Result<()> {
|
|
if let Some(integrity) = &mut self.integrity {
|
|
// Commit every coding index, including disks absent from the write.
|
|
integrity.push(block.shards()).await?;
|
|
}
|
|
self.write_shards(block.shards()).await
|
|
}
|
|
|
|
async fn write_shards<'b>(&mut self, shards: impl ExactSizeIterator<Item = &'b [u8]>) -> std::io::Result<()> {
|
|
assert_eq!(shards.len(), self.writers.len());
|
|
|
|
let budget = self.next_progress_budget();
|
|
{
|
|
let mut futures = FuturesUnordered::new();
|
|
for ((writer_opt, err), shard) in self.writers.iter_mut().zip(self.errs.iter_mut()).zip(shards) {
|
|
if err.is_some() {
|
|
continue; // Skip if we already have an error for this writer
|
|
}
|
|
// A shard write that makes no forward progress within `budget` is
|
|
// failed and its disk dropped, so a stalled peer cannot pin an
|
|
// otherwise-healthy write quorum (rustfs/backlog#1319). `budget`
|
|
// is recomputed per block, so it bounds a stall — not the total
|
|
// transfer time — and a slow-but-honest writer is never killed.
|
|
futures.push(async move {
|
|
match budget {
|
|
Some(budget) => match tokio::time::timeout(budget, Self::write_shard(writer_opt, err, shard)).await {
|
|
Ok(()) => {}
|
|
Err(_elapsed) => {
|
|
*err = Some(Error::Timeout);
|
|
*writer_opt = None;
|
|
}
|
|
},
|
|
None => Self::write_shard(writer_opt, err, shard).await,
|
|
}
|
|
});
|
|
}
|
|
while let Some(()) = futures.next().await {}
|
|
}
|
|
|
|
let nil_count = self.errs.iter().filter(|&e| e.is_none()).count();
|
|
if nil_count >= self.write_quorum {
|
|
return Ok(());
|
|
}
|
|
|
|
let write_err =
|
|
reduce_write_quorum_errs(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum).unwrap_or(Error::ErasureWriteQuorum);
|
|
let summary = build_write_quorum_failure_summary(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum);
|
|
let summary_text = format_write_quorum_failure(&summary);
|
|
runtime_sources::record_erasure_write_quorum_failure("write", quorum_dominant_error_metric_label(&summary));
|
|
error!(
|
|
required = summary.required,
|
|
achieved = summary.achieved,
|
|
failed = summary.failed,
|
|
total = summary.total,
|
|
offline_disks = summary.offline_disks,
|
|
retryable_failures = summary.retryable_failures,
|
|
dominant_error = summary.dominant_error_label,
|
|
returned_error = %write_err,
|
|
errs = ?self.errs,
|
|
"Erasure encode write quorum unavailable: {summary_text}"
|
|
);
|
|
Err(write_err.into())
|
|
}
|
|
|
|
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
|
|
match writer_opt {
|
|
Some(writer) => match writer.shutdown().await {
|
|
Ok(()) => {
|
|
*err = None;
|
|
}
|
|
Err(e) => {
|
|
*err = Some(Error::from(e));
|
|
*writer_opt = None;
|
|
}
|
|
},
|
|
None => {
|
|
*err = Some(Error::DiskNotFound);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn shutdown(&mut self) -> std::io::Result<()> {
|
|
crate::hp_guard!("MultiWriter::shutdown");
|
|
let budget = self.next_progress_budget();
|
|
{
|
|
let mut futures = FuturesUnordered::new();
|
|
for (writer_opt, err) in self.writers.iter_mut().zip(self.errs.iter_mut()) {
|
|
if err.is_some() {
|
|
continue;
|
|
}
|
|
// Shutdown flushes the tail and waits for the remote to finish the
|
|
// HTTP request; a black-hole peer that never responds would hang
|
|
// here forever for a small object whose bytes were fully buffered
|
|
// (so `write` never blocked). Bound it with the same progress
|
|
// budget and drop the stalled writer before the quorum check.
|
|
futures.push(async move {
|
|
match budget {
|
|
Some(budget) => match tokio::time::timeout(budget, Self::shutdown_writer(writer_opt, err)).await {
|
|
Ok(()) => {}
|
|
Err(_elapsed) => {
|
|
*err = Some(Error::Timeout);
|
|
*writer_opt = None;
|
|
}
|
|
},
|
|
None => Self::shutdown_writer(writer_opt, err).await,
|
|
}
|
|
});
|
|
}
|
|
while let Some(()) = futures.next().await {}
|
|
}
|
|
|
|
let nil_count = self.errs.iter().filter(|&e| e.is_none()).count();
|
|
if nil_count >= self.write_quorum {
|
|
return Ok(());
|
|
}
|
|
|
|
let write_err =
|
|
reduce_write_quorum_errs(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum).unwrap_or(Error::ErasureWriteQuorum);
|
|
let summary = build_write_quorum_failure_summary(&self.errs, OBJECT_OP_IGNORED_ERRS, self.write_quorum);
|
|
let summary_text = format_write_quorum_failure(&summary);
|
|
runtime_sources::record_erasure_write_quorum_failure("shutdown", quorum_dominant_error_metric_label(&summary));
|
|
error!(
|
|
required = summary.required,
|
|
achieved = summary.achieved,
|
|
failed = summary.failed,
|
|
total = summary.total,
|
|
offline_disks = summary.offline_disks,
|
|
retryable_failures = summary.retryable_failures,
|
|
dominant_error = summary.dominant_error_label,
|
|
returned_error = %write_err,
|
|
errs = ?self.errs,
|
|
"Erasure encode shutdown quorum unavailable: {summary_text}"
|
|
);
|
|
Err(write_err.into())
|
|
}
|
|
}
|
|
|
|
impl Erasure {
|
|
pub(crate) async fn encode_with_shard_integrity<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
part_number: usize,
|
|
mode: IntegrityEncodeMode,
|
|
protected: bool,
|
|
) -> std::io::Result<(R, usize, Option<Vec<Bytes>>, Option<PreparedIntegrity>)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
{
|
|
let mut integrity = if protected {
|
|
let layout = rustfs_filemeta::shard_integrity::IntegrityLayout::new(
|
|
self.data_shards,
|
|
self.parity_shards,
|
|
self.block_size,
|
|
self.uses_legacy_codec(),
|
|
)
|
|
.map_err(std::io::Error::other)?;
|
|
Some(IntegrityBuilder::new(layout, part_number)?)
|
|
} else {
|
|
None
|
|
};
|
|
let (reader, size, inline) = match mode {
|
|
IntegrityEncodeMode::Inline(hint) => {
|
|
let (reader, size, shards) = self
|
|
.encode_inline_shards_with_integrity(reader, hint, integrity.as_mut())
|
|
.await?;
|
|
(reader, size, Some(shards))
|
|
}
|
|
IntegrityEncodeMode::SingleBlock(hint) => {
|
|
let (reader, size) = self
|
|
.encode_small_direct(reader, writers, quorum, true, hint, integrity.as_mut())
|
|
.await?;
|
|
(reader, size, None)
|
|
}
|
|
IntegrityEncodeMode::Streaming => {
|
|
let (reader, size) = self
|
|
.encode_with_ingest_mode(reader, writers, quorum, use_bytesmut_ingest(), integrity.as_mut())
|
|
.await?;
|
|
(reader, size, None)
|
|
}
|
|
IntegrityEncodeMode::Batched => {
|
|
let (reader, size) = self
|
|
.encode_batched_with_integrity(reader, writers, quorum, integrity.as_mut())
|
|
.await?;
|
|
(reader, size, None)
|
|
}
|
|
};
|
|
let integrity = match integrity {
|
|
Some(integrity) => Some(integrity.finish(size).await?),
|
|
None => None,
|
|
};
|
|
Ok((reader, size, inline, integrity))
|
|
}
|
|
|
|
async fn encode_block(self: Arc<Self>, encode_buf: Vec<u8>, len: usize) -> std::io::Result<(EncodedBlock, Vec<u8>)> {
|
|
let encode_stage_start = stage_timer_if_enabled();
|
|
let encode_once = move || {
|
|
let res = self.encode_data_block(&encode_buf[..len]);
|
|
(res, encode_buf)
|
|
};
|
|
|
|
let (res, returned_buf) = match tokio::runtime::Handle::current().runtime_flavor() {
|
|
// EC encode is a short CPU burst (~110µs per 1MiB block, p99 ~542µs).
|
|
// On the multi-threaded runtime block_in_place parked the worker and
|
|
// churned the scheduler for a cost comparable to the encode itself
|
|
// (rustfs/backlog#932); at this duration a direct inline call is
|
|
// cheaper and equally correct. CurrentThread (and any other flavor)
|
|
// keep spawn_blocking so the sole executor thread is never blocked
|
|
// and block_in_place's multi-thread-only requirement is respected.
|
|
RuntimeFlavor::MultiThread => encode_once(),
|
|
RuntimeFlavor::CurrentThread => tokio::task::spawn_blocking(encode_once)
|
|
.await
|
|
.map_err(|err| std::io::Error::other(format!("EC encode task failed: {err}")))?,
|
|
_ => tokio::task::spawn_blocking(encode_once)
|
|
.await
|
|
.map_err(|err| std::io::Error::other(format!("EC encode task failed: {err}")))?,
|
|
};
|
|
|
|
record_internal_stage_if_enabled("erasure_encode_cpu", encode_stage_start);
|
|
Ok((res?, returned_buf))
|
|
}
|
|
|
|
async fn encode_block_bytes_mut(self: Arc<Self>, encode_buf: BytesMut, len: usize) -> std::io::Result<EncodedBlock> {
|
|
let encode_stage_start = stage_timer_if_enabled();
|
|
let encode_once = move || self.encode_data_bytes_mut_block(encode_buf, len);
|
|
|
|
let res = match tokio::runtime::Handle::current().runtime_flavor() {
|
|
// Same rationale as encode_block: inline the short EC burst on the
|
|
// multi-threaded runtime instead of parking a worker via
|
|
// block_in_place; keep spawn_blocking on single-threaded flavors.
|
|
RuntimeFlavor::MultiThread => encode_once(),
|
|
RuntimeFlavor::CurrentThread => tokio::task::spawn_blocking(encode_once)
|
|
.await
|
|
.map_err(|err| std::io::Error::other(format!("EC encode task failed: {err}")))?,
|
|
_ => tokio::task::spawn_blocking(encode_once)
|
|
.await
|
|
.map_err(|err| std::io::Error::other(format!("EC encode task failed: {err}")))?,
|
|
};
|
|
|
|
record_internal_stage_if_enabled("erasure_encode_cpu", encode_stage_start);
|
|
res
|
|
}
|
|
|
|
async fn encode_small_direct<R>(
|
|
self: Arc<Self>,
|
|
mut reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
require_single_block: bool,
|
|
size_hint: usize,
|
|
integrity: Option<&mut IntegrityBuilder>,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin,
|
|
{
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let mut buf = Vec::with_capacity(small_ingest_capacity(&self, size_hint));
|
|
let total = if require_single_block {
|
|
let read_limit = self
|
|
.block_size
|
|
.checked_add(1)
|
|
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "erasure block_size is too large"))?;
|
|
let read_limit = u64::try_from(read_limit)
|
|
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "erasure block_size exceeds u64"))?;
|
|
(&mut reader).take(read_limit).read_to_end(&mut buf).await?
|
|
} else {
|
|
reader.read_to_end(&mut buf).await?
|
|
};
|
|
|
|
if total == 0 {
|
|
return Ok((reader, 0));
|
|
}
|
|
|
|
if require_single_block && total > self.block_size {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"single-block non-inline fast path expects total <= block_size",
|
|
));
|
|
}
|
|
|
|
let block = self.encode_data_owned_block(buf)?;
|
|
let mut mw = MultiWriter::new(writers, quorum);
|
|
mw.integrity = integrity;
|
|
mw.write_block(&block).await?;
|
|
mw.shutdown().await?;
|
|
Ok((reader, total))
|
|
}
|
|
|
|
/// Encode a small inline object directly into its per-disk bitrot payloads.
|
|
/// The returned bytes are the same `[hash][shard]` representation produced
|
|
/// by `BitrotWriter`, ready to be embedded in each disk's staged `xl.meta`.
|
|
#[hotpath::measure(impl_type = "Erasure")]
|
|
#[cfg(test)]
|
|
pub(crate) async fn encode_inline_shards_with_size_hint<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
size_hint: usize,
|
|
) -> std::io::Result<(R, usize, Vec<Bytes>)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin,
|
|
{
|
|
self.encode_inline_shards_with_integrity(reader, size_hint, None).await
|
|
}
|
|
|
|
async fn encode_inline_shards_with_integrity<R>(
|
|
self: Arc<Self>,
|
|
mut reader: R,
|
|
size_hint: usize,
|
|
integrity: Option<&mut IntegrityBuilder>,
|
|
) -> std::io::Result<(R, usize, Vec<Bytes>)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin,
|
|
{
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let mut buf = Vec::with_capacity(small_ingest_capacity(&self, size_hint));
|
|
let total = reader.read_to_end(&mut buf).await?;
|
|
if total == 0 {
|
|
return Ok((reader, 0, Vec::new()));
|
|
}
|
|
|
|
let block = self.encode_data_owned_block(buf)?;
|
|
if let Some(integrity) = integrity {
|
|
integrity.push(block.shards()).await?;
|
|
}
|
|
let mut inline_shards = Vec::with_capacity(block.shards().len());
|
|
for shard in block.shards() {
|
|
let hash = HashAlgorithm::HighwayHash256S.hash_encode(shard);
|
|
let mut encoded = BytesMut::with_capacity(hash.as_ref().len() + shard.len());
|
|
encoded.extend_from_slice(hash.as_ref());
|
|
encoded.extend_from_slice(shard);
|
|
inline_shards.push(encoded.freeze());
|
|
}
|
|
|
|
Ok((reader, total, inline_shards))
|
|
}
|
|
|
|
#[hotpath::measure(impl_type = "Erasure")]
|
|
pub async fn encode<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
{
|
|
let use_bytesmut_ingest = use_bytesmut_ingest();
|
|
self.encode_with_ingest_mode(reader, writers, quorum, use_bytesmut_ingest, None)
|
|
.await
|
|
}
|
|
|
|
/// Streaming encode with an explicit ingest-buffer strategy. `encode` resolves the
|
|
/// strategy from `RUSTFS_ERASURE_ENCODE_BYTESMUT_INGEST` once per process via
|
|
/// `OnceLock`, so the cached value latches on first use; tests must call this
|
|
/// directly with an explicit flag (not `encode` + env vars) to exercise both paths.
|
|
async fn encode_with_ingest_mode<R>(
|
|
self: Arc<Self>,
|
|
mut reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
use_bytesmut_ingest: bool,
|
|
integrity: Option<&mut IntegrityBuilder>,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
{
|
|
if self.block_size == 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"erasure block_size must be non-zero",
|
|
));
|
|
}
|
|
|
|
// Bound queued encoded blocks by a queue budget; this does not bound
|
|
// reader, encoder, writer, allocator, or process-RSS memory.
|
|
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
|
|
let max_inflight_bytes = erasure_encode_max_inflight_bytes();
|
|
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
|
let (tx, mut rx) = mpsc::channel::<InflightEntry<EncodedBlock>>(inflight_blocks);
|
|
|
|
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
|
let block_size = self.block_size;
|
|
let mut total = 0;
|
|
if use_bytesmut_ingest {
|
|
// HP-10 (rustfs/backlog#931): reserve the EC-expanded block size up front.
|
|
// Both shard-size formulas are monotone in data_len, so the resize to
|
|
// `need_total_size` inside `encode_data_bytes_mut` always stays within this
|
|
// capacity and never reallocates. Reading into uninitialized spare capacity
|
|
// (instead of resize + slice read) also skips zero-filling each fresh buffer.
|
|
let ingest_capacity = expanded_block_bytes.max(block_size);
|
|
// Pre-allocate buffer pool for this encoding session
|
|
let mut buf_pool: Vec<BytesMut> = Vec::with_capacity(4);
|
|
let mut buf = BytesMut::with_capacity(ingest_capacity);
|
|
loop {
|
|
match read_full_buf_or_eof(&mut reader, &mut buf, block_size).await {
|
|
Ok(Some(n)) => {
|
|
debug_assert!(n > 0, "non-zero block_size prevents zero-length reads");
|
|
debug_assert_eq!(buf.len(), n, "ingest buffer length must equal bytes read");
|
|
total += n;
|
|
let encode_buf = buf;
|
|
let res = self.clone().encode_block_bytes_mut(encode_buf, n).await?;
|
|
// Try to reuse buffer from pool, or allocate new one
|
|
buf = buf_pool.pop().unwrap_or_else(|| BytesMut::with_capacity(ingest_capacity));
|
|
let queued_bytes = res.queued_bytes();
|
|
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
|
let send_wait_stage_start = stage_timer_if_enabled();
|
|
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_send_wait", send_wait_stage_start);
|
|
// Return buffer to pool if it has sufficient capacity
|
|
if buf.capacity() >= ingest_capacity && buf_pool.len() < 4 {
|
|
buf_pool.push(buf);
|
|
buf = BytesMut::with_capacity(ingest_capacity);
|
|
}
|
|
}
|
|
Ok(None) => break,
|
|
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
|
if let Some(inner) = e.get_ref()
|
|
&& rustfs_rio::is_checksum_mismatch(inner)
|
|
{
|
|
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()));
|
|
}
|
|
return Err(e);
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
} else {
|
|
let mut buf = vec![0u8; block_size];
|
|
loop {
|
|
match rustfs_utils::read_full_or_eof(&mut reader, &mut buf).await {
|
|
Ok(Some(n)) => {
|
|
debug_assert!(n > 0, "non-zero block_size prevents zero-length reads");
|
|
total += n;
|
|
let encode_buf = std::mem::take(&mut buf);
|
|
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
|
buf = returned_buf;
|
|
let queued_bytes = res.queued_bytes();
|
|
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
|
let send_wait_stage_start = stage_timer_if_enabled();
|
|
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_send_wait", send_wait_stage_start);
|
|
}
|
|
Ok(None) => {
|
|
break;
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
|
// Check if the inner error is a checksum mismatch - if so, propagate it
|
|
if let Some(inner) = e.get_ref()
|
|
&& rustfs_rio::is_checksum_mismatch(inner)
|
|
{
|
|
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()));
|
|
}
|
|
return Err(e);
|
|
}
|
|
Err(e) => {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((reader, total))
|
|
}));
|
|
|
|
let mut writers = MultiWriter::new(writers, quorum);
|
|
writers.integrity = integrity;
|
|
|
|
let mut write_err = None;
|
|
|
|
loop {
|
|
let recv_wait_stage_start = stage_timer_if_enabled();
|
|
let Some(block) = rx.recv().await else {
|
|
break;
|
|
};
|
|
record_internal_stage_if_enabled("erasure_encode_recv_wait", recv_wait_stage_start);
|
|
let block = block.into_inner();
|
|
if block.is_empty() {
|
|
break;
|
|
}
|
|
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(block.queued_bytes());
|
|
let write_stage_start = stage_timer_if_enabled();
|
|
if let Err(err) = writers.write_block(&block).await {
|
|
write_err = Some(err);
|
|
break;
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_write", write_stage_start);
|
|
}
|
|
|
|
if let Some(err) = write_err {
|
|
task.abort_and_wait().await;
|
|
drop(rx);
|
|
let shutdown_stage_start = stage_timer_if_enabled();
|
|
if let Err(shutdown_err) = writers.shutdown().await {
|
|
error!("failed to shutdown erasure writers after write error: {:?}", shutdown_err);
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_shutdown", shutdown_stage_start);
|
|
return Err(err);
|
|
}
|
|
|
|
let (reader, total) = task.join().await??;
|
|
let shutdown_stage_start = stage_timer_if_enabled();
|
|
writers.shutdown().await?;
|
|
record_internal_stage_if_enabled("erasure_encode_shutdown", shutdown_stage_start);
|
|
Ok((reader, total))
|
|
}
|
|
|
|
#[hotpath::measure(impl_type = "Erasure")]
|
|
pub async fn encode_batched<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
{
|
|
self.encode_batched_with_integrity(reader, writers, quorum, None).await
|
|
}
|
|
|
|
async fn encode_batched_with_integrity<R>(
|
|
self: Arc<Self>,
|
|
mut reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
integrity: Option<&mut IntegrityBuilder>,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
{
|
|
if self.block_size == 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"erasure block_size must be non-zero",
|
|
));
|
|
}
|
|
|
|
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
|
|
let max_inflight_bytes = erasure_encode_max_inflight_bytes();
|
|
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
|
let batch_blocks = encode_batch_block_count().min(inflight_blocks);
|
|
let channel_capacity = inflight_blocks.div_ceil(batch_blocks).max(1);
|
|
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<EncodedBlock>>>(channel_capacity);
|
|
|
|
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
|
let block_size = self.block_size;
|
|
let mut total = 0;
|
|
let mut buf = vec![0u8; block_size];
|
|
let mut pending_batch = Vec::with_capacity(batch_blocks);
|
|
let mut pending_batch_bytes = 0usize;
|
|
let mut pending_batch_stage = None;
|
|
loop {
|
|
match rustfs_utils::read_full_or_eof(&mut reader, &mut buf).await {
|
|
Ok(Some(n)) => {
|
|
debug_assert!(n > 0, "non-zero block_size prevents zero-length reads");
|
|
total += n;
|
|
let encode_buf = std::mem::take(&mut buf);
|
|
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
|
buf = returned_buf;
|
|
let queued_bytes = res.queued_bytes();
|
|
pending_batch_bytes = pending_batch_bytes.saturating_add(queued_bytes);
|
|
pending_batch.push(res);
|
|
drop(pending_batch_stage.take());
|
|
pending_batch_stage = Some(rustfs_io_metrics::track_ec_encode_producer_bytes(pending_batch_bytes));
|
|
|
|
if pending_batch.len() >= batch_blocks {
|
|
let send_wait_stage_start = stage_timer_if_enabled();
|
|
if let Err(err) = send_queued(&tx, pending_batch, pending_batch_bytes).await {
|
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_batched_send_wait", send_wait_stage_start);
|
|
drop(pending_batch_stage.take());
|
|
pending_batch = Vec::with_capacity(batch_blocks);
|
|
pending_batch_bytes = 0;
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
break;
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
|
if let Some(inner) = e.get_ref()
|
|
&& rustfs_rio::is_checksum_mismatch(inner)
|
|
{
|
|
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()));
|
|
}
|
|
return Err(e);
|
|
}
|
|
Err(e) => {
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !pending_batch.is_empty() {
|
|
let send_wait_stage_start = stage_timer_if_enabled();
|
|
if let Err(err) = send_queued(&tx, pending_batch, pending_batch_bytes).await {
|
|
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_batched_send_wait", send_wait_stage_start);
|
|
drop(pending_batch_stage);
|
|
}
|
|
|
|
Ok((reader, total))
|
|
}));
|
|
|
|
let mut writers = MultiWriter::new(writers, quorum);
|
|
writers.integrity = integrity;
|
|
let mut write_err = None;
|
|
|
|
loop {
|
|
let recv_wait_stage_start = stage_timer_if_enabled();
|
|
let Some(batch) = rx.recv().await else {
|
|
break;
|
|
};
|
|
record_internal_stage_if_enabled("erasure_encode_batched_recv_wait", recv_wait_stage_start);
|
|
let batch = batch.into_inner();
|
|
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_batch_bytes(&batch));
|
|
let write_stage_start = stage_timer_if_enabled();
|
|
for block in batch {
|
|
if let Err(err) = writers.write_block(&block).await {
|
|
write_err = Some(err);
|
|
break;
|
|
}
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_batched_write", write_stage_start);
|
|
if write_err.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if let Some(err) = write_err {
|
|
task.abort_and_wait().await;
|
|
drop(rx);
|
|
let shutdown_stage_start = stage_timer_if_enabled();
|
|
if let Err(shutdown_err) = writers.shutdown().await {
|
|
error!("failed to shutdown erasure writers after write error: {:?}", shutdown_err);
|
|
}
|
|
record_internal_stage_if_enabled("erasure_encode_batched_shutdown", shutdown_stage_start);
|
|
return Err(err);
|
|
}
|
|
|
|
let (reader, total) = task.join().await??;
|
|
let shutdown_stage_start = stage_timer_if_enabled();
|
|
writers.shutdown().await?;
|
|
record_internal_stage_if_enabled("erasure_encode_batched_shutdown", shutdown_stage_start);
|
|
Ok((reader, total))
|
|
}
|
|
|
|
/// Fast path for small inline objects: skip tokio::spawn + mpsc channel.
|
|
/// Reads all data, encodes directly, writes shards sequentially.
|
|
#[hotpath::measure(impl_type = "Erasure")]
|
|
pub async fn encode_inline_small<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin,
|
|
{
|
|
let size_hint = self.block_size;
|
|
self.encode_small_direct(reader, writers, quorum, false, size_hint, None)
|
|
.await
|
|
}
|
|
|
|
/// Size-aware inline fast path. `size_hint` only controls the bounded initial
|
|
/// allocation; reads remain authoritative.
|
|
#[hotpath::measure(impl_type = "Erasure")]
|
|
pub async fn encode_inline_small_with_size_hint<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
size_hint: usize,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin,
|
|
{
|
|
self.encode_small_direct(reader, writers, quorum, false, size_hint, None)
|
|
.await
|
|
}
|
|
|
|
/// Fast path for single-block non-inline objects: avoids the producer/consumer
|
|
/// pipeline in `encode()` while keeping the same writer/quorum/shutdown semantics.
|
|
#[hotpath::measure(impl_type = "Erasure")]
|
|
pub async fn encode_single_block_non_inline<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin,
|
|
{
|
|
let size_hint = self.block_size;
|
|
self.encode_small_direct(reader, writers, quorum, true, size_hint, None).await
|
|
}
|
|
|
|
/// Size-aware single-block fast path. `size_hint` only controls the bounded
|
|
/// initial allocation; reads remain authoritative.
|
|
#[hotpath::measure(impl_type = "Erasure")]
|
|
pub async fn encode_single_block_non_inline_with_size_hint<R>(
|
|
self: Arc<Self>,
|
|
reader: R,
|
|
writers: &mut [Option<BitrotWriterWrapper>],
|
|
quorum: usize,
|
|
size_hint: usize,
|
|
) -> std::io::Result<(R, usize)>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin,
|
|
{
|
|
self.encode_small_direct(reader, writers, quorum, true, size_hint, None).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
|
|
use crate::error::StorageError;
|
|
use rustfs_rio::HardLimitReader;
|
|
use rustfs_utils::HashAlgorithm;
|
|
use std::future::Future;
|
|
use std::io::Cursor;
|
|
use std::pin::Pin;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::task::{Context, Poll};
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncWrite, AsyncWriteExt, ReadBuf};
|
|
use tokio::sync::oneshot;
|
|
|
|
struct PendingReader {
|
|
entered: Option<oneshot::Sender<()>>,
|
|
dropped: Option<oneshot::Sender<()>>,
|
|
}
|
|
|
|
impl PendingReader {
|
|
fn new() -> (Self, oneshot::Receiver<()>, oneshot::Receiver<()>) {
|
|
let (entered_tx, entered_rx) = oneshot::channel();
|
|
let (dropped_tx, dropped_rx) = oneshot::channel();
|
|
(
|
|
Self {
|
|
entered: Some(entered_tx),
|
|
dropped: Some(dropped_tx),
|
|
},
|
|
entered_rx,
|
|
dropped_rx,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl AsyncRead for PendingReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
if let Some(entered) = self.entered.take() {
|
|
let _ = entered.send(());
|
|
}
|
|
Poll::Pending
|
|
}
|
|
}
|
|
|
|
impl Drop for PendingReader {
|
|
fn drop(&mut self) {
|
|
if let Some(dropped) = self.dropped.take() {
|
|
let _ = dropped.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BlocksThenPendingReader {
|
|
blocks_remaining: usize,
|
|
block: Vec<u8>,
|
|
blocked: Option<oneshot::Sender<()>>,
|
|
dropped: Option<oneshot::Sender<()>>,
|
|
final_block: Option<oneshot::Sender<()>>,
|
|
}
|
|
|
|
impl BlocksThenPendingReader {
|
|
fn new(
|
|
blocks_remaining: usize,
|
|
block_size: usize,
|
|
) -> (Self, oneshot::Receiver<()>, oneshot::Receiver<()>, oneshot::Receiver<()>) {
|
|
let (blocked_tx, blocked_rx) = oneshot::channel();
|
|
let (dropped_tx, dropped_rx) = oneshot::channel();
|
|
let (final_block_tx, final_block_rx) = oneshot::channel();
|
|
(
|
|
Self {
|
|
blocks_remaining,
|
|
block: vec![0x5a; block_size],
|
|
blocked: Some(blocked_tx),
|
|
dropped: Some(dropped_tx),
|
|
final_block: Some(final_block_tx),
|
|
},
|
|
blocked_rx,
|
|
dropped_rx,
|
|
final_block_rx,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl AsyncRead for BlocksThenPendingReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
if self.blocks_remaining == 0 {
|
|
if let Some(blocked) = self.blocked.take() {
|
|
let _ = blocked.send(());
|
|
}
|
|
return Poll::Pending;
|
|
}
|
|
|
|
if self.blocks_remaining == 1
|
|
&& let Some(final_block) = self.final_block.take()
|
|
{
|
|
let _ = final_block.send(());
|
|
}
|
|
self.blocks_remaining -= 1;
|
|
buf.put_slice(&self.block);
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
impl Drop for BlocksThenPendingReader {
|
|
fn drop(&mut self) {
|
|
if let Some(dropped) = self.dropped.take() {
|
|
let _ = dropped.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn erasure_with_zero_block_size() -> Erasure {
|
|
let mut erasure = Erasure::default();
|
|
erasure.data_shards = 1;
|
|
erasure
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct DeferredCommitWriter {
|
|
buffered: Vec<u8>,
|
|
committed: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
|
|
impl DeferredCommitWriter {
|
|
fn new(committed: Arc<Mutex<Vec<u8>>>) -> Self {
|
|
Self {
|
|
buffered: Vec::new(),
|
|
committed,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AsyncWrite for DeferredCommitWriter {
|
|
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
self.buffered.extend_from_slice(buf);
|
|
Poll::Ready(Ok(buf.len()))
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
let buffered = std::mem::take(&mut self.buffered);
|
|
let mut committed = self.committed.lock().unwrap();
|
|
committed.extend_from_slice(&buffered);
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct FailingWriteWriter;
|
|
|
|
impl AsyncWrite for FailingWriteWriter {
|
|
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
Poll::Ready(Err(std::io::Error::other("injected write failure")))
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
struct FailAfterReaderBlocksWriter {
|
|
reader_blocked: oneshot::Receiver<()>,
|
|
writes: Arc<std::sync::atomic::AtomicUsize>,
|
|
}
|
|
|
|
impl AsyncWrite for FailAfterReaderBlocksWriter {
|
|
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
match Pin::new(&mut self.reader_blocked).poll(cx) {
|
|
Poll::Pending => Poll::Pending,
|
|
Poll::Ready(_) => {
|
|
self.writes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
Poll::Ready(Err(std::io::Error::other("injected write failure after producer blocks")))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
struct StallOnWriteWithSignal {
|
|
entered: Option<oneshot::Sender<()>>,
|
|
writes: Arc<std::sync::atomic::AtomicUsize>,
|
|
}
|
|
|
|
impl AsyncWrite for StallOnWriteWithSignal {
|
|
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
self.writes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
if let Some(entered) = self.entered.take() {
|
|
let _ = entered.send(());
|
|
}
|
|
Poll::Pending
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct ShortWriteWriter;
|
|
|
|
impl AsyncWrite for ShortWriteWriter {
|
|
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
Poll::Ready(Ok(buf.len().saturating_sub(1)))
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct ShutdownFailWriter {
|
|
buffered: Vec<u8>,
|
|
}
|
|
|
|
impl AsyncWrite for ShutdownFailWriter {
|
|
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
self.buffered.extend_from_slice(buf);
|
|
Poll::Ready(Ok(buf.len()))
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Err(std::io::Error::other("injected shutdown failure")))
|
|
}
|
|
}
|
|
|
|
/// A writer that models a "black-hole" peer: it accepts up to `stall_after`
|
|
/// bytes and then parks every subsequent `poll_write` forever (never
|
|
/// registering a waker), the way a remote `HttpWriter` wedges once its
|
|
/// bounded buffers fill and the peer never drains the body. With
|
|
/// `stall_after = 0` it stalls on the very first byte. `poll_shutdown`
|
|
/// completes only when `stall_shutdown` is false.
|
|
#[derive(Clone)]
|
|
struct StallingWriter {
|
|
accepted: Arc<std::sync::atomic::AtomicUsize>,
|
|
stall_after: usize,
|
|
stall_shutdown: bool,
|
|
}
|
|
|
|
impl StallingWriter {
|
|
fn stalls_on_write(stall_after: usize) -> Self {
|
|
Self {
|
|
accepted: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
|
stall_after,
|
|
stall_shutdown: false,
|
|
}
|
|
}
|
|
|
|
fn stalls_on_shutdown() -> Self {
|
|
Self {
|
|
accepted: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
|
stall_after: usize::MAX,
|
|
stall_shutdown: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AsyncWrite for StallingWriter {
|
|
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
use std::sync::atomic::Ordering;
|
|
let already = self.accepted.load(Ordering::SeqCst);
|
|
if already >= self.stall_after {
|
|
// Black hole: no forward progress, no waker registered.
|
|
return Poll::Pending;
|
|
}
|
|
let take = buf.len().min(self.stall_after - already);
|
|
self.accepted.fetch_add(take, Ordering::SeqCst);
|
|
Poll::Ready(Ok(take))
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
if self.stall_shutdown {
|
|
Poll::Pending
|
|
} else {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A writer that always makes forward progress but each `poll_write` first
|
|
/// waits `delay` (on the runtime timer). Under a paused virtual clock this
|
|
/// deterministically models a slow-but-honest peer: it must never be killed
|
|
/// by the per-shard stall deadline as long as `delay < stall_timeout`, yet an
|
|
/// absolute cap can still bound its aggregate engagement.
|
|
struct SlowWriter {
|
|
delay: std::time::Duration,
|
|
timer: Option<Pin<Box<tokio::time::Sleep>>>,
|
|
}
|
|
|
|
impl SlowWriter {
|
|
fn new(delay: std::time::Duration) -> Self {
|
|
Self { delay, timer: None }
|
|
}
|
|
}
|
|
|
|
impl AsyncWrite for SlowWriter {
|
|
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
let delay = self.delay;
|
|
let timer = self.timer.get_or_insert_with(|| Box::pin(tokio::time::sleep(delay)));
|
|
match timer.as_mut().poll(cx) {
|
|
Poll::Ready(()) => {
|
|
self.timer = None;
|
|
Poll::Ready(Ok(buf.len()))
|
|
}
|
|
Poll::Pending => Poll::Pending,
|
|
}
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
fn bitrot_writer<W>(writer: W, shard_size: usize) -> BitrotWriterWrapper
|
|
where
|
|
W: AsyncWrite + Send + Sync + Unpin + 'static,
|
|
{
|
|
BitrotWriterWrapper::new(CustomWriter::new_tokio_writer(writer), shard_size, HashAlgorithm::HighwayHash256S)
|
|
}
|
|
|
|
/// Like `bitrot_writer` but with no interleaved hash, so one shard write
|
|
/// issues a single `poll_write` to the underlying fake. Used by the paused
|
|
/// virtual-clock stall tests so each block maps to exactly one modeled delay.
|
|
fn bitrot_writer_plain<W>(writer: W, shard_size: usize) -> BitrotWriterWrapper
|
|
where
|
|
W: AsyncWrite + Send + Sync + Unpin + 'static,
|
|
{
|
|
BitrotWriterWrapper::new(CustomWriter::new_tokio_writer(writer), shard_size, HashAlgorithm::None)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum EncodePipeline {
|
|
Vec,
|
|
BytesMut,
|
|
Batched,
|
|
}
|
|
|
|
async fn aborting_encode_drops_blocked_producer(pipeline: EncodePipeline) {
|
|
const BLOCK_SIZE: usize = 16;
|
|
|
|
let gauge_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE))];
|
|
let (reader, entered, dropped) = PendingReader::new();
|
|
let erasure = Arc::new(Erasure::new(1, 0, BLOCK_SIZE));
|
|
|
|
let encode = match pipeline {
|
|
EncodePipeline::Vec => {
|
|
tokio::spawn(async move { erasure.encode_with_ingest_mode(reader, &mut writers, 1, false, None).await })
|
|
}
|
|
EncodePipeline::BytesMut => {
|
|
tokio::spawn(async move { erasure.encode_with_ingest_mode(reader, &mut writers, 1, true, None).await })
|
|
}
|
|
EncodePipeline::Batched => tokio::spawn(async move { erasure.encode_batched(reader, &mut writers, 1).await }),
|
|
};
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), entered)
|
|
.await
|
|
.expect("producer should enter the blocked reader before cancellation")
|
|
.expect("blocked reader should signal entry");
|
|
encode.abort();
|
|
assert!(matches!(encode.await, Err(err) if err.is_cancelled()), "encode task should be cancelled");
|
|
tokio::time::timeout(Duration::from_secs(1), dropped)
|
|
.await
|
|
.expect("cancelling encode should drop the producer reader")
|
|
.expect("blocked reader should signal producer drop");
|
|
assert!(
|
|
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
|
"cancelling before the first encoded block must not make data visible"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
gauge_baseline,
|
|
"cancelling the encode pipeline must preserve the inflight queue gauge"
|
|
);
|
|
}
|
|
|
|
async fn writer_error_aborts_blocked_producer(pipeline: EncodePipeline) {
|
|
let gauge_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
|
let producer_baseline = rustfs_io_metrics::current_ec_encode_producer_bytes();
|
|
let writer_baseline = rustfs_io_metrics::current_ec_encode_writer_bytes();
|
|
let producer_peak_before = rustfs_io_metrics::current_ec_encode_producer_bytes_peak();
|
|
let writer_peak_before = rustfs_io_metrics::current_ec_encode_writer_bytes_peak();
|
|
let block_size = match pipeline {
|
|
EncodePipeline::Batched => usize::try_from(producer_peak_before.max(writer_peak_before).saturating_add(1))
|
|
.expect("stage peak fits the test address space"),
|
|
EncodePipeline::Vec | EncodePipeline::BytesMut => 16,
|
|
};
|
|
rustfs_io_metrics::set_put_stage_metrics_enabled(true);
|
|
let erasure = Arc::new(Erasure::new(1, 0, block_size));
|
|
let batch_blocks = encode_batch_block_count().min(encode_channel_capacity(
|
|
erasure.shard_size().saturating_mul(erasure.total_shard_count()),
|
|
erasure_encode_max_inflight_bytes(),
|
|
));
|
|
let blocks_before_pending = match pipeline {
|
|
EncodePipeline::Batched => batch_blocks,
|
|
EncodePipeline::Vec | EncodePipeline::BytesMut => 1,
|
|
};
|
|
let (reader, reader_blocked, reader_dropped, _final_block) =
|
|
BlocksThenPendingReader::new(blocks_before_pending, block_size);
|
|
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let mut writers = vec![Some(bitrot_writer(
|
|
FailAfterReaderBlocksWriter {
|
|
reader_blocked,
|
|
writes: writes.clone(),
|
|
},
|
|
block_size,
|
|
))];
|
|
|
|
let result = match pipeline {
|
|
EncodePipeline::Vec => erasure.encode_with_ingest_mode(reader, &mut writers, 1, false, None).await,
|
|
EncodePipeline::BytesMut => erasure.encode_with_ingest_mode(reader, &mut writers, 1, true, None).await,
|
|
EncodePipeline::Batched => erasure.encode_batched(reader, &mut writers, 1).await,
|
|
};
|
|
|
|
let err = match result {
|
|
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
|
|
Err(err) => err,
|
|
};
|
|
let err = StorageError::from(err);
|
|
assert!(matches!(
|
|
&err,
|
|
StorageError::Io(source)
|
|
if source.kind() == std::io::ErrorKind::Other
|
|
&& source.to_string() == "injected write failure after producer blocks"
|
|
));
|
|
assert!(!err.is_quorum_error());
|
|
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
|
.await
|
|
.expect("writer failure should abort the blocked producer")
|
|
.expect("blocked producer should signal reader drop");
|
|
assert_eq!(
|
|
writes.load(std::sync::atomic::Ordering::SeqCst),
|
|
1,
|
|
"writer failure must stop the pipeline before any additional shard write"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
gauge_baseline,
|
|
"writer failure must settle all queued and pending encoded bytes"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_producer_bytes(),
|
|
producer_baseline,
|
|
"writer failure must settle producer stage bytes for every ingest pipeline"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_writer_bytes(),
|
|
writer_baseline,
|
|
"writer failure must settle writer stage bytes for every ingest pipeline"
|
|
);
|
|
if matches!(pipeline, EncodePipeline::Batched) {
|
|
let expected_batch_bytes = u64::try_from(block_size)
|
|
.expect("block size fits the stage gauge")
|
|
.checked_mul(u64::try_from(batch_blocks).expect("batch block count fits the stage gauge"))
|
|
.expect("test batch payload fits the stage gauge");
|
|
assert!(
|
|
rustfs_io_metrics::current_ec_encode_producer_bytes_peak() >= producer_peak_before.max(expected_batch_bytes),
|
|
"batched producer must expose its full pending batch before writer failure"
|
|
);
|
|
assert!(
|
|
rustfs_io_metrics::current_ec_encode_writer_bytes_peak() >= writer_peak_before.max(expected_batch_bytes),
|
|
"batched writer must expose its full batch before writer failure"
|
|
);
|
|
}
|
|
rustfs_io_metrics::set_put_stage_metrics_enabled(false);
|
|
}
|
|
|
|
async fn aborting_full_queue_settles_pending_send() {
|
|
const BLOCK_SIZE: usize = 16;
|
|
|
|
let gauge_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
|
let producer_baseline = rustfs_io_metrics::current_ec_encode_producer_bytes();
|
|
let writer_baseline = rustfs_io_metrics::current_ec_encode_writer_bytes();
|
|
rustfs_io_metrics::set_put_stage_metrics_enabled(true);
|
|
let erasure = Arc::new(Erasure::new(1, 0, BLOCK_SIZE));
|
|
let inflight_blocks = encode_channel_capacity(
|
|
erasure.shard_size().saturating_mul(erasure.total_shard_count()),
|
|
erasure_encode_max_inflight_bytes(),
|
|
);
|
|
let (reader, _reader_blocked, reader_dropped, final_block) =
|
|
BlocksThenPendingReader::new(inflight_blocks + 2, BLOCK_SIZE);
|
|
let (writer_entered_tx, writer_entered) = oneshot::channel();
|
|
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let mut writers = vec![Some(bitrot_writer(
|
|
StallOnWriteWithSignal {
|
|
entered: Some(writer_entered_tx),
|
|
writes: writes.clone(),
|
|
},
|
|
BLOCK_SIZE,
|
|
))];
|
|
let erasure_for_task = erasure.clone();
|
|
let encode = tokio::spawn(async move {
|
|
erasure_for_task
|
|
.encode_with_ingest_mode(reader, &mut writers, 1, false, None)
|
|
.await
|
|
});
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), writer_entered)
|
|
.await
|
|
.expect("consumer should start the first writer call")
|
|
.expect("stalling writer should signal entry");
|
|
tokio::time::timeout(Duration::from_secs(1), final_block)
|
|
.await
|
|
.expect("producer should supply the block whose send fills the queue")
|
|
.expect("reader should signal final block");
|
|
|
|
let expected_queued_bytes =
|
|
u64::try_from((inflight_blocks + 1) * BLOCK_SIZE).expect("queued byte count should fit the gauge");
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while rustfs_io_metrics::current_ec_encode_inflight_bytes() < gauge_baseline + expected_queued_bytes {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("producer should account for the pending send after the queue fills");
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while rustfs_io_metrics::current_ec_encode_producer_bytes() == producer_baseline
|
|
|| rustfs_io_metrics::current_ec_encode_writer_bytes() == writer_baseline
|
|
{
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("full queue must retain producer and writer stage ownership before cancellation");
|
|
|
|
encode.abort();
|
|
assert!(matches!(encode.await, Err(err) if err.is_cancelled()), "encode task should be cancelled");
|
|
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
|
.await
|
|
.expect("cancelling a full queue should abort its producer")
|
|
.expect("full-queue producer should signal reader drop");
|
|
assert_eq!(
|
|
writes.load(std::sync::atomic::Ordering::SeqCst),
|
|
1,
|
|
"cancellation must not resume the stalled writer"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
gauge_baseline,
|
|
"cancelling a full queue must settle queued and pending bytes"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_producer_bytes(),
|
|
producer_baseline,
|
|
"cancelling a full queue must settle the pending producer stage"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_writer_bytes(),
|
|
writer_baseline,
|
|
"cancelling a full queue must settle the stalled writer stage"
|
|
);
|
|
rustfs_io_metrics::set_put_stage_metrics_enabled(false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn cancelling_vec_encode_drops_blocked_producer() {
|
|
aborting_encode_drops_blocked_producer(EncodePipeline::Vec).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn cancelling_bytesmut_encode_drops_blocked_producer() {
|
|
aborting_encode_drops_blocked_producer(EncodePipeline::BytesMut).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn cancelling_batched_encode_drops_blocked_producer() {
|
|
aborting_encode_drops_blocked_producer(EncodePipeline::Batched).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn vec_writer_error_aborts_blocked_producer() {
|
|
writer_error_aborts_blocked_producer(EncodePipeline::Vec).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn bytesmut_writer_error_aborts_blocked_producer() {
|
|
writer_error_aborts_blocked_producer(EncodePipeline::BytesMut).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn batched_writer_error_aborts_blocked_producer() {
|
|
writer_error_aborts_blocked_producer(EncodePipeline::Batched).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn cancelling_full_queue_settles_pending_send() {
|
|
aborting_full_queue_settles_pending_send().await;
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn delayed_reader_error_keeps_source_and_drops_every_encode_path() {
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error("injected request body inactivity")]
|
|
struct BodyInactivity;
|
|
|
|
#[derive(Debug)]
|
|
struct StalledReader {
|
|
data: Cursor<Vec<u8>>,
|
|
timer: Option<Pin<Box<tokio::time::Sleep>>>,
|
|
dropped: Arc<std::sync::atomic::AtomicBool>,
|
|
}
|
|
|
|
impl AsyncRead for StalledReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
if self.data.position() < self.data.get_ref().len() as u64 {
|
|
return Pin::new(&mut self.data).poll_read(cx, buf);
|
|
}
|
|
let timer = self
|
|
.timer
|
|
.get_or_insert_with(|| Box::pin(tokio::time::sleep(Duration::from_secs(300))));
|
|
std::task::ready!(timer.as_mut().poll(cx));
|
|
Poll::Ready(Err(std::io::Error::other(BodyInactivity)))
|
|
}
|
|
}
|
|
|
|
impl Drop for StalledReader {
|
|
fn drop(&mut self) {
|
|
self.dropped.store(true, std::sync::atomic::Ordering::Release);
|
|
}
|
|
}
|
|
|
|
// Explicit entry points select the paths; environment caches and input
|
|
// size heuristics cannot silently turn this into repeated Vec coverage.
|
|
for path in ["direct", "vec", "bytesmut", "batched"] {
|
|
let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let reader = StalledReader {
|
|
data: Cursor::new(vec![7; 64]),
|
|
timer: None,
|
|
dropped: Arc::clone(&dropped),
|
|
};
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = (0..4)
|
|
.map(|_| Some(bitrot_writer(DeferredCommitWriter::new(Arc::clone(&committed)), 32)))
|
|
.collect::<Vec<_>>();
|
|
let erasure = Arc::new(Erasure::new(2, 2, 64));
|
|
let result = match path {
|
|
"direct" => erasure.encode_single_block_non_inline(reader, &mut writers, 2).await,
|
|
"vec" => erasure.encode_with_ingest_mode(reader, &mut writers, 2, false, None).await,
|
|
"bytesmut" => erasure.encode_with_ingest_mode(reader, &mut writers, 2, true, None).await,
|
|
"batched" => erasure.encode_batched(reader, &mut writers, 2).await,
|
|
_ => unreachable!(),
|
|
};
|
|
let error = result.expect_err("stalled input must fail before shard commit");
|
|
assert!(error.get_ref().is_some_and(|source| source.is::<BodyInactivity>()), "{path}: {error:?}");
|
|
assert!(
|
|
dropped.load(std::sync::atomic::Ordering::Acquire),
|
|
"{path} must release its reader/producer before returning"
|
|
);
|
|
assert!(
|
|
committed.lock().expect("committed bytes").is_empty(),
|
|
"{path} must not commit partial shards"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn helper_writers_cover_flush_and_shutdown_paths() {
|
|
let mut failing_write = FailingWriteWriter;
|
|
failing_write.flush().await.expect("failing-write flush should succeed");
|
|
failing_write.shutdown().await.expect("failing-write shutdown should succeed");
|
|
|
|
let mut short_write = ShortWriteWriter;
|
|
let written = short_write
|
|
.write(b"short")
|
|
.await
|
|
.expect("short-write helper should report a partial write");
|
|
assert_eq!(written, 4);
|
|
short_write.flush().await.expect("short-write flush should succeed");
|
|
short_write.shutdown().await.expect("short-write shutdown should succeed");
|
|
|
|
let mut shutdown_fail = ShutdownFailWriter::default();
|
|
shutdown_fail.flush().await.expect("shutdown-fail flush should succeed");
|
|
let err = shutdown_fail
|
|
.shutdown()
|
|
.await
|
|
.expect_err("shutdown-fail writer should reject shutdown");
|
|
assert_eq!(err.to_string(), "injected shutdown failure");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn multi_writer_short_write_fails_before_shutdown() {
|
|
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 32))];
|
|
let err = {
|
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
|
writer
|
|
.write(vec![Bytes::from_static(b"short-write payload")])
|
|
.await
|
|
.expect_err("short writes must fail the shard writer")
|
|
};
|
|
|
|
let err = StorageError::from(err);
|
|
assert!(matches!(&err, StorageError::Io(source) if source.kind() == std::io::ErrorKind::WriteZero));
|
|
assert!(!err.is_quorum_error());
|
|
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
|
|
let mut writers = vec![None, None];
|
|
let (err, summary) = {
|
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
|
let err = writer
|
|
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
|
|
.await
|
|
.expect_err("offline writers cannot satisfy write quorum");
|
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
|
(err, format_write_quorum_failure(&summary))
|
|
};
|
|
|
|
assert_eq!(
|
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let err = StorageError::from(err);
|
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
|
assert!(err.is_quorum_error());
|
|
assert!(summary.contains("offline-disks=2/2"));
|
|
assert!(summary.contains("required=1"));
|
|
|
|
let (shutdown_err, summary) = {
|
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
|
let err = writer
|
|
.shutdown()
|
|
.await
|
|
.expect_err("offline writers cannot satisfy shutdown quorum");
|
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
|
(err, format_write_quorum_failure(&summary))
|
|
};
|
|
|
|
assert_eq!(
|
|
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let shutdown_err = StorageError::from(shutdown_err);
|
|
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
|
assert!(shutdown_err.is_quorum_error());
|
|
assert!(summary.contains("offline-disks=2/2"));
|
|
assert!(summary.contains("required=1"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 32))];
|
|
let mut writer = MultiWriter::new(&mut writers, 2);
|
|
|
|
let err = writer
|
|
.write(vec![Bytes::from_static(b"quorum impossible")])
|
|
.await
|
|
.expect_err("write quorum above writer count must fail");
|
|
assert_eq!(
|
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let err = StorageError::from(err);
|
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
|
assert!(err.is_quorum_error());
|
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
|
let summary = format_write_quorum_failure(&summary);
|
|
assert!(summary.contains("required=2"));
|
|
assert!(summary.contains("erasure write quorum"));
|
|
|
|
let shutdown_err = writer
|
|
.shutdown()
|
|
.await
|
|
.expect_err("shutdown quorum above writer count must fail");
|
|
assert_eq!(
|
|
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let shutdown_err = StorageError::from(shutdown_err);
|
|
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
|
assert!(shutdown_err.is_quorum_error());
|
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
|
let summary = format_write_quorum_failure(&summary);
|
|
assert!(summary.contains("required=2"));
|
|
assert!(summary.contains("erasure write quorum"));
|
|
}
|
|
|
|
// The production wiring (`MultiWriter::new`) must arm a real deadline by
|
|
// default, and honor `0` as "disabled". This guards against the fix silently
|
|
// regressing to wait-forever behavior on the default PUT path.
|
|
#[test]
|
|
fn default_write_progress_policy_is_armed_and_configurable() {
|
|
temp_env::with_var_unset(rustfs_config::ENV_OBJECT_DISK_WRITE_STALL_TIMEOUT, || {
|
|
temp_env::with_var_unset(rustfs_config::ENV_OBJECT_DISK_WRITE_ABSOLUTE_CAP, || {
|
|
let policy = WriteProgressPolicy::default();
|
|
assert_eq!(
|
|
policy.stall_timeout,
|
|
Some(Duration::from_secs(rustfs_config::DEFAULT_OBJECT_DISK_WRITE_STALL_TIMEOUT)),
|
|
"the default PUT path must be protected by a stall deadline"
|
|
);
|
|
assert_eq!(policy.absolute_cap, None, "the absolute cap is disabled by default");
|
|
});
|
|
});
|
|
|
|
temp_env::with_var(rustfs_config::ENV_OBJECT_DISK_WRITE_STALL_TIMEOUT, Some("0"), || {
|
|
let policy = WriteProgressPolicy::default();
|
|
assert_eq!(policy.stall_timeout, None, "0 disables the stall deadline (wait indefinitely)");
|
|
});
|
|
}
|
|
|
|
fn four_shards() -> Vec<Bytes> {
|
|
vec![
|
|
Bytes::from_static(b"shard-0"),
|
|
Bytes::from_static(b"shard-1"),
|
|
Bytes::from_static(b"shard-2"),
|
|
Bytes::from_static(b"shard-3"),
|
|
]
|
|
}
|
|
|
|
/// Four full-`shard_size` shards. A shard shorter than `shard_size` marks the
|
|
/// bitrot writer `finished`, so multi-block tests must send full shards to
|
|
/// keep writing across iterations.
|
|
fn four_full_shards(shard_size: usize) -> Vec<Bytes> {
|
|
(0..4).map(|i| Bytes::from(vec![0x40 + i as u8; shard_size])).collect()
|
|
}
|
|
|
|
// rustfs/backlog#1319: a single black-hole writer that never accepts a byte
|
|
// must be failed within the stall budget so the remaining 3/4 writers still
|
|
// meet a write quorum of 3 — the write must not wait forever.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn multi_writer_write_stall_fails_black_hole_but_meets_quorum() {
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..3).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers = vec![
|
|
Some(bitrot_writer_plain(StallingWriter::stalls_on_write(0), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[0].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[1].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[2].clone()), 64)),
|
|
];
|
|
|
|
{
|
|
let policy = WriteProgressPolicy::new(Duration::from_secs(5), Duration::ZERO);
|
|
let mut mw = MultiWriter::with_policy(&mut writers, 3, policy);
|
|
mw.write(four_shards())
|
|
.await
|
|
.expect("one stalled writer must still satisfy a 3/4 write quorum without hanging");
|
|
}
|
|
|
|
assert!(writers[0].is_none(), "the black-hole writer must be failed and dropped before commit");
|
|
assert!(writers[1].is_some() && writers[2].is_some() && writers[3].is_some());
|
|
}
|
|
|
|
// Two black-hole writers drop the healthy count to 2/4, below the quorum of
|
|
// 3, so the write must fail cleanly (not hang).
|
|
#[tokio::test(start_paused = true)]
|
|
async fn multi_writer_write_stall_two_black_holes_fail_quorum() {
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..2).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers = vec![
|
|
Some(bitrot_writer_plain(StallingWriter::stalls_on_write(0), 64)),
|
|
Some(bitrot_writer_plain(StallingWriter::stalls_on_write(0), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[0].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[1].clone()), 64)),
|
|
];
|
|
|
|
let policy = WriteProgressPolicy::new(Duration::from_secs(5), Duration::ZERO);
|
|
let mut mw = MultiWriter::with_policy(&mut writers, 3, policy);
|
|
let err = mw
|
|
.write(four_shards())
|
|
.await
|
|
.expect_err("two stalled writers must fail the write quorum instead of hanging");
|
|
assert_eq!(
|
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let err = StorageError::from(err);
|
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
|
assert!(err.is_quorum_error());
|
|
}
|
|
|
|
// A small object whose bytes were fully buffered leaves `write` succeeding
|
|
// for a black-hole peer; the stall must then be caught during shutdown so it
|
|
// cannot pin the quorum there either.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn multi_writer_shutdown_stall_fails_black_hole_but_meets_quorum() {
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..3).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers = vec![
|
|
Some(bitrot_writer_plain(StallingWriter::stalls_on_shutdown(), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[0].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[1].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[2].clone()), 64)),
|
|
];
|
|
|
|
{
|
|
let policy = WriteProgressPolicy::new(Duration::from_secs(5), Duration::ZERO);
|
|
let mut mw = MultiWriter::with_policy(&mut writers, 3, policy);
|
|
mw.write(four_shards()).await.expect("all writers accept the buffered shard");
|
|
mw.shutdown()
|
|
.await
|
|
.expect("a single shutdown stall must still satisfy a 3/4 quorum without hanging");
|
|
}
|
|
|
|
assert!(writers[0].is_none(), "the writer that stalled shutdown must be failed and dropped");
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn multi_writer_shutdown_stall_two_black_holes_fail_quorum() {
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..2).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers = vec![
|
|
Some(bitrot_writer_plain(StallingWriter::stalls_on_shutdown(), 64)),
|
|
Some(bitrot_writer_plain(StallingWriter::stalls_on_shutdown(), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[0].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[1].clone()), 64)),
|
|
];
|
|
|
|
let policy = WriteProgressPolicy::new(Duration::from_secs(5), Duration::ZERO);
|
|
let mut mw = MultiWriter::with_policy(&mut writers, 3, policy);
|
|
mw.write(four_shards()).await.expect("all writers accept the buffered shard");
|
|
let err = mw
|
|
.shutdown()
|
|
.await
|
|
.expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging");
|
|
assert_eq!(
|
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let err = StorageError::from(err);
|
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
|
assert!(err.is_quorum_error());
|
|
}
|
|
|
|
// A slow-but-honest writer that keeps completing shards (delay < stall
|
|
// budget) must never be killed, even across many blocks, when no absolute cap
|
|
// is configured.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn multi_writer_slow_but_progressing_writer_is_not_killed() {
|
|
let mut writers = vec![
|
|
Some(bitrot_writer_plain(SlowWriter::new(Duration::from_secs(1)), 64)),
|
|
Some(bitrot_writer_plain(SlowWriter::new(Duration::from_secs(1)), 64)),
|
|
Some(bitrot_writer_plain(SlowWriter::new(Duration::from_secs(1)), 64)),
|
|
Some(bitrot_writer_plain(SlowWriter::new(Duration::from_secs(1)), 64)),
|
|
];
|
|
|
|
{
|
|
let policy = WriteProgressPolicy::new(Duration::from_secs(5), Duration::ZERO);
|
|
let mut mw = MultiWriter::with_policy(&mut writers, 3, policy);
|
|
for _ in 0..8 {
|
|
mw.write(four_full_shards(64))
|
|
.await
|
|
.expect("a writer that keeps making progress within the stall budget must not be failed");
|
|
}
|
|
}
|
|
|
|
assert!(writers.iter().all(Option::is_some), "no slow-but-honest writer should be dropped");
|
|
}
|
|
|
|
// Slow-drip defense: the per-shard stall timer resets on every block, so a
|
|
// peer that dribbles just enough progress each block is not caught by the
|
|
// stall timeout alone. The optional absolute cap bounds its aggregate
|
|
// engagement and fails it within a finite budget, while the healthy writers
|
|
// still meet quorum.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn multi_writer_absolute_cap_bounds_slow_drip_writer() {
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..3).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers = vec![
|
|
Some(bitrot_writer_plain(SlowWriter::new(Duration::from_secs(8)), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[0].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[1].clone()), 64)),
|
|
Some(bitrot_writer_plain(DeferredCommitWriter::new(committed[2].clone()), 64)),
|
|
];
|
|
|
|
{
|
|
// stall (10s) never fires for an 8s-per-block writer, but the 15s
|
|
// absolute cap does once its aggregate engagement crosses the cap.
|
|
let policy = WriteProgressPolicy::new(Duration::from_secs(10), Duration::from_secs(15));
|
|
let mut mw = MultiWriter::with_policy(&mut writers, 3, policy);
|
|
for _ in 0..4 {
|
|
mw.write(four_full_shards(64))
|
|
.await
|
|
.expect("healthy writers keep the 3/4 quorum while the drip writer is bounded");
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
writers[0].is_none(),
|
|
"the slow-drip writer must be failed within the absolute cap, not held forever"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn queued_inflight_bytes_are_settled_on_all_queue_exit_paths() {
|
|
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
|
let (tx, rx) = mpsc::channel(1);
|
|
let queued = vec![Bytes::from_static(b"queued")];
|
|
let queued_bytes = queued_block_bytes(&queued);
|
|
|
|
send_queued(&tx, queued, queued_bytes)
|
|
.await
|
|
.expect("first queue entry should fit");
|
|
|
|
let blocked = vec![Bytes::from_static(b"blocked")];
|
|
let blocked_bytes = queued_block_bytes(&blocked);
|
|
{
|
|
let pending_send = send_queued(&tx, blocked, blocked_bytes);
|
|
tokio::pin!(pending_send);
|
|
assert!(
|
|
futures::poll!(pending_send.as_mut()).is_pending(),
|
|
"full queue must suspend producer send"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
baseline + u64::try_from(queued_bytes).expect("queue bytes fit the gauge"),
|
|
"dropping a pending producer send must compensate its bytes"
|
|
);
|
|
|
|
drop(rx);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
baseline,
|
|
"dropping the receiver must settle every buffered entry"
|
|
);
|
|
|
|
let rejected = vec![Bytes::from_static(b"rejected")];
|
|
let rejected_bytes = queued_block_bytes(&rejected);
|
|
assert!(
|
|
send_queued(&tx, rejected, rejected_bytes).await.is_err(),
|
|
"closed receiver must reject a new send"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
baseline,
|
|
"failed sends must compensate their bytes"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn queued_batch_entry_settles_bytes_before_handoff() {
|
|
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
|
let (tx, rx) = mpsc::channel(2);
|
|
let mut rx = rx;
|
|
let erasure = Erasure::new(1, 0, 16);
|
|
let batch = vec![
|
|
erasure.encode_data_block(b"queued").expect("first block should encode"),
|
|
erasure.encode_data_block(b"batch").expect("second block should encode"),
|
|
];
|
|
let batch_bytes = queued_batch_bytes(&batch);
|
|
|
|
send_queued(&tx, batch, batch_bytes).await.expect("batch should be queued");
|
|
let batch = rx.recv().await.expect("queued batch should be received").into_inner();
|
|
assert_eq!(batch_bytes, queued_batch_bytes(&batch));
|
|
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
baseline,
|
|
"receiving a batch must settle all contained block bytes before shard writes"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn queued_entry_settles_when_a_closed_receiver_accepts_an_outstanding_permit() {
|
|
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
|
let (tx, mut rx) = mpsc::channel(1);
|
|
let permit = tx
|
|
.clone()
|
|
.reserve_owned()
|
|
.await
|
|
.expect("open receiver should reserve queue capacity");
|
|
rx.close();
|
|
assert!(
|
|
matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
|
|
"an outstanding permit must leave the closed queue observably empty"
|
|
);
|
|
|
|
let block = vec![Bytes::from_static(b"late-permit")];
|
|
let block_bytes = queued_block_bytes(&block);
|
|
permit.send(InflightEntry::new(block, block_bytes));
|
|
drop(rx);
|
|
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
baseline,
|
|
"a queue entry sent through an outstanding permit must settle when Tokio drops it"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_shutdowns_writers_after_small_shards() {
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let writer = DeferredCommitWriter::new(committed.clone());
|
|
let mut writers = vec![Some(bitrot_writer(writer, 16))];
|
|
|
|
let erasure = Arc::new(Erasure::new(1, 0, 16));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(b"small payload".to_vec()));
|
|
let (_reader, written) = erasure.encode(reader, &mut writers, 1).await.unwrap();
|
|
|
|
assert_eq!(written, b"small payload".len());
|
|
assert!(!committed.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_bytesmut_ingest_streaming_path_writes_and_shutdowns_writers() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 32;
|
|
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
|
|
.iter()
|
|
.map(|c| Some(bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS)))
|
|
.collect();
|
|
|
|
let payload = vec![0x5a; BLOCK_SIZE * 2 + 7];
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload.clone()));
|
|
let (_reader, written) = erasure
|
|
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, true, None)
|
|
.await
|
|
.expect("BytesMut ingest path should encode the streaming payload");
|
|
|
|
assert_eq!(written, payload.len());
|
|
for (index, committed) in committed.iter().enumerate() {
|
|
assert!(
|
|
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
|
"shard {index} should receive bytesmut-ingest data"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn bytesmut_streaming_encode_observes_all_payload_stage_peaks() {
|
|
let queue_baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
|
let producer_peak_before = rustfs_io_metrics::current_ec_encode_producer_bytes_peak();
|
|
let queue_peak_before = rustfs_io_metrics::current_ec_encode_queue_bytes_peak();
|
|
let writer_peak_before = rustfs_io_metrics::current_ec_encode_writer_bytes_peak();
|
|
let prior_peak = producer_peak_before.max(queue_peak_before).max(writer_peak_before);
|
|
let block_size = usize::try_from(prior_peak.saturating_add(1)).expect("stage peak fits the test address space");
|
|
let erasure = Arc::new(Erasure::new(1, 0, block_size));
|
|
let encoded_block_bytes = erasure.shard_size() * erasure.total_shard_count();
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), block_size))];
|
|
let reader = tokio::io::BufReader::new(Cursor::new(vec![0x5a; block_size * 2]));
|
|
|
|
rustfs_io_metrics::set_put_stage_metrics_enabled(true);
|
|
let result = erasure.encode_with_ingest_mode(reader, &mut writers, 1, true, None).await;
|
|
rustfs_io_metrics::set_put_stage_metrics_enabled(false);
|
|
|
|
let (_reader, written) = result.expect("bytesmut streaming encode should complete");
|
|
assert_eq!(written, block_size * 2);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_inflight_bytes(),
|
|
queue_baseline,
|
|
"completed streaming encode must not retain queue bytes"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_producer_bytes(),
|
|
0,
|
|
"completed streaming encode must not retain producer bytes"
|
|
);
|
|
assert_eq!(
|
|
rustfs_io_metrics::current_ec_encode_writer_bytes(),
|
|
0,
|
|
"completed streaming encode must not retain writer bytes"
|
|
);
|
|
let expected_peak = u64::try_from(encoded_block_bytes).expect("encoded block bytes fit the gauge");
|
|
assert!(
|
|
rustfs_io_metrics::current_ec_encode_producer_bytes_peak() >= producer_peak_before.max(expected_peak),
|
|
"producer peak must observe encoded bytes before queue hand-off"
|
|
);
|
|
assert!(
|
|
rustfs_io_metrics::current_ec_encode_queue_bytes_peak() >= queue_peak_before.max(expected_peak),
|
|
"queue peak must observe encoded bytes pending shard writers"
|
|
);
|
|
assert!(
|
|
rustfs_io_metrics::current_ec_encode_writer_bytes_peak() >= writer_peak_before.max(expected_peak),
|
|
"writer peak must observe encoded bytes after queue hand-off"
|
|
);
|
|
assert!(
|
|
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
|
"stage peak observation must not change writer commit behavior"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_streaming_write_quorum_failure_aborts_and_reports_error() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = vec![
|
|
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
|
|
Some(bitrot_writer(FailingWriteWriter, BLOCK_SIZE / DATA_SHARDS)),
|
|
None,
|
|
None,
|
|
];
|
|
|
|
let payload = vec![3u8; BLOCK_SIZE * 8];
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload));
|
|
let err = erasure
|
|
.encode(reader, &mut writers, DATA_SHARDS)
|
|
.await
|
|
.expect_err("streaming encode must fail when write quorum is unavailable");
|
|
|
|
assert_eq!(
|
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let err = StorageError::from(err);
|
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
|
assert!(err.is_quorum_error());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_inline_small_write_quorum_failure_does_not_commit_partial_data() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = vec![
|
|
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
|
|
Some(bitrot_writer(FailingWriteWriter, BLOCK_SIZE / DATA_SHARDS)),
|
|
None,
|
|
None,
|
|
];
|
|
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(b"write quorum failure payload".to_vec()));
|
|
let err = erasure
|
|
.encode_inline_small(reader, &mut writers, DATA_SHARDS)
|
|
.await
|
|
.expect_err("write quorum failure must fail the inline encode");
|
|
|
|
assert_eq!(
|
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let err = StorageError::from(err);
|
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
|
assert!(err.is_quorum_error());
|
|
assert!(
|
|
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
|
"successful writer must not be committed when write quorum fails before shutdown"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_inline_small_shutdown_quorum_failure_is_reported() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = vec![
|
|
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
|
|
Some(bitrot_writer(ShutdownFailWriter::default(), BLOCK_SIZE / DATA_SHARDS)),
|
|
Some(bitrot_writer(ShutdownFailWriter::default(), BLOCK_SIZE / DATA_SHARDS)),
|
|
Some(bitrot_writer(ShutdownFailWriter::default(), BLOCK_SIZE / DATA_SHARDS)),
|
|
];
|
|
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(b"shutdown quorum failure payload".to_vec()));
|
|
let err = erasure
|
|
.encode_inline_small(reader, &mut writers, DATA_SHARDS)
|
|
.await
|
|
.expect_err("shutdown quorum failure must fail the inline encode");
|
|
|
|
let err = StorageError::from(err);
|
|
assert!(matches!(
|
|
&err,
|
|
StorageError::Io(source)
|
|
if source.kind() == std::io::ErrorKind::Other && source.to_string() == "injected shutdown failure"
|
|
));
|
|
assert!(!err.is_quorum_error());
|
|
assert!(
|
|
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
|
"the successful writer should have committed before shutdown quorum failure was reported"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelling_inline_small_drops_stalled_write() {
|
|
const BLOCK_SIZE: usize = 16;
|
|
|
|
let (writer_entered_tx, writer_entered) = oneshot::channel();
|
|
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let mut writers = vec![Some(bitrot_writer_plain(
|
|
StallOnWriteWithSignal {
|
|
entered: Some(writer_entered_tx),
|
|
writes: writes.clone(),
|
|
},
|
|
BLOCK_SIZE,
|
|
))];
|
|
let erasure = Arc::new(Erasure::new(1, 0, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(vec![0xA5; BLOCK_SIZE - 1]));
|
|
let encode = tokio::spawn(async move { erasure.encode_inline_small(reader, &mut writers, 1).await });
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), writer_entered)
|
|
.await
|
|
.expect("inline writer should enter before cancellation")
|
|
.expect("stalling writer should signal entry");
|
|
encode.abort();
|
|
assert!(
|
|
matches!(encode.await, Err(err) if err.is_cancelled()),
|
|
"inline encode task should be cancelled"
|
|
);
|
|
assert_eq!(
|
|
writes.load(std::sync::atomic::Ordering::SeqCst),
|
|
1,
|
|
"cancellation must drop the stalled write instead of polling it again"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_returns_unexpected_eof_for_truncated_limited_reader() {
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let writer = DeferredCommitWriter::new(committed);
|
|
let mut writers = vec![Some(BitrotWriterWrapper::new(
|
|
CustomWriter::new_tokio_writer(writer),
|
|
16,
|
|
HashAlgorithm::HighwayHash256S,
|
|
))];
|
|
|
|
let erasure = Arc::new(Erasure::new(1, 0, 16));
|
|
let truncated = HardLimitReader::new(Cursor::new(b"short".to_vec()), 10);
|
|
|
|
let err = match erasure.encode(truncated, &mut writers, 1).await {
|
|
Ok(_) => panic!("truncated input must fail"),
|
|
Err(err) => err,
|
|
};
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_rejects_zero_block_size() {
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let writer = DeferredCommitWriter::new(committed);
|
|
let mut writers = vec![Some(BitrotWriterWrapper::new(
|
|
CustomWriter::new_tokio_writer(writer),
|
|
16,
|
|
HashAlgorithm::HighwayHash256S,
|
|
))];
|
|
|
|
let erasure = Arc::new(erasure_with_zero_block_size());
|
|
let reader = tokio::io::BufReader::new(Cursor::new(b"payload".to_vec()));
|
|
let err = erasure
|
|
.encode(reader, &mut writers, 1)
|
|
.await
|
|
.expect_err("zero block size must be rejected");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(err.to_string().contains("block_size"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn encode_works_on_current_thread_runtime() {
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let writer = DeferredCommitWriter::new(committed);
|
|
let mut writers = vec![Some(bitrot_writer(writer, 16))];
|
|
|
|
let erasure = Arc::new(Erasure::new(1, 0, 16));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(b"current-thread payload".to_vec()));
|
|
let (_reader, written) = erasure.encode(reader, &mut writers, 1).await.unwrap();
|
|
|
|
assert_eq!(written, b"current-thread payload".len());
|
|
}
|
|
|
|
// Covers the RuntimeFlavor::MultiThread arm of encode_block /
|
|
// encode_block_bytes_mut, which runs the EC compute inline instead of via
|
|
// block_in_place (rustfs/backlog#932). The other tests default to the
|
|
// current-thread runtime and only exercise the spawn_blocking arm.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn encode_works_on_multi_thread_runtime() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 32;
|
|
|
|
let payload = vec![9u8; BLOCK_SIZE * 3 + 7];
|
|
|
|
// Streaming encode() drives encode_block (Vec ingest).
|
|
let streamed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut streamed_writers: Vec<Option<BitrotWriterWrapper>> = streamed
|
|
.iter()
|
|
.map(|c| Some(bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS)))
|
|
.collect();
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let (_r, streamed_total) = erasure
|
|
.clone()
|
|
.encode(
|
|
tokio::io::BufReader::new(Cursor::new(payload.clone())),
|
|
&mut streamed_writers,
|
|
DATA_SHARDS,
|
|
)
|
|
.await
|
|
.expect("streaming encode should succeed on the multi-threaded runtime");
|
|
assert_eq!(streamed_total, payload.len());
|
|
|
|
// Batched encode() drives encode_block_bytes_mut (BytesMut ingest).
|
|
let batched: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut batched_writers: Vec<Option<BitrotWriterWrapper>> = batched
|
|
.iter()
|
|
.map(|c| Some(bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS)))
|
|
.collect();
|
|
let (_r2, batched_total) = erasure
|
|
.encode_batched(tokio::io::BufReader::new(Cursor::new(payload.clone())), &mut batched_writers, DATA_SHARDS)
|
|
.await
|
|
.expect("batched encode should succeed on the multi-threaded runtime");
|
|
assert_eq!(batched_total, payload.len());
|
|
|
|
// Both ingest paths must produce identical shard bytes regardless of the
|
|
// runtime flavor / dispatch strategy.
|
|
for (index, (a, b)) in streamed.iter().zip(batched.iter()).enumerate() {
|
|
let a = a.lock().expect("streamed shard lockable").clone();
|
|
let b = b.lock().expect("batched shard lockable").clone();
|
|
assert!(!a.is_empty(), "shard {index} should receive committed data");
|
|
assert_eq!(a, b, "shard {index} must match between streaming and batched encode");
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn encode_block_bytes_mut_works_on_current_thread_runtime() {
|
|
let erasure = Arc::new(Erasure::new(2, 2, 64));
|
|
let payload = b"bytesmut current-thread payload";
|
|
let shards = erasure
|
|
.clone()
|
|
.encode_block_bytes_mut(bytes::BytesMut::from(&payload[..]), payload.len())
|
|
.await
|
|
.expect("bytesmut encode should succeed on current-thread runtime");
|
|
|
|
let expected_shard_size = payload.len().div_ceil(erasure.data_shards);
|
|
assert_eq!(shards.shards().len(), erasure.total_shard_count());
|
|
assert!(shards.shards().all(|shard| shard.len() == expected_shard_size));
|
|
|
|
let mut restored = Vec::new();
|
|
for shard in shards.shards().take(erasure.data_shards) {
|
|
restored.extend_from_slice(shard);
|
|
}
|
|
restored.truncate(payload.len());
|
|
assert_eq!(restored, payload);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_batched_writes_full_and_tail_batches() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 32;
|
|
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
|
|
.iter()
|
|
.map(|c| Some(bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS)))
|
|
.collect();
|
|
|
|
let payload = vec![7u8; BLOCK_SIZE * 5 + 3];
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload.clone()));
|
|
let (_reader, total) = erasure
|
|
.encode_batched(reader, &mut writers, DATA_SHARDS)
|
|
.await
|
|
.expect("batched encode should write full and tail batches");
|
|
|
|
assert_eq!(total, payload.len());
|
|
for (index, committed) in committed.iter().enumerate() {
|
|
assert!(
|
|
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
|
"shard {index} should receive committed batched data"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_batched_write_quorum_failure_aborts_and_reports_error() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 32;
|
|
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let mut writers = vec![
|
|
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
|
|
Some(bitrot_writer(FailingWriteWriter, BLOCK_SIZE / DATA_SHARDS)),
|
|
None,
|
|
None,
|
|
];
|
|
|
|
let payload = vec![9u8; BLOCK_SIZE * 8];
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload));
|
|
let err = erasure
|
|
.encode_batched(reader, &mut writers, DATA_SHARDS)
|
|
.await
|
|
.expect_err("batched encode must fail when write quorum is unavailable");
|
|
|
|
assert_eq!(
|
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
|
Some(&Error::ErasureWriteQuorum),
|
|
);
|
|
let err = StorageError::from(err);
|
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
|
assert!(err.is_quorum_error());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_batched_rejects_zero_block_size() {
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let writer = DeferredCommitWriter::new(committed);
|
|
let mut writers = vec![Some(bitrot_writer(writer, 16))];
|
|
|
|
let erasure = Arc::new(erasure_with_zero_block_size());
|
|
let reader = tokio::io::BufReader::new(Cursor::new(b"payload".to_vec()));
|
|
let err = erasure
|
|
.encode_batched(reader, &mut writers, 1)
|
|
.await
|
|
.expect_err("zero block size must be rejected");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(err.to_string().contains("block_size"));
|
|
}
|
|
|
|
/// encode_inline_small: empty reader returns (reader, 0) without writing to any shard.
|
|
#[tokio::test]
|
|
async fn encode_inline_small_empty_stream_returns_zero() {
|
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
|
let writer = DeferredCommitWriter::new(committed.clone());
|
|
// 1 data shard, 0 parity shards, block_size = 16
|
|
let mut writers = vec![Some(BitrotWriterWrapper::new(
|
|
CustomWriter::new_tokio_writer(writer),
|
|
16,
|
|
HashAlgorithm::HighwayHash256S,
|
|
))];
|
|
|
|
let erasure = Arc::new(Erasure::new(1, 0, 16));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(Vec::<u8>::new()));
|
|
let (_reader, total) = erasure
|
|
.encode_inline_small_with_size_hint(reader, &mut writers, 1, 0)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(total, 0);
|
|
// No shutdown was called, so nothing should be committed
|
|
assert!(committed.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_inline_shards_matches_writer_bitrot_layout() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const BLOCK_SIZE: usize = 64;
|
|
let checksum_algo = HashAlgorithm::HighwayHash256S;
|
|
for uses_legacy in [false, true] {
|
|
let erasure = Arc::new(Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy));
|
|
for payload in [Vec::new(), vec![0xA5], vec![0x5A; BLOCK_SIZE - 1], vec![0xC3; BLOCK_SIZE]] {
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload.clone()));
|
|
let (_reader, total, inline_shards) = erasure
|
|
.clone()
|
|
.encode_inline_shards_with_size_hint(reader, payload.len())
|
|
.await
|
|
.expect("inline shards should encode");
|
|
|
|
assert_eq!(total, payload.len());
|
|
if payload.is_empty() {
|
|
assert!(inline_shards.is_empty());
|
|
continue;
|
|
}
|
|
|
|
let raw_shards = erasure.encode_data(&payload).expect("reference shards should encode");
|
|
assert_eq!(inline_shards.len(), DATA_SHARDS + PARITY_SHARDS);
|
|
for (inline, raw) in inline_shards.iter().zip(raw_shards) {
|
|
let mut writer =
|
|
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), raw.len(), checksum_algo.clone());
|
|
writer.write(&raw).await.expect("reference writer should accept shard");
|
|
writer.shutdown().await.expect("reference writer should shutdown");
|
|
assert_eq!(inline.as_ref(), writer.into_inline_data().expect("reference writer should retain bytes"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// encode_inline_small: small payload is encoded into the correct number of shards
|
|
/// and each writer receives data after shutdown.
|
|
#[tokio::test]
|
|
async fn encode_inline_small_payload_writes_all_shards() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
|
|
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
|
|
.iter()
|
|
.map(|c| {
|
|
Some(BitrotWriterWrapper::new(
|
|
CustomWriter::new_tokio_writer(DeferredCommitWriter::new(c.clone())),
|
|
BLOCK_SIZE / DATA_SHARDS,
|
|
HashAlgorithm::HighwayHash256S,
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let payload = b"hello inline small";
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec()));
|
|
let (_reader, total) = erasure
|
|
.encode_inline_small_with_size_hint(reader, &mut writers, DATA_SHARDS, 1)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(total, payload.len());
|
|
// All shards must have received data (shutdown flushed the bitrot header + shard bytes)
|
|
for (i, c) in committed.iter().enumerate() {
|
|
assert!(!c.lock().unwrap().is_empty(), "shard {i} should have received data");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_single_block_non_inline_payload_writes_all_shards() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
|
|
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
|
|
.iter()
|
|
.map(|c| {
|
|
Some(BitrotWriterWrapper::new(
|
|
CustomWriter::new_tokio_writer(DeferredCommitWriter::new(c.clone())),
|
|
BLOCK_SIZE / DATA_SHARDS,
|
|
HashAlgorithm::HighwayHash256S,
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let payload = b"hello single block";
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec()));
|
|
let (_reader, total) = erasure
|
|
.encode_single_block_non_inline(reader, &mut writers, DATA_SHARDS)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(total, payload.len());
|
|
for (i, c) in committed.iter().enumerate() {
|
|
assert!(!c.lock().unwrap().is_empty(), "shard {i} should have received data");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encode_single_block_non_inline_rejects_multi_block_payload() {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
|
|
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
|
|
.iter()
|
|
.map(|c| {
|
|
Some(BitrotWriterWrapper::new(
|
|
CustomWriter::new_tokio_writer(DeferredCommitWriter::new(c.clone())),
|
|
BLOCK_SIZE / DATA_SHARDS,
|
|
HashAlgorithm::HighwayHash256S,
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let payload = vec![1u8; BLOCK_SIZE + 1];
|
|
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload));
|
|
let err = erasure
|
|
.encode_single_block_non_inline_with_size_hint(reader, &mut writers, DATA_SHARDS, BLOCK_SIZE)
|
|
.await
|
|
.expect_err("single-block fast path must reject oversized readers");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(err.to_string().contains("single-block non-inline fast path"));
|
|
for c in committed {
|
|
assert!(c.lock().unwrap().is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn small_ingest_capacity_uses_bounded_size_hint() {
|
|
let erasure = Erasure::new(4, 2, 1024 * 1024);
|
|
assert_eq!(small_ingest_capacity(&erasure, 0), 0);
|
|
assert_eq!(small_ingest_capacity(&erasure, 4 * 1024), 6 * 1024);
|
|
assert_eq!(small_ingest_capacity(&erasure, 16 * 1024), 24 * 1024);
|
|
assert_eq!(small_ingest_capacity(&erasure, usize::MAX), 1024 * 1024);
|
|
|
|
let legacy = Erasure::new_with_options(4, 2, 1024 * 1024, true);
|
|
assert_eq!(small_ingest_capacity(&legacy, 4 * 1024), 6 * 1024);
|
|
|
|
let high_parity = Erasure::new(4, 12, 1024 * 1024);
|
|
assert_eq!(small_ingest_capacity(&high_parity, usize::MAX), 1024 * 1024);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_full_buf_or_eof_returns_none_on_empty_reader() {
|
|
let mut reader = Cursor::new(Vec::<u8>::new());
|
|
let mut buf = BytesMut::with_capacity(16);
|
|
|
|
let res = read_full_buf_or_eof(&mut reader, &mut buf, 8).await.unwrap();
|
|
|
|
assert_eq!(res, None);
|
|
assert!(buf.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_full_buf_or_eof_reads_partial_tail() {
|
|
let data = b"tail".to_vec();
|
|
let mut reader = Cursor::new(data.clone());
|
|
let mut buf = BytesMut::with_capacity(64);
|
|
|
|
let res = read_full_buf_or_eof(&mut reader, &mut buf, 16).await.unwrap();
|
|
|
|
assert_eq!(res, Some(data.len()));
|
|
assert_eq!(&buf[..], &data[..]);
|
|
assert!(buf.capacity() >= 64, "pre-reserved spare capacity must be kept");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_full_buf_or_eof_stops_at_limit() {
|
|
let data = vec![7u8; 32];
|
|
let mut reader = Cursor::new(data.clone());
|
|
let mut buf = BytesMut::with_capacity(64);
|
|
|
|
let res = read_full_buf_or_eof(&mut reader, &mut buf, 16).await.unwrap();
|
|
|
|
assert_eq!(res, Some(16));
|
|
assert_eq!(&buf[..], &data[..16]);
|
|
|
|
// The remaining bytes are still readable as the next block.
|
|
let mut next = BytesMut::with_capacity(64);
|
|
let res = read_full_buf_or_eof(&mut reader, &mut next, 16).await.unwrap();
|
|
assert_eq!(res, Some(16));
|
|
assert_eq!(&next[..], &data[16..]);
|
|
}
|
|
|
|
async fn committed_shards_for_pipeline(pipeline: EncodePipeline, uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
|
|
.iter()
|
|
.map(|c| Some(bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS)))
|
|
.collect();
|
|
|
|
let erasure = Arc::new(Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy));
|
|
let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec()));
|
|
let (_reader, total) = match pipeline {
|
|
EncodePipeline::Vec => {
|
|
erasure
|
|
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, false, None)
|
|
.await
|
|
}
|
|
EncodePipeline::BytesMut => {
|
|
erasure
|
|
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, true, None)
|
|
.await
|
|
}
|
|
EncodePipeline::Batched => erasure.encode_batched(reader, &mut writers, DATA_SHARDS).await,
|
|
}
|
|
.expect("encode should succeed");
|
|
assert_eq!(total, payload.len());
|
|
|
|
committed
|
|
.iter()
|
|
.map(|c| c.lock().expect("committed buffer should be lockable").clone())
|
|
.collect()
|
|
}
|
|
|
|
async fn expected_committed_shards(uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
|
const DATA_SHARDS: usize = 2;
|
|
const PARITY_SHARDS: usize = 2;
|
|
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
|
const BLOCK_SIZE: usize = 64;
|
|
|
|
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
|
let mut writers: Vec<BitrotWriterWrapper> = committed
|
|
.iter()
|
|
.map(|c| bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS))
|
|
.collect();
|
|
let erasure = Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy);
|
|
|
|
for block in payload.chunks(BLOCK_SIZE) {
|
|
let shards = erasure.encode_data(block).expect("reference block should encode");
|
|
for (writer, shard) in writers.iter_mut().zip(shards) {
|
|
let written = writer.write(&shard).await.expect("reference shard should write");
|
|
assert_eq!(written, shard.len());
|
|
}
|
|
}
|
|
for writer in &mut writers {
|
|
writer.shutdown().await.expect("reference writer should commit");
|
|
}
|
|
|
|
committed
|
|
.iter()
|
|
.map(|c| c.lock().expect("committed buffer should be lockable").clone())
|
|
.collect()
|
|
}
|
|
|
|
/// The streaming and batched paths must produce the same bitrot-wrapped shard
|
|
/// bytes as the public block encoder for both shard-size formulas and all block
|
|
/// boundary shapes.
|
|
#[tokio::test]
|
|
async fn bytesmut_ingest_matches_vec_ingest_byte_for_byte() {
|
|
const BLOCK_SIZE: usize = 64;
|
|
let payloads: Vec<Vec<u8>> = vec![
|
|
Vec::new(),
|
|
vec![1],
|
|
vec![2; BLOCK_SIZE - 1],
|
|
(0..BLOCK_SIZE as u32).map(|i| i as u8).collect(), // exactly one full block
|
|
vec![4; BLOCK_SIZE + 1],
|
|
vec![3u8; BLOCK_SIZE * 4], // whole number of blocks
|
|
(0..(BLOCK_SIZE * 3 + 7) as u32).map(|i| (i % 251) as u8).collect(), // partial tail
|
|
];
|
|
|
|
for uses_legacy in [false, true] {
|
|
for payload in &payloads {
|
|
let expected = expected_committed_shards(uses_legacy, payload).await;
|
|
for pipeline in [EncodePipeline::Vec, EncodePipeline::BytesMut, EncodePipeline::Batched] {
|
|
let actual = committed_shards_for_pipeline(pipeline, uses_legacy, payload).await;
|
|
assert_eq!(
|
|
actual,
|
|
expected,
|
|
"streaming shards must match the public block encoder (legacy={uses_legacy}, payload_len={})",
|
|
payload.len()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn encode_channel_capacity_never_returns_zero() {
|
|
assert_eq!(encode_channel_capacity(0, 1024), 1);
|
|
assert_eq!(encode_channel_capacity(4096, 0), 1);
|
|
assert_eq!(encode_channel_capacity(4096, 1024), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn write_quorum_failure_summary_uses_stable_dominant_error_label() {
|
|
let err = Error::from(rustfs_rio::new_test_internode_http_io_error(
|
|
rustfs_rio::InternodeHttpErrorKind::ConnectionReset,
|
|
));
|
|
let summary = WriteQuorumFailureSummary {
|
|
required: 2,
|
|
achieved: 0,
|
|
failed: 2,
|
|
total: 2,
|
|
offline_disks: 0,
|
|
ignored_failures: 0,
|
|
retryable_failures: 2,
|
|
dominant_error: Some(err),
|
|
dominant_error_label: "connection_reset",
|
|
};
|
|
let text = format_write_quorum_failure(&summary);
|
|
|
|
assert!(text.contains("dominant-error=connection_reset"));
|
|
assert!(!text.contains("/rustfs/rpc/put_file_stream"));
|
|
assert!(!text.contains("PUT "));
|
|
}
|
|
|
|
#[test]
|
|
fn encode_channel_capacity_respects_budget_and_hard_cap() {
|
|
assert_eq!(encode_channel_capacity(4 * 1024 * 1024, 32 * 1024 * 1024), 8);
|
|
assert_eq!(encode_channel_capacity(1536 * 1024, 32 * 1024 * 1024), 21);
|
|
assert_eq!(encode_channel_capacity(16 * 1024 * 1024, 32 * 1024 * 1024), 2);
|
|
assert_eq!(encode_channel_capacity(1, usize::MAX), DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BLOCKS);
|
|
}
|
|
}
|