mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
c0a7dbb2bb7ec12c24e6a09c21e978e4cf2608d7
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c0a7dbb2bb |
iam: bind CreateServiceAccount ParentUser to the caller (#11218)
* iam: bind CreateServiceAccount target to caller in AuthorizeIamAction A non-admin holding iam:CreateServiceAccount could pass an arbitrary ParentUser and mint a service account for any identity, inheriting that identity permissions. Add a self-target category so a granted non-admin may only target their own identity; admins remain unrestricted. * iam: authorize CreateServiceAccount against its ParentUser target AuthIamManagement passed UserName as the authorization target for every action, so CreateServiceAccount was authorized with an empty target and the self-target binding never saw the caller-supplied ParentUser. Pass ParentUser for that action so the binding takes effect on the live path. * iam: test CreateServiceAccount binds target to caller Regression test: a non-admin holding iam:CreateServiceAccount may target itself but is denied targeting another identity; admins remain unrestricted. * iam: authorize CreateServiceAccount against ParentUser on the S3 port UnifiedPostHandler passed UserName as the authorization target for every IAM action, so CreateServiceAccount was authorized with an empty target on the S3-port route and the self-target binding never saw the caller ParentUser. Extract iamTargetUserName (ParentUser for CreateServiceAccount, UserName otherwise) and use it from both IAM dispatch surfaces so the binding applies on the live S3-port path as well as the standalone iam server. * iam: test CreateServiceAccount ParentUser binding on the S3 port End-to-end regression test through UnifiedPostHandler: a non-admin holding iam:CreateServiceAccount is denied (403) when targeting another identity and passes authorization when targeting itself. |
||
|
|
75ec5ec193 |
admin: allow setting volume read-only and read/write modes (#11217)
* admin: support setting volume read-only and read/write modes
* admin: address PR review on volume access-mode persistence
Reject trailing JSON values in the SetVolumeReadOnly handler so
requests like {"read_only":true}{} no longer pass validation, and add
a trailing-value case to the invalid-request test.
Propagate .vif persistence failures through the access-mode chain.
PersistReadOnly now returns the SaveVolumeInfo error and rolls back
the in-memory volumeInfo on failure; Store.MarkVolumeReadonly and
Store.MarkVolumeWritable propagate that error and roll back their
noWrite flags, so the API reports failure instead of success while
restart would revert the mode.
* admin: make .vif persistence atomic and preserve error chain
SaveVolumeInfo now writes to a .vif.tmp file, syncs it, renames it
over the target, and fsyncs the directory. A write/sync/close failure
leaves the existing .vif intact, so the PersistReadOnly in-memory
rollback matches the durable state instead of diverging from a
partially written file that restart would apply.
Switch the error wrappers in PersistReadOnly, MarkVolumeReadonly, and
MarkVolumeWritable from %v to %w so callers can use errors.Is and
errors.As to classify persistence failures.
* admin: treat post-rename dir fsync failure as a warning
After os.Rename commits the new .vif, the on-disk file already holds
the requested mode. A directory fsync failure only risks losing the
rename across a crash; returning an error here would make
PersistReadOnly roll back in-memory state while the durable file keeps
the new mode, splitting the replica. Log the failure as a warning
instead, matching the best-effort nature of FsyncDir (already skipped
on Windows).
* admin: distinguish post-rename durability failures and use unique temp files
SaveVolumeInfo now uses os.CreateTemp for the staging file, preventing
concurrent saves for the same volume from colliding on a shared .tmp
path.
A directory fsync failure after os.Rename returns a
NotCrashDurableError instead of being silently swallowed. The rename
already committed the new metadata to disk, so PersistReadOnly,
MarkVolumeReadonly, and MarkVolumeWritable skip the in-memory rollback
for this error type (keeping state aligned with the durable file) while
still propagating the failure to the API. Pre-commit failures continue
to roll back as before.
* admin: continue post-commit work after NotCrashDurableError
MarkVolumeWritable now clears the EIO quarantine and the gRPC handlers
(makeVolumeReadonly step 3, makeVolumeWritable master notification)
proceed with their post-commit work when SaveVolumeInfo returns a
NotCrashDurableError, instead of aborting and leaving the volume
unavailable or the master unaware of the mode change. The durability
warning is still propagated to the API caller. Pre-commit failures
continue to abort early as before.
* admin: handle NotCrashDurableError in tier and EC callers
VolumeTierMoveDatFromRemote and VolumeEcShardsGenerate now check for
NotCrashDurableError from SaveVolumeInfo. When the rename has already
committed the new .vif, they continue with their post-commit work
(backend switch, remote deletion, keeping generated EC shards) instead
of aborting and leaving the on-disk metadata inconsistent with the
file layout. The durability warning is logged for the operator.
|
||
|
|
5d5ea18287 |
topology: fix fatal concurrent map read/write on VolumeLayout.crowded (#11216)
SetVolumeCrowded mutated the crowded map under accessLock.RLock(), while GetWritableVolumeCount reads the same map under RLock() on the Assign hot path. Two concurrent RLock holders with one writing and one reading the map triggers a fatal "concurrent map read and map write" that kills the master process (unrecoverable, bypasses recover). Take the write lock in SetVolumeCrowded instead. This event path is a low-frequency single consumer driven by the crowded-volume event loop, and every other mutation of crowded already holds Lock(); setVolumeCrowded takes no nested locks, so there is no deadlock path. The hot readers (GetWritableVolumeCount, CloneWritableVolumes) keep using RLock. Adds a -race regression test that fails (race detected) on the old RLock and passes with the write lock. Fixes #11211 |
||
|
|
15e4da65f7 |
volume: avoid read-only replica write targets (#11195)
* master: carry replica read-only state in volume lookups * volume: refresh writable replica targets * volume: preserve read-only replicas for deletes * master: propagate read-only delete capability * volume: target delete-capable replicas * volume: honor configured HTTPS for replica deletes * volume: reject insecure delete authorization forwarding * master: broadcast delete capability changes * volume: align Rust replica routing * http: protect credentialed replica redirects * master: preserve digest compatibility for delete capability * volume: propagate read-only state in short heartbeats * volume: report changed short volume state * http: guard TLS client redirects * master: announce mounted volume read-only state * volume: replace changed identity deltas * master: replace incremental volume layouts in order * master: keep moved volume lookup available * volume: announce read-only mounts |
||
|
|
3225d2b0ce |
rust worker: install rustls CryptoProvider to fix TLS panic (#11194) (#11196)
* rust worker: add install_default_crypto_provider helper lance's aws backend pulls aws-lc-rs and reqwest's rustls-tls pulls ring, so rustls 0.23 cannot auto-select a CryptoProvider and tonic's client TLS panics on first use. Add install_default_crypto_provider, pinning the default to aws-lc-rs, mirroring the Rust volume server's helper of the same name. Includes a regression test that builds a TLS channel and panics without the install in this crate, where both providers link. * rust worker: install the crypto provider at startup Call install_default_crypto_provider before any TLS use, the way the Rust volume server does in its main. Without this a worker started with --tls-ca/--tls-cert/--tls-key panics on the first admin dial (#11194). |
||
|
|
70a26cb5d2 |
s3: gate IAM-cache gRPC RPCs behind admin Bearer auth (#11190)
* s3: gate IAM-cache gRPC RPCs behind admin Bearer auth The SeaweedS3IamCacheServer registered on the S3 gateway's internal gRPC port (default 0.0.0.0:18333) accepted PutIdentity/RemoveIdentity/PutPolicy/ DeletePolicy/GetPolicy/ListPolicies/PutGroup/RemoveGroup with no per-RPC authentication. An unauthenticated network peer could call PutIdentity with Actions:[Admin] and write straight into the live accessKeyIdent map that the SigV4 path reads, bypassing S3 authentication entirely. Mirror the filer's IamGrpcServer.checkAdminAuth: require a Bearer token signed with jwt.filer_signing.key (read from the existing s3a.filerGuard) at the top of every IAM-cache RPC. With no key configured the check is a no-op, matching the rest of SeaweedFS's gRPC surface. * credential: attach admin Bearer token to S3 IAM-cache propagation The filer's PropagatingCredentialStore fans IAM mutations out to peer S3 servers over the SeaweedS3IamCache gRPC service. Now that the S3 handlers require a Bearer token signed with jwt.filer_signing.key, attach one to the outgoing propagation context (mirroring shell/iamAdminAuthContext). With no key configured it is a no-op, so deployments that run without the signing key keep working. * credential: mint IAM-cache admin token after master discovery propagateChange attached the admin Bearer token before ListClusterNodes, so master-client retries could run down the (default 10s) token lifetime before the peer S3 fan-out began, leaving peers to reject an expired token and IAM caches stale. Move withIamCacheAdminAuth to after discovery succeeds, immediately before the propagation timeout is derived. * credential: cap IAM-cache propagation timeout below JWT lifetime The propagation fan-out used a fixed 10s timeout. If an operator configures jwt.filer_signing.expires_after_seconds below 10, the admin token can expire while slower S3 peers are still being contacted, leaving their IAM caches stale. Derive the propagation deadline as min(10s, tokenTTL) so it never outlives the token. withIamCacheAdminAuth now returns the token's lifetime (0 = no expiry) for this purpose. |
||
|
|
d8a40ef750 |
ci: pin actions/setup-python to v7 in star_history workflow (#11191)
The star_history workflow referenced actions/setup-python@v8, which does not exist, causing the workflow to fail at the "Set up job" step. Pin to v7, matching the version used across the other workflows. |
||
|
|
e6f2386a0f |
admin: redact S3 secret keys for read-only sessions (#11189)
The Admin UI documents its read-only account as view-only and blocks its
write requests, but the authenticated read routes returned object-store
users with plaintext access and secret keys. A read-only admin user could
retrieve another user's live S3 credential pair from GET /api/users and
GET /api/users/{username} and use it directly against the S3 endpoint,
converting view-only access into the victim identity's object-store
authority.
Redact the reusable secret_key in GetUsers, GetUserDetails, and the
rendered users page whenever the requesting session has the read-only
role. The public access_key identifier is retained so identities remain
browsable; only the reusable secret is stripped. Admin and no-auth
sessions are unaffected.
|
||
|
|
3e85d9ec8e |
admin: bind to loopback by default, guard public unauthenticated bind (#11185)
admin: bind to loopback by default, refuse public unauthenticated bind The admin HTTP server (port 23646) defaulted to binding 0.0.0.0 with authentication disabled when -adminPassword was not supplied, exposing the full admin REST API (user creation, credential issuance, bucket deletion, filer deletion) unauthenticated on the network. This is the footgun described in GHSA-m3m8-mrgq-hf9h. Keep the no-auth mode for local dev, but remove the network exposure: - Add -ip flag (default 127.0.0.1) so the server binds loopback only unless the operator explicitly chooses a public address. - Refuse to start when binding a non-loopback address with no -adminPassword and no [https.admin] mTLS. The operator must enable auth or use loopback. - weed mini sets -ip from its existing -ip.bind; the guard does not apply because mini calls startAdminServer directly, not runAdmin. Addresses GHSA-m3m8-mrgq-hf9h. |
||
|
|
f35e2ccf21 |
s3: warn when a lifecycle change leaves fast-path-stamped objects on their old TTL (#11184)
* s3: warn when a lifecycle change leaves fast-path-stamped objects on their old TTL The per-write TTL fast path (opt-in via s3.bucket.lifecycle.fastpath) stamps a volume TTL at PutObject time that can't be taken back. When an operator lengthens or removes an Expiration.Days rule (or deletes the bucket lifecycle) on a fast-path-enabled bucket, objects already written keep their baked-in TTL and won't be rescued by the change — unlike the default worker-driven path, which re-evaluates the current rules each pass. This is the data-loss direction described in #11183. Surface it: Put/DeleteBucketLifecycle now emit a glog warning and set X-Seaweed-Lifecycle-Fastpath-Warning on the response when the change removes, disables, lengthens, or re-scopes a fast-path-eligible rule. Shortening a rule does not warn (old objects simply expire later, not data loss). Tag-only and overflow-day rules are never on the fast path and never warn. Addresses the warning half of option 2 in #11183. * s3: address review — emit warning after mutation succeeds, fix ID-rename false positive Two issues raised by CodeRabbit, Greptile, and Devin reviews: 1. Failed mutations retained the warning header. The warning was set on the ResponseWriter before storeBucketLifecycleConfiguration / clearStoredBucketLifecycleConfiguration was called; if that failed, the error response carried a warning for a change that was never applied. Now the reason is computed before the mutation but the log and header are emitted only after it succeeds. 2. Rule renames produced false "removed" warnings. fastpathRuleKey used Rule.ID as the sole identity when present, so renaming a rule (same prefix/size/days, different ID) treated the old rule as removed. Replaced with two-pass matching: first by ID, then by fast-path predicates (prefix + size). An ID-only rename with unchanged predicates and days no longer warns. Greedy matching ensures each new rule is consumed by at most one old rule. Added regression tests: ID-only rename (no warn), rename + lengthen (warn), rename + shorten (no warn). |
||
|
|
8a68337256 |
filer: pack SSE chunks into manifests (#11175)
* filer: pack SSE chunks into manifests * s3: resolve encrypted manifests before reads * s3: scope encrypted manifest resolution to ranges |
||
|
|
97154802c5 |
docs(star-history): make the chart taller (#11173)
Change the matplotlib figure size from (10, 4) to (10, 6) so the star history chart renders vertically longer in the README. The regenerated note/star_history.svg reflects the new 5:3 aspect ratio (720x432pt) instead of the previous flat 2.5:1 (720x288pt). |
||
|
|
ff0d5a9adf |
ci(mount-windows): clean up processes and don't let Logs step fail job (#11174)
The "Mount and exercise" step left the weed.exe mini server and the final WinFsp mount running when it exited. The next step's pwsh.exe then failed with STATUS_DLL_INIT_FAILED (0xC0000142), failing a job whose actual test step had passed. The same code passed on both the PR branch and the next master run, so this was a transient launch failure — but it was caused by an unclean environment and made fatal by a diagnostic step. Tear down all weed.exe processes at the end of the test step so subsequent steps launch into a clean environment, and mark the Logs step continue-on-error so a diagnostic step can never fail the job on its own. |
||
|
|
811b8b5734 |
make the remote-mount cache wait configurable per mount (#11168)
* add a per-mount cache_wait_ms to the remote storage mount mapping A read of an uncached remote-only object waits on a hardcoded size tier before it can fall back to the origin, so every ranged read of a large remote-only object pays that wait. Carry the wait in the mount mapping so it can be tuned, or set to zero, per mount. * resolve the cache wait of an uncached remote-only read from its mount The wait came only from the object size, so an operator could not trade cache hits for time to first byte. Both read paths now resolve the mount covering the object and let its cache_wait_ms replace the size tiers. * read straight from the remote when a mount waits zero for its cache A mount used as a streaming source pays the cache wait on every ranged read of an object too large to finish caching, and the caching itself is wasted work. A zero wait now skips the cache call, so both read paths go to the origin immediately. * let remote.mount set the cache wait of a mount remote.mount -cacheWait=0 turns a mount into a streaming source, and any other duration trades cache hits against time to first byte. * keep the size based wait for a version-specific read A read pinned to a version cannot fall back to the origin, since the mounted remote only holds the current key, so a mount that opts out of caching would leave it on the 503 retry loop forever. * let the operator allow a remote-only read to dial an internal endpoint The remote-mount read paths in the filer and the S3 gateway always refused an endpoint resolving to a loopback or private host, so a mount backed by an internal S3 could never be read from its origin, only through the local cache. Both now take the allowance the volume server already has, still off by default. * skip the background cache of a mount that waits zero for its cache GetObjectHandler kicks off caching for every remote-only read, so a mount serving as a streaming source kept downloading whole objects even though no read ever waited for them. * cover a zero cache wait end to end The read has to reach a real origin, so the harness also opts the filer and the S3 gateway into dialing the loopback remote it already allows for the volume server. * resolve the S3 cache wait once so the background cache follows it too The background cache that GetObjectHandler starts read the mount on its own, so it skipped a version-specific read that the foreground path still waits for. Both now ask the same resolver. * answer 404 when the origin of a zero-wait read is gone Metadata can outlive the object it points at, and with no cache to fill the read would sit on the 503 retry path forever. The remote backends already report a missing object as ErrRemoteObjectNotFound. * open the origin at write time for a multipart range Every part of a multipart Range is prepared before any is written, so opening eagerly would hold one origin connection per part and leak the ones already opened when a later part fails to open. * reject a cache wait shorter than a millisecond The mapping stores milliseconds, so -cacheWait=500us truncated to zero and silently turned caching off instead of waiting. * restore the doc comment of cacheRemoteObjectForStreamingWithShortTimeout Extracting the wait resolver left its comment on the new function. * stat the origin before committing a multipart range Opening at write time keeps no connection through the preparation, but it also moved a failure past the point where the multipart body picks the response status, so a gone origin truncated a 206 instead of answering 404. One stat up front puts the status back. * stat the origin once per request Every part of a multipart Range is prepared on its own, so the preflight ran once per range instead of once per read. * map Azure and GCS stream not-found to ErrRemoteObjectNotFound ReadFileAsStream on Azure and GCS returned provider-specific not-found errors instead of ErrRemoteObjectNotFound, so a zero-wait read of a deleted object was misclassified as a transient cache failure and retried indefinitely. Map BlobNotFound and ErrObjectNotExist the same way StatFile already does. * Update weed/remote_storage/gcs/gcs_storage_client.go Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> |
||
|
|
f79d83abf4 |
volume: expire TTL volumes whose only traffic is deletes (#11167)
* volume: count a TTL volume's age from its last write, not the .dat mtime A delete appends a tombstone needle and vacuum rewrites the .dat wholesale, so the file's mtime moves without any write ever landing. The loader read lastModifiedTsSeconds back from that mtime, so every restart of a volume taking delete traffic re-armed expired() for another full TTL: an overwrite-heavy collection kept growing until it hit the max-volume cap. Recover the clock from the newest .idx entry that is not a tombstone and read that needle's append timestamp, falling back to the mtime when no write is recoverable. Only TTL volumes pay for the scan. Fixes #11160 * volume: count the .vif destroy time from the last write too ExpireAtSec is what an EC volume is reclaimed on, and it was recomputed as now+TTL every time the .vif was written. A read-only mark, a tier upload or an EC encode therefore handed an already expiring volume another full TTL, the same way the .dat mtime did. Derive it from the volume's last write, falling back to now for a volume that has not taken one yet so a fresh volume is not born expired. * volume: mirror the last-write TTL clock in the Rust volume server Same recovery as the Go loader: scan the .idx backwards for the newest entry that is not a tombstone and take that needle's append timestamp, leaving the clock on the .dat mtime when no write is recoverable. * volume: mirror the last-write destroy time in the Rust volume server Both .vif writers and the EC encode computed ExpireAtSec as now+TTL, the same way Go did, so the destroy time moved every time the sidecar was rewritten. Route all three through the volume's last write. * volume: report the .dat mtime in the Rust heartbeat, like Go does The Rust server reported its TTL clock as ModifiedAtSecond while Go reports the .dat mtime. The shell's quiet-period gates (volume.tier.move, volume.delete_empty) read that field as "last touched", which a delete has to count towards even though the TTL clock deliberately ignores it -- and with the clock now recovered from the last write, the two drift further apart. * volume: take the newest write by timestamp on a vacuumed volume The reverse .idx scan trusted position, which holds only while the .dat is append ordered. Vacuum rewrites it in key order, and since an overwrite keeps its original key, the highest-key survivor is not necessarily the newest write -- the recovered clock could land up to a TTL early and take the volume with data still inside its TTL. A volume that has been vacuumed (CompactionRevision > 0) now takes the maximum append timestamp over a bounded window of write entries instead. An append-ordered volume still answers in one read. * volume: never guess a vacuumed volume's last write, and resolve wrapped offsets Two holes in the reverse scan, both from review: A vacuumed volume's writes are ordered by key, so any of them can hold the newest timestamp. Reading a capped window sampled the highest keys, which could still miss a recently overwritten low-key needle and expire data inside its TTL. The scan now covers every write a vacuumed volume indexes, and a volume too large to scan keeps the .dat mtime rather than report a partial maximum -- late is recoverable, early is not. A .dat past MaxPossibleVolumeSize wraps the offsets in its .idx, so reading a timestamp at the unwrapped offset picks up an unrelated needle. Resolve the entry against the needle header first and retry one volume size in, the way doCheckAndFixVolumeData already does. * volume: drop GitHub issue references from TTL comments |
||
|
|
567578f08d |
docs(readme): replace star-history.com with self-generated chart (#11171)
* docs(readme): replace star-history.com with self-generated chart The star-history.com SVG is a third-party dependency that can rate limit or go down. Replace it with a GitHub Action that fetches stargazers via the REST API and renders an SVG with matplotlib, committing note/star_history.svg weekly. The README references the committed file directly, so the chart has no runtime dependency on any external service. * ci(star-history): run daily instead of weekly |
||
|
|
070174726d |
docs(readme): replace rate-limited starchart with star-history (#11170)
starchart.cc is rate-limiting the SVG endpoint, so the Stargazers chart renders blank. Switch to star-history.com, which serves a live SVG for this repo and links to the interactive chart. |
||
|
|
e4dc66c66b |
docs(readme): move sponsor section to the end (#11169)
The Patreon CTA and Gold Sponsors logos sat between the logo and the project intro, pushing the actual description below the fold. Move the whole block to a dedicated `# Sponsors #` section after `# License #`, add it to the TOC, and give it a real markdown heading so the anchor works on GitHub. |
||
|
|
1ca19ea2e2 |
mount: add -volumeName to name the disk explicitly (#11165)
* mount: let volumeName take an explicit override volumeName only ever derived the disk's label from -filer.path, -dir, or the filer address, so a name that happened to collide with something else - e.g. a UNC share's own name - could not be changed without moving what was mounted. Give it an override parameter that wins over all three; nothing passes one yet. * mount: add -volumeName to name the disk explicitly Windows has no equivalent of the "weed fuse" -o passthrough that lets a Linux or macOS mount override its derived volname, so a name picked up from -dir - e.g. a UNC share's own name - could not be changed short of moving what was mounted. -volumeName overrides it on every platform. * mount: document -volumeName * mount: scope -volumeName's help text to macOS and Windows Linux has no volume-label mount option for -volumeName to feed, so the flag's own description says where it applies instead of leaving that unstated. * mount: forward -volumeName through the weed fuse option parser weed fuse (the /etc/fstab helper) turns -o key=value into the same MountOptions weed mount takes, but volumeName had no case, so it fell through to being forwarded as a literal, unrecognized FUSE option instead of ever reaching mountOptions.volumeName. * mount: apply -volumeName to FsName on Linux and FreeBSD FsName only ever took the filer address and -filer.path, so -volumeName had nothing to override there and silently did nothing; the skipAutofs case still forces "fuse", since that name is what util-linux/mount requires to recognize the pseudo filesystem. |
||
|
|
5a515adab2 |
s3: HeadObject with partNumber returns the part's size and 206 (#11166)
* s3: HEAD with partNumber reports the part's size and range HeadObject set its headers from the total object size and then only validated the partNumber, so a client probing part 1 with HEAD got the whole object's Content-Length and a 200 while the same GET returned the part's size, a Content-Range and a 206. Resolve the part's byte range before the headers are written, through the range logic GetObject already used, and answer a partNumber HEAD as the ranged HEAD that AWS documents. * s3: answer an unsatisfiable partNumber with 416 InvalidPartNumber GET and HEAD rejected a partNumber past the number of parts with 400 InvalidPart, the code for a missing part in CompleteMultipartUpload. AWS answers a read of a part that does not exist with 416 InvalidPartNumber, which lets a client probing for the part count tell the two apart. The ceph suite pins RGW's 400 InvalidPart here, so the s3tests jobs patch that expectation the way they already patch prefix ordering. * s3: keep the whole-object checksum off a partNumber response The stored checksum covers the whole object, so it is already withheld from a ranged read. A partNumber HEAD now describes one part while the request carries no Range header, so exclude it there too rather than handing a client a checksum that does not match the bytes described. * s3: resolve a partNumber against the parts the object records Completion accepts ascending, not consecutive, part numbers, so the part count is not the highest part number. Comparing the two rejected an uploaded part 3 of a two-part object, and let a request for the absent part 2 fall through to the positional chunk lookup and serve part 3's bytes. Ask the recorded boundaries for the part instead, and keep the count comparison for objects written before boundaries were stored. * s3: apply a client Range within the part on HEAD too GET narrowed the part by a Range sent alongside partNumber; HEAD reported the whole part, so the two disagreed again for a request that carries both. Move the narrowing into the shared range lookup so either verb describes the same bytes. |
||
|
|
a0b1272cc3 |
filer: authorize the chunk proxy and the root listing like the rest of the filer port (#11152)
* filer: require a read token for the root listing maybeCheckJwtAuthorization waved through every GET/HEAD on "/", so a filer with jwt.filer_signing.read.key set still served its root directory listing -- entry names, sizes and chunks[].file_id -- to a caller holding no token at all, and served the same listing to a token restricted by allowed_prefixes. The exemption was added for health checks before the filer had /healthz and /readyz. Both are registered on the default and read-only muxes ahead of the "/" handler and answer without a token, so drop it. Point the mTLS harness at /healthz, which is what it was probing for. * filer: keep the jwt query parameter out of a proxied chunk request The proxy stripped "jwt" from the forwarded query on reads only, on the grounds that a writer's own credential travels there. It does not: an uploader carries its AssignVolume token in the Authorization header, and the query parameter on this path holds a filer credential. Strip it for every method. A volume server has no business seeing a filer token, and because security.GetJwt reads the query before the header, relaying one would hide the writer's own token behind it. * filer: dispatch the chunk proxy after the JWT gate The ?proxyChunkId= branch returned before maybeCheckJwtAuthorization ran, so GET, PUT, POST and DELETE against any needle in the cluster were reachable on the filer's HTTP port with no filer credential, on a filer where every other request answered 401. An anonymous caller read a stored object, replaced its bytes, or deleted the needle, which the master's next vacuum makes permanent. #10434 stopped the filer from minting a volume write token for that caller, which closes the write half only where the volume server has a jwt.signing.key of its own -- not the shipped default, and not what scaffold/security.toml recommends for a filer deployment. The read half stayed open in every configuration, because the filer mints the read token itself. Move the dispatch below the gate. A file id carries no path, so a token restricted by allowed_prefixes cannot be scoped against one and is refused here; every consumer of this endpoint holds an unrestricted token. * filer: mint the volume credential for a proxied write too The proxy minted a volume token on reads and forwarded whatever the caller sent on writes. #10434 made it that way because the branch ran ahead of the JWT gate, so a token minted here would have been signed for an unauthenticated caller; the branch now runs behind the gate, and the credential the caller presents there is a filer one, which a volume server cannot validate and has no business seeing. Mint at the access level the request needs, and drop the caller's Authorization when there is no key to mint from. A proxied uploader then needs only the filer credential, instead of holding one for each hop with a single header to put them in. * mount, mq, filer.sync: send the filer credential for a proxied chunk Every in-tree consumer of ?proxyChunkId= reached the filer anonymously: mount and the broker put the AssignVolume token in the Authorization header, which is a volume credential, and filer.sync sent nothing at all. That was enough only while the branch ran ahead of the filer's JWT gate. Build the URL through one helper, and pick the credential from the URL it returns: a chunk proxied through a filer is a request to the filer, which authorizes it and attaches the volume credential itself, so the token there is a filer one at the access level the request needs. * filer: honor -exposeDirectoryData The flag was declared on all three commands that start a filer and read by none of them: FilerOption.ExposeDirectoryData was only ever assigned from filer.expose_directory_metadata in security.toml, so -exposeDirectoryData=false silently left the listing exposed. Only the TOML key had any effect. Plumb the flag through and let either switch turn the listing off. * filer: count a proxied chunk request once Moving the dispatch below the gate put it after the deferred request observation, so every proxied chunk now landed in FilerRequestHistogram twice, once under its HTTP method and once under chunkProxy. Name the deferred one after the proxy instead, the way the unsupported-method branch already does, which also gives the endpoint the status codes FilerRequestCounter records. |
||
|
|
cda43f1976 |
filer: do not 404 a TUS session on a transient chunk-load failure (#11153)
* filer: do not 404 a TUS session on a transient chunk-load failure readTusSessionInfo already proved the session exists before loadTusSessionChunks is called, so a failure there is a read failure, not evidence the session is gone: a volume-server timeout or a canceled request context surfaces through ListDirectoryEntries the same way a missing session would. Every such error was mapped to writeTusSessionNotFound, answering 404 to HEAD/PATCH and 204 to DELETE. A spec-compliant TUS client trusts that and discards the session, orphaning every chunk it had committed until the 24h expiry sweep, or forever if it never issues a DELETE. Only an error matching filer_pb.ErrNotFound is now reported as not found; anything else answers 500 so the client retries against the same session instead of abandoning it. * test: cover a TUS session's transient chunk-load failure Adds a listErr hook to the in-memory test store, alongside the existing commitErr/deleteErr, to simulate a store or RPC failure from ListDirectoryEntries. HEAD, PATCH and DELETE against a live session all answer with a server error instead of a not-found status when the chunk listing fails transiently, and the session is left on disk untouched. A listing failure that genuinely means not found, filer_pb.ErrNotFound, still answers 404 (204 for DELETE). |
||
|
|
ed9d58873e |
filer.remote.sync: skip an upload whose source entry was deleted or rewritten (#11149)
* filer.remote.sync: skip an upload whose source entry was deleted or rewritten A replay from an earlier offset (-timeAgo) re-emits create and update events for entries the filer has since deleted or rewritten. Their chunks are gone from the volume servers, so the upload can never succeed, and failing the event holds the sync offset before it: every restart of the subscription replays it into the same dead chunks, and progress on everything after it in the log is never persisted. One such entry stops replication for the whole mount. When the upload fails, look the entry up on the filer. Gone, or holding other content than the event described, the event is superseded and is skipped with an error log; the event that superseded it follows in the log and brings the remote to the current state. Otherwise the failure stands and the event is retried as before. Fixes #11148 * filer.remote.sync: compare chunks by file id when deciding an event is superseded filer.IsSameData compares chunk ETags, so a delete-and-recreate of identical bytes, which stores the same content under new file ids and drops the old ones, looked still as described and kept failing the event on its dead chunks. Compare by file id with DoMinusChunks, the way the filer itself decides which chunks an update leaves for deletion: the event is superseded when the current entry no longer references every chunk it named, and still as described when it does, including when more chunks were appended after it. * filer.remote.sync: ask the filer on the first failed upload attempt, not after the backoff The superseded check ran after util.Retry had given up, so every dead entry still cost the full retry cycle, about 13s, before it was skipped: the SDK reports a missing chunk as "RequestError", which IsTransientError takes as worth retrying. Move the check into the retry loop with util.RetryOnError. Any failed attempt asks the filer, and the loop stops at once when the entry is gone, surfacing errSuperseded for the caller to skip. An entry the filer still holds keeps the retry policy it had. filer.remote.gateway shares retriedWriteFile and the same offset-pinning processor, so its three call sites skip a superseded event the same way. |
||
|
|
06838e28b2 |
filer: serve "//" paths at the cleaned path instead of redirecting (#11150)
* filer: serve "//" paths at the cleaned path instead of redirecting
http.ServeMux redirects a non-canonical path ("//", "..") to its cleaned
form, but since Go 1.22 it builds the Location from the already-escaped
path, so it is percent-encoded twice (golang/go#79897). A client that
follows the redirect re-posts "/负极全景" as "/%25E8%25B4%259F...", and
the filer stores a directory literally named "%E8%B4%9F...".
Wrap the filer muxes in CleanPathHandler, which rewrites the request to
the same cleaned path ServeMux would have redirected to and dispatches
directly. The decoded name reaches the handler, the round trip goes
away, and clients that do not follow redirects work too.
Fixes #11125
* filer: keep RequestURI in step with the cleaned path
PostHandler derives storage rules, the bucket and the read-only check from
r.RequestURI while writing the entry at r.URL.Path. After CleanPathHandler
rewrote only the URL, a "//" or ".." request would be placed by the raw
path and written to the cleaned one. Rewrite RequestURI too, as the
redirect-following client used to.
* filer: match storage rules on the decoded write path
PostHandler resolved the storage rule from r.RequestURI, the raw
request-target. Clients percent-encode non-ASCII segments on the wire, so
a read-only or TTL rule configured on "/data/只读/" never matched a POST
to "/data/%E5%8F%AA%E8%AF%BB/" and the write went through. Use r.URL.Path,
the decoded path the entry is actually written to, as the header-based
destination check already does. The query string no longer reaches the
rule lookup, so the "?" trimming in the read-only error is gone.
|
||
|
|
cc281dabc9 |
master: keep new volumes and writes off servers in maintenance mode (#11147)
* master: keep new volumes and writes off servers in maintenance mode The master recorded a volume server's maintenance flag from the heartbeat but never consulted it. A server in maintenance (#7977) is being drained, yet the master kept creating volumes on it whenever it had free slots and kept handing out its volumes for writes. Nothing on the volume server blocks plain HTTP uploads either, so "read-only mode" was only a name. Volume growth: a data node in maintenance mode reports zero free slots through AvailableSpaceFor, which takes it out of every candidate list, feasibility count and capacity reservation. Its slots still roll up into its rack and data center, so the random offset drawn from those totals for an other-rack or other-DC replica could land in space the walk then skips and fail with "No free volume slot found!" while siblings had room; the walk now folds the offset into the space that is actually eligible. This also covers the pre-existing case of an over-committed sibling. Assignment: a replica on a server in maintenance mode is treated like a read-only replica in isAllWritable, so its volume leaves the writable list and returns when the flag clears. Topology.SetDataNodeMaintenanceMode re-evaluates the node's volumes on every change, since heartbeats are digest-based and a full volume list may not follow for a long time. Reads and lookups are untouched. The flag moves to an atomic so the assign and growth paths can read it without the node lock. Heartbeat: the Go volume server sent its state only when it changed, so a master elected while a server sat in maintenance never learned about it. The state now rides along on every heartbeat, as the Rust server already does; the master's compare is an atomic swap, and only a change does work. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * master: hold maintenance mode through vacuum commit and mark-writable SetVolumeAvailable and SetVolumeWritable put a volume back on the writable list on the replica count alone. A vacuum that started before the server entered maintenance, or a vacuum worker's mark-writable arriving after it, handed the volume back to assignment with a replica on the draining server. Heartbeats carry only changed volumes, so nothing re-evaluated it until the volume itself changed. Apply isAllWritable on both paths, the same test EnsureCorrectWritables uses. Also pin that re-evaluating a volume a concurrent disconnect already removed from its layout is a no-op. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * master: record a server's read-only notification on its node before judging the volume A volume server notifies the master the moment it flips a volume between read-only and writable, ahead of the heartbeat that repeats the flag. The layout only set its per-location flag, so isAllWritable, which reads the node's heartbeat copy, still saw the old value: a mark-writable was withheld until the next heartbeat, and a re-evaluation landing between a mark-readonly and its heartbeat put the volume back on the writable list. Record the flag on the node's volume first. AddOrUpdateVolume keeps the digest and the active volume count in step, so the heartbeat that follows finds nothing to change. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * master: a read-only mark does not confirm a provisional volume DataNode.SetVolumeReadOnly went through Disk.AddOrUpdateVolume, which treats its input as a server report and so ended the grace period that keeps a just-grown volume safe from a full report collected before the grow. A volume marked read-only before its first report could then be removed by that stale report. Give Disk a SetVolumeReadOnly that flips the flag and keeps the digest and active volume count in step without touching volumeAddedAt. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> |
||
|
|
9fef11526e |
filer.remote.sync: confirm a missing RemoteEntry against the filer before re-uploading (#11146)
* filer.remote.sync: confirm a missing RemoteEntry against the filer before re-uploading The event is the entry as it was when the update was logged. A chmod or utimes right after a write is logged while the sync is still uploading the write, so it carries no RemoteEntry even though the object is on the remote by the time it is processed. Gating on the event alone turned every such update into a delete and a second upload of the same bytes; cp -p, rsync and Django's FileSystemStorage all write that way. Look up the filer's current entry when the event has no RemoteEntry: the upload stamps it as soon as it completes, so the stamp is there for the race and absent for a file that was never replicated. Skip the update when the entry has since been deleted rather than upload from chunks that may be gone; the delete event that follows removes the remote object. Tests build entries from chunks, which is what IsSameData compares in production, and cover both no-RemoteEntry cases through a stub filer. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.remote.sync: do not delete the remote object before overwriting it in place The update write path deleted the old object and then wrote the new one, even when both are the same key. S3, GCS and Azure all overwrite on write, so the delete bought nothing and left the remote with no object between the two calls, or at all if the write then failed and pinned the offset. On a versioned remote bucket it also left a delete marker per rewrite. Delete only when the key changes, which is what the delete was for. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * filer.remote.sync: trim comments Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> |
||
|
|
24b8646ec3 |
volume: let evacuation proceed on a server in maintenance mode (#11145)
Maintenance mode exists to fence a volume server so it can be evacuated without taking new writes (#7977), but the gate added in #8115 also rejected the RPCs evacuation issues against the source: VolumeMarkReadonly (the first step of every move, and the failure reported in #11066), VolumeDelete (the last step), and VolumeEcShardsDelete (the last step for EC shards). volumeServer.evacuate, volume.move and ec.balance therefore all failed on exactly the server they were meant to drain. Those three RPCs only remove data or restrict the server further, the same class as DeleteCollection and the unmount RPCs that were never gated, so they are exempted from the maintenance check in both the Go and Rust volume servers. Everything that adds data or reopens the server for writes (AllocateVolume, WriteNeedleBlob, BatchDelete, VolumeCopy, ReceiveFile, EC generate/copy/rebuild, vacuum, tiering, VolumeMarkWritable) stays blocked. A side effect is that scrub can now fence broken volumes readonly on a server already in maintenance. Fixes #11066 Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> |
||
|
|
57a5285020 |
ci: turn off cosign's signing config alongside the bundle format (#11144)
Every image-signing job has failed since signing was added (#11129): must provide --new-bundle-format or --bundle where applicable with --signing-config or --use-signing-config Cosign 3 turned on two defaults, not one. The action only disabled --new-bundle-format to keep the .sig tag layout, but --use-signing-config is still on, and cosign refuses that pairing because the signing-config path has nowhere to write its verification material without a bundle. Disabling it too falls back to the default Fulcio and Rekor URLs, the same services the .sig layout always used. The verify step needs no change: cosign verify looks for a referrer bundle first and falls back to the .sig tag when there is none. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> |
||
|
|
8112f2733a |
filer: batch exact lookup RPC, authoritative volume lookup, VolumeDelete status codes (#11122)
* storage: make DeleteVolume errors inspectable with errors.Is An absent volume wraps ErrVolumeNotFound and an only-empty refusal now wraps ErrVolumeNotEmpty with %w instead of %v, so callers no longer have to match on the message. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * volume server: return NotFound and FailedPrecondition from VolumeDelete An absent volume maps to codes.NotFound and a non-empty volume under only_empty to codes.FailedPrecondition, so a caller retiring a volume can treat NotFound as already done. The store message is kept in the status description because the EC empty-replica sweep still matches on it. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * wdclient: add LookupVolumeIdsAuthoritative Bypasses the vid map and asks the provider directly, for callers where a stale positive location is unsafe. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * filer: add LookupDirectoryEntries batch lookup RPC Up to 4096 exact-path lookups in one call, resolved concurrently with results in request order, plus one deduplicated location lookup for every volume the returned entries reference and per-fid read tokens when the filer signs reads. unavailable_volume_is_miss lets cache-style callers take an entry whose volume has no live location as a miss, resolved against the master rather than the filer's location cache. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * filer: test that an expired file entry is deleted on read Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * filer: test that AssignVolume and CreateEntry resolve the same TTL rule Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * master: refuse partial lookups while warming up LookupVolume returned Unavailable during warm-up only when every requested volume was missing. A batch mixing a reported volume with one whose server has not reconnected yet came back as a partial answer with a per-volume not-found, which a caller treating the master as authoritative reads as gone. Any not-found during warm-up is now Unavailable, which callers already retry. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * filer: build batch test requests instead of copying a proto message Copying a generated message copies its internal mutex, which go vet's copylocks check rejects. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * filer: match ErrNotFound with errors.Is and state the miss rule's contract A wrapped not-found from the store would otherwise be reported as an error rather than a miss. The comments now say why a nil location map is the only sign of an unanswered lookup: the provider returns nil when it got no answer and a populated map, with unserved volumes reported as errors, when the master did answer. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * volume server: map absent and non-empty VolumeDelete errors in the Rust server Matches the Go server: an absent volume is NotFound and an only_empty refusal is FailedPrecondition instead of Internal, with the messages the EC empty-replica sweep matches on. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm * filer: test that a malformed entry keeps its error outside cache mode Same test file as the enterprise tree, so the next sync sees one version. Claude-Session: https://claude.ai/code/session_01T4MEV3ETqFFKN46Uu2ZrUm |
||
|
|
eb9f54cebb |
telemetry: type MinDiskBytes so a 32-bit int cannot hold it by accident (#11142)
As an untyped constant it became int when passed to Infof, which overflows on linux/386 and failed the 32-bit vet job. Every field it is compared against is already uint64. Claude-Session: https://claude.ai/code/session_015rYAmF8hV9yypb9yvy4A1z |
||
|
|
1205630c43 |
ci: track the actions in the signing jobs by tag again (#11137)
The repository tracks actions by tag with dependabot; the signing jobs follow the same convention. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa |
||
|
|
0973634fd4 |
telemetry: keep only clusters that store at least 10 GiB (#11138)
* telemetry: tidy the server module after the protobuf bump Claude-Session: https://claude.ai/code/session_01VGiphDxpsKMwu9XpUFhybQ * telemetry: keep only clusters that store at least 10 GiB Fresh weed server runs, CI jobs and throwaway containers each mint their own cluster id. They came in at tens of thousands a day, were most of the counted clusters and held almost none of the bytes, and the state file and the metrics page grew with every one of them. Reports under the floor are counted and dropped, and a state file written before the floor sheds them on the first restart. Claude-Session: https://claude.ai/code/session_01VGiphDxpsKMwu9XpUFhybQ * master: report telemetry only once the cluster stores 10 GiB A throwaway cluster no longer registers itself with its first report a minute after start; a real one begins reporting at the first daily tick after it crosses the floor. Claude-Session: https://claude.ai/code/session_01VGiphDxpsKMwu9XpUFhybQ |
||
|
|
a8f763e717 |
Sign the published Docker images with cosign (#11129)
* ci: composite action that signs and verifies an image with cosign Keyless, by digest, with a verification pass against the calling workflow's own identity right after signing. Signatures use the .sig tag layout rather than the OCI-referrer bundle cosign 3 writes by default, since that is what the verifiers people run today read. Dependabot is pointed at the action so the cosign-installer pin keeps moving. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker release: sign every variant on both registries The merge job signs each variant's multi-arch index on GHCR and Docker Hub once the tag exists, recursively so the platform images are covered too. latest re-tags the same manifest and inherits the signature. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker dev: sign the dev image Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker latest: sign a latest rebuilt by hand Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker release: sign the foundationdb image Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker: sign the per-version foundationdb and rocksdb builds They push to the same repository as the releases, so an admission policy that verifies chrislusf/seaweedfs would otherwise reject them. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker: document image signature verification Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * ci: pin the actions the signing jobs newly run by commit These run with registry credentials and the OIDC token that signs under the repository's identity, so a retargeted tag upstream must not be able to reach them. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker latest: pass the dispatch tag through env, not the script Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker: complete Kyverno policy, digest note, identity scope Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker latest: keep the dispatch tag out of the manifest script too The step predates signing, but the job now holds the OIDC identity. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * ci: pin every action in the jobs that sign The jobs that hold the OIDC identity run these with registry credentials, so a retargeted tag upstream must not reach them. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker release: copy and sign the digest the run created, pin the rest crane copy and the signature both resolved the tag, which another publisher could move between the two steps. The index digest is read once, right after it is created, and the Docker Hub copy and both signatures use it. The manual latest rebuild gets the same treatment. The actions in these jobs are pinned to commits, crane to v0.22.0 by checksum, and the sparse checkout no longer keeps the token. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker latest: the signing job checks out the workflow's own commit The job only assembles and signs manifests, so nothing there needs the source_ref checkout; the local signing action now comes from the same revision as the workflow file that calls it. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa * docker release: take the index digest from the create result imagetools create writes the descriptor it pushed with --metadata-file (buildx 0.32+, the runners ship 0.36), so the digest no longer comes from re-resolving the tag even within the same step. Claude-Session: https://claude.ai/code/session_01A5zMqzaUg1Snur4Yg8xJGa |
||
|
|
fe98520358 |
read: try a replica that stopped answering last, and relearn its volume's locations (#11130)
* http: try a volume server that failed to answer last A cached location list is shuffled on every read, so once a replica dies half the reads keep dialing it first and pay a connect failure or timeout before the healthy replica answers. Remember, per host, when a request got no answer at all and order such hosts last for the next half minute. Once that passes, one read probes the host in its usual place while the others keep it last until the probe settles, so a black-holed server costs one stalled read per interval instead of one per read. Nothing is ever skipped: a host that failed is still tried when the others fail too. Any response, including an error status, counts as reachable. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * filer: refresh a chunk's locations after one of them fails A mount's location cache is only relearned when every cached location fails. When one replica dies and the other still answers, every read succeeds and the dead replica stays in the cache, and in the shuffled order it keeps being dialed first long after the master has dropped it. When a read fails on one location and a later one answers, call the refresh hook so the cached entry is dropped and looked up again. The read that already paid for the failure returns its data; the reads after it start from the locations the master knows now. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * http: claim the probe for every expired host, and try it first The claim was only checked for the first url, so with two replicas whose marks expired together the second was probed by every read at once. Claim each expired host on its own and put the reads that won a claim ahead of the reachable hosts, so a probe is always a real attempt and a lost claim always means the host is tried last. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * filer: refresh a chunk's locations in the streaming read path too The streaming loop had no refresh hook, so a manifest or streamed chunk that failed on one cached location and was served by another kept the stale entry until every location failed. Give it the same hook as the buffered loop, built by one refreshUrls function shared by the reader cache and the stream callers. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * http: probe at most one expired host per read Claiming every expired host in one ordering left all but the first claim without an attempt, since a read stops at its first answer, and a host that had come back waited another interval for nothing. Claim only the first expired host a read sees and leave the rest last and unclaimed, so each following read probes one of them. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * test: start the live server before releasing the dead server's port Closing the dead server first let the live server come up on the same port, in which case the dead location answers and the partial failure under test never happens. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs |
||
|
|
52db7a6aee |
test: write more files than the master grows volumes at a time (#11131)
The cached-location test needs two files on one volume, but it wrote six files into the six volumes a 001 layout starts with, and every so often each file landed on its own volume and the test had nothing to probe. Seven files leave no way to spread them out. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs |
||
|
|
59916d8978 |
helm: values-driven labels on every ingress (#11127)
* helm: values-driven labels on every ingress Each ingress already takes annotations from values, but its labels were a fixed block, so tools that select ingresses by label (ExternalDNS label filters, for one) had nothing to key on. Every ingress block now has a labels map rendered after the standard app.kubernetes.io labels, including the Traefik IngressRouteTCP that shares the filer gRPC values. Claude-Session: https://claude.ai/code/session_01L6eJGXtYkwe1W9QjGeUgr1 * helm ci: render check for ingress labels Claude-Session: https://claude.ai/code/session_01L6eJGXtYkwe1W9QjGeUgr1 |
||
|
|
c3511e7c86 |
ci: let codespell past the sme variable in the mount tests (#11121)
weedfs_stream_mutate_error_test.go names its *streamMutateError local sme, which codespell reads as a misspelling of same/some. It is an identifier, so exempt it beside the other variable-name entries. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ |
||
|
|
31fb46f693 |
volume: rebuild a missing .idx from the .dat (#11115)
* volume: rebuild a missing .idx from the .dat Pointing -dir.idx at a directory that holds no index aborted the whole volume server: checkIdxFile found no .idx and load() called glog.Fatalf. Every row of the index is derivable from the .dat, so walk it in append order and write the index back, which reproduces byte for byte what the server's own writes had left in the old directory. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: keep the index co-located with the data in the Rust server Go's load() drops back to the data directory when an .idx already sits beside the .dat, so naming a --dir.idx does not strand a pre-existing index. Rust had no such adjustment: it opened the new directory with create, and the volume came up on an empty index with every needle invisible. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: rebuild a missing .idx from the .dat in the Rust server Mirrors the Go side. Rust did not abort on a missing index the way checkIdxFile did; it opened the new directory with create and mounted the volume on an empty index, so every needle read as missing while the .dat still held the data. Walk the .dat in append order and write the index back, byte for byte what the server's own writes had left behind. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: stop the idx rebuild at a zero-padded .dat tail An all-zero needle header is unwritten space, not a record. Go's .dat walk keeps reading past it and would index a truncated data file's tail as millions of needle 0 rows; the Rust walk already stops there. Stop the Go rebuild at the same place. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: create the -dir.idx directory when it does not exist Rust's DiskLocation creates the index directory as it takes it; Go only resolved the path, so naming a directory that does not exist yet left every volume unable to open or rebuild its index and took the server down. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: stop the idx rebuild at a torn .dat record A crash between writing a needle's header and its body leaves a record whose declared size runs past the end of .dat. Indexing it puts a row in the .idx that points at bytes that do not exist, which fails every read of that needle and trips the past-EOF check on the next load. Stop at the first record that does not fit, in both servers. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: stop the idx rebuild at a negative-size header A corrupt header whose size field is negative makes the .dat walk advance backwards: NeedleBodyLength adds the negative size, so the next offset is lower than the current one. The Go walk then reads at a negative offset and the rebuild fails, which puts the volume server right back to exiting at startup; the Rust walk seeks past EOF and truncates the index instead. A negative size is never a record, so stop there. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: skip a volume whose index cannot be rebuilt, do not exit glog.Fatalf calls os.Exit(255), so a rebuild that could not write -- a full or read-only index directory -- put the server right back to dying at startup for one bad volume. Return the error instead: loadExistingVolume logs it and skips that volume, which is what the remote-volume branch just above already does and what the Rust loader has always done. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * volume: create the index directory from the rebuild too The rebuild is the first thing to write into a fresh -dir.idx, and it runs before the loaders that create the directory on their way to opening .idx. Create it in both rebuilds so the ordering does not matter. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ * ci: let codespell past the sme variable in the mount tests weedfs_stream_mutate_error_test.go names its *streamMutateError local sme, which codespell reads as a misspelling of same/some. It is an identifier, so exempt it beside the other variable-name entries. Claude-Session: https://claude.ai/code/session_01BYrb2AdJSckq9FdHuqseDJ |
||
|
|
9f6efc8b53 |
filer: a listing over a hard link no longer deadlocks a bounded SQL pool (#11118)
* filer: give the SQL stores' key-value reads their own connections A listing holds the connection its rows are on for the whole iteration, and FilerStoreWrapper calls maybeReadHardLink -> KvGet from inside that iteration, so a hard-linked entry needs a second connection while the first is still busy. Out of one bounded pool that is a deadlock: the listings fill the pool and then wait for a connection none of them will release, and the wrapper's context.WithoutCancel leaves the waiters without a deadline, so the filer stays wedged rather than erroring. The sqlite store shows it at its sharpest -- it allows a single connection, so one listing over one hard-linked entry never returns. On postgres with connection_max_open = 50, 60 concurrent listings over hard-linked entries made no progress at all. Key-value reads now run on their own pool, carved out of connection_max_open rather than added to it, so the operator's cap still bounds what the store opens against the database. An unbounded pool keeps a single pool: nothing can wait there. sqlite's single connection becomes two, one per pool, and its writes get a busy timeout so a write that meets the reader waits instead of failing. Claude-Session: https://claude.ai/code/session_018DWwctzD4T2DmnczPRM47t * sqlite: keep both pools on one database, whatever the dbFile spells A dbFile that already carries URI options got a second "?" appended, which the driver reads as part of the preceding option value, and a bare :memory: is private to each connection, so the key-value pool would open its own empty database and every key-value operation would fail on a missing filemeta. Claude-Session: https://claude.ai/code/session_018DWwctzD4T2DmnczPRM47t * sqlite: assert the busy timeout on the in-memory DSN too Claude-Session: https://claude.ai/code/session_018DWwctzD4T2DmnczPRM47t |
||
|
|
9b61289293 |
Remove the RDMA sidecar prototype and its mount client (#11119)
* rdma: drop the sidecar prototype The Rust engine under it never touched a wire: rdma.rs fabricates pattern bytes and the crate's default feature is mock-ucx, with real-ucx unimplemented since the directory landed. Nothing builds it, no CI runs it, and its only consumer is weed mount's RDMA client, removed next. Two 22MB binaries were committed along with it. Claude-Session: https://claude.ai/code/session_01X3zhqLYwQwEQCrRbuzQKvy * mount: remove the RDMA client that spoke to the deleted sidecar Its only server was the sidecar's HTTP API, and the path could never have worked in production anyway: it served a single chunk per call, ignored the buffer's chunk boundaries, and had no test. Removing it also removes the per-handle cumulative-offset cache, which nothing else used. The -rdma.* mount flags go with it. They defaulted to off and pointed at an address no released build ever listened on. Claude-Session: https://claude.ai/code/session_01X3zhqLYwQwEQCrRbuzQKvy |
||
|
|
1fc80df187 | Update README.md | ||
|
|
8aea9c6ab2 |
readme: get to the point (#11120)
Lead with what SeaweedFS is and how to start it: one command, Docker, Docker Compose, a production-shaped Helm values file, build from source, scale out. Then why: fast, scalable, the S3 API surface with operation counts, the lakehouse with S3 Tables and the engines that share it, the cloud cache, and cross-cluster replication and the rest of the feature list, each pointing at its wiki page. The blob store walkthrough and the master and volume server internals move to the Blob Store Architecture wiki page. The comparisons, benchmark, enterprise and license sections stay. The dev plan is gone, it was done. Claude-Session: https://claude.ai/code/session_014apMEkkquAtAYp89paTkAT |
||
|
|
9089a546fb |
shell: exit non-zero when a piped command fails (#11117)
* shell: non-interactive mode exits non-zero when a command fails A failed command in a piped weed shell run printed 'error: ...' but the process still exited 0, so a CronJob wrapping e.g. echo 's3.lifecycle.run-shard -shards 0-15' | weed shell -master=... reported green while the run aborted partway (shards N+1..15 unwalked). An unknown command likewise exited 0. RunShell now returns the last command failure from the non-interactive stdin path (unknown commands included), and the shell command exits 2 on it. Interactive sessions are unchanged: errors are shown to the operator and the session continues, exiting 0 as before. * shell: route the piped-failure exit through main's shutdown path Review follow-up: os.Exit(2) inside the shell command skipped main's shutdown work. The command now records the status (SetCommandExitStatus) and returns normally; main applies it via setExitStatus before exit(). exit() itself now flushes sentry before os.Exit -- main's deferred sentry.Flush never ran on this path (os.Exit skips defers), so the existing 'flush buffered events before the program terminates' intent only worked for the autocomplete early-return. Exit status 2 on a failed piped run is preserved (verified: piped success exits 0, piped failing command exits 2). * shell: test the registered-command failure path Review follow-up: the error-propagation test only covered unknown commands. A fake registered command now drives processEachCmd's real dispatch path: a failing Do surfaces its exact error (errors.Is) and a succeeding one returns nil. The non-interactive exit status itself is main-level plumbing, verified end to end against the reproduction (piped failure exits 2). * shell: trim the comments added with the exit status Keep the non-obvious why -- why a piped run has to fail its wrapper, why the status is recorded instead of os.Exit'ed -- and drop the narration. Claude-Session: https://claude.ai/code/session_018DWwctzD4T2DmnczPRM47t * shell: fail a piped run with the status weed already uses for that weed.go spends 1 on a command that failed and 2 on a usage or syntax error, and runShell returns true precisely so the usage dump is skipped. Exiting 2 there told a wrapper the command line was wrong. Claude-Session: https://claude.ai/code/session_018DWwctzD4T2DmnczPRM47t --------- Co-authored-by: Carlos Leyva <carlos.leyva@idener.es> |
||
|
|
292145303f |
mount: name the disk without changing what is mounted (#11114)
mount: name the disk after the mount point when the whole tree is mounted The mounted path was the only thing that named the disk, so a mount of the whole tree was labelled with the filer address and the only way to give it a name was to mount a subtree under that name — which hides everything outside it. Fall back to the mount point's own name first, so -dir=\\seaweedfs\Images labels the disk while -filer.path stays "/". Claude-Session: https://claude.ai/code/session_01Q9f8pWBXu1ceJvcQfYRQ7x |
||
|
|
9e34426a56 |
lance: a maintenance job that sorts a table by its declared fields (#11113)
* lance: a maintenance job that sorts a table by its declared fields Lance appends fragments in write order and has no notion of a sorted table, so nothing but a rewrite establishes one, and nothing but another rewrite restores it once rows have been appended. lance_sort reads the order from the dataset's own configuration, falls back to the worker's, and rewrites the table in it. The spec and the marker live in crates/sort rather than in the job, because weed/worker/tasks/iceberg sorts too: two jobs that disagreed about what "id desc nulls-first" means would be two features wearing one name. The sort spills. lance builds its DataFusion runtime with a FairSpillPool and a disk manager, but only when LanceExecutionOptions::use_spilling is set, and that struct derives Default over a plain bool — so Scanner::try_into_stream, which fills its options with ..Default::default(), is precisely the path that does not spill. The job builds the plan with create_plan and executes it with spilling on and the operator's memory budget. The marker rides in the same commit as the data: Operation::Overwrite is the one operation carrying config values alongside fragments, so a sorted table and the record of its sorting cannot disagree. It records the version the sort read, not the one it wrote, which is not knowable while the marker is being assembled. Detection treats anything committed after the sort's own commit as data the sort did not produce — row counts alone cannot see a rewrite that leaves the count where it was, and such a table would look sorted forever. Claude-Session: https://claude.ai/code/session_015SZkLTUvd1svDu4xdr6Q3y * lance: identify a sorted table by the files it wrote, not its version Review found three ways the version-based marker misjudges a table, and they share a cause: the version a sort produces is not knowable while the marker is being assembled, so the marker recorded the version it read and detection inferred the rest. A commit that rebases past a conflict lands on a different number, and the inference then reads a rewrite into an ordinary table — a full re-sort, and its indices, for nothing. Data file names do not have that problem. They are chosen before the commit, so the commit can carry them, and they do not change with the version it lands on. The marker now records how many files the sort wrote and a digest of their names, and detection asks whether the table still holds them: the same files means untouched, the same files followed by more means appended, anything else means the data was replaced. That also closes the hole the row threshold left. A replacement that grew the table by fewer rows than min_unsorted_rows read as sorted, however many rows had actually moved; the threshold now applies only where the sorted files are still in place, which is what it was for. A marker without a row count is stale rather than a zero to compare against, and deletes stop forcing a re-sort — they write a deletion file beside the data rather than rewriting it, and removing rows does not unsort the ones that remain. Sort fields are also compared exactly rather than case-folded. Arrow schemas are case-sensitive, so `id` and `ID` are two columns, and folding them together rejected a valid order. Claude-Session: https://claude.ai/code/session_015SZkLTUvd1svDu4xdr6Q3y * lance: count the rows appended after a sort, not the table's net growth Review found that rows deleted from the sorted fragments hide appended rows one for one: the threshold compared the live row count against the count recorded at sort time, so 800 deletions and 300 appends read as a table that shrank, and a table where deletions keep pace with appends stays "sorted" with an unsorted tail forever. The fragments say it directly. The marker already records how many fragments the sort wrote, so the ones after that prefix are exactly what arrived since, and the manifest carries each fragment's live row count — physical rows less its deletions. Counting those is the arithmetic the threshold was always meant to do, and it needs no row count from the marker at all. A fragment whose length the manifest does not record cannot be counted, and a table that cannot be judged is one to sort rather than one to leave alone forever, so an uncountable appended fragment reads as stale. Claude-Session: https://claude.ai/code/session_015SZkLTUvd1svDu4xdr6Q3y |
||
|
|
97b54adcf6 |
iceberg: sort compaction bins on disk instead of in memory (#11112)
A sorted rewrite collected every row of a bin into one slice and sorted it there, so a bin larger than the worker's heap could not be sorted at all. sort_max_input_mb existed for that reason and skipped the bins it capped. parquet-go's SortingWriter buffers sort_buffer_rows rows, encodes each buffer as a sorted run, and merges the runs at close; backing those runs with a FileBufferPool keeps them in files rather than on the heap. sort_spill_dir says where, defaulting to the system temp directory — NewFileBufferPool resolves an empty path to the working directory, which is not what an unset setting means. The output now also declares its sorting columns, which the plain writer the sorted path used never did. Claude-Session: https://claude.ai/code/session_015SZkLTUvd1svDu4xdr6Q3y |
||
|
|
8398af3572 |
filer: route exclusive and conditional creates to the entry's ring owner (#11109)
* proto: resync the java copy of filer.proto The Makefile keeps other/java/client/src/main/proto/filer.proto a verbatim copy, but AssignVolumeResponse.fsync and SubscribeMetadataResponse.flushed_ts_ns landed without it. Copy them over; no behaviour change. Claude-Session: https://claude.ai/code/session_01Fx1Hx8RqsJqHpbfbgTf4WJ * filer: route exclusive and conditional creates to the entry's ring owner CreateEntry with o_excl is a FindEntry-then-Insert. The per-path lock added for it makes that atomic only on the filer running it, and the store's insert is an upsert on every backend, so two filers both pass the existence check and both report success. mkdir(2) then succeeds twice for the same path. The same hole sits under the condition precondition, whose comment already told callers to route the key's writes to the owner filer themselves. Do it on the server instead, with the mechanism ObjectTransaction already uses: resolve the entry's ring owner and forward one hop, bounded by is_moved. The ring's membership comes from the master, so it tolerates a stale view and reassigns when a filer dies, neither of which a client's configured filer list can do. Every creator gets this — mount, S3, the filer's own HTTP surface, the Java client — not only the ones that opted in. Plain creates are upserts whoever applies them, so they stay local and pay nothing. The route key shares the S3 gateway's namespace so an object's ObjectTransaction and its CreateEntry land on the same filer's per-path lock. Claude-Session: https://claude.ai/code/session_01Fx1Hx8RqsJqHpbfbgTf4WJ |
||
|
|
938a15eb98 |
filer: keep a moved key on its prior owner while the ring settles (#11108)
ObjectTransaction forwards to the ring owner so one filer's per-path lock arbitrates every writer of a key. But a ring change hands the key over before the new owner has rebuilt the locks the prior owner still holds, so for the cooling-off window both can grant it. LockRing.PriorOwner exists for exactly this and nothing consulted it. Route to the prior owner while that window is open. LockRing.WriteOwner resolves prior-else-current under one read lock, so the pair cannot come from different rings and name the same filer twice. An unreachable owner fails the request rather than falling back to the current one. gRPC reports a response lost in transit as Unavailable, indistinguishable from a request the owner never saw, so re-sending elsewhere could re-apply what the owner already committed; and an owner unreachable from here may be partitioned rather than down, still serving the key to everyone else — which is the split brain the routing exists to prevent. The window is bounded: once it closes the ring hands the key to its new owner. The owner resolution and the forward move into writeOwner/forwardToWriteOwner so the next routed RPC reuses them rather than copying the block. Claude-Session: https://claude.ai/code/session_01Fx1Hx8RqsJqHpbfbgTf4WJ |
||
|
|
0f05957bc4 |
filer: self-heal chunk manifest reads when volume locations go stale (#11107)
* filer: self-heal fetchWholeChunk on stale volume locations Upstream #10156/#10800 wired cache invalidation into the buffer-based read paths, but manifest resolution still goes through fetchWholeChunk, which returns the raw error on failure. When cached volume locations are stale (volume tiered to remote storage, server rolled), resolving a large multipart file fails permanently even though other locations are healthy. Thread the ChunkGroup's cacheInvalidator through ResolveChunkManifest / ResolveOneChunkManifest / fetchWholeChunk, and on failure invalidate, re-lookup and retry once via the existing retryFetchWithFreshLocations helper. The streaming bytesBuffer is reset before the retry so partial bytes from the failed attempt cannot corrupt the manifest proto.Unmarshal. Non-mount callers pass nil and keep their semantics. * filer: move the manifest self-heal tests in with the other manifest tests Also make the stale server stream a prefix and then abort mid-body, which is what actually leaves partial bytes in the buffer: an HTTP error status returns before ReadUrlAsStream ever calls the writer, so a 500 never exercised the Reset the tests claimed to cover. Claude-Session: https://claude.ai/code/session_01FK3oGC5ZVeJYvNBWgb9JUD * filer: keep the cached volume locations when a manifest read is cancelled A cancelled or timed-out read says nothing about where the volume lives, so dropping the location and going back to the master only costs the next reader a round trip. PrepareStreamContentWithThrottler already guards its self-heal this way. The guard also goes inside retryFetchWithFreshLocations, since the caller can be cancelled between its own check and the invalidation, and that covers the reader cache and prefetch paths too. fetchWholeChunk returns the context error rather than the stream failure it provoked, and ResolveOneChunkManifest wraps with %w so errors.Is still sees it. That matters even where no invalidator is passed: volume.fsck resolves manifests with nil and tells its own abort from a corrupt manifest that way, so the cancellation check sits ahead of the nil-invalidator return. Claude-Session: https://claude.ai/code/session_01FK3oGC5ZVeJYvNBWgb9JUD * filer: self-heal manifest reads on the filer and s3 paths too Every caller that already holds the location cache backing its lookup function can hand it over: the filer's read, copy and deletion paths and the log cache have the MasterClient right there, and s3api has the FilerClient. MinusChunks takes one for the same reason, since the deletion path resolves manifests through it. Only the shell tools and the replication sinks, whose lookup functions cache privately with nothing to invalidate, keep passing nil. Claude-Session: https://claude.ai/code/session_01FK3oGC5ZVeJYvNBWgb9JUD --------- Co-authored-by: bruce-zzz <bruce.zou@hhy-data.com> |
||
|
|
cd064f6eef |
shell: clean up the target copy when a merge upload fails (#11104)
* shell: clean up the target copy when a merge upload fails A replicated write commits the needle to the local volume before it fans out to the other replicas, so an upload that reports failure can still have left a copy on the target. fs.mergeVolumes printed "failed to move" and carried on, so that copy stayed behind forever: the filer is never re-pointed at it, and nothing else knows it exists. One sick replica orphans roughly half the chunks of a merge, two thirds with three copies, since the entry node is picked at random from the replicas and the replica upload uses MaxAttempts 1. A volume with a single copy has no such window: the write is one local append that either succeeds or leaves nothing. Delete the needle we may have written before continuing. The source side already did exactly this, so deleteMovedSourceNeedles is renamed to deleteOrphanedNeedles and reused for both ends. Claude-Session: https://claude.ai/code/session_01XjiMGK72F4Gs3yWNJhnVjy * shell: verify the cookie before deleting a merge target needle The target cleanup deletes a needle an upload may or may not have written, and BatchDelete matches on the needle id alone. Needle ids come from one global sequence, so normally nothing else can hold that id — but a volume restored from elsewhere, or one written either side of a master sequence reset, can, and then a failed move deletes a live needle out from under its filer entry. Have the volume server verify the cookie for those. Source needles keep deleting by id: they are the ones the filer just pointed at, matching every other filer-driven delete. Claude-Session: https://claude.ai/code/session_01XjiMGK72F4Gs3yWNJhnVjy * shell: delete the target copies an abandoned manifest rewrite leaves rewriteManifestChunk moves sub-chunks one at a time and only then uploads the rewritten manifest. Every error after the first successful move — a nested rewrite failing, the marshal, the manifest upload — returned without touching the copies already written to the target volumes. The filer keeps pointing at the old manifest, so those copies orphan, one per sub-chunk moved so far. Track them alongside the sources and delete them on the way out. Nested rewrites hand theirs up so an outer failure clears the whole subtree. A failed UpdateEntry deliberately still leaks its copies: that error can also mean the filer applied the update and lost the response, and deleting there would turn a leak into data loss. Claude-Session: https://claude.ai/code/session_01XjiMGK72F4Gs3yWNJhnVjy * shell: give the plan its room back when a manifest rewrite is abandoned allocate reserves plannedSize against the chosen target for every move a multi-target source makes, and release hands it back when the move fails. Abandoning a manifest rewrite now deletes the copies that did land, so those reservations stopped matching anything on disk: the plan kept counting bytes that are gone and refused later chunks with "no target volume has room". Release them alongside the delete. Nested rewrites hand theirs up so an outer failure unwinds the whole subtree's accounting. Claude-Session: https://claude.ai/code/session_01XjiMGK72F4Gs3yWNJhnVjy |
||
|
|
7620e96171 |
expose whether a volume replica is backed by remote storage, and prefer local replicas (#11105)
* expose whether a volume replica is backed by remote storage
Volume locations returned by lookups do not indicate whether a replica
has been tiered to remote storage. Readers cannot distinguish a local
replica from a remote-backed one, so they may hit a remote-backed
replica first even when a local replica is available.
Add DataInRemote to the lookup location message, populate it from the
master's volume info, and carry it through the wdclient vid map so
clients can prefer local replicas when resolving chunk locations.
* wdclient: prefer local volume replicas over remote-tier replicas on lookup
LookupFileIdWithFallback (and the publicUrl variant in FilerClient)
didn't honor the DataInRemote flag when shuffling URLs, so the
DataInRemote patch only took effect in LookupVolumeServerUrl. Apply
the same ReorderToFront(localUrls) to sameDcUrls/otherDcUrls so
non-remote replicas stay at the front, matching the existing vidMap
convention.
* wdclient: propagate DataInRemote across tier transitions on existing replicas
When a volume is tiered to remote storage or a remote-backed replica is
restored locally, the cached DataInRemote on the same volume-server URL
stayed at its old value because two pieces of state never updated:
* master_grpc_server.go only split newVolumes and (already-tracked) volumes
into NewVids vs RemoteVids. ChangedVolumes went straight to NewVids, so
the broadcast announced the re-classified volume as a fresh arrival and
the client had no way to tell whether its existing cache was stale.
* vid_map.addLocationToMap early-returned when an entry already had the
same URL. A tier transition reports the same URL with DataInRemote
flipped, so the cached entry stayed at the old classification.
Wire both sides together: ChangedVolumes now go through the same IsRemote
split as newVolumes, and addLocationToMap replaces the existing entry in
place when the URL matches but DataInRemote has changed. The server
reference key only depends on URL/grpc port, so the refcount does not
move across the flip.
Adds vid_map_remote_transition_test.go covering the local->remote and
remote->local paths so the in-place update and the cache-key stability
are pinned by tests.
* wdclient: prefer local replicas across data-center boundaries
The previous local-first ordering hoisted local URLs to the front of each
data-center bucket separately, then concatenated same-DC before other-DC.
That meant a same-DC remote replica could still be tried before an
other-DC local replica even though the local one would answer cheaply.
Reorder once across the full candidate list: concatenate same-DC and
other-DC first, then ReorderToFront pulls every local replica to the very
front while preserving the DC preference inside each tier. Apply the same
ordering in all four lookup paths so the cached vidMap, the
LookupFileIdWithFallback provider path, FilerClient.GetLookupFileIdFunction
(PublicUrl-preferred variant), and the deprecated filer.LookupFn all agree:
- weed/wdclient/vid_map.go (LookupVolumeServerUrl)
- weed/wdclient/vidmap_client.go (LookupFileIdWithFallback)
- weed/wdclient/filer_client.go (LookupFileId)
- weed/filer/reader_at.go (LookupFn)
Strengthen the existing local-first tests: vidmap_client_localfirst_test
now asserts both endpoints are present (not just the local one is first),
and slice_test asserts an exact match instead of accepting two orderings.
Add TestLookupFileIdWithFallbackGlobalLocalFirst to pin the cross-DC
ordering invariant: any local replica (same or other DC) precedes every
remote-tier replica; within each tier DC1 precedes DC2.
Add docstrings to ToVolumeLocations, ReorderToFront, LookupVolumeServerUrl,
LookupFileId, GetVidLocations, GetLocations, LookupFileIdWithFallback, and
updateVidMap so the touched lookup paths are described in one place.
* topology: broadcast tier transitions on existing replicas
When a volume replica is tiered to remote storage or restored locally, the
wdclient's cached DataInRemote went stale: every connected client kept
preferring a remote-backed replica over a freshly restored local one, or
demoted a freshly tiered remote replica. The fix in commit
|
||
|
|
1d335357d6 |
helm: name both replication keys in the enableReplication comment (#11103)
The comment said enableReplication overrides "master & filer defaultReplicaPlacement", but the two components take different keys: master.defaultReplication and filer.defaultReplicaPlacement. Claude-Session: https://claude.ai/code/session_014Yr1Asxq3qTjJo2r9G4cLX |
||
|
|
eed5e8cdf6 |
s3: return the multipart object checksum in the CompleteMultipartUpload response (#11101)
* s3: return the multipart object checksum in the CompleteMultipartUpload body S3 carries the flexible-checksum members of CompleteMultipartUploadResult in the XML body, not in response headers, so every SDK read back an empty checksum from an upload that asked for one. Claude-Session: https://claude.ai/code/session_01Huux1uh7JxAbf8yypMYrMk * s3: echo the checksum algorithm and type from CreateMultipartUpload The upload directory already records both, but the response dropped them, so a client could not confirm which checksum its parts had to carry. Claude-Session: https://claude.ai/code/session_01Huux1uh7JxAbf8yypMYrMk * test: multipart upload reports the object checksum it was asked for Covers every algorithm end to end: the create response echoes the algorithm and type, the complete response carries the checksum, and it matches what a later HEAD reports. Claude-Session: https://claude.ai/code/session_01Huux1uh7JxAbf8yypMYrMk |
||
|
|
5f787a25c3 |
master: survive a volume layout deleted twice (#11098)
* master: survive a layout deleted twice Two volume servers dropping the last replica of volumes that share a layout both find it empty and both delete it. The loser's lookup misses, and the single-value type assertion on the result crashed the master before the caller could look at the found flag. Claude-Session: https://claude.ai/code/session_01WmX6Rchx298NQksHDXg7sk * master: remove a layout and read it back in one step DeleteVolumeLayout looked the layout up and then deleted it, so two deleters could each release the lookup ownership of the same layout, or one could find nothing to release at all. Have the map hand back what it removed. Claude-Session: https://claude.ai/code/session_01WmX6Rchx298NQksHDXg7sk |
||
|
|
86761cc7d5 |
filer: keep empty folders that are s3tables catalog entries (#11102)
* s3tables: build the catalog attribute keys from one shared prefix Every attribute the catalog stores on a bucket, namespace, table or view entry is spelled out with the same literal prefix. Name it once in s3_constants so code outside the package can recognize a catalog entry without repeating the string. Claude-Session: https://claude.ai/code/session_01GfZsc4cyNB2yr6KYLRv9q1 * filer: keep empty folders that are s3tables catalog entries A namespace, table or view is a directory whose extended attributes are the catalog record. Its files can live elsewhere - a rename moves only the catalog pointer and leaves the data at the old path, and a view has no files at all - so an empty one is still a live entry. Drop a table, then rename another table onto that name: the drop queues the old table's folders, the rename recreates the name path, and two minutes later the cleaner deletes it and cascades into the namespace, losing a table the catalog still lists. Claude-Session: https://claude.ai/code/session_01GfZsc4cyNB2yr6KYLRv9q1 * filer: drop a queued cleanup when the folder is created again A cleanup is queued against the folder that was found empty. If that folder is deleted and a new one takes its name, the queue entry outlives the folder it was about and the next pass deletes the replacement. A drop followed by a rename onto the dropped name does exactly this: the name path comes back as a live catalog entry two minutes before the queue is read. Claude-Session: https://claude.ai/code/session_01GfZsc4cyNB2yr6KYLRv9q1 |
||
|
|
23a6b8feb5 |
filer_pb: walk the whole tree when the BFS start path ends in a slash (#11099)
* filer_pb: build BFS child paths with FullPath.Child A start path with a trailing slash produced "/dir//sub" for every subdirectory, and the filer only trims a trailing slash, so those listings came back empty and the walk stopped after the first level. Claude-Session: https://claude.ai/code/session_01Jp9tXRpBv9gvh8fkaVFqxQ * filer_pb: normalize the BFS start path Entries directly under the start path were reported with the caller's trailing slash, so filer.meta.backup wrote them under a directory the incremental stream never names again. Claude-Session: https://claude.ai/code/session_01Jp9tXRpBv9gvh8fkaVFqxQ |
||
|
|
9ea52db219 |
s3: validate the version-id header used as a filer path segment (#11097)
* s3: reject a version-id header that is not a valid path segment
putToFiler stored the client-supplied Seaweed-X-Amz-Version-Id header
verbatim into object metadata. That value is later read back and used
as a filer path component when building the .versions/v_<id> path, so a
value containing "/", "\" or ".." could steer retention/legal-hold
writes and remote-cache reads outside the object's own bucket tree.
Validate the header with isValidVersionID before storing it, the same
check the versioned read paths already apply, and reject the request
otherwise. Server-set version ids ("null" and generated hex) pass.
Claude-Session: https://claude.ai/code/session_011QqNaxZwnpHgMoAZNp3RkY
* s3: validate a stored version-id before using it as a path
The retention and legal-hold sinks build a .versions/v_<id> path from a
version id read back out of object metadata, and the remote-cache path
builder does the same from either the request or the stored id, without
the isValidVersionID check the other version-id consumers apply. Guard
these so a value that is not a valid path segment falls back to the
regular / unversioned path instead of steering the write or read out of
the bucket tree.
Claude-Session: https://claude.ai/code/session_011QqNaxZwnpHgMoAZNp3RkY
|
||
|
|
23adeb37e2 |
s3: check Object Lock on directory-marker keys before bucket deletion (#11096)
recursivelyCheckLocksWithClient tested EntryHasActiveLock only on non-directory entries, so a directory-marker object (an S3 key ending in "/") that carries retention or a legal hold was recursed into but never lock-checked. DeleteBucket then saw no locks and removed the bucket, destroying an object under active Object Lock along with the rest of the bucket. DeleteObject already enforces the lock on the same key, so the two paths disagreed. Check the directory entry for an active lock before recursing. Claude-Session: https://claude.ai/code/session_011QqNaxZwnpHgMoAZNp3RkY |
||
|
|
3cdfe648eb |
sftp: reject an empty password (#11095)
ValidatePassword compared the stored and supplied passwords with subtle.ConstantTimeCompare, which returns 1 for two zero-length slices. A user provisioned for public-key-only auth has an empty stored password, so an empty supplied password authenticated as that user whenever "password" was among the enabled auth methods (the default). Treat an empty stored or supplied password as a non-match. Claude-Session: https://claude.ai/code/session_011QqNaxZwnpHgMoAZNp3RkY |
||
|
|
398277a15d |
mini: expose -volume.max (#11100)
mini hardcoded the per-directory volume limit to 0, so the volume server always auto-sized it as free disk space divided by the volume size. That sizing reserves a whole volume size for every writable volume, so a workload spreading small objects over many buckets runs out of slots long before the disk fills and assign starts failing with "no free volumes left". Same name and semantics as the flag weed server already carries, and still 0 (auto) by default. Claude-Session: https://claude.ai/code/session_01BiQLeBvZLzG8XitjiypDKu |
||
|
|
68a175ef6f |
deps: drop the apache/thrift replace, v0.24.0 carries the 32-bit fix (#11077)
The replace pinned thrift to a post-v0.23.0 master commit so 32-bit GOARCHes would compile. That fix shipped in v0.24.0, so the replace was only overriding the v0.24.0 require back to the pseudo-version and holding the build below the CVE-2026-43871 fix. Claude-Session: https://claude.ai/code/session_01C5BpSeYD3yULWmfVXwPRmB |
||
|
|
8f2daad338 |
topology: mirror the writable volume list in a set (#11076)
Membership was a linear scan over a slice, and ensureCorrectWritables runs it for every volume on every heartbeat, so the master's steady-state cost per volume server is quadratic in that server's volume count. BenchmarkSyncDataNodeRegistration, median of 3: 1000 volumes 565.7us -> 535.0us -5.4% 100000 volumes 1.665s -> 55.3ms -96.7% Allocations are unchanged at both sizes, so the difference is the scan. Claude-Session: https://claude.ai/code/session_01P3pE6J2UPFp6G3ksfMV4s1 |
||
|
|
40b3d32fe5 |
test: metadata operations on unlinked open files and removed open directories (#11075)
The POSIX suites skirt this: pjdfstest's unlink/14.t covers only fstat and pread on an unlinked descriptor — its driver has no fchmod at all and never opens a directory. Pin the full rule in the FUSE integration suite: ftruncate, fchmod, futimes, fstat, and the f*xattr calls keep working between the removal of the last name and the final close, for a file after unlink and a directory after rmdir, with nlink 0 and the changes visible to a following fstat. Claude-Session: https://claude.ai/code/session_01GYqLENjZzbV5hgt4L8cSAK |
||
|
|
86a189ff80 |
mount: keep metadata operations working on a removed open directory (#11073)
* mount: remember the entry of a directory removed while still referenced A directory removed while a descriptor is open on it keeps its inode until the kernel's final forget, but unlike a file it has no handle to live on through: OpenDir hands out only a listing cursor. Keep the last-known entry in memory, keyed by inode, from rmdir until that forget. Claude-Session: https://claude.ai/code/session_01GYqLENjZzbV5hgt4L8cSAK * mount: serve metadata ops on a removed open directory from its remembered entry fchmod, futimens, and the f*xattr calls on a descriptor whose directory was removed failed with ENOENT: maybeReadEntry resolved the inode to a path, and rmdir had already dropped it. Fall back to the remembered entry the same way an unlinked file falls back to its open handle. Mutations publish a changed copy back rather than editing in place, so a concurrent reader never sees a half-applied change, and the empty path keeps nlink 0 in every reply. Claude-Session: https://claude.ai/code/session_01GYqLENjZzbV5hgt4L8cSAK * mount: stash the entry the delete itself returned, not an earlier snapshot A chmod landing between Rmdir's entry load and the delete RPC would be resurrected pre-change: the remembered entry was the earlier local snapshot. The filer serializes the delete against updates under the path lock and hands the entry back in the delete event, so prefer that, keeping the local load for the sticky-bit check and as fallback when no event comes back. Claude-Session: https://claude.ai/code/session_01GYqLENjZzbV5hgt4L8cSAK * mount: drop a remembered entry whose insert lost to the final forget The forget's cleanup runs between RemovePath and the insert when the kernel evicts the inode concurrently, finds nothing, and the entry would sit in the map for the life of the mount. Re-check the inode after inserting and take the entry back out; every interleaving now ends with the map empty. Claude-Session: https://claude.ai/code/session_01GYqLENjZzbV5hgt4L8cSAK * mount: insert the remembered entry under the inode table lock The post-insert HasInode re-check could be fooled by inode number reuse: a lookup landing between the forget and the check makes the number look alive and the stale entry stays, keyed to someone else's inode. Do not check after the fact — RemovePath now runs the retention callback inside its critical section, where the forget that releases under the same lock cannot have run and cannot be missed. Publishes need no such fence: their open descriptor keeps the kernel from issuing the final forget in the first place. Claude-Session: https://claude.ai/code/session_01GYqLENjZzbV5hgt4L8cSAK |
||
|
|
77a9dd4b9e |
s3: route per-key object authorization through a shared helper (#11072)
* s3: share the per-key object authorization across copy and delete AuthorizeCopySource and AuthorizeObjectDelete both authorize a key the request URL does not name by evaluating the bucket policy and IAM against a synthetic per-key request; only the method and action differed. Extract that into authorizeObjectKeyAction and make the two callers thin wrappers. No behavior change. Claude-Session: https://claude.ai/code/session_01Qo7p6VsoWxMo8816ogJFk5 * s3: route POST Object uploads through the shared object authorization POST Object uploads (presigned-POST / HTML form) authorized the write with only the coarse per-identity Write action, unlike the other write paths which also check the resolved object against the bucket policy and IAM. Route POST through authorizeObjectKeyAction via a new AuthorizeObjectWrite so it is authorized like the equivalent PUT. Claude-Session: https://claude.ai/code/session_01Qo7p6VsoWxMo8816ogJFk5 * s3: test POST Object per-key authorization Drives a signed POST upload and checks the per-key authorization decision for a denied, permitted, and admin caller. Claude-Session: https://claude.ai/code/session_01Qo7p6VsoWxMo8816ogJFk5 |
||
|
|
34f5442e9b |
s3api: push the listing prefix down to the filer in ListObjectVersions (#11070)
The version walk listed every directory with no prefix, transferring all 1024-entry batches over gRPC and filtering gateway-side - and kept paging past the point where names can no longer match. On wide directories (many sibling orgs/jobs next to the requested prefix) that is most of the transfer, decode, and CPU cost of every page. Derive the next path component of the requested prefix per directory level and hand it to the filer listing. A name holds no slash, so a directory whose name does not start with the component cannot contain a matching key and a file that does not cannot be one; stores with native prefixed listing (sql, leveldb) turn this into a range scan and stop the stream at the end of the prefix zone. Claude-Session: https://claude.ai/code/session_01FquvGtTD2zA3uMZGQHAuV4 |
||
|
|
0ba21174bf |
volume: an already-deleted EC needle is not a delete failure (#11071)
* volume: an already-deleted EC needle is not a delete failure Deleting a needle that is already gone is what the caller asked for, and the non-EC paths have always said so: BatchDelete reports StatusNotModified when DeleteVolumeNeedle finds nothing to do, and DeleteHandler answers 404 from its ReadVolumeNeedle pre-check. The EC branches had no such case, so ErrorDeleted fell through to a generic failure -- 500 from both, and DeleteHandler also counted it in VolumeServerFileWriteFailures, inflating a failure metric on a replayed or duplicated delete. The filer already tolerates this by string-matching "already deleted" on the result, which leaves an error message load-bearing; the status is now right at the source instead. Claude-Session: https://claude.ai/code/session_01P3pE6J2UPFp6G3ksfMV4s1 * volume: close the EC fixture's disk location Close stops the location's disk-space goroutine and releases the mounted EC volume's file handles, which otherwise live until the test binary exits. Claude-Session: https://claude.ai/code/session_01P3pE6J2UPFp6G3ksfMV4s1 |
||
|
|
81ca5cb6c6 |
s3api: drop two redundant filer round-trips per listed version entry (#11068)
* s3api: drop two redundant filer round-trips per listed version entry ListObjectVersions paid two avoidable getEntry calls while walking a bucket, both re-fetching data the walk already held: - getObjectVersionList re-read the .versions directory entry that every caller had just received from listing the parent directory (or from its own sibling probe). Pass the entry down instead: one RPC saved per object listed. - getObjectOwnerFromVersion, on a version with no stamped owner, re-fetched the same version entry its OwnerID had been extracted from. The refetch cannot answer differently, so data written before owners were stamped cost one futile RPC per listed version, forever. All round-trips on this path are sequential, so on large versioned buckets (Veeam-style workloads) they add up to a visible share of per-page latency and gateway CPU. Claude-Session: https://claude.ai/code/session_01FquvGtTD2zA3uMZGQHAuV4 * s3api: treat a nil .versions entry as an empty version list filer_pb.GetEntry's contract permits (nil, nil) for an absent entry, and the old internal lookup answered that case with an empty list. Keep that answer now that the entry arrives from the caller. Claude-Session: https://claude.ai/code/session_01FquvGtTD2zA3uMZGQHAuV4 |
||
|
|
4c9cbf72bc |
s3api: stop retrying a definitive NotFound in getLatestObjectVersion (#11067)
The .versions lookup retried every error through the full backoff ladder, so a missing key spent 12.7s (8 attempts, 100ms..6.4s) before the pre-versioning fallback could answer. NotFound is an answer, not a transient failure: gate the retries on isRetryableFilerErr, the same classifier retryFilerOp already uses, which also stops retrying for callers whose context is canceled or past its deadline. GetObject already treats NotFound on .versions/ as definitive; this brings the retention/tagging/ACL/attributes/delete/copy paths that go through getLatestObjectVersion in line with it. Claude-Session: https://claude.ai/code/session_01FquvGtTD2zA3uMZGQHAuV4 |
||
|
|
2ef0e60aeb |
filer.sync: export replication lag, event counters, and in-flight jobs (#11069)
* filer.sync: count received, processed, and failed events and export in-flight jobs The metadata processor admits at most -concurrency jobs and blocks the subscription stream past that, so the backlog lives in the source filer's metadata log and cannot be counted here. What can be measured honestly: events read off the stream, replication outcomes, and worker saturation. in_flight_jobs pinned at the concurrency limit means the sync itself is the bottleneck; near zero means it is caught up or starved by the source. Claude-Session: https://claude.ai/code/session_01VGUXS7kdqiaXenTYVCg6vw * filer.sync: export replication lag in seconds Lag is now minus the freshest of the processed watermark and the last idle heartbeat: the watermark stops at the last real event, so a quiet caught-up stream would otherwise show phantom lag. A ticker drives the gauge because the offset callback only fires while events flow and freezes exactly when the workers are saturated. Until the first event or heartbeat the gauge stays unset rather than reporting lag against a zero or stale resume offset. Claude-Session: https://claude.ai/code/session_01VGUXS7kdqiaXenTYVCg6vw * filer.sync: track replicated data sizes alongside event counts An event count hides that 32 in-flight jobs can be 32 KB or 300 GB. Byte counters mirror the event counters, and in_flight_bytes pairs with in_flight_jobs. An event's size is the chunk delta - new chunks the old entry does not already have - so deletes, renames, and attribute-only updates count zero and byte rates reflect data movement, not metadata churn. Claude-Session: https://claude.ai/code/session_01VGUXS7kdqiaXenTYVCg6vw * grafana: chart the new filer.sync metrics The lag panel reads lag_seconds directly instead of deriving it from sync_offset, and the sync row gains event rate, throughput, and the in-flight jobs and bytes gauges. Claude-Session: https://claude.ai/code/session_01VGUXS7kdqiaXenTYVCg6vw * filer.sync: a pinned failure keeps showing as lag An idle heartbeat means the stream is consumed, not that every event replicated. While a permanent failure pins the watermark, letting the heartbeat advance lag_seconds or the sync_offset gauge would report a caught-up stream with an unreplicated event in it, so both now ignore heartbeats until a restart replays the failure. Claude-Session: https://claude.ai/code/session_01VGUXS7kdqiaXenTYVCg6vw * filer.sync: in-flight gauges survive subscription retries A subscription retry builds a new processor sharing the gauge children while the old processor's jobs may still be draining, so setting the gauge from either side's local count clobbers the other. Each job now increments and decrements for itself, keeping the total truthful across generations. Claude-Session: https://claude.ai/code/session_01VGUXS7kdqiaXenTYVCg6vw |
||
|
|
9d5525e747 |
master: keep periodic volume growth to the data centers a layout lives in (#11060)
* master: keep the periodic growth scan to data centers hosting the layout The rack-aware scan planned growth for every data center in the topology, so a collection pinned to one DC (fs.configure -dataCenter) sprouted volumes in all the others within one scan cycle. Plan only for data centers already hosting the layout's volumes; an empty DC gets its volumes from the DC-constrained assign that first asks for them. The lastGrowCount divisor likewise counts only the racks the scan can plan for. Claude-Session: https://claude.ai/code/session_01J22TVTyoCMzdHyJirsLMG5 * master: pin periodic must-grow growth to a single-DC layout's data center The must-grow and crowded paths of the periodic loop grow with no DataCenter, so even with the scan fixed a pinned collection's volumes could still land in any DC once lastGrowCount demands more writables. Stamp the grow request with the layout's data center when its volumes all live in one; layouts spanning DCs keep unconstrained growth. Claude-Session: https://claude.ai/code/session_01J22TVTyoCMzdHyJirsLMG5 * master: never pin growth of a cross-DC-replicated layout A layout whose replication spans data centers cannot legitimately live in one DC; observing a single hosting DC there means the other DCs are down. Do not encode that outage as a placement constraint. Claude-Session: https://claude.ai/code/session_01J22TVTyoCMzdHyJirsLMG5 * master: bound the hosting-DC walk by the answer it needs listVolumeDataCenters walked every location of the layout under accessLock — ~190ms for a million volumes, twice per layout per cycle, stalling assigns behind the read lock. Stop once enough distinct DCs answer the caller's question: two for the single-DC check, the topology's DC count for the scan. A spanning million-volume layout now finishes in microseconds; only a layout truly confined to fewer DCs still pays a full walk, the same cost class as the under-replication count this loop already takes each cycle. Claude-Session: https://claude.ai/code/session_01J22TVTyoCMzdHyJirsLMG5 |
||
|
|
8873f9775c |
shell: reset noLock in the admin script dispatcher too (#11059)
Three dispatchers reuse one CommandEnv: the interactive shell, the master's maintenance script runner, and the plugin worker's admin script handler. The first two were fixed; this is the third. It changes nothing today -- ForceNoLock already exempts this path -- so it is here to keep the rule the same everywhere rather than resting on that exemption staying in place. |
||
|
|
1996c6aec6 |
volume: open volume files with O_NOATIME (#11055)
* volume server: open volume files with O_NOATIME Nothing reads the atime of .dat, .idx, .sdx, or EC files, but every needle read still dirtied the inode: even relatime writes atime on the first read after each write, so an actively written volume paid a metadata write per read/write cycle, and strictatime mounts paid one per read. Open the serving handles with O_NOATIME, falling back to a plain open when the file belongs to another owner (EPERM). Claude-Session: https://claude.ai/code/session_015uVY4diBgEn3VYQoc2eMuD * seaweed-volume: mirror the O_NOATIME volume file opens Same change as the Go volume server: serving handles for .dat, .idx, .sdx, .ecx, .ecj, and shard files open with O_NOATIME on Linux, with a plain-open fallback on EPERM. Claude-Session: https://claude.ai/code/session_015uVY4diBgEn3VYQoc2eMuD * route the tier-down and recreate .dat opens through the no-atime helper Review caught the Rust tier-down swap opening the local .dat directly. The Go swapToLocalDatBackend and the zero-length read-only .dat recreate in maybeWriteSuperBlock had the same gap: all three install long-lived serving handles. Claude-Session: https://claude.ai/code/session_015uVY4diBgEn3VYQoc2eMuD |
||
|
|
0c59c0fb05 |
master: scope the startup capacity shed to a truly empty topology (#11058)
* master: scope the startup capacity shed to a truly empty topology The retryable "no volume server capacity registered yet" shed checked capacity for the requested disk type, so a cluster serving only other media -- where that capacity will never register -- shed every assign until the client's deadline instead of failing fast. An unsteered write to such a cluster hung for its full HTTP deadline and surfaced "context deadline exceeded" in place of "No writable volumes". Shed only while no disk type has any registered capacity, and name the unserved medium in the fast failure. Claude-Session: https://claude.ai/code/session_01TF7FQghfDkpdoZgakTMX4R * master: name the unserved medium for every fail-fast caller The diagnostic sat in the growth-initiator block, so a follower joining an in-flight growth and a growth-disabled master failed the same way with only the generic pick error. Wrap at the fail-fast break instead, which every caller reaches, and cover all three paths in the test. Claude-Session: https://claude.ai/code/session_01TF7FQghfDkpdoZgakTMX4R |
||
|
|
721499a05a |
release: judge downstream releases by their run, excluding runner-queue time (#11057)
Claude-Session: https://claude.ai/code/session_01Y228KU8MLsGmfpcbGxgjwh |
||
|
|
0f4a7d0803 |
shell: keep noLock to the command that set it (#11052)
noLock says "this invocation changes nothing" -- volume.balance, volume.move,
volume.copy, volume.merge and volume.fix.replication all set it for a dry run,
and none clears it. The CommandEnv is created once and reused by both
dispatchers, the interactive shell and the master's maintenance script runner,
so a simulation left every later command unlocked:
volume.balance -noLock # changes nothing
volume.move ... # mutates, and skips its lock
Reset before dispatch in both, where the invocation begins. forceNoLock is
untouched: that is set once, deliberately, for a trusted path.
|
||
|
|
87474c2f21 |
s3: let attached policies authorize CreateBucket (#11049)
* s3: resolve admin bucket subresources to their specific S3 actions Encryption, requestPayment, publicAccessBlock and ownershipControls requests reached the policy engines as s3:*, so only a policy granting all of s3 could authorize them. Map each subresource to its AWS action, with DELETE sharing the PUT permission as AWS does. Claude-Session: https://claude.ai/code/session_01XH7iM88ZqWMEvsLB8tkWPQ * s3: authorize CreateBucket as s3:CreateBucket in the policy engine A plain bucket-level PUT is registered with ACTION_ADMIN, which resolved to s3:*, so no attached policy short of s3:* could match it. Federated sessions whose policy explicitly allowed s3:CreateBucket were always denied while the same policy worked for object operations. Resolve it to s3:CreateBucket, like DeleteBucket already resolves. Claude-Session: https://claude.ai/code/session_01XH7iM88ZqWMEvsLB8tkWPQ |
||
|
|
b8049bc633 |
shell: keep fs.mergeVolumes from spinning past the finished moves (#11050)
* filer_pb: walk a re-delivered directory only once in TraverseBfs A directory handed back twice by a listing (a page-boundary race with concurrent renames, or a store whose ordering misbehaves) was enqueued twice; the second walk re-lists the same subtree and can keep the traversal from ever terminating. Claude-Session: https://claude.ai/code/session_01XH7iM88ZqWMEvsLB8tkWPQ * filer_pb: fail a directory listing whose pagination stops advancing A full page ending on the very name the cursor started from re-fetches the same page forever; a store whose listing order does not advance past the cursor turns any full-directory read into a silent infinite loop. Return an error naming the stuck cursor instead. Claude-Session: https://claude.ai/code/session_01XH7iM88ZqWMEvsLB8tkWPQ * shell: skip foreign-collection manifests in fs.mergeVolumes Every manifest chunk in the namespace was resolved, downloading its manifest needle, even when the merge plan only touches one collection. Sub-chunks live in the manifest's own collection, so a manifest on a volume outside the plan's collections cannot reference a source volume; skip it and spare a cluster-wide download pass that looks like a hang after the real moves finish. Claude-Session: https://claude.ai/code/session_01XH7iM88ZqWMEvsLB8tkWPQ |
||
|
|
23d424248d |
sts: session duration no longer clamped to the web identity token exp (#11048)
* sts: session duration no longer clamped to the web identity token exp The assumed-role session lifetime is governed by DurationSeconds and the configured tokenDuration/maxSessionLength, matching AWS. Clamping to the already-verified token's exp made short-lived id_tokens (GitLab issues ~2-minute ones) yield unusable sessions regardless of configuration. Claude-Session: https://claude.ai/code/session_01XH7iM88ZqWMEvsLB8tkWPQ * sts: cover session duration against short-lived web identity tokens The mock OIDC provider now carries the token exp through to the identity like the real provider, so the integration test would catch the clamp. Claude-Session: https://claude.ai/code/session_01XH7iM88ZqWMEvsLB8tkWPQ |
||
|
|
32df246a81 |
mq: fix idle-cleanup shard deadlock that permanently wedges the broker's topic map (#11051)
mq: remove emptied topics after the cleanup iteration, not inside it cleanupIdlePartitions called manager.topics.Remove from inside manager.topics.IterCb. IterCb holds the shard's read lock while running the callback, and Remove takes the same shard's write lock, so removing an emptied topic self-deadlocked the cleanup goroutine. The pending writer then blocked every later reader of that shard, permanently hanging ListTopicsInMemory and, for shard-mates, TopicExistsInMemory. On the Kafka gateway this surfaced as flaky e2e consumer-group tests: one minute after any earlier topic went idle, the broker's first 'Removing empty topic' wedged the map, every gateway ListTopics/TopicExists RPC burned its full 5s timeout, Metadata could no longer finish inside kafka-go's 5s coordinator deadline, and consumer groups looped in PreparingRebalance until the test timed out. Collect the emptied topic keys during the iteration and remove them afterwards via RemoveCb, re-checking emptiness under the shard lock so a topic that just gained a partition is kept. Claude-Session: https://claude.ai/code/session_014yA6c8JQcY6MqPXCT13yYA |
||
|
|
d3b8030a69 |
master: shed assigns retryably until volume servers register capacity (#11032)
An assign arriving before any volume server has heartbeated saw zero available space and failed outright with a plain error no client retries, so the first write to a fresh bucket answered 500 while the cluster was still starting. Distinguish a topology with no registered capacity from a genuinely full one: fail fast only when registered capacity is exhausted, and shed ResourceExhausted otherwise so the client's retry budget rides out the startup window. Claude-Session: https://claude.ai/code/session_018G9kWFgy8BaBAEkYV3YL9n |
||
|
|
9bafeb6139 |
ec: refuse to mount a 0-byte shard file when the index has entries (#11030)
* ec: refuse to mount a 0-byte shard file when the index has entries The startup scan already skips (and eventually deletes) zero-sized shard files as residue of a failed copy, but the mount RPC path opens the file directly with no size check, so an explicit VolumeEcShardsMount over a truncated file registers a size-0 claim. A registered empty shard serves nothing while advertising ownership: with placement pinned to the owning disk, it would keep attracting re-copies to a file that was never valid. The one legitimate 0-byte shard is the empty volume's: encoding a volume with no live needles produces a 0-byte .ecx and 0-byte shards, and that mount must keep working (TestMountEcShards_EmptyEcxMountsSuccessfully). So the gate compares against the index: AddEcVolumeShard (Go) and EcVolume::add_shard (Rust) refuse a 0-byte shard file only when the volume's .ecx has entries. Go's AddEcVolumeShard grows an error return for this; the loader cleans up the refused shard and, when it just created the EcVolume, unregisters that too. The mount loop already collects non-ENOENT failures per disk and keeps scanning, so a sibling disk holding a real copy still wins. Regression tests in both trees: an empty shard beside an index with entries is refused and leaves nothing registered; an empty shard of an empty volume still mounts. Claude-Session: https://claude.ai/code/session_01AWpefvdi4U3HLng18x5CJ9 * ec: release the duplicate shard when a mount retry re-loads it Review follow-up: AddEcVolumeShard keeps the existing shard and reports added=false for a shard this disk already registered, but the loader discarded that result, so every retried LoadEcShard leaked the duplicate it had just opened — an fd and a mount-gauge increment per retry. Release both and return the existing volume. Regression test pins the gauge. Claude-Session: https://claude.ai/code/session_01AWpefvdi4U3HLng18x5CJ9 * ec: close the test DiskLocation instead of only its EC volumes Review follow-up: DiskLocation.Close() also stops the background goroutine NewDiskLocation starts; closeEcVolumes left it running for the rest of the test process. Both uses are this PR's own tests. Claude-Session: https://claude.ai/code/session_01AWpefvdi4U3HLng18x5CJ9 * rust: unregister the just-created EcVolume when its first mount is refused Review follow-up: when the first mount of a volume rejects its shard (e.g. the new 0-byte-beside-nonempty-index refusal), the Rust mount path had already inserted the EcVolume and propagated the error without removing it — a zero-shard registration advertising a mount that serves no data while pinning the .ecx/.ecj descriptors (and, since placement's mounted tier keys off it, steering shard placement at this disk). Remove it on the way out, exactly as the Go loader already does; a volume that already holds shards keeps them (the RPC's first-error-aborts contract). Regression test covers both. Claude-Session: https://claude.ai/code/session_01AWpefvdi4U3HLng18x5CJ9 * rust: skip already mounted shards on a mount retry Review follow-up: EcVolume::add_shard replaces self.shards[id] for a shard the volume already holds, and the mount loop then bumps the ec_shards gauge although the mounted count did not grow — gauge drift on every mount retry, and a serving fd swapped for no reason. Skip shard ids the volume already reports, mirroring Go's AddEcVolumeShard added=false handling. Regression test pins the gauge across a duplicate mount (unique collection label: the gauge is process-global and tests run in parallel). Claude-Session: https://claude.ai/code/session_01AWpefvdi4U3HLng18x5CJ9 |
||
|
|
74b520113e |
ec: pin auto-selected shard placement to the disk that already owns the shard (#11029)
* ec: pin auto-selected shard placement to the disk that already owns the shard A multi-disk server legitimately mounts one EC volume on several disks, so FindEcShardTargetLocation's per-volume tiers tie at "mounted" and the free-shard-count tie-break decides — pointing at whichever disk is emptier, not at the disk that already holds the shard being placed. A re-copy of a shard the server already has (a retried ec.balance / ec.rebuild move) then lands on a sibling disk, and both disks register the same (volume, shard id): the shard is reported to the master from two disk ids, and which claimant serves reads or survives a later unmount/delete becomes an accident of Locations order. Add a tier above "mounted": a disk that already claims one of the shard ids being placed wins, ahead of the space filters too — re-copying in place needs no new shard slot, and a genuinely full disk should fail the write rather than silently split the claim. Applied to the Go selector and the VolumeEcShardsCopy auto-select (ReceiveFile refuses mounted EC volumes, so no claim can exist there) and mirrored in the Rust volume server. Claude-Session: https://claude.ai/code/session_01AWpefvdi4U3HLng18x5CJ9 * ec: refuse a copy batch whose shards are already owned by different disks Review follow-up: ownership-aware selection ranks a mixed-owner batch (shard 0 on disk A, shard 2 on disk B — the legitimate multi-disk spread) into one destination, so the copy would still duplicate the losing disk's claim. No production caller sends such a batch (balance moves one shard, rebuild and encode copy shards the target lacks), so fail closed: report every owning disk via Store.EcShardOwnerDisks and refuse the copy with an error naming them, telling the caller to split per shard or pass disk_id. Go and Rust, with unit tests for the owner-reporting contract. Claude-Session: https://claude.ai/code/session_01AWpefvdi4U3HLng18x5CJ9 |
||
|
|
88c873ecd4 |
ec: uniform shard block layout (#10932)
* ec: uniform shard block layout An EC volume is striped as 1GiB blocks until less than one row remains, then 1MiB blocks, and consecutive blocks land on different shards. With ec.encode's -fullPercent 95 against the 30GiB default limit, ~30% of every volume sits in that 1MiB tail, so a 4MB filer chunk there is five stripes on five servers. New encodes now use one block per shard, sized ceil(datSize/dataShards) rounded up to 1MiB and recorded in the .vif (EcShardConfig.block_size, also carried by the .ecsum manifest). A needle now maps to one shard unless it is larger than the block or straddles a boundary. The chosen size equals the legacy layout's padded shard length for every input, so shard sizes, capacity math, and the shard-size credibility checks are unchanged; only the byte placement moved. Reads, decode, and scrub resolve the block sizes from the volume's .vif; absence keeps the legacy interpretation, so existing EC volumes read exactly as before. Rebuild is layout-agnostic. weed fix -ecx recovers the layout from the .vif, else the .ecsum sidecar, and with neither de-stripes under both candidate layouts and keeps the one that indexes more valid needles. Same change in the Rust volume server, which now also streams the encode in 256KB sub-batches like Go instead of allocating whole blocks, and computes the large-row count as shardSize/largeBlock to match Go on exact multiples. On a 26MB fixture both encoders produce byte-identical shards, and a Go-written .vif parses in Rust with the block size intact. * ec: resolve the rust ecx rebuild through the recorded layout The Rust rebuild path regenerated a lost .ecx by scanning the logical .dat through a hand-rolled pure-1MiB striping, which was already wrong for legacy volumes with large-block rows and is wrong for any uniform volume with a block past 1MiB. Route the scan through locate_data with the .vif-recorded block size, the same mapping the read path uses. Also seed the new tests' random data instead of the deprecated global math/rand.Read. * ec: fail the Rust ecx rebuild on any shard read error A read error mid-scan published the entries collected so far as a successful .ecx, and read_at's byte count was ignored so a legal short read passed as complete — a truncated or failing shard could produce a silently incomplete recovery index. Exact-read semantics in read_from_data_shards, error propagation in the needle walk, and a truncated-shard regression test. * ec: fail the mount on an unreadable or malformed vif Both servers silently fell back to the legacy layout when an existing .vif could not be read or parsed. Every new encode records a positive uniform block size there, so the fallback mounted the same shards with legacy offset math and could return wrong data. Absent stays legal (legacy volumes predate the sidecar), and a zero-byte stub still reads as absent (Go's MaybeLoadVolumeInfo convention, now mirrored in Rust); a present-but-unreadable or malformed .vif fails the mount instead. * ec: bound the reconstruct fan-out of one needle's intervals A degraded interval fans out a read to every reachable shard location, each with a buffer the size of the interval. Reading a needle's intervals in parallel multiplied that by the interval concurrency: a needle spanning 8 blocks could hold 8 x MaxShardCount remote reads and buffers at once, where the sequential version peaked at MaxShardCount. Give each needle a single reconstruct budget its intervals share, held for the buffer's lifetime, so separate reads stay independent but one read cannot multiply its own fan-out. * ec: drop the duplicated shard-size formula calculateExpectedShardSize reimplemented the padding rule that UniformBlockSize already owns — TestUniformBlockSizeMatchesLegacyShardSize asserts the two agree for every input — so a change to the rule would have had to be made in both. Defer to the helper, keeping the historic answer for an empty .dat. * ec: resolve the shard block layout from whatever records it Four places still answered the layout question by inference when a record of it was available, or accepted an answer that was not one: - A mount with no .vif defaulted to the legacy layout; the bitrot sidecar records the same config at encode time, so take it when present, as weed fix -ecx already does. The vif itself is now parsed once per mount rather than twice. - The Rust ecx rebuild derived its row count from the padded shard extent, which under the legacy layout reads a shard that is an exact large-block multiple as one row too many. Pass the encode-time .dat size from the .vif and keep the extent as the fallback. - weed fix -ecx read the block size outside the EC-config guard (collapsing the unknown sentinel into a definitive legacy), only wrote the recovered layout back when the .vif was absent rather than unusable, and broke a scan tie by candidate order instead of the documented reach. - The uniform layout tripped writeDatFile's large-block ambiguity guard, which cannot apply when the large and small blocks are the same size. * ec: give the index-recovery tests a parseable vif The fixtures wrote the literal bytes "volinfo" as the source .vif and the recovery copies it verbatim, so the receiving server then mounted the volume from a .vif it could not parse. That used to pass by silently defaulting to the legacy layout; a mount now refuses a vif it cannot read, which is what the tests were exercising all along without meaning to. * ec: validate the layout a vif records, not just its syntax Review follow-ups on the mount-strictness change: - A .vif can parse and still record a block size no encoder could have produced (negative, or not a whole number of small blocks). Both servers took it and mapped every read through it. ValidateBlockSize / the Rust mirror now refuse the mount, the same way an unparseable vif does; 0 stays valid as the legacy two-tier layout. - The bitrot-sidecar fallback accepted parity_shards == 0 and summed the counts in their own width, so values near the ceiling wrapped past the MaxShardCount bound. Require both counts and sum in a wider type. - weed fix -ecx treated a config with only DataShards > 0 as usable, so a half-written .vif suppressed the recovery paths AND survived the rewrite. Require a complete, in-range config before trusting it. - Returning the vif-load error left the .ecx and .ecj descriptors open; repeated mount attempts on malformed metadata could exhaust them. * ec: refuse to act on a layout the metadata does not establish - The worker encode only logged a failed .vif write and skipped it in the distribution set, and treated the .ecsum write as best-effort. A worker whose disk filled after the much larger shards landed could still distribute, mount, verify shard inventory, and delete the source replicas — leaving holders with shards whose geometry nothing records. Both writes and both inclusions are encode success conditions now. - A generation-matching .ecsum that disagreed with the .vif geometry only disabled checksums in Go, and in Rust was not compared at all, so protection stayed On while reads used the other layout. Both files record the layout their generation was encoded with, so a disagreement now fails the mount. * ec: reject an invalid recorded block size in weed fix -ecx A .vif with valid shard counts but a negative or unaligned block size was marked usable: a positive invalid value pinned the scan to a geometry that de-stripes to garbage, and a negative one ran the dual scan but left the invalid .vif in place afterwards. Validate it with the same rule the mount applies, and when it fails leave the layout unknown so the scan recovers it and the file is rewritten. * ec: validate the sidecar layout weed fix -ecx recovers from The .ecsum fallback was taken on DataShards > 0 alone, so a CRC-valid sidecar carrying the wrong generation, an incomplete ratio, or an unaligned block size would pin the reconstruction to one incorrect uniform-layout candidate instead of letting the dual scan decide. Require generation 0, a complete in-range ratio, and a valid block size; anything less leaves the layout unknown, which is the answer that still recovers by scanning. * ec: let only a genuinely absent sidecar choose the legacy layout With no .vif the bitrot sidecar is the only record of a volume's layout, and the mount fallback read a failed load, an unusable config, or a sidecar stamped for another generation as "assume legacy". A uniform generation-0 volume could therefore mount with legacy or another generation's geometry and answer reads with the wrong bytes. Present-but-unusable now fails the mount; only actual absence keeps the legacy defaults. Shared as EcShardConfigFromSidecar so every caller reads the sidecar the same way. * ec: treat a recorded-but-impossible layout as corruption, not as legacy - A .vif whose ecShardConfig is PRESENT but records an impossible ratio was answered with the default 10+4 and the legacy block layout, in both languages. That reads a uniform volume's shards at the wrong offsets and returns the wrong bytes. Only an entirely absent config still means "this predates the record"; a present one that cannot be true fails the mount. - The shard-count bound summed two uint32 counts as int, which wraps on a 32-bit build: 0x7fffffff + 0x7fffffff lands at -2 and slips under MaxShardCount. ValidEcShardCounts sums in uint64, and every EC call site that checked a recorded ratio now goes through it. * ec: rebuild on the geometry the sidecar records, and flag it when it disagrees The rebuild RPC passes BackgroundECContext, so RebuildEcFiles resolves the layout itself — and it resolved a missing or invalid .vif to the default 10+4 with the legacy block size. Two consequences: a 12+4 volume was reconstructed through a 10+4 matrix, which produces wrong bytes and never regenerates shards 14-15; and the chosen geometry then contradicted a valid uniform sidecar, which loadRebuildSidecar reported as BitrotOff — silently skipping the input and regenerated-shard checksum checks precisely when the volume had already lost its metadata. The layout now resolves from the bitrot sidecar (found across the server's disks, not just beside the base name) before falling back to the defaults, and a present-but-impossible ratio fails instead of being replaced. A sidecar that contradicts the chosen geometry is BitrotInvalid, which the existing unsafeIgnoreSidecar override still lets an operator push past. * ec: let the Rust rebuild read metadata off a sibling disk read_ec_shard_config searches only the location the rebuild writes into, so a volume whose .vif or generation-0 .ecsum sits on another of the server's disks resolved to the default 10+4 with the legacy block layout — the Rust half of the geometry-guessing the Go rebuild just stopped doing. It then reconstructs a custom-ratio or uniform volume through the wrong Reed-Solomon matrix and de-striping geometry. The rebuild now looks for the .vif in its own location and then each sibling, falls back to the generation-0 sidecar wherever that lives, and only defaults when neither exists anywhere. The encode-time .dat size the ecx rebuild needs is resolved the same way. * ec: resolve a rebuild's vif from every directory that may hold it RebuildEcFiles probed only <data-base>.vif. The caller knows the selected location's index directory and the sibling locations, but passed neither for metadata: additionalDirs carried shard directories only, and were searched for shards and the checksum sidecar. A split -dir/-dir.idx layout, or a disk holding only shards, therefore resolved a pre-sidecar custom-ratio volume to 10+4 and reconstructed through the wrong matrix — never regenerating shards 14-15. The caller now hands over the index and sibling directories, and the resolver probes the vif across all of them, matching what the Rust resolver already does for both the vif and the sidecar. * ec: make every rebuild consumer agree on the layout it resolved - The post-rebuild bitrot backfill re-derived the geometry from this directory's .vif alone and dropped the block size entirely, so a rebuild that resolved its layout from a sibling, the sidecar, or a uniform vif wrote a manifest describing a DIFFERENT layout — one later mounts reject, or that covers only the default shard count. The layout is resolved once now, through an exported ResolveRebuildECContext, and the rebuild and the backfill share that answer. - The Rust rebuild collected only each location's data directory, so a sibling's INDEX directory — where a split -dir/-dir.idx layout keeps .ecx/.ecj/.vif — was never probed, and a custom-ratio volume still resolved to 10+4 with the legacy layout. Both directories of every location are carried now, deduped against the rebuild's own. - A shard delivery can bring the checksum manifest with it, but the receive path only writes the file: a server that already had the volume mounted kept its resolved protection state (off) until a remount. The mount RPC re-resolves it once the shards it describes have been added. * ec: cover the rebuild's directory search with tests Reviewers flagged the sibling index directory twice, and the fix that closed it had no test of its own: the assembly sat inline in the rebuild handler, reachable only through a gRPC call against a populated store. Lifting it into rebuildSearchDirs / select_rebuild_location makes the rule assertable — a sibling contributes BOTH its data and its index directory, a shared index directory is listed once, and the rebuild's own data directory never repeats. Writing the Rust cases surfaced that the two implementations do not agree on where the rebuild's own index directory belongs, and both are right: Go's resolver takes a single directory list, so that directory has to be inside it, while Rust's takes the rebuild's data and index directories as their own arguments and would search them twice. The tests now state which contract each side is holding to, so neither drifts into the other's shape. Pure refactor otherwise; no behaviour change. * ec: search the index directory for the layout sidecar The Rust resolver looked for the generation-0 .ecsum in the rebuild's data directory and the sibling list, but not in the rebuild's own index directory — while the .vif lookup directly above it did, and Go's findBitrotSidecar has always checked both bases. On a split -dir/-dir.idx location that directory is where the metadata lives, and callers leave it out of the sibling list precisely because it is passed here separately, so nothing searched it. With no .vif anywhere the sidecar is the only surviving record of the layout. Missing it resolved a 12+4 uniform volume to 10+4 with the legacy striping — the test added here fails with (10, 4, 0) against the old code — and the rebuild then reconstructs through the wrong matrix and writes .ecx offsets that no reader can follow. * ec: let the rebuild see its own index directory The Rust rebuild takes a single flat directory list — the shape Go's RebuildEcFiles uses — so it cannot be handed the rebuild location's index directory separately the way the layout resolvers are, and the handler was passing the sibling list, which deliberately omits exactly that directory. On a split -dir/-dir.idx location that is where .ecx and .vif live, so the shard and index lookups could not see them. Go has always carried that directory in additionalDirs; this lines the two call sites up. * ec: let a config-free vif fall through to the layout sidecar A .vif that carries no ecShardConfig answers nothing about the layout, so it is no more informative than an absent one — but both trees treated its mere existence as the end of the search. Go went straight to the 10+4 legacy defaults without consulting the sidecar at all; Rust returned whatever ec_shard_config_from could make of a single directory. A 12+4 uniform volume with a legacy config-free vif therefore resolved as 10+4 legacy, and every read landed at the wrong shard offset. The sidecar lookup was also single-directory on both sides, while a split -dir/-dir.idx layout keeps .vif and .ecsum with the INDEX. Go's findBitrotSidecar has always taken both bases; the callers here passed only the data base, and the Rust bitrot resolver derived its path from the data base alone. Rust's layout resolver now takes a candidate directory list — data, index, then any siblings — and searches all of it, which also removes the early return that made the vif's presence decisive. load_vif_info_across_dirs reported `dir` even when load_vif_info had found the vif in `dir_idx`. Nothing reads that field today, so this changes no behaviour; it stops the next caller that resolves the rest of the volume's metadata against the answer from being sent to a disk holding none of it. Absence stays legal throughout: a volume with neither record is genuinely legacy. Present-but-unusable still fails the mount, now in the config-free-vif branch too. * ec: activate a delivered sidecar on every per-disk runtime A vid mounts as one EcVolume per disk, each with its own resolved protection state, but the post-delivery reload used the first-match lookup and so touched exactly one of them. The siblings kept reporting no protection until a remount — and since shard distribution deduplicates the metadata files onto the first target disk for a node, the runtime that got the .ecsum is not necessarily the one the lookup returns. Iterate every runtime instead, via a new FindAllEcVolumes and its Rust mut equivalent. Combined with each runtime now resolving its sidecar against its index directory as well as its data directory, a server sharing one -dir.idx across its disks activates all of them from the single delivered copy. The Rust volume server had no post-mount reload at all; it gets one here, matching Go. * ec: resolve the delivered sidecar across every EC metadata directory Reloading every per-disk runtime, added last round, did not by itself make the delivered manifest reachable. Startup mirroring copies .ecx/.ecj/.vif to every shard-bearing disk so each mounts self-contained, but deliberately not .ecsum, and a repair delivers exactly one copy. Each runtime was resolving against its own two directories, so every sibling of the disk that received the file kept reporting no protection however often it reloaded. Resolve one authoritative copy across every EC metadata directory instead of duplicating the file. Mirroring .ecsum would have to keep pace with a file that is rewritten as shards are repaired, and would not help the reported case at all: the delivery happens at runtime, and mirroring only runs at startup. The regression test pins both halves — a reload restricted to the volume's own directories still finds nothing, and the same reload given the server's metadata directories turns protection on. * ec: ask every directory before writing a TOFU baseline After a rebuild the opportunistic backfill asks whether this volume already has a checksum manifest, and answered from the data base alone. A split -dir/-dir.idx layout keeps the sidecar with the index, and a multi-disk server may keep it on a sibling, so an existing manifest read as absent. The consequence is worse than a missed read. On a false "no" the backfill writes a fresh sidecar at the data base from whatever the shards say right now — and the data base is the first candidate every resolver checks, so that TOFU baseline shadows the real manifest rather than sitting beside it. A shard that was silently corrupt gets blessed, and the record that would have caught it stops being consulted. FindBitrotSidecar exports the search the package already used internally, so the question is asked of the data base, the index base and the sibling disks — the same candidates the rebuild resolves its layout from. * ec: refuse a shard block size no encoder could have produced weed fix -ecx derived one from the raw shard extent, so a truncated or partially copied shard wrote a .vif that NewEcVolume then permanently refuses — the volume the tool was run to rescue could never mount again. An extent that is not a whole number of small blocks cannot have come from a uniform encode, so it is no longer offered as a candidate, and nothing unvalidated reaches the .vif. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 * ec: derive the .vif's dat size and block size from one measurement VolumeEcShardsGenerate stat'ed the .dat before the encode while WriteEcFiles stat'ed it again to size the blocks. A write landing between the two produced a .vif whose own two fields describe different files. WriteEcFiles now leaves both on the context, and fills a placeholder context in place so the caller can read them back. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 * ec: keep the source volume until every holder serves its shard layout The uniform layout rides in a .vif field older volume servers never knew: they discard it, mount the shards as legacy and return wrong bytes with nothing erroring, and the shard files are the same length either way so no other check notices. The upgrade order lived only in the release note. VolumeEcShardsInfo now reports the block size the holder actually serves, in both the Go and Rust servers, and the pre-delete verification refuses to drop the source unless every reachable holder echoes the one the shards were encoded with — while a rollback still exists. A server that predates the field answers 0, which is the negative answer. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 * ec: drop the rebuild's dead block-size parameters generateMissingEcFiles never reads largeBlockSize/smallBlockSize — Reed-Solomon reconstruction is layout-agnostic — so passing the legacy constants only advertised a layout the rebuild does not use. Also move UniformBlockSize's doc off ValidateBlockSize. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 * ec: warn about EC defaults only when the mount used them The "vif file not found, using defaults" warning fired even after the bitrot sidecar supplied a non-default layout, sending anyone triaging wrong bytes after the legacy layout the volume never mounted on. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 * ec: stat the distributed bitrot sidecar once The strict check re-stat'ed the file immediately before the stat that already gates inclusion, and a failed sidecar write now fails the encode outright, so the first could only fire on a deletion between the two lines. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 * ec: say what the reconstruct budget actually bounds A shard's buffer stays in bufs until its interval reconstructs, which is after the read that filled it released its permit, so the semaphore bounds round trips in flight and not retained bytes. Peak memory is the intervals reconstructing at once times the shards each reaches times the interval size. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 * test: let the fake volume server report its delivered EC layout The pre-delete verification now asks each holder which shard block layout it serves, and a fake that always answered "unset" looked exactly like a volume server too old to know the field. Distribution ships the .vif to every holder alongside its shards, so read the layout back out of it as a real holder does. Claude-Session: https://claude.ai/code/session_011FRRoNKBiGbH58rs2AQyA7 |
||
|
|
3967ca23be | rust: cover the READS scrub reconstruction path (#11027) | ||
|
|
fcc2ea61d3 |
ec: scrub a volume through its parity data (#11006)
* Introduce a new `READS` scrub mode. `READS` performs a full volume scrub but, unlike `FULL`, it will attempt to reconstruct data for missing/damaged shard intervals from other shards in the cluster when necessary. The goal of this check is to ensure that EC volume contents _are readable by Seaweed_ even on a degraded storage state, by exercising parity data which is not read in `FULL` mode. This is useful not only to validate data is user-readable, but also to detect potential parity shard issues which may be difficult to pinpoint otherwise - particularly for older volumes lacking sidecar data, and hence unaffected by `CHECKSUM` scrubs. For regular volumes, this operation is equivalent to `FULL`. Example: ``` > ec.shard.unmount --volumeId=1 --shardId=0,3,11 --delete --apply Live shard topology for volume ID 1 (14 shards): 0@10.200.18.89:9001 1@10.200.18.89:9002 2@10.200.18.89:9003 3@10.200.18.89:9004 4@10.200.18.89:9005 5@10.200.18.89:9006 6@10.200.18.89:9007 7@10.200.18.89:9008 8@10.200.18.89:9009 9@10.200.18.89:9013 10@10.200.18.89:9010 11@10.200.18.89:9011 12@10.200.18.89:9012 13@10.200.18.89:9020 Will unmount + delete 3 shard(s): 0@10.200.18.89:9001 3@10.200.18.89:9004 11@10.200.18.89:9011 Unmounting shard 0@10.200.18.89:9001 for volume ID 1... Deleting shard 0@10.200.18.89:9001 for volume ID 1... Unmounting shard 3@10.200.18.89:9004 for volume ID 1... Deleting shard 3@10.200.18.89:9004 for volume ID 1... Unmounting shard 11@10.200.18.89:9011 for volume ID 1... Deleting shard 11@10.200.18.89:9011 for volume ID 1... All done! > ec.scrub --volumeId=1 --node=10.200.18.89:9002 --mode=full using FULL mode Scrubbing 10.200.18.89:9002 (1/1)... Scrubbed 6 EC files and 1 volumes on 1 nodes Got scrub failures on 1 EC volumes and 1 EC shards :( Affected volumes: 10.200.18.89:9002:1 Affected shards: 10.200.18.89:9002:1:0 > ec.scrub --volumeId=1 --node=10.200.18.89:9002 --mode=reads using READS mode Scrubbing 10.200.18.89:9002 (1/1)... Scrubbed 6 EC files and 1 volumes on 1 nodes ``` * ec: report the shards a READS scrub had to rebuild A READS scrub that recovers an interval was recording nothing, so a volume missing three shards came back clean and nobody repaired it. The unreadable shard is now recorded before the rebuild is attempted: READS reports the same broken shards as FULL and differs only in whether the needles themselves failed, which is the signal worth having - shards are gone, data is still there. forceDeletedNeedlesCheck now applies to READS as well, in the shell and in the RPC guard: it runs the same needle walk as FULL. Regenerated the proto instead of hand-editing it, so the pancis typo (which protoc-gen-go-grpc emits into eight other files here) and the header whitespace stay as generated. Mirrors into the Rust volume server, which also now honors force_deleted_needles_check rather than hardcoding it off. Claude-Session: https://claude.ai/code/session_014yMNebkUjSbx9sfUCWJJtq * ec: answer a deleted needle from a READS rebuild as deleted #11020 gave the Rust recovery a deleted flag alongside its bytes, and it answers a deleted needle with no bytes at all. The READS scrub appended that empty answer, which does not compile against the new signature and, once it did, would leave the needle short and report the size mismatch as damage. Zero-fill the interval instead, the way the direct read beside it already does: the assembled needle then reaches read_bytes as the delete-state mismatch the walk already tolerates. Go takes the same branch off the flag its recovery returns, rather than discarding it. Claude-Session: https://claude.ai/code/session_014yMNebkUjSbx9sfUCWJJtq --------- Co-authored-by: Lisandro Pin <lisandro.pin@proton.ch> |
||
|
|
ba5b14b457 |
master, filer, s3api: bound the collection deletes that strand a caller (#11026)
* master: bound each volume server DeleteCollection, and finish the fan-out A collection delete fanned out to every volume server holding it with context.Background(), so a server that accepted the connection and then went quiet held the whole delete open with nothing to end it. Each RPC is bounded now, on the same budget allocateVolumeTimeout gives the other master-to-volume-server admin RPC. The volume server runs the delete to completion regardless of the request context, so giving up costs the confirmation and not the deletion. The walk itself is the caller's, not a per-server one: - It outlives the caller. A cancelled request must not abandon a destructive fan-out part-done, with volumes left behind and no request still running to come back for them. - It no longer stops at the first server that refuses, which left the collection on every server after it in the list. The first failure is still what is reported, and the collection stays in the topology so a later delete comes back for the rest. - It sends one RPC per server rather than one per replica. ListVolumeServers reports a node once for every replica it holds, while DeleteCollection removes the whole collection from the server it reaches, so a collection with thousands of volumes repeated the same whole-collection delete thousands of times over. Both passes run too. Returning after a failed normal pass left the collection's EC shards in place with nothing left to retry them. Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP * master: delete the EC shards behind /col/delete too The HTTP handler carried its own copy of the volume-server walk and only ever ran the normal pass, so a collection deleted through it kept its EC shards. It shares the gRPC path now, which also gets it the bounded RPCs and the one-per-server fan-out. Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP * filer: bound the collection delete a bucket delete leaves behind Deleting a bucket entry deletes its collection afterwards, deliberately detached from the request so a client that hangs up cannot strand the bucket's volumes. Detached meant unbounded, though: with the master down or mid-election the wait for a leader has nothing to end it, so the handler parks, and the client retrying behind it parks another. It keeps outliving the request and now carries a deadline of its own. The budget bounds the wait, not the work: the master keeps deleting on its own fan-out once asked, so giving up costs the confirmation. Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP * s3api: bound the collection RPCs a bucket creation and deletion issue Neither carried a deadline, so a transient failure anywhere down the chain held the S3 request open until the client gave up on it. Both budgets are taken outside the filer failover walk, so one budget covers the whole walk rather than granting each filer a fresh one. The walk itself stops when that budget is spent, and stops without blaming anyone: the caller's own expiry is not evidence against the filer that was answering, and the next filer has no time left to answer in either. Recorded as a filer failure, a slow master upstream would flag every filer in the walk, and the three failures that open the circuit take unrelated object reads down with them. Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP * s3api: a failed collection listing no longer fails a bucket creation PutBucket lists collections to notice a leftover one it is about to reuse. The result feeds a warning and nothing else -- s3a.exists is what decides whether the bucket already exists -- yet a transient failure of that listing returned 500 and refused the creation. It is advisory now, so a failure is logged and the creation continues, exactly as it does when the listing returns false. Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP |
||
|
|
7dc3835b02 |
s3: an abort answered mid-part no longer leaves the upload completable (#11025)
* s3: reject a part whose upload was aborted while its body was in flight The upload-exists check runs before the part body is read. An abort answered during the read deletes the upload directory, and the part write that follows re-creates it, so the aborted upload is listed nowhere yet completes. Re-check after the write: only createMultipartUpload stamps the destination key on .uploads/<id>, so a directory without it is one the part write resurrected. Drop it along with the part and answer NoSuchUpload. Claude-Session: https://claude.ai/code/session_01ByZ49KQdUtHhmjG6SpNczT * s3: reject a copied part whose upload was aborted mid-copy UploadPartCopy has the same window as UploadPart: the upload-exists check runs before the bytes are copied, and the part write that follows re-creates the directory an abort removed. Both the re-encryption and the raw-copy path re-check before answering. Claude-Session: https://claude.ai/code/session_01ByZ49KQdUtHhmjG6SpNczT * s3: do not complete an upload whose directory holds no upload record A .uploads/<id> directory that a part write created rather than createMultipartUpload carries no destination key, no owner and no encryption settings. Completing one turned stray parts into an object; answer NoSuchUpload instead. Claude-Session: https://claude.ai/code/session_01ByZ49KQdUtHhmjG6SpNczT * s3: log the part left behind when the resurrected directory survives abortMultipartUpload can fail to remove what the part write re-created. The client still hears NoSuchUpload, since the upload is gone either way and a retry would only write another part, but the leftover is worth a line. Claude-Session: https://claude.ai/code/session_01ByZ49KQdUtHhmjG6SpNczT |
||
|
|
c858e01a09 |
ec: split the shard-interval recovery into a gather and a rebuild (#11005)
* ec: split the shard-interval recovery into a gather and a rebuild Recovering an interval is now one function doing the local seeding, the waved peer fetch, the shard accounting and the Reed-Solomon rebuild, under a memory budget. Splitting the gather from the rebuild makes the rebuild a plain function over a set of intervals, which is testable on its own and reusable by the parity checks a full scrub wants. The rebuild refuses a parity target, and the caller checks that before the gather so a doomed target costs no fan-out. ReconstructData rebuilds data shards only, so asking it for a parity shard returned no error and left the slot nil, and the caller copied that out as a successful read of zeroes. Only data shard ids reach here today, so this is a guard, not a live fix. Claude-Session: https://claude.ai/code/session_014yMNebkUjSbx9sfUCWJJtq * ec: rebuild only the EC shard the read asked for ReconstructData rebuilds every missing data shard. The gather stops as soon as DataShards intervals are in hand, so on a distributed volume it routinely finishes holding parity where data is missing -- and each of those data shards is then rebuilt into an interval-sized buffer, decoded, and never read. Ask for the one shard the read needs. The budget covers it now too: DataShards gathered plus the one the rebuild allocates. It never covered the rebuild's output, and with ReconstructData that output was up to ParityShards buffers. The required mask is Total() long rather than DataShards. reedsolomon documents both lengths, but its presence scan walks every shard and indexes the short mask past its end, so the documented short form panics whenever a parity shard is absent - which here it usually is. Claude-Session: https://claude.ai/code/session_014yMNebkUjSbx9sfUCWJJtq |
||
|
|
cd5013f116 |
Re-check an EC shard map a failed read has disproved (#11023)
* Re-check an EC shard map a failed read has disproved A read that fails against a cached location drops that shard from the map, which leaves it one short of complete -- and a map one short is trusted for seven more minutes. So a moment's trouble between volume servers cost minutes in which every read of that shard skipped the direct fetch and paid for a Reed-Solomon recovery instead, at DataShards times the memory and the peer load. Mark the map when a read disproves it, and re-check a marked map on the same eleven-second footing as one that never had enough shards to begin with. The mark clears on refresh, so it buys one prompt re-check rather than a master lookup per read. The tiers move into a helper; they were three overlapping conditions in one expression, and the reading of them was not obvious. Rust keeps the entry rather than dropping it -- a dead peer fails fast on the next attempt, and it was the freshness window, not the entry, hiding a shard that had moved. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Invalidate the location of an EC shard whose own read failed Recovery fans out to the other shards, so the one whose direct read just failed is the only location nothing ever invalidates: a shard that moved to another server was reconstructed on every read until the map's own window expired, up to thirty-seven minutes for a map still complete. Mark the map there too. The entry stays -- a moved shard's old holder fails fast, and the next refresh is seconds away. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Consume the stale mark before the lookup, not after A read that fails while the master is answering has disproved the very map that answer is about to install, and clearing the mark on the refresh's return swallowed it. Clear it where it is acted on instead. A lookup that then fails loses the mark, which costs nothing: the refresh time is only advanced on success, so the next read looks up regardless. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Judge the shard map and consume its mark in one critical section Reading the mark and clearing it were two separate acquisitions, so a mark raised between them was cleared by a refresh that had not seen it. In Go that gap was a few instructions; in Rust the mark was read when the read first snapshotted the volume and cleared at the decision point, with the local interval reads in between. Take both under one hold. Rust needs a mutex rather than an atomic to do it, and no longer carries the mark through the snapshot. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Put the stale mark back when the lookup does not answer for it Consuming the mark up front assumed the lookup would supersede it. A lookup that fails, or comes back with fewer than DataShards holders, supersedes nothing: the map is unchanged, its refresh time unadvanced, and with the mark gone the map a read had disproved is trusted for its full window again on the strength of a lookup that never landed. Put the mark back on both branches. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN |
||
|
|
624deaf3a4 |
mount: implement fallocate (#11021)
* mount: implement fallocate instead of reporting it unsupported Fallocate answered ENOSYS, so the kernel marked the mount as having no fallocate and returned EOPNOTSUPP. glibc then fell back to its emulation, which preads a byte from every block already inside the file to see if it is allocated; on a write-only descriptor that pread is EBADF, and posix_fallocate returned it. Volume space is assigned when a write is flushed, so nothing can be reserved up front: a range inside the file is answered OK untouched, and one past the end grows the file the way a truncate would. A mode we cannot honor is refused with ENOTSUP, not ENOSYS, so the kernel keeps sending the ones we do. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 * mount: let a fallocate that allocates nothing past the quota and worm guards A range already inside the file, and any FALLOC_FL_KEEP_SIZE request, reserve no space and rewrite no entry, but the preflight refused them with ENOSPC on a full mount and EPERM on a worm-enforced file. Decide the no-op first and guard only the growth. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 * mount: charge a fallocate growth to the uncommitted byte counter Write charges the counter by how much the file grew, so the writes that fill a range fallocate already extended charge nothing and the real-time quota check never sees that data — only the periodic filer refresh does. Count the growth where it happens. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 * mount: charge a truncate-up growth to the uncommitted byte counter Same gap Fallocate had: Write charges the counter by how much the file grew, so the writes that fill a range ftruncate already extended charge nothing and the real-time quota check never sees that data. Count the growth where it happens; a shrink still leaves the counter alone, since it is only ever raised and then reset by the periodic filer refresh. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 |
||
|
|
7bb0a1c127 |
s3: replay a delete whose reply the transport dropped (#11022)
* s3: stop retrying a delete the filer refused for a non-empty folder The filer looked and the children are there, so the answer will not change. retryFilerOp spent six attempts and up to 3.1s of backoff on it before the caller could act on the condition it was already holding. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: thread the request context through the unversioned delete path doDeleteEntry issued every DeleteEntry on context.Background(), so an S3 client that hung up left the gateway working on its behalf, out of reach of both cancellation and the per-request retry allowance that DeleteMultipleObjectsHandler installs. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: treat a cancelled filer RPC as terminal, not transient isRetryableFilerErr matched context.Canceled and DeadlineExceeded by sentinel, which only holds while the error is still local. Once it has crossed gRPC it is a status, so an abandoned request was retried six times on behalf of a caller that had already gone. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: replay a delete whose reply the transport dropped A delete is idempotent at the filer, which answers an entry that is already gone with an empty resp.Error, so a reply lost in transit can be reissued rather than surfaced. Surfaced, it becomes a 500 on the bucket delete, which boto3 resends and is then answered NoSuchBucket, or a per-key InternalError inside the 200 of a multi-object delete, which no SDK retries at all. The replay runs through retryFilerOp, so it draws on the allowance the request already installs rather than paying a backoff per key, and stops for a caller that has gone. rm and rmObject re-enter WithFilerClient per attempt, so each one walks the failover list again on a connection the failed attempt had invalidated; the multi-object loop holds one client for the batch, so there the replay reuses it. Classification stays structural. The filer reports its own refusals in resp.Error, which carries no status and has the deleted path - and, for a recursive delete, the children it stopped on - formatted into it, so no key name can steer the decision either way. rm and rmObject now take the caller's context. Cleanup and rollback paths pass context.Background() deliberately: they have to run whether or not the caller is still waiting. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD * s3: share one retry allowance across multipart completion cleanup The unused-entry loop deletes once per entry, and each delete now retries, so a filer that stays unavailable held the response for 3.1s per entry after the object was already committed. Claude-Session: https://claude.ai/code/session_01XqaJrwgXQ5GSUpyzRbe5nD |
||
|
|
af6f69740c |
Read metadata log chunks the way the mount reads every other chunk (#11018)
* Replay metadata log chunks the way the mount reads every other chunk The subscription's log-chunk replay built its own lookup, which always resolves volume server addresses. A mount started with -volumeServerAccess=filerProxy cannot reach those, so every fresh subscription failed on the previous minute's persisted segment and resubscribed a second later, forever. Take the lookup from the caller instead; the mount hands over the one it uses for file reads, which also keeps publicUrl and the bounded location cache in play. Claude-Session: https://claude.ai/code/session_01NGqrxYj7cHUpSrL249n3Z6 * Keep a log chunk read failure off the filer connection A metadata subscriber reads persisted log chunks over HTTP from volume servers and hands whatever went wrong back as the subscription's error. "connection refused" from a volume server then matched the transport patterns that decide a gRPC channel is dead, so every failed replay closed the shared filer ClientConn and cancelled the assign and upload RPCs riding on it with "the client connection is closing". Mark those read failures so they are judged for what they are. Claude-Session: https://claude.ai/code/session_01NGqrxYj7cHUpSrL249n3Z6 |
||
|
|
95248f7492 |
Bound the memory an EC shard recovery holds (#11020)
* Reconstruct an EC shard from the shards already on this server recoverOneRemoteEcShardInterval only ever fanned out to the cached shard locations, so a server holding shards of the volume still fetched them over gRPC from itself -- and when the peers were unreachable it could not reconstruct at all, even holding the whole volume on local disk. Seed the Reed-Solomon buffers from the locally mounted shards first; each one is a peer round trip, and an interval-sized buffer, the fan-out no longer needs. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Fetch only the EC shards reconstruction still needs The recovery fan-out read every surviving shard, so a 10+4 volume pulled 13 interval-sized buffers to feed Reed-Solomon 10 -- a third more memory held, and a third more load asked of peers that were, by definition, already having trouble. Fetch what is missing, and widen only when some of those reads fail. A shard reporting the needle deleted ends the walk: the rest would only answer the same. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Bound the bytes EC recovery holds in flight Recovery is the one read path that multiplies the served bytes: it holds an interval-sized buffer per shard until Reed-Solomon runs, and a peer that is slow to fail keeps them all alive for the whole gRPC timeout. Nothing bounded how many of those fan-outs ran at once, so a transient problem between volume servers turned every read into a DataShards-fold allocation and the server died of it -- 64 concurrent 4MB intervals pin 3.6GB, and that is a small burst. Charge each recovery against a process-wide budget, so a burst queues on the semaphore instead of on the heap. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Answer a deleted EC needle as deleted, not as a failed recovery A holder reporting the needle deleted is authoritative: deletes are never invented and never undone. Recovery already collected that flag, then dropped it on the branch where too few shards came back -- so a read of a deleted needle that had to recover surfaced as "cannot recover shard", and the volume server answered 500 where it owed a 404. Carry the flag out of the shortfall, and let it decide ahead of the error it came with. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Check the encode run of a locally seeded EC shard in Rust The Rust recovery seeded Reed-Solomon straight from the mounted shards, without the encode-run check the remote reads and Go's readLocalEcShardInterval both apply. A volume remounted from a newer encode between the read's snapshot and its recovery would have fed mixed-generation bytes into the reconstruction. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Say what the recovery budget actually guarantees Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN * Seed Rust EC recovery from shards on every local disk find_ec_volume returns the first disk's EcVolume, so a reconciled volume whose shards are split across data dirs had the siblings ignored and could report "cannot recover" while holding enough shards locally. Resolve each shard together with the disk that owns it, the way Go's recovery already does, and check that owner's encode run. Claude-Session: https://claude.ai/code/session_01SM5ARdPvFcnvGWNpBPgNRN |
||
|
|
23241cf0f1 |
Let filer.sync move past a chunk the source cluster no longer has (#11019)
* Name the failure when the source cluster cannot locate a chunk's volume LookupFileId formatted a nil err into the message it returned, so the only thing a caller could do with "no locations for this volume" was match on the text. Return a typed error instead. Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK * Fail a source chunk read on a failure status instead of copying the error page ReadPart never looked at the response status, so a volume server answering 404 for a needle vacuum had removed came back as a successful read whose body was the error page. The caller counted those bytes as file content and reported a size mismatch — a corruption claim about data the source had simply lost — and a 404 from one replica ended the search instead of trying the next. Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK * Stop retrying a chunk the source cluster can no longer produce A chunk whose volume vacuum has removed fails the same way on every attempt, but the retry loop had no way to say so and kept going forever. The sync job holding it never finished, so it pinned the offset watermark at the event ahead of it and filer.sync never checkpointed again — alive, quiet, and permanently behind. Wait the source out for a grace period long enough to cover a volume server restart or a master failover, then give up and mark the failure permanent. Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK * Let replication continue past an entry whose source data is gone An entry the source can no longer read holds the sync offset forever: the event fails on every replay, so the checkpoint never moves past it and every later event stays uncheckpointed, however long the sync keeps running. Nothing brings those bytes back, so skip the entry with an error naming it and carry on. Skip only while the source is demonstrably still serving other chunks. A volume with no locations reads the same whether it was vacuumed away or every replica is down, and during a cluster-wide outage that answer comes back for every chunk — skipping then would drop live files wholesale. Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK * Propagate a missing source chunk instead of waiting when supersession is unverifiable An incremental sink's dated target keys cannot be mapped back to a source path, so nothing here can tell a chunk the source lost from one a later version already replaced. Waiting out the grace period would stall every vacuumed needle for half an hour; hand the failure to the caller, which has the event's real source key. Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK * Wait out a gone volume once, not once per file it held A volume vacuum removed took every file it held with it, and each chunk was timing its own grace period. With a bounded chunk executor those waits serialize, so one gone volume holding many files stalls the sync for far longer than the grace period — the wedge again, only slower. Track the wait per source volume on the sink instead: the first chunk to find it unlocatable starts the clock, every later chunk inherits it and gives up as soon as it has run out, and a chunk the source does serve clears it. Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK * Probe the source with a read, not a lookup, before writing an entry off A lookup only proves the source master still has the topology. If every volume server is unreachable while the master still lists them, the probe passed and the sink wrote off an entry whose data was merely out of reach. Read the probe chunk instead, and say in the log that the entry stays unreplicated. Claude-Session: https://claude.ai/code/session_01SRPEP4jRu29FbLSN6bjLaK |
||
|
|
60893c5ef3 |
Classify a filer error before a user-controlled path is wrapped into it (#11004)
* util, pb: classify a filer error by the status the server sent DoSeaweedListWithSnapshot wrapped a failed ListEntries with %v, dropping the gRPC status, so IsTransientError fell back to matching substrings against a message that now held the caller's path. Keep the status with %w and let it decide, reading the server's own text rather than the wrapper's. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * s3: keep the bucket and prefix out of the list retry decision A bucket named transport, or a prefix under logs/unavailable/, made a PermissionDenied listing look transient and got it retried; a key holding the not-found sentence suppressed a retry that should have run. Both checks now read the filer's status, and only fall back to the text when there is none. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * filer, s3: classify a delete failure before the path is wrapped into it The filer put the non-empty-folder marker behind its own "delete directory %s" wrapper and the gateway matched it as a substring, so a key named after the marker turned a real delete failure into the demote-the-marker no-op and the request answered 204. Keep the marker leading the message that crosses the wire, turn it back into a sentinel where the response is read, and match that. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU |
||
|
|
9e06e1d0f9 |
Report a delete the filer rejected instead of answering success (#11003)
* s3tables: report a delete the filer rejected deleteDirectory discarded DeleteEntryResponse and checked only the transport error, so DeleteTable, DeleteNamespace, DeleteView and DeleteTableBucket answered 200 for a delete the filer refused. Call filer_pb.DoRemove, which reads resp.Error and still treats a missing entry as success. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * admin: report a delete the filer rejected The bucket delete, the file browser handlers and the topic retention purger all discarded DeleteEntryResponse, so a delete the filer refused came back as success. Call filer_pb.DoRemove, which reads resp.Error. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * credential: report a delete the filer rejected DeleteUser, DeletePolicy and the full-sync cleanup loops discarded DeleteEntryResponse, so a rejected delete answered success and left the credential file in place. The service account path in the same store already read resp.Error; the rest now do too, via filer_pb.DoRemove where not-found is already tolerated. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * shell: report a delete the filer rejected remote.configure -delete, remote.cache and the remote metadata sync discarded DeleteEntryResponse, so a rejected delete printed as removed. Call filer_pb.DoRemove, which reads resp.Error. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * mq: report a delete the filer rejected The consumer offset group purge and the coordinator assignment delete discarded DeleteEntryResponse. Call filer_pb.DoRemove, which reads resp.Error. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * iam: count only the revocation entries the filer actually deleted The expiry sweep discarded DeleteEntryResponse, so a rejected delete was counted as purged and the entry stayed. Call filer_pb.DoRemove, which reads resp.Error, matching the role and provider stores beside it. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * mount: fail rmdir when the unary fallback delete was rejected The streaming branch turns DeleteEntryResponse.Error into an error, the unary fallback dropped it, so rmdir of a non-empty directory answered OK off the stream and ENOTEMPTY on it. Surface it in both. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * s3tables: fail DeleteTableBucket when the directory delete is refused The handler only failed when both the leaf entry and the directory delete failed, so a refused bucket directory delete still answered 200 with the bucket in place. The directory is the bucket, so it decides; the leaf entry stays best-effort. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU |
||
|
|
742b2f5896 |
s3: share one retry allowance across a batch delete (#11001)
Every key in a multi-object delete drives its own retryFilerOp, so a filer that is briefly unhealthy multiplied one op's ~3.1s of backoff by a key count the client picks. The batch now carries a single allowance in its context, sized to one op's worst case; once it is spent the remaining keys fail fast with a per-key error instead of holding the request goroutine. A single-object delete carries no allowance and keeps its full retries. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU |
||
|
|
902a12fd6f |
wdclient: bound the wait for a master leader by the caller's context (#11002)
* wdclient: bound the wait for a master leader by the caller's context WithClient waited on GetMaster with context.Background(), so a caller that arrived while no master leader was known parked in a 200ms poll loop until one appeared, whatever deadline it had already set on the RPC. Each retry above it then left another goroutine in the same wait. Take the context in WithClient and WithClientCustomGetMaster and hand it to GetMaster, and stop the retry loop once it is done. The dial keeps context.Background(): fn brings its own RPC context, so a cancellation seen here cannot be attributed to the shared connection. Call sites pass whatever they hold: the request context in the filer's CollectionList, DeleteCollection and Statistics handlers and in the credential store's propagation, the operation context in the shell's s3.bucket.delete and the kafka gateway's broker and filer discovery, and context.Background() where there is none - the shell commands, the admin dashboard wrapper, and the exclusive locker's initial lease. The locker's release keeps its own uncancelled context so a slow unlock cannot turn into a ghost lock. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * wdclient: test that WithClient gives up with the caller's context Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * wdclient: cut the master retry backoff short when the caller gives up util.Retry sleeps unconditionally between attempts, so a transient error arriving just before the caller's deadline still cost it a full backoff step. Use the context-aware util.RetryWithBackoff, the same helper the volume lookup in this file already uses. Two call sites went with it: the shell's lock-holder lookup builds its three second bound before WithClient so it also covers finding the leader, as its comment already promised, and the filer's post-delete collection cleanup goes back to an uncancelled context - the entry is already gone, so a caller that hung up must not leave the collection behind. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU * wdclient: test that a cancel during backoff ends the retry Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU |
||
|
|
d850f36513 |
s3: distinguish a failed bucket lookup from a missing bucket on HEAD (#11000)
HeadBucket treated any lookup error as ErrNoSuchBucket, so a transient filer failure answered 404 instead of 500 and clients stopped retrying. Split the two cases the way the bucket policy handlers already do. Claude-Session: https://claude.ai/code/session_01BjDWtZsCoZY6x4pdDmGWxU |