TestSubscribeLoop_FlushProvenGapSkipsToRetained deleted the log files
once the eviction watermark's own flush had landed, while the windows
sealed after it were still queued. Those flushes then wrote their files
back, and the subscriber served them from disk instead of taking the
gap-skip path, so the windows whose files really were gone came out
missing. Wait through the last sealed window instead.
* mount: drop the unused go-fuse fs package dependency
WFS embedded fs.Inode but never used any of its methods, and the only
other reference was RENAME_EXCHANGE, a constant sitting next to three
literals. Removing both drops fs and five internal packages from the
mount build graph.
* mount: build the package on windows
Windows has no fcntl lock types, no O_ACCMODE and no x/sys/unix, so a
handful of constants kept weed/mount pinned to unix even though the code
using them is portable in-memory logic. Route them through per-OS shims
and give setBlksize a windows no-op.
The POSIX lock table now compiles on windows but stays unreachable:
WinFsp resolves byte-range locks in its own kernel driver, so nothing
will feed it there.
go.mod points at a go-fuse branch commit and needs repinning to a release
tag once that lands.
* ci: cross-compile for windows
Nothing caught the unix-only constants creeping into weed/mount until a
release build failed.
* mount: let readdir feed a sink instead of the kernel buffer
doReadDirectory wrote directly into fuse.DirEntryList, which is the
kernel's wire format. A front end that is not the kernel would have to
pack entries only to parse them straight back out.
Route it through DirEntrySink instead. ReadDir and ReadDirPlus pass the
reply buffer, so nothing changes for the FUSE server.
* mount: pin go-fuse v2.9.4 for the windows build
WFS embedded fs.Inode but never used any of its methods, and the only
other reference was RENAME_EXCHANGE, a constant sitting next to three
literals. Removing both drops fs and five internal packages from the
mount build graph.
* s3 remote: honor s3.support_tagging in UpdateFileMetadata
The write path already skips tagging when the remote is configured
without tagging support, but the metadata-update path sent
PutObjectTagging or DeleteObjectTagging unconditionally. On remotes
that reject tagging requests, any metadata update failed -- including
updates with no tags at all, which land on the DeleteObjectTagging
branch.
* s3 remote: drop the never-read supportTagging field
Every maker set it, nothing read it: the tagging decision is made from
conf.S3SupportTagging. Keeping a field that looks like the switch but
is not invites exactly the inconsistency the previous commit fixed.
* remote storage: actually delete objects when a directory is removed
On object-store backends RemoveDirectory returned nil without doing
anything, so a directory delete synced to the remote as a successful
no-op and the objects under that prefix stayed there forever. Nothing
surfaced the divergence: the sync logged rmdir, advanced its offset,
and the local namespace looked clean.
Deleting a bucket-level directory on a filer store that can drop a
whole bucket emits no per-child delete events at all, so the single
rmdir event was the only chance to clean up the remote.
Each backend now lists the prefix and deletes what it finds: S3 in
DeleteObjects batches of one listing page, GCS and Azure per object.
The prefix always ends with a slash so a sibling like dir2 survives
deleting dir, and errors propagate so a failed delete is retried
instead of silently skipped. A directory that maps to the bucket root
is left alone: wiping every object in the bucket from one namespace
event is too destructive, and bucket removal already has its own path.
* gcs remote: wrap the per-object delete error
The listing error in the same function already wraps, so the delete
error should stay inspectable with errors.Is as well.
* s3 remote: name the empty-listing test for what it checks
The prefix in that test is a normal directory; what is empty is the
listing. The bucket-root guard has its own test.
* s3 remote: report the scope of a failed delete batch
A DeleteObjects response can carry per-key errors for up to a
thousand keys. Surfacing only the first hid how much of the batch
failed, and surfacing all of them would build an unbounded error
string, so report the count with the first failure as the sample.
Shutdown drops the sealed-chunk map references, but an in-flight
uploader goroutine holds the final reference and releases its budget
slot only after reacquiring chunksLock. Asserting Used()==0 immediately
after Shutdown races those releases on slow runners. Poll with a bounded
deadline instead.
A zero-data needle lands in .dat as a size-0 record, byte-identical to
a delete marker, so scans that walk .dat count it as deleted. Once in
1024 writes newRandomNeedle produced one, and the idx-head repair then
skipped a row TestRepairIdxHeadTombstones_ReadOnlyVolume expected back.
* s3: test the GetCallerIdentity handler
The handler had no test, only XML marshalling, so nothing pinned that a
caller presenting session credentials is reported as the assumed role
rather than the user who minted the session.
* s3: drive AssumeRoleWithWebIdentity over HTTP with a real OIDC token
Coverage reached the OIDC path either at the IAMManager service layer or
through the Authorization: Bearer shortcut. Nothing exercised the public
STS entry point an AWS SDK actually calls, which is where parameter
parsing, the IAMManager dispatch and the XML response shape live.
* s3: test that every STS route emits an audit entry
STS responses go out through WriteXMLResponse, which never calls PostLog,
so track() is the only thing that logs them. A route registered outside it
would mint credentials with no audit trail and nothing would notice. STS
has three routing layers, so a new action is easy to attach to the wrong
one.
* s3: make the STS tests assert what they claim to cover
The audit routing test ran against an uninitialized STS service, so every
case answered 503 and a non-404 status was the only evidence the request
had reached STS at all - the POST-body case could have been served by the
dispatcher's IAM branch and still passed. Back it with a real STS service
and assert the STS response namespace, which IAM and S3 responses do not
carry.
The session policy case checked that the policy travelled in the token
rather than that it restricted anything; assert the narrowed bucket is
allowed and another bucket is refused.
Give the forged-token case the same claim set a valid token gets, so it
cannot pass for want of a claim, and cover both rejection paths: a key we
do not publish, and a key id absent from the JWKS.
The crowded map keeps volumes that later became unwritable, so their
state survives transient writability flips without flapping. But volumes
packed to capacity (fs.mergeVolumes) or turned read-only stay above the
crowded threshold and get re-marked on every heartbeat, so the raw map
size can permanently exceed the writable count. ShouldGrowVolumes then
returns true forever, every assign-path grow request passes the gate,
and the periodic grow loop fires too, creating volumes without bound --
worse with -volumePreallocate.
Count crowded as the intersection with writables instead: growth checks
and the layout gauges only see crowded volumes that can still take
writes.
* s3: log the requester's principal ARN in the audit entry
An STS session authenticates as an opaque session subject, so requester
alone gave an operator no way back to the assumed role or the session
name. Record the principal ARN next to the identity name and emit it as
requester_arn.
* s3: record the caller identity in the STS handlers
AssumeRole, GetFederationToken and GetCallerIdentity verify the caller
themselves and are not wrapped by the auth middleware that records the
identity, so every audit entry for minting a session had an empty
requester.
* s3: resolve the audit principal ARN the way policy evaluation does
A JWT-authenticated identity carries no PrincipalArn — the auth layer
hands the principal over in a request header — so reading the field
directly left requester_arn empty for OIDC callers. buildPrincipalARN is
the resolver the policy path already uses: header first, then the
identity's own ARN, then a synthesized user ARN for legacy identities
that have none.
* s3: keep an admin's role session scoped to the role
AssumeRole copied the caller's admin standing into the minted session as
the is_admin claim, which short-circuits base policy evaluation. An admin
assuming a scoped-down role therefore kept full access and the role's
attached policies, explicit denies included, were never evaluated.
Only a session the caller assumed for itself carries the claim now — a
legacy static admin has no IAM policies for such a session to inherit.
* s3: name the caller when it assumes a session for itself
An identity that carries no principal ARN left the self-assumed session
with an empty role name in its assumed-role ARN. callerPrincipalArn
synthesizes the canonical user ARN for that case.
* test: cover listings spanning a run of retracted keys
A listing drops entries whose current version is a delete marker. When a run
of consecutive entries drops out, the page being filled can come back empty,
and an empty page is easily mistaken for the end of the listing — everything
after the run then never appears and the caller is told those objects do not
exist.
Backup repositories produce exactly this shape: a batch of keys under one
prefix is retracted while writing continues under the next.
Covers a retracted run before live keys and between live keys, walked with
page sizes smaller than the run so at least one page is filled entirely from
entries that get dropped, plus the version view of the same namespace where
every version and every delete marker must still be reported.
* test: sweep every page size in both walks and paginate the version listing
A listing on a versioned bucket is served from metadata cached on the
.versions directory entry so the whole listing is a single scan. The cache
carried size, mtime, ETag, owner and the delete-marker flag but not the
storage class, so newListEntry found none and fell back to STANDARD.
The result was that HEAD and the listings disagreed about the same object:
HEAD reported the class the object was stored with, while ListObjectsV2 and
ListObjectVersions reported STANDARD for every object. Clients that filter or
tier on storage class act on the listing.
Caches the class alongside the other listing fields, clears it with them, and
copies it in the routed RECOMPUTE_LATEST path so both finalize paths agree.
* test: compare ListObjects and ListObjectVersions over the same namespace
The two listings walk the same tree through separate code paths, so a client
navigating by versioned listings can see a different namespace than one
navigating by plain listings, and concludes keys are missing that are plainly
there. Testing each path on its own never catches that; only comparing them
does, and nothing compared them.
Asserts both report identical current keys and identical common prefixes
across a backup-shaped tree: nested prefixes, a prefix naming an object
exactly, a key that is simultaneously an object and the parent of other keys,
a partial key fragment, and a prefix matching nothing.
The version view is reduced to what a plain listing reports — latest versions
that are not delete markers — so the comparison is like for like.
* test: guard against truncated pages and cover the delete-marker path
* test: pin verb parity on lock-arbitration keys through acquire and release
Backup clients arbitrate repository ownership by writing and retracting small
keys under a fixed prefix and re-probing them, each probe using a different
verb. They trust those verbs to agree; a key reported present by one and
absent by another makes the client either spin or declare the repository
corrupt, and neither shows up as an error on the storage side because each
individual answer is locally correct.
The keys are written and immediately deleted by version id, which is the cycle
that empties a version container, so parity is asserted on both sides of the
delete and across repeated re-acquire cycles where residue accumulates.
Reports which verbs disagreed rather than just failing.
* test: run the reacquire cycle on every lock key, and drain probe bodies
* test: pin that an unusable version id is refused, never resolved
A version id containing a path separator, or "." / "..", can never name a
stored version. Resolving one to the null or latest version instead would let
a caller destroy a live version by asking for one that does not exist, on a
bucket configured for immutability.
The guard exists today and holds on every verb; it had no test. Pins two
properties: such a request is refused with a client error rather than a 5xx
(a 5xx invites endless retries of something that can never succeed), and the
version that does exist survives every refused request.
* test: require exactly 400 for an unusable version id
* test: cover delete idempotency on versioned object-locked buckets
Backup clients probe and retract lock keys continuously, so they routinely
delete keys and versions that are already gone, and they batch those deletes
alongside keys that do exist. S3 makes all of that succeed; returning an error
turns ordinary lock arbitration into a job failure.
The behaviour is correct today but had no coverage, and it runs through the
object-lock retention check, which is the most likely place for a missing
object to start being reported as an error.
Covers: deleting a key that never existed, deleting a well-formed version id
that names nothing (twice, and without disturbing the version that does
exist), and a batch whose middle key is missing — every requested key must
come back under its own name rather than silently taking another row's slot.
* test: verify the deletes actually took effect, not just that they returned
* s3: report a peer that went away as ClientDisconnected, not IncompleteBody
A streaming PUT whose body ends early is always reported as IncompleteBody
(400). That collapses two cases with opposite causes: the peer vanished
mid-upload, and the peer sent fewer bytes than it promised while still
connected. The first points at the network path, the second at the client,
and once merged they cannot be told apart from the logs.
Split out ClientDisconnected (499) and select it when the request context
shows the peer is gone. The upload itself keeps running on a background
context so chunks still finish, which means cancellation races the read
error; a missed signal degrades to IncompleteBody exactly as before.
* s3: note what request-context cancellation is taken to mean
* wdclient: age vid map entries by generation instead of chaining snapshots
The vid map kept its history as a linked list of past snapshots, trimmed
in place by storing nil into a node's cache pointer. That cost up to six
full copies of the volume-location map, a recursive walk taking a
different lock per level, and deletes that had to cascade through every
generation. It also had to special-case explicitly-empty entries, or
fallback would resurrect locations a newer snapshot had cleared.
Keep one map instead, and stamp each entry with the generation it was
learned in. resetVidMap bumps the generation and drops entries that were
not relearned within the retained window, which is the same retention
the chain provided: an entry survives DefaultVidMapCacheSize resets.
The first write of a generation replaces an entry rather than merging
into it, so a volume that moved answers with where it is now — the
property a fresh map per reset used to give for free. Entries are
copy-on-write, so locations handed to a caller are no longer shifted
underneath it by a concurrent delete.
The map is never swapped now, so the client-side lock and its stable /
current accessors go away with it.
* wdclient: make vid map entries immutable and drop them once emptied
Review follow-up. Updating an entry in place left the copy-on-write
guarantee resting on callers never holding the entry pointer; install a
new entry instead, so the rule is simply that a stored entry never
changes.
Deleting a volume's last location now drops the entry rather than
keeping an empty one, which a client that never resets would otherwise
hold for every volume it ever saw deleted. Lookups already treat an
empty entry as a miss, so nothing observable changes.
* wdclient: let the newest generation decide between regular and EC locations
GetLocations checked the regular map first whatever its generation, so a
volume that was EC encoded kept answering with the regular copies the
previous master knew until they expired — for as long as the retained
window, since nothing relearns a copy that no longer exists.
The snapshot chain did not have this problem: the newest map was
consulted first and only a volume it knew nothing about fell through to
older ones. Restore that by comparing generations, with regular copies
winning a tie, since a tie means one generation reported both.
Sealing a logic chunk index that already held a sealed chunk dropped the
old chunk's only reference and freed its page chunk. That chunk's upload
may not have started reading it yet — Execute() returns as soon as the
job is handed to a goroutine — so mem.Free could hand a live 2 MiB mem
chunk back to the slot pool, the next NewMemChunk would overwrite it,
and the in-flight upload shipped whatever bytes were there. Under fio
randwrite the volume server rejected those needles with "Content-MD5 did
not match md5 of file data" and the FUSE write failed with EIO.
Give the sealed chunk a second reference for its upload, dropped only by
the upload itself, and let the upload unindex itself only while it still
owns the index — the unconditional delete could evict a newer sealed
chunk and hide its dirty pages from readers.
resetVidMap trims the cache chain by storing nil into a node's cache
pointer once it ages past vidMapCacheSize. A lookup that missed in its
own map and only then loaded that pointer could find the link already
severed, reporting "not found" for a volume that stayed resolvable the
whole time.
Load the link first, while it is still guaranteed live. A published
vidMap's cache pointer only ever goes from its ancestor to nil, so
reading it earlier can never yield staler history.
* filer: let a nested path rule turn worm off
mergePathConf ORs the booleans, so worm set on a bucket could never be
lifted on a directory under it, while every string field is overridden by
the more specific rule. Make worm tri-state instead: unset inherits, set
wins. readOnly, fsync and disableChunkDeletion keep the OR, so a nested
rule still cannot escape a lock the bucket set.
Configurations written before this carry an explicit "worm": false on
every rule, because they are marshalled with EmitUnpopulated. Reading
those back as an override would quietly drop worm from nested paths, so
filer.conf is now stamped with a version and the flag is dropped to unset
when the version predates it.
* filer: copy the worm value out of the matched rule
mergePathConf aliased the pointer into the merged result, so a caller that
wrote through it would reach into the stored rule.
* test: pin that a .vif replication outranks the superblock
Store.ConfigureVolume rewrites the .vif and never the replica-placement byte in
the .dat, so that byte keeps whatever the volume was created with for good.
readSuperBlock reads it and then overrides it from the .vif, which is what makes
a replication change take effect and survive a remount.
Invert that and every replication change silently reverts on the next mount,
while the .vif on disk still records what the operator asked for -- a durability
setting quietly going back to its old value, with nothing to indicate it.
Worth pinning rather than reading off the code, because the field beside it
resolves the other way: version takes the superblock over the .vif. Two fields,
one function, opposite precedence, each a line to invert wrongly.
Covers the empty case too, since a .vif that declares no replication has to
leave the superblock standing or a volume whose replication was never
configured would be forced to whatever the zero value parses as.
* test: drop the unreachable nil check on MaybeLoadVolumeInfo
It initialises the returned pointer before the existence check and every
return is naked, so it never yields nil. Guarding against it implied a
contract the callee does not have.
The master could die with a nil pointer dereference inside
raft.(*server).TakeSnapshot. The leader and follower loops ask for a
snapshot on every iteration and callers can ask at any time, so several
goroutines built one at once, all writing to the one pendingSnapshot
field. Whichever finished first saved it and set the field to nil, and
the rest either dereferenced nil while attaching peers and state, or
stored nil as the current snapshot, leaving the master with no snapshot
to send to a lagging peer.
v1.2.0 holds a mutex for the length of a snapshot and keeps the one being
built in a local. It also stops a snapshot recovery that cannot finish
from being reported as done, writes snapshots to one side and renames
them into place, loads the snapshot covering the most of the log rather
than the last one in filename order, and takes the lock that guards the
peer map on both sides, where a snapshot cloning the peers alongside a
membership change was a concurrent map iteration and write.
* s3: allow copying an object onto itself in a versioned bucket
The copy writes a new version instead of overwriting in place, which is how an
earlier version is restored. Buckets with versioning off or suspended keep
rejecting a self-copy that changes nothing.
* s3: cover the suspended-versioning self-copy rejection
Suspended versioning overwrites the null version in place, so a self-copy that
changes nothing stays rejected. Pin that alongside the never-versioned case.
* s3: keep the list marker exclusive for versioned objects
A versioned object lives in a "<key>.versions" directory, so the entry name
never matched the marker and start-after/marker returned the marker key itself.
* s3: match the list marker against the raw entry name too
A backend that echoes the marker it was given returns the ".versions" directory
name, which no longer matched once the comparison used the object name alone.
Cover both, and unit test each half.
TestProxyReadDropsCallerJwtQueryParam mints a read token up front and requires
the token the volume server would evaluate to equal it byte for byte. The expiry
claim has one-second resolution -- GenJwtForVolumeServer sets it from
jwt.NewNumericDate(time.Now().Add(...)) -- so two mints on either side of a tick
produce different strings for the same authority and the same file, and the
assertion fails for a reason the test is not about.
It surfaces on the 32-bit job, where the runner is slow enough that the HEAD
subtest (the second one, after a full proxy round trip) lands in a later second
than the mint at the top of the test. Confirmed directly: minting the same file
id with the same key either side of a boundary yields different tokens.
Assert what the test is actually about instead -- that the credential decodes
against the read key and authorizes this file id -- which holds whatever second
it is minted in, and is a closer statement of the property than string equality.
* webdav: answer PROPFIND child stats from the listing
golang.org/x/net/webdav discards the FileInfo that Readdir returned and stats
every child again, five times over, so a PROPFIND on a directory costs five
sequential filer lookups per entry: 15006 lookups and 1.4s for 3000
subdirectories, 24s for 60000. Windows Explorer times out well before that.
Hold the entries a listing already fetched for the lifetime of the request and
serve those stats from them - 6 lookups and 0.03s for the same 3000 entries.
WebDavFile.Stat has to stop dropping its request context for the held entries
to be reachable.
* webdav: keep the request context on the lookups a listing drives
stat, Readdir and Seek reached the filer on context.Background(), so a client
that walked away left the listing streaming and the lookups running. Seek also
missed the entries the listing had already fetched.
Write and cleanup paths keep their own context - a cancelled request must not
abandon a flush half done.
Short-lived clusters report once under a fresh raft topology id and never
again, so CI runs and demo stacks each become their own cluster. Held
forward for the whole active window they pile up, and the volume server
line climbs every day while capacity stays flat.
Sum the fleet series over confirmed clusters only, the same set the
version and OS charts already use.
The reports are what tell us which versions and cluster sizes are
actually in use, and almost nobody flips the flag on, so the numbers we
have are close to useless. Default it on for master, server and mini,
and say in the flag help and the startup log how to turn it off.
Nothing new is collected: still an in-memory cluster id that changes on
restart, version, os, server counts, volume count and disk bytes, sent
once a day by the leader master only.
Counting by node lost the per-node ShardsInfo, and with it the shard ids the
old summary printed -- the message an operator gets when the pre-delete check
refuses now says only how many shards each node holds.
That is the wrong half. A set holding shards 0-9 and one holding 4-13 are
both "10 shards", and only the ids say whether what survived can rebuild the
volume, or which node to go looking at. Keep the count and list the ids
beside it.
* ci: move the fusermount3 repair into a composite action
Three copies of the same block were already drifting apart, and the
target comes from PATH: only ever add setuid root to a root-owned,
non-symlink binary under the system bin paths, and say why otherwise.
* test: say that the process exited in the wait errors
"process exit status 1 before ... accepted connections" is missing its
verb. Also mark the SIGTERM return discarded - it fails with
os.ErrProcessDone exactly when the select below already handles it.
* ci: prefer the distro fusermount3 over escalating a shadow copy
The shadowing /usr/local/bin/fusermount3 is not root-owned either, so
setting its setuid bit would have handed root to a binary the runner
user owns - the repair now symlinks the distro one earlier in PATH and
touches nothing, keeping the in-place chmod for a root-owned binary with
no distro alternative. A setuid bit only grants root when root owns the
file, so accept an existing one only then.
* ci: run the FUSE workflows when the shared action changes
Their paths filters listed each workflow file but not the composite
action all three now call.
generateEcShards writes shards beside the source volume, so encoding a
volume that lives on a non-default medium puts them on that medium while
-diskType still says hdd. The pre-delete check counted only the -diskType
bucket, so it saw a complete set as zero shards, called it unrecoverable
and aborted -- leaving the volume as both a .dat and a full shard set,
which every later reader then disagrees about.
Count by node across disks, as waitForEcShardsToRegister in the same file
already does. The spread check is unaffected: it locates shards through
collectEcShardBitsByNode and only uses diskType to find free slots.
* ci: restore the setuid bit on a shadowed fusermount3
Newer ubuntu-22.04 runner images carry a source-built fusermount3 in
/usr/local/bin that shadows the distro one in PATH and is not setuid
root. go-fuse looks the helper up through PATH, so every unprivileged
mount fails with "mount failed: Operation not permitted".
* test: fail a fuse test as soon as its mount process dies
A mount that cannot mount at all exits within a second, but the harness
still waited out the 30s readiness timeout and then reported "mount
point not ready within timeout", leaving the real cause buried in the
log tail. Watch the child processes and report their exit instead.
* mount: report a failed mount without a goroutine dump
A mount failure is an environment problem - no /dev/fuse, fusermount not
setuid, stale mount point - and the all-goroutine stack dump Fatalf adds
buries the one line that says so.
* s3: register the advertised ip with the master
The cluster address came from the bind ip, falling back to the
auto-detected interface, so -ip never reached the S3 registration.
weed mini -ip=localhost binds the wildcard and ended up registering
whatever interface happened to sort first -- on a host with VPN
interfaces, an address that stops routing once the tunnel drops.
IAM changes are pushed to registered S3 servers over gRPC, so every
mutation then blocked the full 10s propagation deadline before logging
a failure, and cluster.ps and the admin UI listed a node nothing could
reach. Identities still arrived through the /etc/iam metadata
subscription, so this cost latency and visibility, not credentials.
Add an advertise ip to the gateway option, preferring it over the bind
address, and wire the parent -ip through server, filer and mini.
* s3: treat any unspecified bind address as a wildcard
net.ParseIP + IsUnspecified covers ::, [::] and the expanded IPv6 forms
instead of only the 0.0.0.0 literal, so an IPv6 wildcard bind no longer
registers an address peers cannot dial. Host names parse as nil and stay
addresses in their own right. Apply the same guard to the advertised ip.
* s3api: load document-style policies from the advanced IAM config
The advanced IAM file doubles as the S3 identity config when only
-s3.iam.config is given. protojson drops its "document" field, so every
policy landed with empty content and warned "skipping invalid policy" on
each reload. Worse, if the same file also declares identities the empty
content sticks in the policy map and fails the whole runtime policy sync
into the IAM manager, so policies created later never reach it.
* iam: skip an unparsable policy instead of failing the whole runtime sync
One policy the engine cannot parse aborted SyncRuntimePolicies before it
touched anything, so every other policy stayed unsynced and the engine
kept serving whatever it last held.
* s3api: reject a non-role RoleArn in AssumeRole as a bad request
arn:aws:iam:::user/name can never resolve to a role, but the handler ran
it through the trust-policy check and answered "not authorized to assume
role", pointing the caller at a permission problem they do not have.
* s3api: build the policy content before touching the entry
Deleting "document" up front meant a marshal failure left the policy with
neither field, so a later rewrite would emit it with no definition at all.
* iam: pin the fail-closed handling of an unparsable policy
Say in the comment that dropping it from the desired set deletes it from
the engine on purpose, and cover it with a test.
* s3api: widen the non-role RoleArn test to canonical ARN shapes
The reported ARN omits the account id; a user ARN that carries one, and a
non-principal ARN, must be rejected the same way.
* volume: recover .idx rows overwritten by tiered deletes
A delete on a read-only volume backed by a remote tier used to write its
tombstone row at .idx offset 0 rather than appending it, so each delete
overwrote one more row at the front and lost the Put rows indexing the
first needles in .dat. Those needles 404 even though .dat still holds
them, and rebuilding .idx with weed fix means stopping the server and
pulling the whole .dat back from the tier.
The damage has a fingerprint -- .idx opening with a run of offset-0
tombstones, which a healthy .idx never does -- and .idx and .dat grow in
lockstep, so the lost rows indexed exactly the first N .dat records.
Detect it at load and re-derive them from a header-only walk over the
head of .dat, cheap even against a remote tier, appending only the keys
the .idx no longer names.
* rust volume: mirror the .idx head tombstone recovery
Port the Go detection and repair: an .idx opening with a run of offset-0
tombstones lost the Put rows indexing the first needles in .dat, so
re-derive them at load from a header-only walk over the head of .dat and
append the keys the .idx no longer names.
* volume: put recovered .idx rows back in front instead of appending
Appending left the offset-0 tombstone run at the head, so every later
load re-walked .idx to the tail to notice the volume was already
recovered, and the rows for the head of .dat sat past the .dat-tail row
-- costing CheckVolumeDataIntegrity its O(1) path and breaking the
ascending append order BinarySearchByAppendAtNs assumes.
Rewrite .idx as the recovered rows followed by its current contents,
through a temp file and a rename. .idx is back in .dat append order, so
a later load stops after reading one row.
* volume: keep the .idx mode when the repair replaces it
The recovery renames a fresh temp file over .idx, so a fixed 0644 (Go)
or whatever the umask allows (Rust) would silently widen an index an
operator had locked down. Carry the mode off the file being replaced.
* s3tables: read Iceberg manifest lists that omit the Avro format version
The Iceberg spec pins the Avro header metadata of manifest files but says
nothing about manifest lists, so writers disagree. Java and PyIceberg record
"format-version"; DuckDB writes no header metadata at all. iceberg-go reads a
missing entry as v1, so every v2 manifest listed in a DuckDB-written list is
rejected with
manifest file's 'format-version' metadata indicates version 2,
but entry from manifest list indicates version 1
and, because v1 has no "content" field, delete manifests silently decode as
data manifests.
ReadManifestList derives the version from the record schema the writer
embedded - v2 added "content" and the sequence numbers, v3 added
"first_row_id" - and splices it into the header before handing the bytes to
iceberg-go. Lists that already carry the entry, and input that is not a
parseable Avro container, go through untouched.
* iceberg: parse DuckDB-written manifest lists in maintenance and data preview
Every manifest list read - the four maintenance operations and the admin
table data preview - went straight to iceberg-go, so tables written by DuckDB
failed detection and all of compact, remove_orphans, rewrite_manifests and
expire_snapshots before they touched anything. Route them through
s3tables.ReadManifestList, which recovers the format version the writer left
out of the Avro header.
This also restores the manifest content type on those tables: with the list
read as v1 every delete manifest looked like a data manifest, which hid
deletes from the compaction guard and made the preview report a table with
position deletes as having none.
* telemetry: total disk usage over time counted each cluster on one day
GetMetrics aggregated s.instances, which holds only each cluster's most
recent report. Every cluster therefore landed in a single date bucket --
the day it last reported on -- so the chart plotted the disk usage of
clusters that went silent that day, and piled the whole live fleet onto
today. Aggregate the daily histories instead, reusing the day alignment
that the per-cluster size series already does.
* telemetry: don't pad the charts with days the server has no history for
The dashboard asks for 30 days, but daily history only starts when a
server first collects it, so the charts opened on a run of zeros and then
jumped -- reading as a fleet that appeared overnight. Start the window at
the oldest sample on hand when it is younger than the requested range.
* s3api/iceberg: report the reason a table schema was rejected
newTableMetadata swallowed the iceberg-go error and returned nil, so every
schema the metadata builder refused came back as a bare 500 "Failed to build
table metadata". A v3-only column type is the common case: creating a table
with a variant field but no format-version 3 property leaves the client with
nothing, while "variant is not supported until v3" sits in the server log.
Return the error instead and classify it. Schema, spec and argument failures
are the caller's input, so they answer 400 with the underlying reason; the
rest stay 500. Paths that build placeholder metadata with no schema keep
their existing 500 via newEmptyTableMetadata.
* s3api/iceberg: fail LoadTable when placeholder metadata cannot be built
buildLoadTableResult dropped a nil from the placeholder path straight into
the response. That serializes as "metadata":null under HTTP 200, which no
Iceberg client can parse -- a worse outcome than the 500 the nil was meant
to signal.
Return an error instead and let the five callers answer 500. The nil-return
convention goes away with it, so the commit and transaction paths check an
error rather than a sentinel.
* s3api/iceberg: route rejected schemas through writeManagerError
The two helpers added here duplicated work the package already does.
writeManagerError is the canonical error-to-response mapper -- it already
downgrades client-input failures to 400 and defaults the rest to 500 -- so
teach it the iceberg-go schema and spec sentinels instead of standing up a
parallel classifier. The placeholder wrapper was a pure alias for
newTableMetadata with nil arguments; call that directly.
No behavior change beyond the 500 message, which now reads err.Error()
like every other manager error rather than carrying its own prefix.
The staged-new-volume placement skipped a disk holding the vid's EC shards using only the in-memory ecVolumes map, missing a shard present on disk but not mounted. Also scan the candidate disk for <vid>.ecNN files, so the promise holds regardless of mount state.
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
volume: skip a shard-holding disk when staging a decoded volume
ReceiveFile staged-new-volume mode picked any free disk of the target
medium. Skip a disk that already holds the vid's EC shards (Go
DiskLocation.FindEcVolume / Rust ec_volumes), so a decoded .dat never
lands in the same directory as a shard. This lets a caller safely stage
onto a shard host that has a spare disk, instead of requiring a host with
no shard of the vid at all.
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
Decoding EC shards back to a normal volume in place reconstructs <vid>.dat
in the shards' own directory, so the vid is momentarily registered as both
an EC and a normal volume in one location — the load/scan path then sees it
as both, risking mount ambiguity and needle loss. VolumeEcShardsToVolume
still supports that in-place path; this adds the primitives to decode onto
a *clean* peer instead:
- ReceiveFile gains a staged-new-volume mode: when the volume does not
exist here and ReceiveFileInfo.disk_type is set, pick a free-slot disk
of that medium and write <base><ext>.copying (not a valid volume name,
so the scanner never half-loads a partial push).
- VolumeEcShardsToVolume gains from_staged: adopt the pushed .dat/.idx/
.vif — rename .copying into place under a .note in-progress marker,
then mount — so <vid> lands on the peer only as a normal volume.
The caller decodes the shards off-box and streams the finished volume to a
peer holding no shard of the vid on the target medium. Go and Rust volume
servers get identical handlers. Proto: ReceiveFileInfo.disk_type (12; 8-11
reserved for versioned-EC), VolumeEcShardsToVolumeRequest.from_staged (3) +
disk_type (4).
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
VolumeMarkReadonly mutates raft-replicated master topology, so it must
reach the leader. notify_master_volume_readonly targeted the static seed
(config.masters.first()), so after any master failover it hit a follower
and failed "not current leader". Prefer current_master_url (the live
leader the heartbeat tracks), fall back to the seed before the first
heartbeat, mirroring store_ec.rs and Go's vs.GetMaster().
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
* s3: list the buckets an attached IAM policy grants
ListBuckets served an identity authorized by an attached IAM policy only
the buckets it had created itself. A user granted s3:ListBucket on a
bucket someone else provisioned could GetObject and ListObjectsV2 against
it, but the bucket never showed up in the listing any S3 client uses to
build its bucket picker.
The owner-index fast path is only valid for an identity whose grants name
every bucket it can reach, and the routing check assumed a policy could
never be enumerated. Read the names out of the policy instead: statements
that allow s3:ListBucket on a concrete bucket ARN become candidates, and
the per-bucket permission re-check still decides what is listed. A policy
that can reach a bucket it does not name -- a wildcard resource, a policy
variable, a NotResource, an STS session policy -- falls back to the full
scan, which evaluates the policy per bucket.
* s3: share the attached policy name lookup
authorizeWithIAM and the ListBuckets enumeration both built an identity's
policy names the same way, its own plus the ones from its enabled groups.
Pull that into one helper so group eligibility is decided in a single place.
* s3: read policy actions the way the IAM authorizer matches them
The IAM authorizer matches action names case-insensitively, so a policy
granting "S3:LISTBUCKET" or "S3:*" authorizes a list. The ListBuckets
classifier read those actions with the local case-sensitive matcher and
found no grant, so once the owner index was ready the buckets that policy
allows dropped out of the listing.
Match the action the looser way in the classifier: case-insensitive, and
true for any pattern holding a policy variable. Over-matching only costs a
candidate the per-bucket permission check then rejects, while under-matching
hides a bucket the caller can read.
* s3: infer a multipart grant in any case
The classifier matches action patterns case-insensitively but looked the
requested action up in a canonical-cased set, so "S3:UPLOADPART" missed the
s3:PutObject inference that the authorizer makes. Key the set for lookup in
lower case, matching how the IAM authorizer holds it.
* azure: let the blob endpoint be configured
The service url was always derived as <account>.blob.core.windows.net,
which leaves out Azure Government, Azure China, and private endpoints.
Name the blob service url instead and those accounts become reachable.
The url has to be https, since the account key or the bearer token would
otherwise travel in the clear.
* azure: reject an endpoint that carries no hostname
A url like https://:443/ has a host of ":443", so the emptiness check on
Host let it through and the request only failed once it reached Azure.
The hostname is what has to be there.
df on a mount shows the space the cluster gives up to the data: every
replica of a regular volume, every shard of an ec one. That is the honest
answer for capacity planning, but it is not the question a user asks when
they want to know how much of their data is stored.
Add -df.logical. The master reports the logical sizes alongside the raw
ones: one replica per regular volume, the data shards of each ec volume
counted once. Free space is divided by the copies the requested
replication makes, so used plus available stays the amount of data the
mount can still write, and it comes off the cluster-wide usage rather
than one collection's, since capacity is cluster-wide too.
Statistics through a filer resolves an unset replication to the filer's
default rather than the master's, matching where the writes it is sizing
for actually land.
The flag governs the quota check too, so a mount has one notion of how
much it is using. A filer that predates the new fields sends zeros, and
the mount keeps reporting the raw sizes.
Statistics aggregates the volume layouts of a collection, but EC volumes
are tracked outside collectionMap, so they were reported as nothing. A
mount over a cluster whose volumes have mostly been encoded showed a df
used size of a few GiB against terabytes of EC data.
Walk the data nodes and add the EC volumes of the requested collection.
Every shard copy counts, parity included, the way a regular volume's used
size counts every replica, so used size stays the space the cluster
actually occupies.
File count comes from the volume-wide .ecx and .ecj counts, taking the
largest a holder reports rather than summing them: both files travel with
the shards on a move, so several nodes can report the same tombstones.
* azure: authenticate the blob sink with Entra ID
Shared account keys have to be distributed and rotated everywhere a sink
runs. Leaving account_key empty now falls back to the identity chain, so a
workload identity or managed identity carries the authorization instead.
* azure: authenticate remote storage with Entra ID
The remote storage client demanded an account key and refused to start
without one. Fall back to the identity chain when it is absent, and let
azure.client_id pin a user-assigned identity.
* azure: reject a malformed storage account name
The account name is interpolated into the service URL, so a name carrying
a "/", "?" or "@" moves the authority elsewhere and an authenticated
request follows it. Hold callers to Azure's own naming rule instead.
* azure: keep a leftover environment key off the identity path
A configured client id asks for Entra ID, but AZURE_STORAGE_ACCESS_KEY
still filled in the account key behind it. An old mounted secret would go
on authenticating until it rotated, and the failure then blamed the key.
* azure: say what the identity path reads from the environment
A pinned client id alone is not enough for workload identity: the tenant
and the projected token come from the environment, and missing them only
surfaces later, when a token is first requested.
* s3: apply filer identity changes despite a static config file
A -config file with inline identities disabled the metadata-subscription
reload entirely, leaving the best-effort filer->s3 push as the only way
s3.configure changes could reach a running gateway. Reload on IAM events
regardless: the merge keeps the file's identities protected, and a full
credential-manager snapshot now also drops dynamic identities the store
no longer has, so revocation works without a restart.
* s3: log identity propagation failures as warnings
* s3: retry failed IAM reloads and reconcile policies and groups
An event-driven reload that fails now hands off to a coalescing retry
loop, so a transient filer error cannot strand a revoked credential
until the next IAM event. Full-state merges also drop dynamic policies
the store no longer has, keeping the static file's, and treat the group
snapshot as authoritative even when empty.
* s3: serialize IAM configuration loads
The SIGHUP file reload, subscription reloads, the retry loop, and the
postgres poll run on different goroutines. Without an end-to-end lock a
load holding an older store snapshot can commit after a newer one and
revert it. Hold reloadMu from snapshot through commit in both load
entry points; partial merges from pushed updates stay lock-free and
self-heal through the next event-driven reload.
* s3: keep static-file groups through full-state reconciliation
Group names from the static config file are tracked like identities and
policies, and a full snapshot that does not carry them keeps the current
definition and its memberships instead of dropping them.
* credential: include groups in postgres configuration snapshots
Full-state reconciliation treats absent groups as deleted, so a
snapshot that never carries them would erase every dynamic group.
* s3: revoke static-file groups dropped from the config file
A file reload is authoritative for the file's group set while keeping
dynamic groups, mirroring how full snapshots are authoritative for
dynamic groups while keeping the file's.
* credential: fail filer snapshots on unreadable entries
A skipped identity or policy file made the load report success with an
incomplete snapshot, which reconciliation reads as deletion and the
retry loop never sees. Unparseable content is still skipped: it is
durable, matches boot behavior, and must not block reloads forever.
* s3: ignore groups in static config files
Groups are managed through the IAM API and the dynamic store; no
deployment defines them in a bootstrap config file. Ignoring them with
a warning removes the two-directional group merge: full snapshots are
plainly authoritative and file reloads never touch groups.
* s3: require a bucket-policy action to write a bucket policy
PutBucketPolicy and DeleteBucketPolicy were gated on ACTION_WRITE, the same
action that grants object writes. An explicit Allow in a bucket policy
short-circuits IAM entirely -- authRequestWithAuthType sets policyAllows and
skips VerifyActionPermission -- so anyone who could write an object could
author a policy granting itself, or anonymous, anything on the bucket.
That is what separates a bucket policy from the sibling bucket controls also
gated on ACTION_WRITE: rewriting cors or lifecycle can destroy data, but only
a policy hands out access.
Give the two verbs their own actions, mapped to the AWS names that were
already defined but unrouted. ACTION_ADMIN would also have closed it, but it
resolves to s3:* for IAM identities, forcing a blanket grant on a user holding
a precise s3:PutBucketPolicy. Admins are unaffected, since isAdmin
short-circuits CanDo, and an operator can delegate with PutBucketPolicy:bucket.
The route binding is asserted from the router source: checking the action
constants alone still passes when the route says ACTION_WRITE.
* s3: also read the action from a direct iam.Auth call in the route test
Routes read iam.Auth(cb.Limit(handler, ACTION)), a multi-value pass-through:
Limit returns (http.HandlerFunc, Action) and those become Auth's parameters, so
the action Auth authorizes on is Limit's second argument and the two cannot
disagree -- Auth(Limit(h, X), Y) does not compile.
A route that skipped Limit and called Auth with its own action would compile,
though, and the test reported that as a missing route rather than as the wrong
action. Recognise the two-argument Auth form so it names the action instead.
* s3: make the bucket-policy actions grantable through an IAM policy
The new actions close the escalation only if an operator can grant them, and
they were not reachable: MapToStatementAction had no entry for PutBucketPolicy,
so an IAM policy naming s3:PutBucketPolicy was rejected outright with "not a
valid action". GetBucketPolicy was unmapped the same way.
DeleteBucketPolicy was mapped, but to ACTION_ADMIN -- granting an identity
permission to delete a bucket policy handed it full administrative access.
Map all three to the actions the router now uses, and add the reverse
direction so an identity holding them renders back as a policy statement
instead of a bare "s3:".
* admin: offer the bucket-policy permissions in the user editor
The two new actions are otherwise only grantable by hand-editing identity JSON
or by calling the IAM API, so an operator using the UI cannot delegate bucket
policy management without granting Admin.
Regenerating this file also picks up codegen the repo has not taken yet: the
checked-in _templ.go files were produced by templ v0.3.1001 while go.mod pins
v0.3.1020, so the generator rewrites the attribute-value calls. That churn is
confined to this one file; running `make generate` in weed/admin reproduces it
across all 36.
* volume: gate the admin RPCs that only shell and workers call
checkGrpcAdminAuth covered 19 of the 48 VolumeServer RPCs, so an operator who
sets -whiteList expecting it to cover the gRPC surface gets partial coverage.
Extend it to ten that mutate state and are only ever called by the shell or a
worker: SetState, VolumeCopy, the EC generate/rebuild/copy/unmount/to-volume
pair, both tier moves, and VolumeTailReceiver. That is safe because the same
callers already reach gated RPCs today -- VolumeMarkReadonly, VacuumVolume*,
VolumeEcShardsDelete, VolumeDelete -- so a whitelist deployment already lists
those hosts. Nothing here is on a master or peer path, which is what made the
earlier fail-closed gate break multi-host clusters.
The split is by caller rather than by blast radius: the guard matches a peer IP
against the whitelist, and a whitelist holds masters, shell hosts and workers,
not every peer volume server. Gating a call one volume server makes to another
would break replication, EC and tiering, so those stay open.
Two test fakes embedded a nil grpc.ServerStream and only implemented Send;
they now implement Context, which the streaming RPCs read to authorize.
* volume: fail the build when a gRPC method skips the admin gate
The admin gate is an opt-in list in a 48-method service, which is how it
drifted down to covering 19 of them: nothing tied adding an RPC to deciding
whether it needed the gate.
Parse volume_server.proto, walk the AST of every *VolumeServer method, and
require each RPC to either call checkGrpcAdminAuth or appear in
ungatedVolumeServerRPCs with the reason it stays open. A stale entry naming an
RPC that no longer exists fails too, so the list can't quietly stop exempting
anything.
The exemptions are the cluster-internal calls -- replica sync, EC shard
distribution, vacuum reads, backup, tailing -- plus the read-only and liveness
RPCs. Closing the cluster-internal ones needs a peer identity rather than an
IP whitelist; recording them here makes that a visible decision instead of an
omission.
The AST walk also corrects the count: a line-window scan credits
VacuumVolumeCheck and VolumeServerStatus with a neighbouring function's guard.
* volume: fix EC decode/reconstruct index locality under -dir.idx
EC->replicated decode failed under -dir.idx and on multi-disk with "volume not
found on disk". The reconstruct rebuilds the .dat on the data disk but the
on-demand VolumeMount scans only the data directory, matching on .idx/.vif;
with the rebuilt .idx off in the index directory it matched the volume's
leftover EC .vif and skipped the volume as EC metadata.
- Resolve the EC .ecx local-first: prefer the copy co-located with the shards
over the shared -dir.idx copy, with a non-empty preference so a 0-byte local
stub still yields to a valid sibling (the cross-disk fallback).
- Co-locate the rebuilt .idx with the .dat at the end of the reconstruct so the
mount finds it; sweep .ecx/.ecj from both the data and index directories on
Destroy so a stale copy cannot re-mount as a phantom EC volume.
- Add VolumeConsolidateIndex: once the EC shards are deleted, unmount, move the
.idx/.sdx from the data disk back to the -dir.idx directory (copy fallback
across filesystems), and remount. A no-op without -dir.idx.
* volume: tests for EC index locality (local-first .ecx, sweep, consolidate)
- NewEcVolume prefers a non-empty local .ecx over the shared index dir, and a
0-byte local stub yields to a non-empty shared copy (the #9212 fallback).
- Destroy sweeps .ecx/.ecj from both the data and index directories.
- ConsolidateVolumeIndex moves a co-located index back to the -dir.idx dir and
keeps the volume mounted; no-op without a separate index dir.
- RenameOrCopyFile moves a file and drops the source.
* volume: relocate the decoded index in place, without a read gap
ConsolidateVolumeIndex previously unmounted the volume, moved the index, and
remounted it. Between the EC-shard delete and the remount the volume had neither
a normal nor an EC form mounted, so a read landing in that window got a
not-found (or was proxied away).
Move the index in place instead: RelocateIndexTo takes the data-file write lock,
closes the needle map and data backend, moves the .idx (and derived .sdx), then
retargets dirIdx and reloads — the same close-swap-load CommitCompact uses. The
volume never leaves the mounted set, so a concurrent read blocks briefly on the
lock rather than failing. The test now writes a needle before consolidating and
reads it back after, proving the in-place reload keeps the volume serving.
* volume: address review — maintenance guard, no orphan on copy failure
- VolumeConsolidateIndex now rejects the request under maintenance mode, like
VolumeConfigure and the other mutating volume RPCs.
- RenameOrCopyFile rolls the cross-device copy back when the source cannot be
removed, so a failed move never leaves two divergent copies (the loader would
keep the data-dir one while the idx-dir orphan goes stale).
- RelocateIndexTo logs a failed reopen-after-failed-move instead of swallowing
it, since that leaves the volume unusable until the next load.
The sentinel stores hardcoded a 30s read timeout and a 1m retry backoff.
After a sentinel failover every request that picked a pooled connection to
the old master sat there for 30s before the connection was retired, and the
pool timeout derived from it (read timeout + 1s) queued the rest behind
them. The other redis stores took the go-redis defaults with no way to tune
anything.
Read the dial, timeout and pool knobs from each redis store section instead,
keeping the go-redis default for every key left unset.
security.GetJwt reads the "jwt" query parameter before the Authorization
header, and the proxy forwarded the caller's whole query apart from
proxyChunkId. So on a read, where the filer mints a volume token and sets the
header itself, a caller-supplied ?jwt= silently outranked it: the volume
server validated a credential the caller chose rather than the one the filer
attached, and the read failed with a 401 the filer could not explain.
Drop it on the read path, where the filer owns the credential. Writes keep
theirs -- the proxy forwards a writer's own AssignVolume token either way, so
the query parameter is just a second channel for the same credential and
stripping it would break a caller that presents it that way.
Nothing in the tree passes a jwt by query; maybeAddAuth always sets the
header.
The S3 write path cut fixed 8MB chunks, so an object stored through S3
chunked differently from the same bytes stored through the filer, WebDAV
or a mount, and -maxMB had no effect on it. Read maxMB from the filer
configuration at startup and use it, falling back to 8MB when the filer
reports none.
* filer: add a placement overlay seam for the write path
New volumes take their disk type, replication, and data center from the
explicit request or the matched filer.conf rule. That leaves no way for a
feature to steer a whole collection onto a medium without an operator
writing an fs.configure rule by hand.
Add a generic PlacementOverlay hook on the filer: a func that maps a
collection to a placement override, installed by a factory the way the
plugin-worker handlers register. detectStorageOption consults it between
the explicit request value and the filer.conf rule, so it overrides the
rule but yields to a value the caller asked for.
The seam names no feature concepts, so it stays generic; a downstream
build registers the overlay it wants (e.g. a storage-class Landing tier).
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
* filer: address review on the placement overlay seam
Honor ResolvePlacement's ok flag explicitly rather than relying on empty
values falling through the util.Nvl chain, and log at V(4) when the
overlay steers a collection. Document that RegisterPlacementOverlay is
init-only, so the unsynchronized read in NewFiler cannot race the write.
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
* filer: claim the base fid when minting a volume read token
GenJwtForVolumeServer stamped the fid verbatim, but the volume server strips a
trailing _N delta suffix before comparing the claim, so a token minted for a
batch-assigned fid like 3,01637037d6_1 was checked against 3,01637037d6 and
never matched. Reading such a chunk through the filer returned 401 wherever
jwt.signing.read.key was configured.
Strip the suffix before minting, via a helper shared with the proxyChunkId
validation that was already doing the same thing inline.
* filer: don't mint a volume write token for an anonymous proxy caller
The ?proxyChunkId= branch dispatches and returns before the JWT gate, so
whatever credential the proxy attaches is reachable without authentication.
It attached a token from maybeGetVolumeReadJwtAuthorizationToken, which fell
back to the write signing key when jwt.signing.read.key was unset -- the
configuration scaffold/security.toml recommends for a filer, since read JWTs
are only supported in a master+volume setup. An anonymous
DELETE /?proxyChunkId=<fid> therefore arrived at the volume server holding a
write-key token scoped to that fid, and the volume server honored it.
Sign read tokens with the read key only. The fallback bought nothing on a
read anyway: a volume server enforces read JWTs solely when that same key is
set, so when the fallback fired the read was unchecked regardless.
Mint only for reads. Writers proxied through the filer carry their own volume
JWT from AssignVolume, forwarded with the rest of the caller's headers, so
weed mount -filerProxy uploads are unaffected. Moving the dispatch below the
JWT gate instead would have broken them, since that token is signed with
jwt.signing rather than jwt.filer_signing.
On a read with nothing to mint, drop the caller's Authorization rather than
relaying it: there it is a filer credential, and forwarding it would hand a
volume server a token it never used to see.
* filer: keep proxied writes out of the read concurrency semaphore
The semaphore is named and documented for reads -- it exists so replication
bursts can't open hundreds of connections to one volume server -- but it was
applied to every proxied method. A write queued behind sixteen in-flight reads
can wait past the 10s default expiry of the AssignVolume token it carries, and
the volume server then answers 401. shouldReassignUpload treats a 4xx as final,
so the uploader replays the same expired token instead of re-assigning and the
write fails up to the caller.
This only became reachable once the filer stopped re-minting a fresh token
after the wait.
LookupFileId only requires the fid to contain a single comma, and the value
is pasted straight into the volume server URL path, so ?proxyChunkId=3,x/../../status
resolves to volume 3 and then addresses an endpoint the caller never named:
Go sends the dot segments verbatim, the volume server's mux cleans the path
and redirects to /status, and the filer follows the redirect and relays the
body. That reaches any handler on the volume server -- status, stats, the UI --
past a filer that operators expect to be the only exposed surface.
Parse the fid before the lookup and answer 400 when it doesn't parse.
A trailing _N delta suffix from batch assigns is legal, so it is stripped
first, but only when it is a non-empty run of digits. The volume server strips
at the last "_" unconditionally, which is safe there because its fid came out
of a path the mux parsed and so cannot hold a "/"; here the value is raw query
input, and an unguarded strip would reduce "3,01637037d6_1/../../status" to a
valid fid and wave the traversal through.
The queue holds sixteen sealed windows, which is a memory bound only
while a window is BufferSize. An entry larger than that grows its window
to fit, and the depth then multiplies straight through: sixteen queued
copies of a 100 MB window is 1.6 GB of flush data alone.
Account the queued bytes and make producers wait once they pass the
ceiling the depth was chosen for. What is charged is the pooled slab
rather than the window length, since mem.Allocate rounds up to a size
class and the queue holds the whole slab. A window larger than the whole
budget still goes through on its own, so an oversized entry is never
stuck.
Windows are admitted in the order they were sealed. A producer can now
park here for seconds, and letting a later window overtake an earlier one
would persist them out of order and walk lastFlushedOffset and
lastFlushTsNs backwards.
A window is copied into its slab under the write lock, before the
reservation is taken, so a burst of concurrent oversized writers would
each hold a full copy in hand while queueing up -- memory the budget
never sees. Large writers wait for queue headroom before they take the
lock, which throttles the burst; it does not bound it, since a writer
that passes the check still seals unconditionally.
Take any room in the queue before the shutdown escape, too: the window is
already sealed by then, so dropping it loses records the caller was told
were accepted. A shutdown that races a full queue can still drop one --
that predates this change and needs the flush loop's lifetime reworked.
Size a grown window to the entry rather than to twice it: the extra room
only bought space for a second oversized record in the same window, which
doubles the flush copy and the snapshot taken of it. The overflow guard
halved its bound for that doubled allocation, so raise it to match what
is now allocated and what maxBufferSize documents.
* volume: reject needle blob writes to read-only volumes
WriteNeedleBlob appends the blob to .dat and only then calls nm.Put. On a
read-only volume the needle map is a SortedFileNeedleMap whose Put always
fails, so the append is never indexed and never rolled back.
Nothing upstream stops this: volume.check.disk picks its targets from the
master's cached topology, which goes stale the moment a volume server marks
a replica read-only itself — a failed data integrity check at load, or an
EIO quarantine. Each sync attempt then grows the .dat of a replica that is
supposed to be frozen by one unindexed needle, and reports it as "invalid
argument", the bare os.ErrInvalid the needle map returns.
Check IsReadOnly before touching .dat, same as the upload path does.
* volume: say which needle and volume failed to index
An index write that fails surfaced as a bare errno with no volume, no needle
and no file — "invalid argument" for a read-only needle map, or a plain
ENOSPC when .idx lives on its own filesystem via -dir.idx. Both were logged
at V(4), so by default the operator saw only the errno the client got back.
* filer: write the metadata log in pieces a volume server will accept
A single oversized metadata event grows the log buffer past the volume
server's fileSizeLimitMB, and the flush of that buffer is then rejected
forever: the retry loop has no exit, so the blob at the head of the queue
blocks every later flush and the metadata feed stalls until restart.
Split the flushed buffer into BufferSize pieces, on record boundaries
where possible so each piece still decodes on its own, and retry each
piece separately so a partial success is not replayed. Log files are
already read as a chunk stream, with a whole-file fallback when a chunk
does not decode standalone, so a record may cross a piece boundary.
* log_buffer: let go of a window array grown for an oversized entry
An entry larger than BufferSize grows the window array to 2*size+4, and
window arrays cycle through SealBuffer rather than being freed. One such
entry therefore leaves every later window carrying its size, and
currentSnapshotView allocates a snapshot as wide as the array on each
window, so a few KB of metadata keeps paying for it.
Drop the array when SealBuffer hands it back. Growth is on demand, so
the next oversized entry just reallocates.
* iceberg maintenance: store merged data files as chunks, not inline
saveFilerFile had no size threshold, so compaction wrote whole merged
parquet files -- hundreds of MB -- as Entry.Content. That puts the
parquet bytes verbatim in the filer store and sends them through the
metadata change log again as one event.
Keep manifests and metadata JSON inline, upload anything larger to
volume servers in chunks, assigning through the filer so the path's
storage rules apply.
* filer: follow the file size limit the volume servers report
The starting piece size is a constant, so a cluster whose
-fileSizeLimitMB is set below it would reject every piece and wedge just
the same. The rejection names the limit, so take it from there and
re-cut the rest of the flush to fit.
Piece the buffer one at a time rather than up front, since the size can
change partway through a flush.
plugin: stop the scheduler lock test racing its own background loops
TestRunLaneSchedulerIterationLockBehavior constructed the plugin with a
cluster-context provider, which makes New start a background scheduler
loop per lane. Those loops call runLaneSchedulerIteration on the same
lane the test then drives by hand, so a loop could consume the due job —
running detection and pushing the next-detection time forward — before
the manual call observed the lock. The Default case then saw the lock
acquired zero times and failed intermittently.
Construct without the provider so no loops start, and set the provider
afterward so the manual iteration can still detect. This is the pattern
scheduler_status_test.go already uses for the same reason.
Reproduced under -race -count=100 -p 4 before, green after.
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
* util: match transient error messages case-insensitively, expose the message form
The same condition reaches different layers capitalized differently -- a
volume server relays its idle timeout as "I/O timeout" inside a JSON string --
and the callers that grew their own substring lists all lower-case first.
Also split out IsTransientErrorMessage for the paths that carry only the text,
such as the per-file status strings in a batch delete response, and pick up
"no route to host" and "network is unreachable" from the gRPC classifier.
* filersink: classify transient network errors through util.IsTransientError
The local list caught i/o timeout, connection reset, and broken pipe but not
connection refused, no such host, unexpected EOF, the syscall errnos, or the
gRPC and S3 overload codes. Keep only the bare io.EOF case, which is transient
here -- a truncated chunk read -- but a clean stream end elsewhere.
* filer deletion: reuse util.IsTransientErrorMessage for the network patterns
Six of the sixteen patterns were already covered. Keep the ones specific to
this pipeline -- read-only volumes, lookup failures, backpressure -- and note
why context cancellation stays retryable here: it decides whether to requeue
the deletion, not whether to retry a call.
* wdclient: fold the shared classifier into the volume lookup retry check
The string tail duplicated the shared list and missed the syscall errnos and
net.Error timeouts. Keep "connection" and "timeout", which are broader than
the shared classifier on purpose: a volume lookup is a cheap read-only call.
* filer.replicate: commit the kafka offset after replicating, not on receipt
The partition consumer committed the offset as soon as it handed the message
to the channel, so a sink write that failed was logged and the message was
already behind the committed offset -- never redelivered, permanently missing
from the sink.
Commit in onSuccessFn instead, and hold the committed offset behind the
oldest offset that failed to replicate so a restart redelivers from there.
* filer.replicate: delete the sqs message after replicating, not on receipt
ReceiveMessage deleted the message before the replicator had a chance to run,
so a failed sink write dropped it for good. Move the delete into onSuccessFn
and leave the message in the queue otherwise, letting the visibility timeout
redeliver it.
* util: retry transient errors, not just the ones containing "transport"
util.Retry only retried when the error string contained "transport", so a
plain "read: connection reset by peer" from S3 got zero retries. Classify
the error instead: net timeouts, connection resets, and the throttling and
overload replies S3 and gRPC return are all worth another attempt, while a
cancelled or expired context is not.
* filer sync: hold the sync offset behind a failed event
A sync job that returned an error was logged and forgotten, and the
watermark advanced past it anyway. The offset is the durable resume point,
so the event was never replayed: for filer.remote.sync that left the file
present locally, absent on the remote, with no RemoteEntry and nothing to
retry it.
Pin the watermark at the oldest failed event. Later events keep flowing,
but the persisted offset stays behind the failure, so a restart replays it.
The scaffold advertises enable_tls, ca_cert_path, client_cert_path and
client_key_path under redis2, redis2_sentinel and redis_cluster2, but only the
plain redis2 and redis3 stores ever read them, and under a different name,
enable_mtls. Sentinel and cluster setups quietly connected in plaintext.
Build the TLS config in one place and use it from all six stores. enable_mtls
still works. The CA and the client key pair are optional now, so enable_tls
alone verifies against the system roots, and ServerName is left unset so
go-redis validates each address the sentinel and cluster clients dial.
Deleting the only object under a prefix in a versioned bucket writes a
delete marker and keeps the version history, so the filer directory
survives with nothing a current-version listing would return. A delimited
ListObjects kept reporting that path in CommonPrefixes, because the
prefixes come from the directory tree rather than from the keys, while a
listing scoped inside the prefix correctly came back empty.
Probe a directory before reporting it: one that holds entries but no key
the listing returns is neither a CommonPrefix nor a path the
trailing-slash probe answers for. Empty directories keep the meaning they
have today, and the probe only runs for buckets with versioning
configured, the only ones that can reach this state.
A maintenance snapshot carried only its own labels — merged-files,
delete-groups and friends — and no summary counters, so every engine that
reads a table's size out of the current snapshot summary reported nothing
for it: PyIceberg's inspect.snapshots, Trino's $snapshots and Spark's
DESCRIBE all read total-records, total-data-files and total-files-size
verbatim, and a table lost them the moment compaction touched it.
Accumulate the files each operation adds and removes, and render them the
way the spec defines: the added-*/removed-* counters from the files
themselves, then the totals carried over from the parent snapshot.
Carry a total only when the parent recorded it. Iceberg treats a missing
total as zero, which turns a compaction replacing two files with one into a
negative total-data-files, or a table with millions of rows into
total-records: 0. Leaving the field out lets a reader fall back to the
manifests instead of believing a made-up number.
Compaction also accounts for the delete files it consumes, so a run that
folds every delete into the rewritten data reports them as removed.
The worker assumed every file of a table sits under its catalog path, so
loadFileByIcebergPath stripped the scheme off a recorded location and joined
the remainder onto /buckets/<bucket>/<ns>/<table>. A table the REST catalog
placed elsewhere in the bucket — which is what a client gets whenever the
catalog path is already occupied — then resolves to a doubled path:
lookup /buckets/lake/source/t/lake/source/t-0cd81bca-.../metadata/snap-.avro
so the very first manifest list read fails and the job fails again on every
scan interval, indefinitely.
Resolve absolute references (s3:// URIs and /buckets paths) from the bucket
root and keep relative ones under the table's own directory; the
bucket-relative form is now the canonical key everywhere references are
compared. That directory comes from the metadata location the catalog stores,
so reads, writes and deletes all land where the table's other files are
instead of splitting it across two trees. References outside the table's
bucket are rejected rather than silently misresolved.
Rewritten position-delete files now name their data file by absolute URI,
the way the table itself names it, instead of a path relative to the table.
The dashboard charted one summed disk-usage line, so a step in the total
gave no hint which cluster moved. A new panel stacks each cluster's daily
size as its own band: the top of the stack is the fleet total, each band
is one cluster, and the clusters past the twentieth are summed into an
"other" band so the stack still adds up to the total.
The series is built from the per-cluster daily histories and served by
/api/cluster-sizes. Clusters report roughly once a day at no fixed hour,
so a day with no report carries the previous value forward — dropping it
to zero would sag the total every day as the clusters that have not
reported yet fall out from under it. A cluster that stops reporting past
the active window ends at its last sample instead of holding capacity
forever. Ranking is by the most recent day, tie-broken on cluster id so
the colors do not shuffle between refreshes.
Hover and click resolve to the band under the pointer: Chart.js's builtin
interaction modes match the nearest line, which on a stack of thin bands
is rarely the band being pointed at. Clicking one fills the per-cluster
history lookup below it.
LiveMoveVolume and the copy, tail, delete, mark, replicate, and
configure helpers around it issued every RPC on context.Background(), so
a caller had no way to bound or abort a move once it started. They now
take a context, which the exported LiveMoveVolume in particular needs:
callers outside the shell drive long moves and want to stop them.
The deferred restore in copyVolume runs on a detached, bounded context
rather than the caller's. Marking the source writable again is cleanup,
and cancelling the copy must not skip it and leave the volume readonly —
the same guard balance_task.go already applies for the same reason.
Shell commands pass context.Background(): their Do signature carries no
context, and changing it would touch every command in the package.
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
* filer: stamp a log position on lookup and remote-cache responses
Metadata events are logged after their store write and stamped with the
filer clock. Reading that clock before serving an entry therefore gives
a timestamp with a causal guarantee: every event at or below it is
reflected in the returned entry. Clients caching filer state can use it
as the entry's version to order the response against subscription
events, including events committed before the call but delivered after
it.
* mount: version open file handles by filer log position
A subscription event refreshing an open handle did a second lookup; a
transient failure left the handle pinned to its old entry with no
retry, since the subscription cursor had already advanced. The deeper
problem is ordering: the handle is a cache written by three unordered
channels — the async invalidation worker, local mutation acks, and
open-time lookups — and overwriting cached state safely requires
knowing which write is newer.
The filer log timestamp is that order, and it now travels with every
value instead of being derived out of band. Events carry it natively;
lookup and remote-cache responses carry the log position stamped before
the serving read; mutation acks carry it in their returned event; and
the local store pairs each read with a version cursor advanced under
the same lock as the store write. Each handle records the version its
entry reflects, and one rule replaces the per-site reasoning: state at
or below the handle's version is old news and must not be installed.
The invalidation itself applies the event's own entry — no lookup, so
no transient-failure window — except under a cached parent, where the
store entry is the ordered merge of the event and anything applied
since, and its version outranks the event's. An uncached parent
receives no store writes, so a hit there would be a stale leftover
masking the event. A vacated path (delete, rename away) keeps the last
entry so unlinked-but-open reads still work. Directory builds version
the completed directory at the listing snapshot and re-invalidate
buffered events at that version, since their mid-build refresh ran
against an incomplete store.
The tests replay every race this replaces machinery for: rollback of a
newer local flush (queued, cached, and read-through), stale leftovers
under uncached parents, the build window including abort, handles
opened after an event was queued, events landing mid-lookup, and
undelivered events at remote-cache time across a filer failover.
* filer: serialize the log position fence with mutations, stamp mutation acks
The fence stamped before an unlocked entry read could precede state the
read returned: a mutation writes storage first and assigns its event
timestamp only at notify time, so a lookup racing that window handed
the mount an entry newer than its fence, and the event's later delivery
looked like fresh news — destroying dirty pages for a change the handle
already had. The mutation handlers already hold an exclusive per-path
lock across read, write, and notify; the lookup and remote-cache reads
now take it shared around the stamp and the read, making the fence
exact: everything at or below it is in the entry, nothing above it is.
A no-change update returns success without an event, leaving the mount
nothing to fence with even though the response confirms current state.
Create and update acks now carry a log position stamped under the same
lock, and the mount falls back to it whenever the ack has no event.
Also regenerate the VT marshalers, which the earlier generation missed:
without them a VT round-trip silently zeroed every log position.
* java: sync filer.proto
* mount: scope store versions to what they vouch for; atomic handle install
The store's version cursor claimed too much. Advanced by local mutation
acks and directory listing snapshots, it inflated the version of store
reads for unrelated paths whose events the subscription still owed, and
those events were then fenced out permanently. The cursor now tracks
subscription progress only — events arrive in log order, so everything
at or below it has been delivered for every path — and a completed
listing records its snapshot as a per-directory floor instead of a
global claim. Local acks never touch it: they version their own handle
directly. Buffered build events advance the cursor at delivery, since
their store write may never happen (abort) while their invalidation is
already queued; their read-through directory pairs no store read with
it, and rename fragments are applied first.
Concurrent first opens raced: a slower opener's older lookup could
overwrite the newer entry a faster opener had installed, while the
monotonic version kept the newer timestamp — an old entry fenced at a
new version, immune to every correcting event. Entry and version are
now installed as one decision under the handle map lock, and an install
that does not outrank the handle's version is dropped.
The remote-cache commit also escaped the fence: it wrote storage and
notified without the path lock, so a lookup's shared-locked fence and
read could land between the two and hand out the cached state
under-versioned. The commit now re-reads and writes under the exclusive
path lock, and backs off entirely when the entry changed during the
download — the concurrent writer supersedes the cached content.
* mount: floors gate store applies; installs respect handle users; renames join the fence
A directory floor certifies the listing state as of its snapshot, but a
delayed event at or below the floor was still applied to the store —
rolling the content back to pre-snapshot state while the floor kept
claiming the snapshot version, so the correcting events were fenced out
of every future read. Events are now gated against the affected
directory's floor, each half of a rename independently.
Fences are lower bounds: a listing or lookup can include a mutation
whose event has not been delivered yet, and that event later passes
every gate carrying state the handle already holds. Such a re-delivery
now advances the version without destroying dirty pages or reinstalling
the entry — invalidating local writes over a no-op was the real damage
in every remaining under-fence window, including the unlocked listing
snapshot, which no per-path lock can serialize.
The concurrent-open install moved from the map lock to the handle lock
every reader, writer, and invalidation synchronizes on, and rejects
what cannot improve the handle: dirty state (local writes would be
lost), unversioned lookup responses (they cannot outrank anything, and
two zero-version opens must not overwrite each other), and anything not
strictly newer. New handles are still fully initialized before the map
exposes them.
Renames committed metadata and emitted events with no path lock, so a
lookup could read the renamed state under a fence preceding its events.
Both rename handlers now hold the source and destination locks, ordered
by path, across commit and notification; descendants of a renamed
directory are not individually locked and rely on the no-op re-delivery
handling above.
* mount: per-entry store versions replace the cursor and directory floors
The store's aggregate versions — a global subscription cursor and
per-directory listing floors — were versions at coarser granularity
than the values they described, and every over-claiming bug in this
series traced to that gap: an aggregate vouching for state its source
never saw. Each store entry now carries the filer log position of the
write that produced it — the event that applied it, or the listing
snapshot that inserted it, recorded in the store's key-value space
under the same lock as the entry write. The store becomes what the
handle already is: a last-writer-wins register with one rule, install
only what outranks the current claim.
The cursor, the floors, their advancement rules, the pairing ordering
constraint, and the floor gating all collapse into that rule. Applies
are gated per entry, each half of a rename independently; an
unversioned local write clears the claim its content no longer proves;
version records lingering after a bulk folder wipe cannot fence a
recreate, since a claim only blocks while its entry exists. Listing
inserts are stamped at build completion, before the buffered replay so
newer replayed events override the stamp.
Filer side, the fence dance every versioned read must perform is now a
single choke point, fencedFindEntry, so a future read RPC gets the
lock-serialized stamp by construction rather than by convention.
* mount: judge no-op re-deliveries against an immutable base, not the live entry
The equal-state skip compared the incoming event to the live handle
entry, but local writes mutate the live entry — size, timestamps,
chunks — so a delayed event re-delivering the base the handle was
opened with no longer matched, and the installer destroyed the dirty
pages and rolled the entry back over nothing new. The handle now keeps
an immutable snapshot of the filer state it last installed or
acknowledged, refreshed at every install and mutation ack (flush acks
snapshot the request entry before the id mapping mutates it), and the
no-op judgment runs against that base: an event carrying the base
brings nothing, whatever the live entry has diverged to since.
* mount: tombstones for versioned deletes, absence floors, copy enrollment
Four gaps in the per-entry version protocol, all the same shape: a
versioned fact with nothing carrying its version.
A deletion is a fact about a path with no entry left to hold it —
clearing the record let a delayed older event resurrect the deleted
path, permanently, since the deletion's own redelivery is
dedup-suppressed. Versioned deletes now leave a tombstone record that
fences without an entry; renames tombstone their source the same way.
Plain records still only block while their entry exists, so records
lingering after a bulk folder wipe cannot fence a recreate.
A completed listing proves absences as well as presences: a name it
omitted was deleted as of the snapshot, and a delayed create below the
snapshot re-creates it. The snapshot is kept per directory strictly as
an absence fence, consulted only when a path has neither an entry nor
a version record — present entries carry their own versions and never
touch it, which is what separates this from the over-claiming floor it
replaces.
A rebuild against a pre-upgrade filer returns no snapshot; stamping
now clears the children's records in that case, so a reinserted entry
cannot reactivate the stale claim its previous incarnation left
behind and reject valid events below it.
Server-side copies installed the copied entry without enrolling in the
base protocol, so the copy's own event differed from the stale
pre-copy base and destroyed writes made to the destination after the
copy. The install now refreshes the base and takes its version from
the fenced readback.
* mount: deletion facts outlive the cache's knowledge of the entry
A versioned delete of a path the store held no entry for recorded
nothing, so a delayed older event recreated the path — permanently,
with the deletion's redelivery dedup-suppressed. The tombstone is now
written whenever a versioned event vacates a path: the deletion is a
fact about the path, not about what this cache happened to hold.
For an absent entry, the listing's absence floor now speaks whatever
older record remains: a tombstone at one position does not exhaust
what is known about the path when a newer snapshot has confirmed the
name still absent, and an event between the two was slipping past
both.
A committed copy whose readback failed installed a synthesized base
with local timestamps; the copy's real event legitimately differs from
it, and was read as foreign state — destroying writes made to the
destination after the copy. The handle now marks that its own event is
en route and adopts that event's state as the base without touching
the live entry or the dirty pages; the adoption is one-shot, so a
genuinely foreign event still invalidates.
* mount: authoritative acks cancel pending event adoption; tombstones scoped and pruned
The copy-event adoption flag could outlive its purpose: a flush after
the failed readback installs a newer base and advances the version, the
copy's own event is then version gated without consuming the flag, and
the next genuinely foreign event was silently adopted — base advanced,
live entry and dirty pages untouched — leaving the mount to later
overwrite that remote change. Every local acknowledgment now installs
its base through one helper that also cancels any pending adoption: the
ack supersedes the mutation the adoption was waiting for.
Tombstones were written for every versioned delete under the mount and
survived directory eviction by design, growing LevelDB with historical
deletions on delete-heavy mounts. They are now scoped to directories
whose cached state the fence actually protects — an uncached parent
never serves from the store nor applies the resurrecting insert — and a
completed listing prunes the direct-child tombstones its absence floor
supersedes, leaving only those above the snapshot. The store gains a
key-prefix visitor for the sweep.
* mount: acked saves install their value; trailer snapshots; direct-child prune range
A version must never advance without its value. saveEntry stamped any
open handle with the acknowledgment's version, but a handle opened
while the save was in flight holds the pre-mutation entry — stamping it
fenced out the events carrying the state it lacked, permanently, with
the local apply performing no invalidation and the redelivery
deduplicated. The acknowledged entry is now installed together with its
version, through the same guarded install the racing-open path uses:
under the handle lock, only when it outranks the handle, never over
dirty local writes.
Empty listings return no in-band snapshot — a snapshot-only response
would be read as an entry by older consumers — so directories that end
empty gained no absence floor and their tombstones were never pruned.
The filer now sends the snapshot in the stream trailer, which older
clients ignore, and the client reads it when no in-band snapshot
arrived. Empty directories get real floors, their tombstones prune,
and their buffered replays gain the snapshot filter instead of the
replay-all fallback.
Version records now encode the parent directory and name separated by
a NUL, making a directory's direct children one contiguous key range:
the tombstone prune scans exactly them under the cache lock, instead
of walking every descendant record — the whole store, for root.
* mount: fix dirty-page loss, uid/gid base, download race, copy adopt, leak; dedup
Correctness fixes from the versioned-invalidation review:
- A foreign delete/rename-away of a file held open with unflushed local
writes destroyed the dirty pages unconditionally. A process may keep
writing to an unlinked-but-open file and those writes were already
acknowledged; preserve the pages when the handle is dirty.
- downloadRemoteEntry stored the handle's base with filer-side uid/gid
while every candidate it is later compared against is in local form,
so under a non-identity UidGidMapper an unchanged re-delivery looked
foreign and force-destroyed dirty pages. Map the base to local.
- downloadRemoteEntry wrote the entry/base/version triple under only the
handle's shared lock, so two concurrent reads of the same remote-only
file could tear it. Serialize the install with a dedicated mutex
(invalidation is already excluded by the exclusive handle lock).
- A committed server-side copy whose readback failed adopted the FIRST
event past the version gate as its base; a foreign write delivered
first was silently swallowed. Adopt only an event whose content
matches the synthesized base — the copy's own event — and install any
other normally.
- The deferred-create path relied on AcquireFileHandle installing the
passed entry on a pre-existing handle, which the version rework
dropped. Restore that install in the compat wrapper; the versioned
open path keeps its gated install.
Growth and hot-path cost:
- Per-entry version records and tombstones leaked when a directory was
evicted or read-through without a rebuild. An uncached directory
gates its own inserts, so its records fence nothing; clear a
directory's child version records when it is wiped for eviction.
- FindEntry paid for the version KvGet on every lookup/getattr cache hit
and threw it away. FindEntry now reads only the entry; the hot
lookupEntry cache-hit path skips the version entirely.
Cleanups:
- Extract ackVersionTsNs over the shared response interface, replacing
the metadata-event-else-log-ts snippet copy-pasted at four ack sites.
- Extract acquireRenamePathLocks, replacing the verbatim sorted
two-path lock fence in both rename handlers.
* mount: no resurrection on foreign delete, version no-event acks, gate downloads, tighten copy adopt
Follow-ups to the review patches:
- Preserving dirty pages on a foreign delete let the next flush pass the
isDeleted guard and CreateEntry, resurrecting the remotely-unlinked
name. Mark the handle deleted in the vacate branch: the open fd can
still read its buffered writes, but a flush no longer recreates the
file.
- A no-event acknowledgment (log fence only) synthesized a metadata
event with TsNs 0, so the cache stored the entry unversioned and an
older subscriber event rolled it back. Stamp the synthesized event
with the ack's log position at all four ack sites.
- downloadRemoteEntry serialized its install but did not check the
version, so an older response arriving last overwrote the entry/base
while the monotonic version kept the newer value, fencing corrections
out. Install only when the response is at least as new as the handle.
- sameEntryContent compared only size and chunks, so a foreign chmod
with unchanged content was adopted as the copy's own event. Compare
everything except server-assigned timestamps, so a metadata-only
foreign change installs instead.
* mount: trim comments to the non-obvious why
The versioning work accumulated multi-line comment blocks restating what
the code says. Keep the constraint a reader cannot derive — why a fence
is exact, why a version must not advance without its value, why an
uncached parent's records fence nothing — and drop the rest.
* mount: distinguish rename from delete, tighten the download and adopt gates
- A rename emits a nil old-path invalidation just like an unlink, so the
vacate branch marked the handle deleted and later writes through the
already-open descriptor were skipped instead of persisted. Carry the
delete/rename distinction on the invalidation and mark only an actual
delete.
- The remote-download install accepted an unversioned response
regardless of the handle's version, so during a rolling upgrade a
delayed response could install stale content under a newer version.
Require the response to be at least as new, with one exception: a
handle still lacking local chunks takes the content anyway — it cannot
read without it — but does not claim the response's log position.
- Copy-event adoption returned without installing, so a foreign touch
arriving before the copy's own event lost its timestamps. Content is
unchanged either way, so the dirty pages stay valid; a clean handle now
takes the entry, while a dirty one keeps its diverged version.
* mount: one directory floor instead of a record per child; agree on TTL
Review feedback:
- Build completion wrote one KV record per direct child inside the cache
write lock, so a large directory stalled every other cache operation
for O(children) store writes. The directory's listing snapshot already
covers every child it saw; make that floor the version for any child
without a record of its own, and a child earns a record only when a
later event touches it. One map write per build replaces the per-child
writes, with the same fencing.
- The presence probe read the store directly and so counted a
TTL-expired entry as present, judging the path by a record describing
content that has logically vanished. It now applies the same expiry
the read path does, and an expired path falls back to its directory
floor.
- Preserve ErrNotFound identity when the commit-time re-read finds the
object deleted, so callers still surface a 404.
- Assert the rename-away source fence timestamp in the invalidation test.
Also record the tombstone ceiling: distinct deleted names in a cached
directory accumulate until it is rebuilt or evicted, which prunes
everything at or below the new snapshot.
* mount: pin the fence's clock domain instead of letting skew decide
A log-position fence is stamped by one filer's clock under that filer's
in-process lock, so comparing it to an event another filer logged is
comparing two unrelated clocks. The two error directions are not equally
costly: applying an event the fence already covered is a re-apply the
base-equality check absorbs, while skipping one it does not cover leaves
the handle holding exactly the state the event was meant to correct,
with the subscription cursor already past it — the unhealable staleness
this whole PR exists to remove.
So refuse to guess. Fences now carry the signature of the filer that
stamped them, and a handle records it alongside the position. An event
is only fenced out when the filer that logged it is the one that stamped
the fence — the logging filer appends its own signature, so its presence
identifies the clock domain. Events from any other filer are applied.
Positions taken from events keep comparing as before; the subscription
already delivers those in order.
The invalidation callback takes a struct now: it carries the path,
entry, position, delete/rename distinction, and signatures, and was
about to need a fifth positional parameter.
* mount: follow a foreign rename; key page invalidation on content, not equality
- A rename's old-path invalidation now carries the destination, and the
handle follows the file there: an open fd tracks the inode, and leaving
it on the old path made its next flush recreate that name instead of
updating the renamed file.
- Dirty pages overlay content, so only a content change invalidates them.
Keying that on exact equality meant any timestamp-only event destroyed
them, which the copy-adoption marker existed to paper over — a foreign
touch could consume the marker and leave the copy's own event to drop
the post-copy writes. Comparing content instead makes the marker
unnecessary, so it is gone: a metadata-only event keeps the overlay,
and a dirty handle keeps its diverged entry unless foreign content
supersedes it.
- A remote download response that is merely older is now refused even
when the handle still lacks chunks; only an unversioned one is taken
(and claims no position), since an older response's content predates
what the handle reflects.
- A refused or unversioned download no longer publishes to the metadata
cache, where a zero-position event would clear the entry's version and
let an older subscriber event roll the cache back.
* mount: page invalidation keys on content alone; unversioned writes claim no position
- sameEntryContent compared everything but timestamps, so a foreign
chmod, chown, or xattr change counted as a content change and
destroyed the dirty-page overlay. It was strict only to serve the
copy-adoption marker, which is gone; its one caller now asks the
question it actually needs — did the bytes change — so metadata-only
events leave the overlay alone.
- A rename over an existing file destroys that file, but its open handle
was left live and still pointed at the name the renamed source now
occupies, so its flush could overwrite it. MovePath already reports the
displaced inode; mark that handle deleted.
- An acknowledgment was refused whenever its position was numerically
lower, even when a different filer stamped the fence it lost to. Two
known, differing signatures mean unrelated clocks, so the comparison no
longer applies there; unknown signatures still compare as before.
- A local write with no log position behind it now records that
explicitly instead of deleting its version record. Absence means the
directory listing covers the path, which is why the snapshot floor
applies; local content the listing never saw must not inherit it, or
the events that would correct it are fenced out.
* mount: widen the existing lookup functions instead of forking WithVersion twins
The versioning work grew a parallel function for every accessor that
needed to return a log position — lookupEntryWithVersion beside
lookupEntry, maybeLoadEntryWithVersion beside maybeLoadEntry,
FindEntryWithVersion beside FindEntry, AcquireFileHandleWithVersion
beside AcquireFileHandle, advanceEntryVersion beside
advanceEntryVersionTsNs, plus a getPbEntryWithVersion wrapper and an
InsertListedEntriesForTest hook. Two names for one operation is two
places to keep in step, and the split let callers pick the one that
happened to compile.
Each pair is now the single original name carrying the position, with
callers that do not want it discarding it. filer_pb.GetEntry returns the
fence its response already carried rather than a mount-side wrapper
re-issuing the lookup, and InsertEntry takes the position its content
reflects rather than a test-only twin that inserted without one.
The one behavioural knot the merge exposed: AcquireFileHandle had been
installing the entry on a pre-existing handle only in its unversioned
form, which conflated 'the caller is authoritative' with 'the lookup had
no version'. Deferred create is the only caller that means the former,
so it now installs explicitly and the map function just acquires.
On renewal failure the renew goroutine stored isLocked=false before its
deferred renewGoroutineRunning.Store(false) ran. A concurrent RequestLock
interleaving there reacquires the lease (sees isLocked=false), sets
isLocked=true, then its CompareAndSwap on renewGoroutineRunning fails because
the old goroutine's flag is still set — so no replacement renewer starts. The
lock is then held locally with nothing renewing it, and silently expires on
the master after the lease TTL, admitting a second holder.
Clear renewGoroutineRunning before isLocked on the failure path so the
reacquire path always starts a fresh renewer. Builds and vets clean; no
behavior change on the success path.
Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
The copying and tagging tests force-drop each bucket's collection at the
master so volume slots are freed deterministically between tests. But the
copy-tests CI job runs its master on 9336 and the tagging Makefile on 9338,
while the tests default to 9333 — the cleanup dialed a dead port and quietly
no-oped. Each test bucket then grows 7 volumes against -volume.max=100, and
whenever async deletion lagged the data node ran out of slots and PutObject
500ed with "No writable volumes and no free volumes left".
Set MASTER_ENDPOINT where the master port is non-default: the copy-tests
workflow step, and the copying/tagging Makefiles (derived from MASTER_PORT).
* telemetry: validate reports on the collect endpoint
/api/collect is anonymous, so reports can't be authenticated, but a
real master can't produce a non-UUID topology_id, a version outside
N.NN(-enterprise), an unknown GOOS/GOARCH, or absurd counts — reject
those to keep casual junk out of the collected data, and cap the
request body at 4 KB.
* telemetry: integration test fixtures pass collect validation
The test's topology id and version were exactly the junk shapes the
new validation rejects; use a UUID and a plain version number.
telemetry: confirmed-cluster stats
Count a cluster as confirmed once it has reported on >=2 distinct UTC
days (per-cluster history makes this a length check). Version/OS
distributions in /api/stats are computed over confirmed clusters, so a
one-shot injected report can't appear in them; falls back to all active
clusters while no confirmed ones exist (fresh server). Adds the
seaweedfs_telemetry_confirmed_clusters gauge and a dashboard card.
telemetry: per-cluster usage history
Keep one compact sample per cluster per UTC day (disk bytes, volume
count, volume servers), retained for -max-age and persisted in the
state file. Serve it at /api/history?cluster_id=...&days=90 and add a
per-cluster lookup with disk/volume charts to the built-in dashboard.
* telemetry: persist server state across restarts
The telemetry server kept the instance map and Prometheus gauges only
in process memory, so every deploy or restart reset all collected
metrics until clusters re-reported over the next 24h.
Snapshot the instance map to a JSON state file (atomic tmp+rename) on
a debounced interval and on SIGTERM, and restore it on startup,
preserving received_at so the cleanup and active-cluster windows stay
correct. Defaults to data/telemetry-state.json, which the deployed
systemd unit's WorkingDirectory already provides; -state-file=''
disables.
* telemetry: keep instances for 90 days by default
With state now persisted across restarts, a longer retention default is
meaningful; raise -max-age from 30 to 90 days so per-cluster data
survives long enough for quarterly views.
ci: build telemetry server from its own module in deploy workflow
telemetry/server has had its own go.mod since #9924, so building
./telemetry/server/main.go from the repo root fails with 'no required
module provides package'. Build from within the module instead.
telemetry: key value gauges by cluster_id only
The value gauges were labeled {cluster_id, version, os}, so a cluster
reporting back after an upgrade started a new series while the old one
kept its last value forever: sum() double-counted every upgraded
cluster, and per-cluster history broke at every version change.
Key the five value gauges by cluster_id alone so each cluster keeps one
continuous series across upgrades; version/os metadata stays on
cluster_info (deleted and re-set on change), available to value queries
via 'on(cluster_id) group_left' joins. Update README accordingly.
telemetry: aggregate /api/metrics per day so dashboard charts render
The dashboard expects {dates, server_counts, disk_usage} as parallel
arrays, but GetMetrics returned per-instance {date,value} lists under
different keys, so the two over-time charts never rendered.
* helm: generate the SFTP host key per install
The SFTP secret template shipped one fixed ed25519 host key, so every
install that did not override it presented the same host identity.
Generate the key at install time instead, following the
getOrGeneratePassword pattern: an existing secret keeps its key across
upgrades, except the previously bundled one, which is replaced with a
freshly generated key on the next upgrade.
* helm: create the SFTP host-keys secret the deployments mount
Both the sftp and all-in-one deployments mount /etc/sw/ssh from
<fullname>-sftp-ssh-secret, but no template created it, so a default
install could not start its pod and host keys only reached the server
when enableAuth happened to mount them elsewhere. Create the secret
with a generated ed25519 key, keeping whatever keys an existing secret
already holds. The sshPrivateKey default becomes empty: the file it
pointed at only exists when enableAuth mounts /etc/sw, and a configured
but missing key file is fatal to the server, while hostKeysFolder now
always has a key.
* helm: test SFTP host key generation and secret lifecycle
Template checks: keys render into the secret the deployments mount,
parse as PKCS#8 ed25519, differ between installs, and the render
carries no key material from the chart itself; existingSshConfigSecret
and all-in-one wiring covered. On the kind cluster, exercise the
secret lifecycle: a generated key survives upgrades, the key earlier
chart versions bundled is replaced, and operator-managed keys are kept
untouched. chart-testing now also installs with sftp enabled, where
the pod only becomes ready if the server loads the generated host
key.
* helm: treat a whitespace-only stored SFTP host key as missing
A whitespace-only secret value skipped regeneration and then rendered
an empty key file.
* helm: mount the SFTP host keys secret at the configured hostKeysFolder
The secret was mounted at a fixed /etc/sw/ssh, so a custom
sftp.hostKeysFolder pointed the server at an empty directory. Mount at
the configured path in both the sftp and all-in-one deployments, and
pin flag/mount agreement in the rendering tests.
* s3: track manifest blob ownership through multipart completion
Manifest blobs made three orphan paths. A partial fold that failed midway
kept its earlier batches on volume servers while the write fell back to
flat chunks; the fold now records each saved blob and deletes them on
error. A completion that failed after preparing left its fresh manifests
behind on every retry; the completion state now owns them and deletes
them unless a failed rollback left the version entry still holding them.
And a completed upload removes its parts metadata-only, which stranded
the part-manifest blobs superseded by flattening; those are collected
during flattening and deleted once the completion commits.
Two shared-chunk hazards nearby: the version-file rollback deleted its
data, destroying the still-registered parts (worse once manifests
resolve to inner chunks), and the idempotent-replay cleanup data-deleted
leftover parts whose chunks the live object references. Both are
metadata-only now.
* s3: trim chunk manifest comments
* s3: test manifest fold rollback and part range selection
The fold-with-rollback and the boundary-to-byte-range logic were only
exercised by hand against a live server; give both an injectable seam
and cover the fold, the below-threshold and SSE no-ops, the midway
failure deleting the blobs it saved, and offset-vs-legacy-index range
selection including indexes that no longer address the chunk list.
* erasure_coding: WriteDatFile takes the encode-time dat size for the shard block layout
* volume server: derive EC decode layout from the encode-time dat size, not the live extent
* erasure_coding: test decode after tail deletions shrink the live extent below a large-block row
* seaweed-volume: write_dat_file_from_shards takes the encode-time dat size for the shard block layout
* seaweed-volume: derive EC decode layout from the encode-time dat size, not the live extent
* seaweed-volume: test decode after tail deletions shrink the live extent below a large-block row
* erasure_coding: reject decoding with no data shards
* worker: record the encode-time dat size in the .vif
* erasure_coding: fall back to the shard-derived layout only when the encode-time dat size is missing
* erasure_coding: reject an ambiguous shard-derived block layout
* seaweed-volume: fall back to the shard-derived layout only when the encode-time dat size is missing
* seaweed-volume: reject an ambiguous shard-derived block layout
* filer: accept a WriteCondition on UpdateEntry, under the per-path lock
UpdateEntry was a bare read-modify-write: the precondition check, the
chunk garbage diff, and the store write could interleave with a
concurrent update to the same path. Take the per-path lock CreateEntry
already holds, and evaluate an optional CreateEntry-style WriteCondition
under it, failing with FailedPrecondition like expected_extended.
* filer: IF_CHUNKS_EQUAL write condition compares the stored chunk fid set
A chunk-preserving read-modify-write (tagging, setattr, copy-in-place)
races UpdateEntry's garbage diff: if a concurrent update empties the
chunk list first, the stale writer's commit resurrects fids that are
already queued for deletion, stranding the entry on a dead needle once
vacuum reclaims it. The reverse also holds: a writer that read an empty
chunk list can wipe chunks a concurrent update just added.
IF_CHUNKS_EQUAL guards both: the stored chunk fid multiset must still
equal what the caller read, order-independent, with an empty fids list
expecting no chunks. Absent entry counts as no chunks for CreateEntry
overwrites and transactions.
* filer: delete and append serialize on the entry path lock
DeleteEntry queues the entry's chunks for deletion and AppendToEntry
rewrites the chunk list, but neither held the per-path lock, so either
could interleave with a conditional update between its precondition
check and its write — a passed IF_CHUNKS_EQUAL would then resurrect
fids already on the deletion queue, or clobber a freshly appended
chunk. AppendToEntry keeps the cluster lock for cross-filer append
serialization; the path lock covers the local read-modify-write.
* filer: reuse lockPath in UpdateEntry lookup
s3: fold large chunk lists into manifest chunks on the direct write path
The S3 gateway uploads chunks itself and hands the filer a fully prepared
entry. On the routed write path (ObjectTransaction) the filer stores that
entry as-is, so a large PutObject or CompleteMultipartUpload persisted its
whole flat chunk list - a 900GB object carries 120k chunk references in one
entry. Manifestize on the gateway before the entry is written, the same way
mount, WebDAV, and filer.copy prepare theirs.
Multipart part boundaries also record byte offsets now: the stored chunk
indexes stop matching the entry once the list is folded, and partNumber
reads plus GetObjectAttributes prefer the offsets. Legacy index-only
records still work, with bounds checks instead of a possible panic.
Copy paths resolve a manifested source into data chunks before their
per-chunk copy loops - copying a manifest chunk raw would store its blob
as object data still pointing at the source - and a large copied list is
folded again on the destination. Completion likewise resolves manifest
chunks a part entry may carry (the filer folds an oversized UploadPartCopy
range) before rebasing part offsets.
The maintenance operations built manifest, manifest-list, data-file and
metadata-log paths with a bare path.Join("metadata", ...), so a single
maintenance run wrote scheme-less relative paths into the new snapshot,
the metadata-log and the table xattr. SeaweedFS reads those back fine
because normalizeIcebergPath accepts both forms, but strict readers
resolve every location through S3FileIO and fail with "Invalid S3 URI,
cannot determine scheme", leaving the whole table unreadable after any
maintenance commit.
Add absoluteIcebergPath, the inverse of normalizeIcebergPath, and apply
it at every site that authors locations: rewrite_manifests, compact,
rewrite_position_delete_files, and the shared commit path. The base is
derived from the bucket and table path since that is where the worker
physically writes, matching the locations the REST catalog generates.
The pre-move stale check compared the master's location urls (host:port)
literally against the proposal's node addresses, which carry the grpc
suffix (host:port.grpcPort). Every move failed as "stale move: volume no
longer on source" even though the volume was still there. Normalize both
sides through pb.ServerAddress before comparing.
* regenerate master_grpc.pb.go with protoc-gen-go-grpc v1.6.2
The other generated pb files are already on v1.6.2; this one was stale.
* shell: keep unlock from racing the lease renewal
A renewal RPC in flight while ReleaseLock runs re-creates the lock on the
master after the release deletes it, and can blank the client name if the
renewal reads it mid-release. The stale-token release is then ignored, so
the lock stays held (sometimes anonymously) until it expires. Serialize
the renew and release RPCs, and set the client name before flipping
isLocked so the renewal never sends a partial acquisition.
* shell: restart lease renewal after a failed renewal
The renewal goroutine exits on error but never cleared its running flag,
so later locks in the same process were never renewed and silently
expired after ten seconds.
* shell: show who holds the cluster lock
A blocked lock command gave no hint that another client holds the lock
(the refusals only surfaced at -v=2), and cluster.status reported the
shell's own lock state as if it were the cluster's. Add a
GetAdminLockStatus RPC to the master so lock prints the holder before
blocking and cluster.status shows the actual cluster-wide holder. Both
degrade silently against masters without the RPC.
* shell: bound admin lock RPC attempts with timeouts
The lease, renew, release, and holder-status calls all ran without a
deadline, so an unresponsive master could hang the renewal goroutine,
an unlock (which now waits on the renewal mutex), or the shell prompt.
Give each attempt its own short context; the retry loops still resolve
a fresh leader on the next try.
* master: reject admin token release on non-leaders
A follower holds no lock state, so it answered a release with success
while the leader kept the lock until expiry. Refuse like LeaseAdminToken
does so the client can try the leader instead.
* shell: leave the lock release call unbounded
A release cut short by a deadline leaves the lock held on the master
until it expires, so a slow master would turn every unlock into a
ten-second ghost lock. Restore the single fire-and-forget attempt;
the timeouts stay on the lease and renew paths, where a stalled call
forfeits the lease anyway.
* shell: release only the token unlock started with
A RequestLock racing a slow release (the admin presence lock does this
on shutdown) could have its freshly acquired token sent in the release
request or zeroed by the trailing stores. Capture the token once under
the mutex and compare on clear so a concurrent acquisition survives an
in-flight unlock.
Reads of remote-backed entries now record hit or miss in
SeaweedFS_remote_cache_read_total{source,bucket,result} on the filer HTTP
path and the S3 gateway, so cache effectiveness of mounted buckets can be
graphed. Inline-content entries count as hits since they are served
locally without chunks. The filer purges the per-bucket series when the
bucket directory is deleted, so a standalone filer does not accumulate
series across bucket delete/recreate churn.
Only the heartbeat path read the .dat mtime; collectStatForOneVolume left
the field at 0, so /status consumers could not tell how long a volume had
been idle.
Commit 8bff3b32 changed BatchDelete to keep processing after a cookie
mismatch but left the integration test asserting the old early-break
behavior, breaking Volume Server Integration Tests (grpc - Shard 1) on
master. Align the test with the new semantics and port the same
break->continue to the Rust volume server, which runs the same suite
via VOLUME_SERVER_IMPL=rust.
The initiator's shed check (initiatedGrow != HasGrowRequest) compares
against an err from a PickForWrite that may predate the growth
concluding: the grower registers its volumes before clearing the flag,
so when growth lands between the failed pick and the check, the assign
shed ResourceExhausted even though a writable volume was already
registered. Re-pick once after observing the conclusion and shed only
if the volume layout still has nothing writable. Applies to both the
gRPC Assign and the HTTP dirAssign paths, which share the shed logic.
Flaked in CI as TestAssignInitiatorWaitsForItsOwnGrowth; reproduced
deterministically by widening the enqueue-to-check window.
The dev container workflow prebuilds the Rust volume server from
seaweed-volume/ but did not watch that directory, so Rust build fixes
landed without this workflow validating them.