Commit Graph
14645 Commits
Author SHA1 Message Date
Chris LuandGitHub 5269d93fa8 s3: let a suspended-versioning multipart completion replace the null delete marker (#10585)
* s3: let a suspended-versioning multipart completion replace the null delete marker

In a versioning-suspended bucket a DELETE writes a null delete marker into the
key's .versions directory. CompleteMultipartUpload then writes the new null
version at the regular path but left that marker in place, so the completion
returned 200 and the object listed while HEAD and GET kept resolving the marker
and answered NoSuchKey. PutObject already handles this; do the same on the
multipart path.

* s3: order the suspended-versioning null cleanup behind the multipart write

Removing the null delete marker before writing left a failed completion having
already published the key's newest real version: the marker was gone, the
pointer still named it, so reads rescanned .versions and promoted the older
version. Do both fixups only once the write commits, pointer first so reads
never see a pointer aimed at a marker that is no longer there, and fail the
completion when the pointer cannot be cleared instead of returning 200 for an
object HEAD and GET still miss - a non-ErrNone finalize keeps the upload
directory, so the caller's retry replays it.

Also cover a pre-suspension real version in the regression test.

* s3: skip the suspended null cleanup when a concurrent write won the key

The completion's .versions fixups are unconditional rewrites of shared state and
the routed path runs off the object write lock, so a DELETE landing between the
multipart write and the cleanup had its own null delete marker erased - leaving
a successfully deleted key readable as an older retained version. Re-read the
object first and leave the cleanup alone unless it is still the one we wrote.

This narrows the window rather than closing it; a compare-and-set pointer flip
is the real answer and wants its own change.

* s3: re-read the completed object from the filer that took the write

The guard compared the object against our upload id through the routed read,
which skips an owner it recently found unreachable and falls back local-first.
A write that just landed on the owner could then read as superseded on another
filer, skipping the cleanup and leaving the key unreadable - the bug this set
out to fix. Read back from the filer the write went to instead.

* s3: trim the suspended-completion comments to the non-obvious why

* s3: lift the suspended null-write finalize into a named helper

The pointer-then-marker ordering is policy shared by every suspended null write,
not something the multipart path should be stating on its own; putSuspendedVersioningObject
and the copy path each restate it today. Give it a home next to the versioned
finalize helpers, and reuse the canonical key normalizer and the existing test
helpers rather than open-coding both.

* s3: retire the null delete marker on a suspended-versioning copy

The suspended CopyObject branch cleared the .versions latest pointer but left the
null delete marker a preceding DELETE wrote. While the regular-path object owns
the null slot that marker is shadowed, so it reads and lists correctly - but it
resurfaces as a phantom delete for a key nobody deleted once that null version
goes away. Route the branch through the shared finalize.

* s3: keep the suspended null cleanup from erasing a concurrent delete

Retiring the marker on the copy path reopened the race the multipart path had
already closed: a DELETE landing between the write and the cleanup lost its own
marker, so a rescan promoted an older version under a deleted key. Move the
ownership check into the shared finalize, keyed on the attribute that identifies
the caller's write, so both paths get it.
2026-08-05 11:49:36 -07:00
Chris LuandGitHub 7063b3e14c s3 lifecycle: bound the daily-replay pass so a quiet cluster stops wedging the job (#10578)
* s3 lifecycle: bound the daily-replay subscription at the pass boundary

A pass opens one meta-log subscription and 16 shard drains, then waits
on all of them. Nothing told the subscription where the pass ends, so
the only exit was the fan-out spotting an event past runNow — i.e. some
unrelated write landing under /buckets after the pass started. On a
cluster that goes quiet the reader parks in Recv, every shard drain
starves on an empty channel, and Run never returns. The job sits at
stage "starting" with the executor slot held and no log line, so
expiry stops cluster-wide until someone restarts the worker.

The pass covers (globalStartTsNs, runNow], so say that: UntilNs on the
subscribe request makes the filer end the stream once it has shipped
that range. The reader then closes the event channel on the way out,
which is what unblocks the fan-out and the drains when the stream
finishes on its own rather than by cancellation.

Same fix retires the other silent hang: a reader that failed early
(subscribe error, stream error) also left every drain waiting forever.

* s3 lifecycle: keep a halted shard from starving the shared fan-out

A drain that halts mid-stream (BLOCKED / RETRY_LATER / an RPC error on
dispatch) returns while the fan-out is still routing that shard's
events. After 256 of them the per-shard buffer is full and the fan-out
blocks on the send, so no other shard sees another event. Run's
WaitGroup never drains, and the teardown that would cancel the reader
sits behind that wait — the pass wedges exactly like an idle
subscription did, with one S3 hiccup as the trigger.

Keep discarding the channel after runShard returns. The events are
past this shard's saved cursor and get re-scanned next pass anyway.

* s3 lifecycle: assert the starved shard actually made progress

The fan-out test only checked that Run returned, which a version that
quietly dropped the second shard's events would also satisfy. Assert
the dispatch landed and the cursor moved.

recordingClient gains a per-object outcome map: the two shards dispatch
from separate goroutines, so pinning BLOCKED by call index was a race
waiting to pick the wrong shard.

* s3 lifecycle: fail the pass when the shared subscription dies

Closing the event channel on reader exit is what unblocks the shard
drains, but it also means a subscribe that never opened, or a stream
that broke mid-pass, now ends every drain cleanly. Run logged that at
V(2) and returned the shard result — so a filer failure produced a
green lifecycle job that had processed nothing.

Surface it as the pass error. Cursors still hold what was processed and
tomorrow resumes there; what changes is that the job stops claiming
success.

Cancellation has to stay a non-error — the shell driver's -runtime cap
is a truncated pass, not a failed one — and a canceled gRPC stream
arrives as a status code, not a wrapped context.Canceled, so isCanceled
checks both forms the way the rest of the tree does.

* s3 lifecycle: decide reader cancellation by intent, not status code

A stream we cancel and a stream the filer cancels both arrive as
codes.Canceled, so classifying the reader's exit by its error let a
truncated pass report success whenever the failure happened to carry a
cancellation status.

Intent is knowable exactly, so read that instead: the pass stops on
purpose only when the caller's context ended (the shell driver's
-runtime cap) or the fan-out hit the pass boundary itself. Everything
else is a broken subscription and fails the pass.

TestRun_ServerSideCancelFailsThePass and TestRun_CappedPassIsNotAFailure
are the same codes.Canceled from the reader with opposite verdicts —
the pair only passes because the decision no longer looks at the error.

* s3 lifecycle: time out a subscription that stops delivering

UntilNs ends a healthy stream and gRPC keepalive catches a dead
connection, but neither reaches a filer that keeps answering pings while
its handler has stopped producing. The pass would wait on that forever,
since s3_lifecycle is the one job type with no execution timeout.

Bound the wait for each response at 20 minutes, and opt into the filer's
idle heartbeats so a caught-up stream proves liveness instead of looking
stalled. The default sits above the filer's 15-minute metadata-gap
recovery budget, so a subscriber legitimately parked on a gap is never
mistaken for a stalled one.

Recv is only interruptible by killing the RPC, so it moves to its own
goroutine behind a per-response deadline. The timer covers only the wait
on the filer — dispatch to Events happens outside it, so a slow consumer
can't trip the watchdog.

Approach and the 20-minute figure are from #10577 by way of comparing
the two fixes; the wiring differs because the reader here ends the pass
by closing its event channel rather than cancelling the fan-out.

* s3 lifecycle: trim the comments added by this branch

Keep the non-obvious why, drop the prose restating what the code says.

* s3 lifecycle: snapshot reader intent where the reader stops

Sampling ctx.Err() during teardown reads it after the drains and cursor
saves have run. A reader that failed while the deadline was still live,
on a pass whose teardown then outlives that deadline, was classified as
an intentional stop and reported success.

Sampling earlier in Run is not the fix either: before the shard wait, a
legitimately capped pass has not reached its deadline yet and would be
misclassified the other way. Intent belongs where the reader actually
stops, so the reader goroutine records it next to the error it returns.

Reported by greptile on #10578.

* s3 lifecycle: cover the worker-dispatched pass with nothing due

The e2e suite drives the shell command in 14 of 15 files; the one test
on the real admin->worker path backdates an object, so its own delete
pushes a meta-log event past the pass boundary and ends the pass. The
branch where a pass has nothing to dispatch was never exercised through
the worker.

Cover it, asserting the pass returns on its own: no admin cancellation,
and the executor slot free for the next one.

This is not a regression test for the wedge. A pass used to end when any
write landed past its boundary, and on a shared test cluster something
usually does — the whole suite passes on the unfixed build, verified.
The deterministic guards stay the dailyrun unit tests; this one would
catch a pass that hangs unconditionally.
2026-08-05 08:41:37 -07:00
Chris LuandGitHub 44e546a933 shell: pick tier.move replica targets with the shared placement picker (#10582)
The command chose destinations by walking its location list in order, so it
neither preferred a node near the source nor spread a burst of copies. Replica
placement and "this node already holds the volume" move into the Accept
predicate; the ranking and the per-pick reservation come from placement.

Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
2026-08-05 00:27:14 -07:00
Chris LuandGitHub 7d6c83b126 s3: stop treating a directory marker as a versioned object (#10573)
* s3: delete a directory marker instead of versioning it

The key "dir/" is stored as the filer directory itself, so a delete marker
cannot stand in for it without hiding the children underneath, and its history
has to sit inside the directory it describes, where listings keep meeting it.
Delete it the way an unversioned bucket already does: remove the directory
when nothing is left under it, demote it to a plain directory when children
remain, and drop a history an older build recorded for it.

* s3: stop resolving directory markers through a version history

Nothing records one for them any more, so the lookups that read it are dead
weight - and the one in the listing was a filer round trip per directory
marker returned, which for a bucket that keeps a marker per directory is the
whole listing cost. A listing reads what a directory stands for straight off
the entry it already has; a unit test pins that N markers cost one ListEntries
rather than N+1. The guard that keeps a history left inside a directory by an
older build from surfacing as a key named after it stays.

* s3: do not let deleting "dir/" destroy the object at "dir"

Writing under an existing object turns that object's entry into a directory
while it keeps its data, so the keys "m2" and "m2/" end up sharing one entry.
Stripping the entry to delete "m2/" therefore wiped the object at "m2" - a
different key, and in a versioned bucket one no delete marker records. Leave a
directory holding uploaded data alone; "m2/" does not name it.

* s3: make the directory-marker delete fail closed and take the write lock

The guard that spares a promoted file only fired when the entry read
succeeded, so a transient filer error fell through to the delete and could
destroy the object at "dir" anyway. Fail the request instead, take the object
write lock so the entry cannot change between the check and the delete, and
report a stale history that cannot be removed rather than leaving it to keep
naming the key in ListObjectVersions.

* s3: check If-Match inside the directory-marker delete lock

The lock belongs to the caller: taking it inside the delete nested it under the
batch handler's own lock, and since every lock from a gateway shares one owner
the inner release would have freed it while the outer caller still assumed it
held it. Both callers now own the lock, the single-object path re-checks
If-Match inside it the way the other delete paths do, and a batch delete of a
trailing-slash key in an unversioned bucket goes through the same marker path
instead of the raw delete. A history lookup that fails now fails the delete.
2026-08-05 00:24:54 -07:00
Chris LuandGitHub 03388c4beb placement: let callers reject candidates placement cannot judge (#10581)
Replica placement rules and "this node already holds the volume" are
constraints the picker has no way to model. The predicate receives the
candidate's rack and data center, because the constraints needing them are
exactly the ones a bare node cannot express.

Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
2026-08-05 00:18:22 -07:00
Chris LuandGitHub aceab0802e placement: move the target picker out of the shell package (#10580)
weed/shell is the CLI command layer: every file there registers a command in
init(). Importing it for placement drags that whole surface, and its
registration side effects, into callers that run no CLI.

The picker now depends only on master_pb and storage/types, with a node type
of its own -- smaller than the balancer's Node, which also carries the volumes
it holds, and placement never needs those.

Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
2026-08-05 00:12:04 -07:00
Chris LuandGitHub 4b12af036c shell: add a reusable target picker for volume moves (#10579)
* shell: add a reusable target picker for volume moves

Picks the emptiest node near the source: locality first (same rack, then same
data center), emptiest within a tier. Free bytes decide where the cluster
reports them, free slots break the tie.

The pick is spent in the passed topology, so planning several moves from one
snapshot spreads them instead of stacking every one on whichever node started
emptiest.

Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu

* shell: order targets on one metric, and reserve the real volume size

Comparing some pairs on free bytes and others on free slots is intransitive, so
the winner depended on sort order. One node too old to report filesystem bytes
now puts every candidate on slots.

Reserving the tier average let a batch of large volumes overcommit a
destination; callers pass what the move actually consumes.

Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
2026-08-05 00:07:39 -07:00
Chris LuandGitHub 3c549b33ab ci: deal volume server tests across shards instead of bucketing by letter (#10576)
Both workflows split the suite with ^Test[A-H] / ^Test[I-S] / ^Test[T-Z].
Test names cluster, so shard 2 drew 50 of the 114 grpc tests and 30 of
the 64 http ones, and spent 13m51s against 7m48s and 9m09s for its peers.

Listing the tests and dealing them out one at a time splits them 38/38/38
and 21/22/21, and keeps splitting evenly as tests are added. The pattern
is computed once into the environment rather than repeated in the summary
step, where the two copies had to be kept in agreement by hand.
2026-08-04 21:42:34 -07:00
Chris LuandGitHub f5fd5450d8 ci: cross-compile each target in its own job (#10575)
The four targets ran in a shell loop at ~3m13s each, so the job took
13m29s and gated the whole workflow by itself: everything else finished
within 8m13s. A matrix runs them concurrently, ~3m45s wall clock.

fail-fast is off so one broken target still reports the other three,
instead of one target per push.
2026-08-04 21:21:15 -07:00
Chris LuandGitHub 505049a4de volume: skip directory fsync on Windows, report a failed makeupDiff (#10572)
* volume: skip directory fsync on Windows

* ci: run the windows jobs for the whole vacuum path

Both windows jobs start the same weed mini cluster, so both exercise the
volume server's vacuum path, but only one of them watched a single file
in it. Cover the compact, reconcile and load files in both.

* volume: report a failed makeupDiff instead of discarding it

The cleanup removes assigned to the same err the makeupDiff failure was
held in, so an aborted compaction returned nil once both removes
succeeded. The master then recorded the vacuum as committed and the
volume reloaded against the discarded generation.

* volume: correct the fsyncDir comments after the windows skip

Both comments described the old shape, where windows fell through to a
sync whose error was swallowed.

* volume: keep the makeupDiff failure ahead of its cleanup errors

A failed remove of .cpd/.cpx outranked the failure that abandoned the
compaction, so the caller saw the cleanup error instead of the cause.
Log it and return the original, matching the Rust do_commit_compact. A
leftover temp file is rolled back by reconcile on the next start.
2026-08-04 21:02:52 -07:00
Chris LuandGitHub d448e9db7b iceberg: withhold the S3 endpoint from credential-vending clients (#10570)
* iceberg: withhold the S3 endpoint from credential-vending clients

A client that sends X-Iceberg-Access-Delegation: vended-credentials builds
its storage credential out of the LoadTable config and drops the one it was
configured with. We vend no credentials, so the endpoint we advertised left
DuckDB signing nothing: every metadata and data file came back 403, and its
attempt to refresh the empty credential 404ed on stage-created tables.

Answer those clients with no config at all so they keep their own
credentials. Clients that do not ask for delegation still get the endpoint.

* iceberg: mark load responses as varying on the delegation header

The FileIO config in a table or view load response now depends on whether
the client asked for vended credentials, so a cache between us and the
client must key on that header rather than on the URL alone.

* test: cover the DuckDB vended-credentials access pattern

Runs weed mini with -s3.externalUrl, which is what makes the catalog
advertise an endpoint at all, and checks both halves: a plain LoadTable
still gets the endpoint, while one asking for vended credentials never gets
an endpoint without the credentials to sign with. The DuckDB round trip
creates a table from a query and reads it back, which is the flow that
failed with 403 on every data file.
2026-08-04 18:14:34 -07:00
Chris LuandGitHub 474a0713b0 s3: honor the version history of a directory marker (#10571)
* s3: stop listing a directory marker whose latest version is a delete marker

A directory marker is stored as the filer directory itself, so deleting the
key "dir/" writes its delete marker into dir/.versions while the directory
keeps its mime and stays a key object. Every listing kept reporting the key.
Consult that history before treating the entry as a key, and demote it in
memory when it is delete-marked so live children still hold the prefix.

Also skip the container's own .versions entry while listing inside it: the
suffix match read it as the history of a nested object named "", which
surfaces as a phantom dir/dir key as soon as a live directory version exists.

* s3: a directory marker with version history is not also the latest null version

The directory entry behind the key "dir/" is that key's null version, but
list-object-versions reported it with IsLatest hardcoded true. After a delete
the key came back twice, once as the delete marker and once as a null version,
both claiming to be latest. Read the pointer under the directory instead.

* s3: resolve directory markers through their version history on GET and HEAD

GET and HEAD short-circuit any trailing-slash key straight to the filer
directory, so a directory marker kept answering 200 after its delete marker
was written. Resolve the key from dir/.versions first when the bucket is
versioned: a delete-marked current version answers 404 with
x-amz-delete-marker, a named delete-marker version answers 405, and a key
with no history keeps today's directory-probe behavior untouched.

* s3: re-creating a directory marker retires its delete marker

PutObject on a trailing-slash key never looked at the bucket's versioning
state, so re-creating a marker after a delete left the latest-version pointer
on the delete marker and the key stayed invisible to every versioned read.
Point the key back at the directory entry, which is its null version, and drop
the null version .versions may still hold — the same two steps a suspended
write already takes, now shared.

* s3: fail a directory-marker request whose version history cannot be read

Every lookup of dir/.versions treated any error as "no history", so a filer
hiccup served the directory entry for a key whose current version may be a
delete marker, reported a null version as latest over one, and let a PUT
report success without retiring the delete marker it was meant to retire.
Only a confirmed absence takes the no-history path now.

* s3: cancel the directory-marker probe stream instead of abandoning it

The probe answers off the first entry and returns, leaving the ListEntries
stream open for the life of the parent context. Give it a context of its own.
2026-08-04 18:14:00 -07:00
Chris LuandGitHub d01ed36118 test: cover delete-on-close on the windows mount (#10561)
* test: cover delete-on-close on the windows mount

Windows software creates temporaries with FILE_FLAG_DELETE_ON_CLOSE and
never deletes them explicitly. The conformance suite showed a file
outliving its last handle — an aborted test left its file behind and
every later test hit a name collision — but nothing in this suite asks
for the flag, because os offers no way to.

Skips where the flag is unavailable rather than passing quietly.

* test: fail delete-on-close on a real error instead of skipping

Skipping on any error meant a refused flag looked the same as a platform
that cannot ask for it, so the test could pass by never running. It now
skips only on that one sentinel and reports everything else.

Also stops printing a nil error when the file is still there after its
last handle closed, and checks the closes it was discarding.
2026-08-04 17:42:30 -07:00
Chris LuandGitHub 312cfe5ae1 Fix volume.merge corrupting every needle it copies (#10565)
* Give volume.merge the needle size the target actually indexes by

needleBlobFromNeedle returned the size Append reports, which is
Size(n.DataSize) - payload bytes only. The .dat header, the needle map and
WriteNeedleBlobRequest.Size all use n.Size, which additionally covers the
flags, name, mime and lastModified fields.

Every needle volume.merge copied therefore landed with a too-small size. The
target indexed it at that length, so every later read failed the header check
in ReadBytes with a size mismatch, and on v3 the fresh AppendAtNs stamp landed
NeedleHeaderSize+DataSize+NeedleChecksumSize into the blob - exactly on the
flags byte - overwriting flags, name size, mime size and the first mime bytes
with the top of a timestamp. Needles came back with flags 0x18, no name, no
mime and a phantom TTL parsed from two arbitrary timestamp bytes; the ones
that decoded as expired 404 and vacuum would drop them. Since merge rebuilds
every replica from the merged copy, no clean replica survives.

Return n.Size, which Append fills in as it serializes, matching what the
normal write path stores via nm.Put.

* Reject needle blobs whose size disagrees with their own header

WriteNeedleBlob trusts the caller's size for two destructive things: it is
what goes into the needle map, and it is where the v3 AppendAtNs stamp is
written inside the caller's buffer. A caller passing the payload-only DataSize
convention corrupts both, and nothing surfaces until the needle is read back -
by which point every replica may already have been rebuilt from it.

Parse the blob's own header and refuse the write when the two disagree.
Mirrored in the Rust volume server.
2026-08-04 16:58:25 -07:00
Chris LuandGitHub b1fecf3b44 mount: mark windows files archived and ignore a zero timestamp (#10559)
* mount: mark windows files archived and ignore a zero timestamp

Windows synthesises NORMAL when a file reports no attributes at all, which
is not the same as ARCHIVE and is what create_fileattr_test checks.

Utimens also wrote a zero timestamp through. Windows sends zero for a field
it is not setting, and storing it put 1970 in the atime overlay, which then
overrode the entry's real time — so a file created a moment ago reported an
access time of 1970 whenever the caller asked through an open handle.
Reading the path instead went down a different route and looked right,
which is why a probe of a fresh file showed nothing wrong.

* mount: match the file type by its mask, and only treat the epoch as unset

S_IFDIR is part of the multi-bit type field rather than a flag, so masking
against it alone also matched a symlink, which shares the bit. A regular
file is now identified by the type mask.

Rejecting every timestamp at or below zero also rejected a date genuinely
before 1970. Only the epoch itself is what Windows sends for a field it is
not setting, so that is all that is refused.

create_fileattr goes back on the known-failures list: the archive fix
works and the test simply moves on to ask for READONLY too, which needs
Chflags. Taking it off was premature.

* mount: drop the time overlays when an inode is released

atimeMap and dirMtimeMap are keyed by inode and were only ever trimmed by
a random eviction at capacity. Inodes are derived from the path, so a
delete and recreate hands the same number to a different file, which then
reported the previous file's access time — a file created a moment ago
answering with a time from long before it existed.

Cleared when Forget actually releases the inode, not on every decrement:
a partial forget still has users. Forget now reports that so callers
holding state keyed by the inode know when to drop it.

* ci: keep getfileinfo listed while its access time is unexplained

Two causes have been fixed and neither closed it, so the honest state is
listed-with-a-reason rather than removed in hope.

* mount: drop timestamp overlays while the inode table is locked

Forget released the inode under the table's lock but cleaned up the
atime and dir-mtime overlays after returning from it. Inode numbers are
derived from the path, so a lookup arriving in that window is handed the
same number back and can store a time that the cleanup then deletes.

Run the cleanup at the release point instead, as a callback under the
lock. The directory-cache purge stays deferred until after the unlock,
where it has to be.

Claude-Session: https://claude.ai/code/session_01EgY2QA3iiPtiu6ww3P2EBn
2026-08-04 16:42:33 -07:00
Chris LuandGitHub 50464702d2 ci: key the Rust cargo cache on the toolchain that built it (#10568)
Key the Rust cargo cache on the toolchain that built it

The cache key was rust-<Cargo.lock hash> with a bare rust- restore prefix, so
one seaweed-volume/target survived across runner images. cargo tracks its own
inputs but not the runner's C toolchain, so build-script output for C
dependencies is reused even when the system libc underneath it changed.

That is how the Rust jobs got wedged: the cached aws-lc-sys objects reference
__isoc23_sscanf and __isoc23_strtol, symbols glibc only grew in 2.38, while the
jobs link on ubuntu-22.04 with glibc 2.35. Every job died at

  rust-lld: error: undefined symbol: __isoc23_sscanf
    >>> referenced by bcm.c in archive libaws_lc_sys-*.rlib

with nothing in the tree to explain it, and no amount of re-running helped
because the poisoned entry was hit every time.

Fold the glibc and rustc versions into the key so a toolchain change misses the
cache and rebuilds instead of producing an unlinkable target/.
2026-08-04 16:14:20 -07:00
Chris LuandGitHub b452a5e41b s3: honor a bucket owner recorded as an identity (#10567)
* s3: resolve a bucket owner recorded as an identity

The admin UI and weed shell record a bucket's owner as an identity name in
s3-identity-id and never write the account id the S3 API stores alongside it,
so such a bucket looked unowned: its ACL owner fell back to the default admin
account, and under the default BucketOwnerEnforced ownership every object
uploaded to it was stamped with that account instead of the bucket owner.

Resolve the identity to its account when no account id is recorded, in the one
place both the bucket metadata and the bucket config derive the owner from.

* s3: drop the recorded account when the bucket owner is reassigned

Changing the owner of a bucket created through the S3 API left its old account
id behind, and that outranks the identity when the owner is resolved, so the
new owner never took effect for object ownership or the bucket ACL.
2026-08-04 16:00:04 -07:00
Mohit TalniyaandGitHub 0815ad78f6 fix(volume): persist the leveldb needle map watermark at batch boundaries (#10557)
levelDbWrite persists the replay watermark when its updateWatermark
argument is true. Put and Delete passed "watermark == 0", which is true
on exactly the writes that carry no checkpoint and false on the batch
boundary that carries one. The two cases were inverted:

  recordCount % watermarkBatchSize != 0 -> watermark 0, flag true
      -> re-persists a zero on 9999 of every 10000 writes
  recordCount % watermarkBatchSize == 0 -> watermark N, flag false
      -> drops the only value worth saving

The stored watermark therefore never left 0. Recovery stayed correct,
because replaying .idx from offset 0 is a superset of replaying from N
and replay is idempotent, so this never surfaced as a failure. It only
meant generateLevelDbFile walked the entire index on every rebuild, and
every needle write paid a second leveldb Put to rewrite the same zero.

Pass "watermark != 0" so the boundary write checkpoints and the writes
in between leave the key alone.

Verified on a 25000-needle volume: the stored watermark now reads 20000
instead of 0, and a rebuild replays 5000 entries instead of 25000.

The new test drives a full batch of Puts and a full batch of Deletes to
cover both call sites.
2026-08-04 13:33:31 -07:00
Chris LuandGitHub edaee0e426 ci: run each conformance test on its own (#10564)
* ci: run each conformance test on its own

Run as one batch with --no-abort, a test that fails part way leaves its
files behind and the next one fails creating them, so the report showed a
cascade of failures that were really one. The whole rdwr and flush group
passes when run alone, and was only ever collateral.

Each test now gets its own directory and its own invocation, which costs
a process start per test and makes the list mean what it says.

* ci: clear a case directory before reusing it

-Force creates the directory but leaves anything already in it, so a
leftover from an interrupted run would defeat the isolation this exists
to provide.

* ci: list the one real failure isolation exposed

With each test on its own, 31 of 32 pass. The exception is rdwr_mmap_test,
which compares mapped bytes against what was written and finds them
different — a genuine data mismatch that only appeared once the test could
run to completion instead of tripping over a previous one's leftovers.
2026-08-04 13:24:38 -07:00
Chris LuandGitHub f46b2a1925 Stop the filer test helpers from pinning gigabytes of log buffers (#10560)
* log buffer: wake the interval loop on shutdown instead of sleeping through it

loopInterval parked in time.Sleep(flushInterval) and only re-checked
IsStopping when it woke, so a buffer shut down early kept both loop
goroutines - and the PreviousBufferCount+1 slabs of BufferSize they
reach - alive for up to a full interval afterwards. Select on shutdownCh
against a ticker instead, and give the loops a WaitGroup so a test can
observe that they exit.

* test: release the filers the server tests build

Every helper here left its filer's meta log buffer running, so each test
pinned PreviousBufferCount+1 buffers of BufferSize for the rest of the
run: ~3.5GB of live heap across the package, which overruns the address
space on linux/386 and kills the 32-bit job with an out-of-memory throw.

Thread the test through the helpers so the buffer is shut down on
cleanup, and shut the subscribe harness's filer down outright - its
deletion loop keeps the whole filer reachable otherwise. That harness
quiesces its flush path first, since Filer.Shutdown closes the store a
flush still in flight would write through.
2026-08-04 11:38:38 -07:00
Chris LuandGitHub 5a5cd15054 mount: report . and .. from windows directories (#10556)
* mount: report . and .. from windows directories

WinFsp strips the dot entries for the root itself and expects every other
directory to report them, the way a real NTFS enumeration does: its
dirctl test asserts a subdirectory's first two entries are "." and ".."
and that a hundred files enumerate as 102 entries. Dropping them
unconditionally is what fails querydir_test.

The Go test that guarded the old behaviour went with it: os.File.Readdir
filters dot entries itself, so it could never have observed either way.

* mount: give the windows dot entries their directory type

The readdir fills an attribute block only for real children, so "." and
".." arrived with a zeroed one and were reported with mode 0. Windows
refuses to enumerate a directory whose first entry is not marked as a
directory, which is the assertion querydir_test fails on with
STATUS_OBJECT_NAME_NOT_FOUND.

They now carry the type the readdir already knew. The explorer walk also
names any unexpected entry rather than only counting, so a dot entry
leaking through reads differently from a missing file.
2026-08-04 10:44:43 -07:00
Chris LuandGitHub 89ce6e175d ci: run WinFsp's conformance suite against the windows mount (#10555)
* ci: run WinFsp's conformance suite against the windows mount

The FUSE mount is held to pjdfstest with an empty known-failures list;
the Windows mount had 24 hand-written tests. winfsp-tests is what WinFsp
uses to check a filesystem behaves like NTFS, and --fuse-external points
it at ours instead of the bundled memfs, so it is the same bar in the
same shape: anything failing that is not listed is a regression.

It reaches oplocks, security descriptors, POSIX unlink-and-rename and
directory-buffer resumption — the places a Windows filesystem actually
breaks, and none of which the current suite touches.

known_failures.txt starts with the four groups that cannot pass by
construction. The first run will show what else needs listing.

* ci: make the conformance runner fail loudly instead of running empty

The first run reported "0 excluded entries" and then died with
STATUS_DLL_NOT_FOUND, so it never tested anything while looking like a
normal failing run.

winfsp-tests links against winfsp-x64.dll, which the installer puts
somewhere the loader does not search, so the WinFsp bin directory goes on
PATH. A missing or empty known-failures list is now an error rather than
a silent run with nothing excluded, which would read as a clean sweep
with no known failures. ${env:ProgramFiles(x86)} needs the braces, and a
mount point without a trailing separator makes Join-Path build a path
relative to the drive's current directory rather than its root.

* ci: read winfsp-tests failures from its report, and list the real ones

The first run exited zero with 30 of 50 tests reporting KO, and the job
went green: --no-abort keeps the suite going past a failure and the exit
code stops reflecting them, so trusting it meant the check could not fail.
The report is now parsed for KO lines and each one named in the error.

known_failures.txt is populated from that run rather than guessed. The
groups are real gaps, not suite quirks: cached and overlapped IO fails as
a block, delete-while-open has no pending state, Windows file attributes
and creation time are not round-tripped, and directory enumeration does
not resume from a marker.

* ci: stop excluding the extended attribute tests

Forwarding landed, so the group runs instead of being taken on trust —
which is the only coverage it has had.
2026-08-03 22:26:22 -07:00
Chris LuandGitHub b8cba2982c mount: tell windows about changes made elsewhere (#10553)
* mount: tell windows about changes made elsewhere

Nothing invalidates a Windows client's cache from this side, so a file
created or removed by another mount, the S3 gateway or the filer API
stayed invisible in Explorer until the user refreshed by hand. The mount
already receives those events; they just had nowhere to go.

WFS gains a listener for every applied metadata event, and on Windows
that turns into the WinFsp notification for the path. A rename reports
both ends, since the destination's own event may never arrive when it
falls outside this mount.

* mount: report a removed directory as a directory

Entry is nil once a path is vacated, so asking it whether the thing that
went away was a directory always answered no and every removal was
reported as a file. Windows watches the two through different filters, so
a folder removed elsewhere never refreshed.

The invalidation now carries what used to be there, which the event
already knew and simply was not passing on.

* mount: report a rename destination once

The event stream already carries a second invalidation describing the new
path, so reporting RenamedTo here sent the destination twice — and always
as a create, so a moved directory arrived as a create followed by a
mkdir.
2026-08-03 22:17:09 -07:00
Chris LuandGitHub a0e278f86f mount: forward extended attributes on windows (#10554)
weed/mount implements all four xattr operations and the filer stores the
values, but the Windows adapter overrode none of them, so cgofuse's
defaults answered every call with 'not implemented'. WinFsp advertises
extended attribute support either way, because cgofuse registers the
callbacks unconditionally, so applications were told the volume has them
and then refused on every use. Attributes written from Linux were
invisible from Windows.

Untested in CI: exercising Windows extended attributes needs the native
NtSetEaFile path rather than anything in os or PowerShell.
2026-08-03 21:55:59 -07:00
Chris LuandGitHub e377149d39 mount: support mounting on Windows through WinFsp (#10536)
* mount: add the WinFsp filesystem adapter

WinFsp speaks a path-based FUSE dialect; weed/mount implements the
inode-based raw protocol the Linux kernel uses. This translates between
them so Windows runs the same filesystem code as everywhere else rather
than a second implementation: paths resolve to inodes one Lookup at a
time, and the raw operations run unchanged underneath.

Errno translation is spelled out rather than passed through. Go numbers
Windows errnos as offsets from APPLICATION_ERROR, so the raw value would
mean something unrelated by the time WinFsp read it.

Hard links return ENOSYS since WinFsp has none, and byte-range locks stay
with its kernel driver rather than the mount's lock table.

Not reachable from the mount command yet.

* mount: build the winfsp errno table with explicit precedence

Platforms alias errnos differently: freebsd has no ENODATA and linux makes
ENOATTR the same value as it. A map literal with colliding constant keys
does not compile, so build the table and let the first entry win, keeping
the general codes their own meaning.

* mount: wire the winfsp adapter into the mount command

RunMount was one function doing filer setup, mount-point preparation and
serving. The setup is the same everywhere, so it moves to mount_common.go
and each platform keeps only what differs.

Windows differs mostly in the mount point: WinFsp wants a drive letter or
a path that does not exist yet, so none of the unix preparation applies,
and a bad one is worth rejecting up front because WinFsp reports failure
as a bare false. Adds -windows.caseInsensitive for software that expects
Windows naming rules.

* ci: mount on windows and exercise it

Builds weed.exe, installs WinFsp, starts a cluster, mounts S: and runs a
test suite against it: round trips at several sizes, offset writes,
rename, delete, nested directories, concurrent writers, and a directory
wide enough to stand in for the case that prompted this.

Nothing else here can run the Windows mount, so without this the adapter
is only known to compile.

* ci: build the windows mount without cgo

The runner has MinGW, so cgo is on by default and cgofuse compiles its
cgo variant, which needs WinFsp's headers. The nocgo variant loads the
DLL at run time and is what the released weed.exe uses.

* mount: make the winfsp path splitting portable and test it

resolve and resolveParent had the splitting inline in a windows-tagged
file, so the cases that matter most there — both separators, empty and
dot components, the root having no parent to create in — could not be
tested on any runner that builds this.

* test: check the windows mount persists across a remount

Reading a file back through the same live mount proves nothing about
durability; the answer can come from the mount's own caches. Write the
fixtures, confirm the filer serves them with the mount out of the path,
then re-read after a teardown and remount.

* test: cover the windows mount operations that had none

Truncate, append, chtimes and the hard-link refusal were implemented but
never exercised, and the errno table was only unit-tested for mapping,
never end to end. Adds names that have to survive the UTF-16 boundary,
rename over an existing target and across directories, and concurrent
handles on one file rather than one file each.

* ci: dial the filer over ipv4 and run the persistence phases

localhost resolves to ::1 first on windows and the cluster binds ipv4
only, so the mount's grpc dial was refused while the http readiness
probe passed by falling back to ipv4.

* ci: pin the cluster to loopback and probe ports by connecting

weed mini advertises the runner's LAN address and binds filer grpc there,
so the mount's dial to 127.0.0.1:18888 was refused while http answered.

The readiness probe also passed with nothing on 18888: Test-NetConnection
reported success for a port that then refused a connection, so it now
opens a socket instead.

* ci: report listening ports before mounting

The readiness probe connects to the filer grpc port and the mount is then
refused on it, which cannot both be true; print the actual state.

* ci: run the cluster, mount and tests in one step

The runner tears down a step's process tree when its shell exits, so the
cluster started in an earlier step was already gone: the readiness probe
passed against a live filer, the step ended, and the mount then found
nothing listening. A diagnostic step reported no weed.exe at all.

Everything that needs those processes alive now shares a step.

* mount: key windows file io on the handle, not the path

Read and Write walked the path on every call to fill in a NodeId the raw
filesystem never reads: both look the file up by handle. Under eight
writers creating files in one directory the walk transiently missed and
the write failed with ENOENT before reaching the filesystem at all.

Same for flush, fsync and the release calls. O_EXCL now fails on an
existing name instead of taking it over, and Symlink is refused: the
entry is easy to create but WinFsp only follows it once the reparse
point is wired up, so it read back as an empty file.

* mount: translate cgofuse open flags for windows

cgofuse reports MSVC's numbering and the raw filesystem tests Go's, so
only the access mode and O_TRUNC lined up: O_EXCL arrived as O_APPEND and
O_CREAT as nothing at all.

Also report which handle a failed write was using, to tell a handle that
was never issued from one released while still in use.

* mount: report which step of a windows create failed

A concurrent create fails with ENOENT and the path walk, the parent
lookup and the create itself are indistinguishable from the caller.

* ci: send weed logs to stderr on windows

glog writes to its own files by default, so the mount's own error output
never reached the redirected log. Its flags are global and have to come
before the subcommand.

* mount: resolve known paths from the inode table on windows

Every create walked the parent chain with a filer lookup per component.
With eight writers creating files in one directory that is hundreds of
concurrent lookups of the same parent, and lookupEntry reports an
authoritative ENOENT when the directory is cached, the entry is not in
the cache and the inode table has no record — a window a concurrent
refresh can open for a directory that plainly exists.

A path the mount already tracks now resolves straight out of that table.

* test: sync the windows persistence fixtures before closing

The mount is killed rather than unmounted, so anything still queued for
flush is legitimately lost and the test was measuring crash durability
while calling it persistence. A 9MB file lost four chunks that way.

* mount: keep the lookup refresh on the target path

Resolving a tracked path straight from the inode table skipped Lookup,
which is also what refreshes the entry: a truncate then read back the
pre-truncate size. Only the parent chain takes the shortcut now, which
is where the concurrent creates were racing anyway.

* mount: log every windows resolve failure

Open suppressed ENOENT and Getattr logged nothing, which hid the two
callbacks that can report a missing file during a create.

* mount: drop dot entries from windows directory listings

readdir reports "." and ".." for the kernel, but Windows enumerates a
directory without them and displays whatever it is handed, so a folder of
200 files listed 202. Go's ReadDir filters them, which is why only the
PowerShell walk caught it.

* mount: flush queued writes when windows mount is interrupted

The signal handler exits the process the moment its hooks return, so the
WaitForAsyncFlush after Serve never ran on ctrl-c and queued writes were
dropped.

* mount: let windows mount over an empty directory

WinFsp turns a directory mount point into a reparse point, which NTFS
allows on an empty directory and refuses on a populated one. The check
rejected every existing directory, so the ordinary habit of creating the
mount point first failed with a message saying it should not exist.

CI now mounts over a pre-created directory and writes through it.

* ci: run the windows mount check on any pull request

It is the only thing that exercises the Windows mount, so restricting it
to pull requests based on master skipped it for stacked ones. Replaces
the branch name that was pushed to trigger it.

* mount: do not log a missing windows entry as an error

Windows probes for entries that do not exist as a matter of course, so
ENOENT from getattr and open is an answer rather than a fault and would
have filled the log.

* mount: take the fast path for parent chains in every windows resolve

Narrowing it to resolveParent left Getattr and Open re-walking the parent
with a filer lookup per component, and those are what Windows calls
before a create: eight writers in one directory still raced a meta cache
refresh there. Only the final component needs the Lookup refresh.

The pass that suggested otherwise came from a run five times slower than
the failing ones, where the race had no room to appear.

* mount: drop the windows path resolution shortcut

Resolving from the inode table skipped the Lookup that refreshes an
entry, and a truncate then read back its old size. Applying it only to
the parent chain kept truncate correct but left concurrent creates
failing, and applying it to the final component too inverted that. The
two cannot both be satisfied this way, so this returns to looking up
every component and leaves the concurrent create failure open.

* mount: fall back to the open handle when a deferred entry is evicted

A create that defers the filer write leaves the entry only in the local
cache. Creating many files at once pushes the directory past the hot
threshold and evicts it, taking that placeholder with it, so a lookup
went to the filer, found nothing, and reported a file that plainly
exists as missing.

The handle still holding the unflushed entry is authoritative for it.
Caught by concurrent creates over a Windows mount, which resolves a path
on every call rather than relying on a kernel dentry cache.

* mount: let cgofuse resolve to the version the module graph requires

rclone already depends on cgofuse at a newer commit than the v1.6.0 pin,
so readonly builds refused the go.mod until it matched what MVS picks.
The interface and flag values the adapter uses are unchanged there.

* mount: wait for a pending async flush before looking up on the filer

Open, unlink and rename already wait, but a plain lookup went straight
to the filer and read pre-close metadata: truncate a file, close it, and
a path probe during the flush window reported the old size. The kernel
attr cache hides this on linux; a front end that resolves paths on every
operation hit it directly.

* mount: reject a umask wider than the file mode it becomes

ParseUint allowed 64 bits and the result is narrowed to os.FileMode,
which is 32, so an out-of-range umask truncated silently instead of
being reported as unparseable.

* mount: address review findings on the windows mount

WaitForAsyncFlush closed its channel unconditionally and shutdown reaches
it from both the interrupt hook and the path that resumes after serving,
so a ctrl-c could panic on a second close.

The deferred-entry fallback read an open handle's entry without its lock,
which is what the other two readers of that field take so FromPbEntry
does not walk the chunk slice mid-append. The async-flush wait also sat
ahead of the meta cache, making every stat of a recently closed file
queue behind uploads; it belongs just before the filer is consulted.

Windows entries were persisted as uid 0: the raw filesystem stores
InHeader's owner and the adapter left it zero. They now carry the
identity the mount was started with.

The errno table used Linux numbering while cgofuse decodes MSVC's, so
ENAMETOOLONG arrived as EDEADLK and five others were likewise wrong; a
windows test pins each value to cgofuse's own constant.

Also: break the filer handshake loop on success rather than always
running ten rounds, accept a drive letter written S:\\, report a missing
WinFsp instead of panicking, keep commas out of the volume label, and
drop -windows.caseInsensitive, which told WinFsp the mount folds case
while lookups stayed exact.

* mount: return windows lookup references so the inode table stays bounded

Every operation that hands back an EntryOut grants a reference the Linux
kernel returns with FORGET. WinFsp has no FORGET, so the adapter took one
per path component per call, plus one per child of every readdirplus, and
never gave any back: inodeToPath grew for the life of the mount. Walking
the 200k-file directory this exists for stranded 200k references.

The adapter now plays the part the kernel plays. Each resolution releases
what it took, and an open handle keeps the reference for its inode until
Release, counted because the raw filesystem reuses one handle for repeated
opens. Holding it is not optional: completeAsyncFlush skips the metadata
flush when the saved path no longer maps to the inode, so releasing early
would lose a close's metadata.

Also stops persisting the display owner. -o uid=-1 makes WinFsp report the
calling user whatever we say, but the value handed to the raw filesystem is
written to the filer, and 4294967295 is what every other client would read.
-windows.uid and -windows.gid set what is recorded.

* mount: fix windows behaviours the reference implementations guard against

WinFsp has no ro option — it discards the flag and leaves the volume
writable — so -readOnly accepted writes and deletes. The refusal now
happens in the operations themselves.

Windows sends times around its own 1601 epoch, which arrive as a large
negative second count; casting them through stored a year-1601 timestamp
that every other client then read. Those are now left alone. rclone
carries the same guard.

Chown returned ENOSYS, and WinFsp passes a chown failure straight out of
SetSecurity, so Explorer's Security tab and icacls failed for edits that
were not about ownership. It now accepts and discards.

Only create and mkdir presented a caller; the rest sent uid 0, which
hasAccess treats as root, so deletes and renames skipped the permission
check that creates got. Every operation presents the same identity now.

A drive letter written S:\ reached WinFsp unnormalised, which recognises
a drive only as exactly two characters and then failed as a directory
path. A test also pins the open flag translation, since swapping O_EXCL
and O_TRUNC would turn 'fail if it exists' into 'truncate it'.

* mount: answer windows getattr and truncate from the open handle

WinFsp keeps the path a handle was opened with and never updates it when
the file is renamed, so resolving the path again fails on a handle that is
still perfectly valid — the ordinary write-temp-then-rename save pattern.
The handle already knows its inode, which also removes a full path walk
from two operations WinFsp calls constantly.

Readlink on the root now refuses. WinFsp probes there to decide whether
the volume has symlinks and enables them unless it fails, and with them on
it resolves a path a component at a time, each one reaching us as its own
walk — all for a feature Symlink already refuses.

* mount: require the windows mount directory not to exist

WinFsp creates the directory itself with FILE_CREATE and removes it when
the filesystem goes away, so an existing one — empty or not — fails with
"mount point in use". Allowing an empty directory was wrong, and the CI
check that appeared to prove otherwise was the vacuous one: listing a
plain directory succeeds whether or not anything is mounted on it, so the
step passed while the mount had failed and the writes went to local disk.

That check now waits for the reparse point, which is what caught this.

* mount: apply review comments on the windows mount

-windows.uid and -windows.gid reached the adapter but not the filesystem
parameters, which is what carries the owner written to the filer, so the
flags changed nothing.

Readdir re-resolved the path while Getattr and Truncate answer from the
handle; a directory renamed during an enumeration then failed on the
stale path WinFsp still holds.

Utimens now honours UTIME_OMIT instead of writing whatever came with it.

* mount: tag the unix-only lock tests away from windows

The production lock files were tagged when the package was made to build
on windows, but the tests that exercise them were not, so anything that
compiles tests for windows still failed on syscall.F_WRLCK.

* ci: vet the mount tests for each target too

Only compiling the non-test build let an untagged test keep a per-OS
syscall constant without anything noticing.
2026-08-03 21:20:26 -07:00
Chris LuandGitHub 4992ac1ca9 mount: keep the xattr flag constants off freebsd (#10552)
* mount: keep the xattr flag constants off freebsd

x/sys/unix has no XATTR_CREATE or XATTR_REPLACE there, and weedfs_xattr.go
is already tagged away from freebsd for that reason. Putting them in a
!windows file dragged them back in, so master stopped building for
freebsd.

* ci: cross-compile freebsd and darwin too

The windows-only check missed a freebsd break in the very file it was
added to guard, because nothing else on a pull request compiles them.
2026-08-03 14:47:20 -07:00
Chris LuandGitHub c191b2fe01 iceberg: let clients select their table bucket as the catalog warehouse (#10549)
* iceberg: accept bare bucket names and ARNs as the catalog warehouse

Only s3://<bucket>/ was recognized. A warehouse spelled as a bare table
bucket name or as the s3tables bucket ARN -- the two forms users reach for
first, the latter being what AWS S3 Tables itself takes -- was silently
dropped, so every call landed on the default "warehouse" bucket and failed
with "table bucket warehouse not found".

* iceberg: report a missing table bucket as 404, not 500

Pointing a client at a table bucket that does not exist -- which every
client with no warehouse set does, since the default bucket "warehouse"
rarely exists -- returned InternalServerError with a message naming a
bucket the client never asked for. Answer 404 and say how to select one.

* admin: show the warehouse in the PyIceberg example

The example connected without one, so it always resolved to the default
table bucket and every client that copied it failed on the first call.

* test: pin bearer auth against a table bucket that exists

The subtest called the catalog with no warehouse and accepted 500 as proof
that auth had passed, since the default bucket does not exist. A missing
table bucket now answers 404, which the test read as an auth failure. Give
it a real table bucket so only 200 passes.

* test: assert the missing-bucket guidance reaches the client

The status and error type were checked but not the message, which is the
part of the mapping that tells a user how to select a table bucket.

* test: encode the warehouse query value

The ARN case pasted raw colons and slashes into the query string. Go's
parser tolerates them, so the test passed without modelling how a client
actually sends the request.
2026-08-03 13:25:37 -07:00
Chris LuandGitHub 63a180ef75 telemetry: sync the server module with the client_golang bump (#10551)
The server module has its own go.mod and replaces the root module from
../.., so bumping prometheus/client_golang in the root leaves this one
pinned below what the replacement needs and 'go build' refuses to run.
2026-08-03 13:22:50 -07:00
Chris LuandGitHub 88fd2d1be8 telemetry: stack volume servers per cluster, drop the total disk usage chart (#10550)
* telemetry: stack volume servers per cluster over time

The fleet-wide server count says how many volume servers reported, but not
who they belong to. Carry per-cluster counts in /api/cluster-sizes and draw
them the same way as cluster sizes, sharing one cluster ranking so a cluster
keeps its colour across both stacks.

* telemetry: drop the total disk usage chart from the dashboard

The stacked cluster sizes chart right below it has the same fleet total as
its stack height, plus the per-cluster breakdown. /api/metrics still serves
the aggregate for anyone graphing it elsewhere.
2026-08-03 13:13:48 -07:00
Chris LuandGitHub cc2775d9f2 s3: register an identity's inline account instead of collapsing it into admin (#10548)
* s3: register an identity's inline account instead of collapsing it into admin

Credential stores persist an account inline on the identity and never
emit a top-level accounts list, so every user created through the IAM
API or the admin UI with an email hit the "non exist account ID" branch
and was given the shared admin account. Distinct users then presented
the same owner id, so ownership checks could not tell them apart and
each passed for the others' buckets.

Treat an id missing from the account map as undeclared rather than
invalid: register it, keeping an email another account already claimed.
Both load paths now resolve the account through one helper.

* s3: refresh an undeclared account from the identity that carries it

The merge path starts from the live account cache, so an identity
upserted with the same account id but a new email or display name kept
the cached copy: the new address never reached the email index and the
replaced one still resolved. Changing a user's email through the admin
UI takes exactly that path.

An account registered from an inline block is only described by the
identity carrying it, so refresh it and move its email claim. Accounts
from a top-level list and the predefined defaults are marked declared
and stay authoritative.

* s3: let an account reclaim an email once its holder moves away

Two identities can carry the same email, and the second to load leaves
the lookup with the first. Returning early when the incoming metadata
matches the cached account meant the loser never re-ran the claim, so an
address freed by the holder's update resolved to nobody until the loser
itself changed. Re-index on the unchanged path, which is a no-op while
another account still holds the address.
2026-08-03 12:45:42 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
846f9f7e7d build(deps): bump github.com/prometheus/client_golang from 1.24.0 to 1.24.1 (#10544)
build(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.24.0 to 1.24.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.24.0...v1.24.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 09:31:55 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
196d4808a0 build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.25 to 1.32.33 (#10540)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.25 to 1.32.33.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.25...config/v1.32.33)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.32.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 09:31:12 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2a2ad05f1a build(deps): bump github.com/ydb-platform/ydb-go-sdk/v3 from 3.144.5 to 3.146.3 (#10538)
build(deps): bump github.com/ydb-platform/ydb-go-sdk/v3

Bumps [github.com/ydb-platform/ydb-go-sdk/v3](https://github.com/ydb-platform/ydb-go-sdk) from 3.144.5 to 3.146.3.
- [Release notes](https://github.com/ydb-platform/ydb-go-sdk/releases)
- [Changelog](https://github.com/ydb-platform/ydb-go-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ydb-platform/ydb-go-sdk/compare/v3.144.5...v3.146.3)

---
updated-dependencies:
- dependency-name: github.com/ydb-platform/ydb-go-sdk/v3
  dependency-version: 3.146.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 09:31:00 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b7be05fb41 build(deps): bump github/codeql-action from 4 to 4.37.4 (#10541)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 09:30:18 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2b2bbc625b build(deps): bump docker/login-action from 4.5.1 to 4.6.0 (#10539)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.1 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.1...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 09:30:06 -07:00
Chris LuandGitHub a9de90ae29 test: wait for every queued flush before deleting the log files (#10546)
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.
2026-08-03 09:26:42 -07:00
Chris LuandGitHub 4f692bf9c3 mount: build the package on windows (#10535)
* 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
2026-08-03 01:07:49 -07:00
Chris LuandGitHub 529ffa5c86 mount: drop the unused go-fuse fs package dependency (#10534)
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.
2026-08-03 00:53:31 -07:00
813a4b0711 fix(filer): stop skipping recent unflushed events on metadata subscription gaps (#10501)
* fix(filer): don't skip unflushed events on metadata subscription gaps

A subscriber that falls behind the in-memory log ring during a write
burst could have its read position jumped past events that were evicted
but not yet flushed to disk — silently lost for filer.backup/filer.sync/
mount subscribers. Route all three gap-skip sites through
resolveDiskGapResume: only skip past windows older than a settled
horizon (2*LogFlushInterval); recent gaps wait for the flush and
re-read disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(filer): harden metadata gap-skip guard

Address review findings on the settled-horizon guard:

- local subscriptions: gate the skip on the buffer's flush watermark
  observed before the disk read (resolveLocalGapResume) — a disk miss
  is then proof the gap is empty, with no wall-clock assumptions
- aggregated subscriptions: cap skips strictly below the horizon
  boundary (persisted reads exclude ts <= cursor) and pace capped
  advances, so the sliding horizon cannot cause disk-probe spinning
- replace unbounded sync.Cond waits with a bounded select on the
  buffer's subscriber channel + retry timer + ctx cancellation,
  eliminating the lost-wakeup stall

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(filer): close remaining gap-skip loss paths from re-review

- log_buffer: a sentinel-offset (time-based) read below the earliest
  in-memory entry silently started at earliest, skipping a window that
  may hold evicted-but-unflushed events. Track the ring's eviction
  watermark (lastEvictedTsNs) and keep the inclusive fast path only
  when nothing at/after the position was ever evicted; otherwise
  return ResumeFromDiskError so the subscription gap guard decides.
- capped horizon advances stay on the disk-probe path (never expose a
  mid-gap position to the memory read) and keep pacing
- gap jumps land just below earliest: positions are exclusive, so the
  earliest entry itself is still delivered
- subscriber notification keys include clientId/epoch so a replacement
  stream never inherits a channel the old stream's cleanup closes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(log_buffer): generalize the eviction-watermark read gate

Third-review findings:

- epoch/zero-time reads bypassed the eviction watermark: gate them the
  same way, so a SinceNs=0 subscriber cannot silently start at the
  earliest retained entry after an unflushed window was evicted
- apply the watermark gate regardless of the cursor's batch offset
  (batch offsets carry no meaning for time-based reads); this also
  serves adjacent cursors (earliest == current+1) from memory instead
  of stalling them in the gap loop
- LoopProcessLogData reader names include clientId/epoch, since they
  are registered as subscriber keys internally (same collision as the
  outer notification keys)
- test: pin the gate (below/at watermark, epoch-after-eviction) and
  update the slow-consumer test to the sharper contract — complete
  in-memory history is served from memory; disk only once evicted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(log_buffer): watermark equality is unsafe for inclusive sentinel cursors

Sentinel (Offset <= 0) time-based cursors search from ts-1ns, i.e. they
read inclusively of their own timestamp — and the evicted window may end
exactly at that timestamp. Allow watermark equality only for exclusive
(positive-offset) cursors; sentinel cursors must be strictly above it.
Also shut down the test buffer and pin the equality cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(filer): wait on an empty aggregated buffer instead of falling through

When a disk read finds nothing for a ResumeFromDiskError gap and the
aggregated buffer has no readable entries (zero earliest time),
resolveDiskGapResume declines to advance and control fell through to the
in-memory read — which returns ResumeFromDiskError again immediately,
spinning through the probe cycle without any wait. Fold the case into
the existing recent-gap branch so it waits (notification, cancellation,
or the retry interval) before re-probing, matching the local
subscription path, which already waits unconditionally.

* fix(filer): serve the exact eviction-boundary entry without another flush wait

When the flush watermark has passed the earliest in-memory entry but that
entry sits exactly one nanosecond above the cursor, the exclusive resume
target collapses onto the cursor and resolveLocalGapResume declines to
advance — while a sentinel cursor at the eviction watermark keeps
deferring the in-memory read to disk. Progress then depends on the next
flush cycle. Re-arm the cursor with a positive (exclusive) offset instead:
ReadFromBuffer explicitly allows positive-offset cursors at the watermark,
so the boundary entry is served from memory immediately.

Also promote the aggregated path's horizon-capped gap skip to a warning:
that skip may pass events a stalled flush lands later (the aggregated
ring has no flush watermark to gate on), so operators should see when a
flush stall outlasts the settled horizon.

* fix(filer): resume a gap skip with an exclusive cursor

The resume position landed one nanosecond below the earliest in-memory
entry but kept the inclusive sentinel offset. When that timestamp is also
the eviction watermark, the read gate answers ResumeFromDiskError for an
inclusive cursor, the disk read finds nothing, and the resume target
collapses onto the cursor, so neither helper can advance: the subscriber
parks on timed waits forever.

A skip is only taken once the gap is proven empty, so nothing remains to
deliver at the resume timestamp. Resume exclusively instead, and let an
inclusive cursor already sitting on the target count as progress.

* fix(filer): gate aggregated gap skips on eviction, not wall clock

Wall-clock age never proved persistence. The aggregated ring has no flush
watermark because peers persist their own local logs, so a horizon of
2 * LogFlushInterval was standing in for one. While the disk stayed empty
the horizon kept sliding forward and walked the cursor past
evicted-but-unflushed events one window at a time - the volume-outage
stall this change set exists to survive.

The ring does carry a real proof: its eviction watermark. If nothing at or
after the cursor was ever dropped, memory still holds every entry after it
and the gap is provably empty. Below the watermark entries were dropped
and only the producing peer's flush can supply them, so wait for that
flush instead of advancing. The skip that remains is the one the ring can
prove, which keeps the infinite-loop guard for a genuinely empty gap.

* perf(filer): stop re-probing disk on every metadata append

A subscriber parked on a gap woke on the log buffer's subscriber channel,
which fires on every append. On the aggregated path each wake re-ran a
full ReadPersistedLogBuffer - a ListDirectoryEntries plus a readahead
goroutine - so one parked subscriber turned every cluster-wide metadata
event into a store query, exactly while the cluster is already struggling
with the flush stall that parked it. An append also cannot settle the gap:
what this waits on is a peer persisting its own log, which nothing local
signals. Wait on the timer alone there.

The local buffer does signal its flush on that channel, so keep it, but
drain the stale token first: otherwise the appends riding the same channel
spin the wait at write rate. The retry interval covers the notification
the drain discards.

* fix(filer): surface a metadata subscriber parked on a gap

Refusing to skip an unresolved gap trades silent loss for a silent stall,
and a stall is no easier to diagnose: filer.sync and mount followers just
stop advancing, with no error on either end. The only trace was a V(3)
line nobody runs with.

Warn on entry and once a minute after, and carry a subscribe_gap_stalled
gauge for the duration, so a flush that never lands shows up as a stalled
subscriber rather than a consumer that mysteriously went quiet.

* refactor(filer): drop the metadata listener cond with no waiters left

Both listenersCond.Wait() sites are gone, so listenersWaits never leaves
zero, the guard in the filer's notify callback never fires, and the two
Broadcast calls around client registration wake nobody. Remove the cond,
its counter and its lock, and pass a nil notify func: the log buffer
already skips a nil one, and the subscriber channels carry these wakeups
now.

* test(log_buffer): drive the eviction watermark through a real seal

Both eviction tests wrote lastEvictedTsNs directly, leaving the one line
that sets it uncovered: copyToFlushInternal has to read slot 0 before
SealBuffer shifts it out, and moving that read one statement later still
passed every test. Append past the ring instead, and check the watermark
appears only on the seal that drops a window, matches that window's stop,
and then advances. The buffer under test never flushes, matching the
aggregated meta ring where the watermark is the only emptiness proof.

* refactor(filer): build the subscriber reader name once per stream

It was rebuilt on every loop iteration from values that cannot change for
the life of the stream. Hoist it next to the notification key it mirrors.

* docs(filer): tighten the gap-handling comments

Several ran to eight lines restating the same reasoning at each site.
Keep the non-obvious why, drop the retelling.

* fix(filer): mark a confirmed disk position as an exclusive cursor

A disk read returns the timestamp of the last entry it handed to the
subscriber, but the cursor built from it stayed inclusive. Land that
cursor exactly on the eviction watermark and the read gate sends it back
to disk for an entry the disk just delivered; the re-read finds nothing,
neither emptiness proof holds for an inclusive cursor there, and the
subscriber parks - until some later flush, or forever while one stays
stalled. Everything after the watermark was sitting in memory the whole
time.

Carry the offset instead, so it also covers the ring evicting onto the
cursor after the read rather than before it. The constant is no longer
gap-specific, so it is now named for what it asserts.

* perf(filer): re-park a gap wait woken by an ordinary append

Draining one stale token did not bound anything: the subscriber channel
carries an append per metadata write, so under continuous writes the next
one satisfies the wait immediately. During the write burst these stalls
come from, each parked subscriber ran the recovery loop at write rate
rather than the intended two-second cadence.

Only a flush can settle a gap, so check the flush watermark on wake-up and
go back to waiting if an append is all that arrived. The retry timer is
created once, so re-parking does not extend the interval.

* fix(filer): keep a disk-derived cursor inclusive

Marking every disk position exclusive assumed the entry at that timestamp
was the only one there. On the aggregated stream it is not: disk can
deliver one filer's persisted event at T while another filer's event at
the same T is still unflushed in the aggregate ring. The exclusive cursor
skipped it, and since the persisted reader also excludes ts <= its start,
nothing would ever bring it back.

Take the weaker guarantee instead. The reason the exclusive cursor was
introduced - an inclusive one landing on the eviction watermark parks
forever - is better answered in the resolvers: at the watermark memory
holds nothing (retained windows start strictly after it) and the persisted
reader cannot return that entry at any later time either, so refusing the
gap buys nothing and never ends. Skip it whatever the cursor's
inclusivity. That proof does not depend on a flush, so the local resolver
takes it as a second, independent disjunct alongside its flush watermark.

* perf(filer): wake a parked gap wait on flushes only

Re-parking on an append bounded the work but not the wake-ups: the
subscriber channel carries one per metadata write, so a parked subscriber
still took a scheduling round-trip per write. Worse, draining it kept the
channel empty, so every writer's non-blocking notify succeeded instead of
falling through - the burst paid for the wake-ups too.

Give the log buffer a flush-only subscriber list, notified from loopFlush,
and park on that. The append channel now fills once and stays full, which
is exactly the state the non-blocking send is designed for.

* fix(filer): stop the gap-stall gauge from leaking a series per connection

clientName is req.ClientName + "@" + peer address, so it carries the
client's ephemeral source port and changes on every reconnect. Labelling
the gauge with it minted a new series per connection, and clearing it only
ever Set(0), so nothing was ever released - a client in a reconnect loop
grows the filer's metric map and /metrics payload without bound. Key it on
the stable client-supplied name, as the neighbouring subscribe gauge
already does, and delete the series on teardown.

Two logging fixes ride along, both in the same reporter: the resume
warning was unpaced while the park warning throttles to one a minute, so a
burst that parks and resumes every couple of seconds warned on every
cycle; and clear() doubled as the teardown path, announcing "resumed
after 14m0s parked" for a client that actually gave up and disconnected
still behind.

* fix(filer): give a parked gap wait the exits the read loop has

A park never re-enters the read loop, so every exit that loop relies on
stopped working while a subscriber was parked. It kept scanning the filer
store every 2s for a client that a higher-epoch reconnect had already
superseded, until the TCP connection finally died - hours, on a half-open
one. It never reached the only code that honors UntilNs, so bounded
callers like `weed shell fs.verify` and `filer.meta.tail -until=` hung
instead of exiting. And a notification channel closed out from under it
turned the bounded wait into a spin, since a receive on a closed channel
returns instantly.

Check all three where the subscriber actually waits. Bound the wait too:
waiting is productive while the window is still queued for flush, but a
peer that never returns, or whose filer store this filer cannot read at
all, makes it permanent - and a subscriber that silently stops delivering
is no better than one that silently skips. Fail the stream after that
instead of hanging, loudly enough to say which gap and for how long.

* fix(log_buffer): keep the eviction gate out of the shared read path

The gate belonged to the filer's subscribe loops but was installed in
ReadFromBuffer, which the message queue shares and which has no gap
handling of its own. Three MQ paths broke on it: GetUnflushedMessages
asks for everything in memory past the flush watermark and got
ResumeFromDiskError instead, so SQL results silently dropped unflushed
rows; a RESET_TO_EARLIEST consumer's epoch cursor was sent to disk, and
the MQ disk reader resets an empty read back to epoch, so the whole
partition replayed on every pass; and a disk cursor landing on the
watermark carried offset -2, which the gate refused and the disk reader's
ts <= start filter also skips, so neither side could ever serve it. The
rewrite also dropped the old Offset <= 0 requirement, letting a stale
positive-offset cursor jump to memory over on-disk history, and refused
any negative-timestamp cursor even with nothing evicted.

Restore the read path exactly as it was and keep only lastEvictedTsNs,
which is the useful primitive. The filer loops now consult the watermark
themselves before reading memory, which is where the gap handling that
makes the refusal actionable already lives - and they check it every pass,
not just when the disk came up empty, since a disk read can leave the
cursor short of the watermark too.

* fix(filer): read the log file whose window spans past its own name

A log file is named for the start of the window it holds, but a window
runs up to a flush interval longer, so "12-30" can hold entries through
12:31:58. File selection compared the cursor's minute against that name
and skipped anything sorting earlier, so a subscriber resuming at 12:31:10
never saw the rest of that file: the read reported nothing on disk while
the entries sat in it.

That was survivable when a miss only meant "wait and retry", but the gap
resolvers now read a miss as proof the range is empty and move the cursor
past it, which turns those entries into silent loss. Start the file scan a
flush interval early; entries are still filtered against the exact cursor,
so this only opens one more file and never re-delivers.

* fix(filer): resume a gap with a cursor the memory read will serve

Reverting the eviction gate put ReadFromBuffer back to refusing every
positive-offset cursor below the in-memory window, but the resolvers still
handed back one - earliest-1 marked exclusive. So the resume bounced
straight to ResumeFromDiskError, the disk had nothing, and the resolver
saw its own cursor as no progress and parked: a subscriber stalled, and
after the new bound failed outright, with the whole gap sitting in the
ring the entire time.

The exclusive marking only existed to dodge a park the gate itself caused,
and the gate is gone. Resume at earliest with the sentinel offset, which
is the position master used and which case 2.1 reads inclusively. That
makes the resume unconditionally ahead of the cursor, so the progress
check it needed goes away with it.

* fix(filer): stop dropping a log file whose window outruns its name

Listing the earlier file was not enough: the iterator then decided whether
to read it by comparing the *following* file's name against the cursor,
which treats that name as an upper bound on this file's contents. It is
not one. A file is named for the start of the window it holds,
minute-truncated, and the window runs up to a flush interval longer, so
"12-30" can hold an event at 12:31:20 while "12-31" sits right after it -
and a cursor at 12:31:10 skipped straight past the event.

Bound the decision on the file itself: skip it only when its name plus the
minute truncation plus a flush interval still lands at or before the
cursor. That also covers the last file in the queue, which the old check
never skipped because it had no successor to compare against.

* fix(filer): stop a chunk-ref read from rewinding the subscriber

CollectLogFileRefs reports the minute-level name of the last file it
shipped, and the caller assigns that straight to the read position. Since
the scan now reaches back a flush interval to catch a spanning file, a
request at 12:31:10 that picks up the 12-30 ref moved the cursor to
12:30:00 - so the memory read that followed replayed events older than the
client's own SinceNs and re-sent what the chunk reader had already been
handed. The same rewind was reachable before, within a minute, whenever
the last file's name sorted behind the request.

Clamp the reported position to the one that was asked for. It still
under-advances by design, since the server never reads the entries it
ships refs for and cannot know where they end.

* fix(filer): resume below earliest so a one-entry window is not skipped

Moving the resume onto earliest itself was wrong for the smallest window
there is. A sealed window holding a single entry has startTime ==
stopTime, and the sealed-buffer lookup only enters a window whose stopTime
is strictly after the cursor, so a cursor sitting exactly on earliest
walks past it and its sole event is never delivered. Low-volume metadata
windows are routinely one entry, which is precisely when losing it is
hardest to notice.

Go back to one nanosecond below, which takes the startTime.After branch
and returns the whole window, and keep the sentinel offset the memory read
requires. The earlier test used an active multi-entry buffer, where both
cursors happen to work; the new one seals single-entry windows and
compares which entry comes back.

* fix(filer): count a persisted read as progress only when it moves

A chunk-ref read reports the minute-level name of the last file it
shipped, now clamped so it never rewinds, so it comes back non-zero even
when it names the position the subscriber already held. The loops read
non-zero as progress: they cleared the stall timer, then found the cursor
still short of the eviction watermark and parked again. Every retry
re-shipped the same refs and reset the timer, so the bound that is
supposed to end an unrecoverable stall was never reached - and a
chunk-capable client buffers those refs waiting for an event that never
comes, so it just accumulates duplicates.

Require the reported position to be strictly ahead of the cursor.

* fix(filer): count the evicted ranges the aggregated stream cannot prove

The eviction watermark belongs to the merged ring, but the disk it gets
checked against is the union of every peer's own log and each peer flushes
on its own schedule. A read that lifts the cursor from below the watermark
to above it may have done so entirely on a peer that is already ahead,
while a lagging peer still holds unflushed events inside the range just
crossed; when it flushes them they sit behind the cursor and are never
delivered. One aggregate maximum is not proof that every peer persisted
the range.

Nothing available locally separates that from the ordinary case where
every peer had in fact persisted it: the aggregator tracks peers by
address while log files carry a random per-filer id, so "has this peer
flushed through T" cannot be answered here at all. Deciding it needs the
source filer's own flush watermark carried on the subscribe stream, which
is a wire change this does not make. Count and log the crossing so the
window is at least measurable instead of invisible.

* fix(filer): send log file refs through the pipelined sender

sendLogFileRefs wrote on the raw gRPC stream while pipelinedSender's
goroutine concurrently calls Send for queued memory events - two senders
on one stream, which gRPC forbids. The window used to open once per
fall-behind; the gap retry loop now reopens it every pass. Routing refs
through the sender also restores ordering: refs used to overtake up to
1024 queued events, and the client treats any non-ref message as the
signal to process buffered refs, so overtaken refs were applied against
the wrong position.

The reason refs bypassed the sender was the batcher: their TsNs of 0
reads as far behind, and the client recognizes refs by the top-level
field alone - a refs envelope would drop its Events tail, and refs inside
Events would be applied as an empty event. Teach the batcher instead:
refs messages always go solo, and one drained mid-batch is sent solo
right after that batch.

* fix(filer): count parked subscribers instead of flagging them by name

The per-client gauge series could not work. Its label was rebuilt from the
peer address at first, which leaks a series per reconnect; keyed on the
client-supplied name instead, it collides - every mount registers as
"mount" - so one stream's teardown deleted a parked sibling's live series,
and the sibling never re-created it because its own park state said the
gauge was already set. Either way the alert this gauge exists to drive
goes dark.

A count needs no identity: Inc on park, Dec on resume or teardown, scope
as the only label. Client details stay in the logs.

Also start warning only once a stall has outlived the warn interval.
park() warned immediately on every first park, so catch-up churn that
parks and resumes every couple of seconds logged a warning pair per cycle
- exactly the flood the pacing was supposed to prevent, burying the
long-stall warnings that matter.

* fix(filer): one gap resolver, and re-arm an unservable adjacent cursor

The two resolvers were the same function - the aggregated one is the
local one with a flush watermark of zero, since its ring never flushes -
duplicated down to the comment justifying the resume target. A fix
applied to one and not the other is how the two streams drift; merge
them.

The merge carries the one behavioral fix both copies needed. Timestamp
collision bumps make adjacent entries exactly 1ns apart, so an entry
ending an evicted window leaves the cursor exactly one below earliest
with a positive batch offset. The resume target then equals the cursor
and both copies refused it as no progress - but that cursor cannot be
served (ReadFromBuffer refuses positive offsets below the window) while
the sentinel resume at the same timestamp is, and both deliver exactly
the entries after it. Refusing parked a subscriber whose data was
entirely in memory: until the next flush locally, and through a
15-minute stall failure on the aggregated path. Advance on equal target
when the held cursor is exclusive; a sentinel there is already served,
so it still refuses.

* fix(filer): a bounded subscription parked exactly on UntilNs is finished

The park's UntilNs check was strict while the bound is inclusive and
cursors are exclusive: a disk read whose last entry sits exactly on
UntilNs leaves the cursor there with everything up to the bound already
delivered. If the next range was an unprovable gap, the completed
subscription parked anyway and eventually failed - fs.verify hanging and
then erroring on a healthy cluster.

* fix(filer): re-derive the subscribe loops as one state machine

The loops had grown three generations of gap handling - a post-disk-read
branch, a post-memory-read branch, and an every-pass guard bolted on in
front of the memory read - each consulting state the others mutated. The
worst interaction wedged the aggregated stream permanently: diskExhausted
compared the disk result against the cursor that result had just updated,
and paired it with a ResumeFromDiskError latch that only a resolver
advance cleared, so one fall-behind sent every later pass into the gap
block and LoopProcessLogData never ran again. Mounts kept a
healthy-looking stream and applied nothing.

Both loops now run the same derived sequence. One disk pass; progress is
the pre-update cursor against the result, freshly each pass. One gap
decision before the memory read: a cursor the ring evicted past either
keeps draining the disk (it just advanced), resolves forward (the gap is
proven empty), or parks - and a cursor memory refused with nothing
evicted after it re-arms onto the retained window. The stale-latch branch
is gone: the error is only consulted on a pass whose own disk read came
up empty. All four parks go through one parkOnGap helper, so the exits
live in one place. The eviction watermark is read after the disk read and
the same value feeds both the guard and the unproven-crossing report,
which previously compared against a snapshot taken before a potentially
minutes-long backlog read and missed evictions landing during it. The
next-day jump now clears the stall reporter - it used to leave a stale
park epoch that could kill the next brief park instantly at the 15-minute
bound - and counts its own watermark crossing.

Chunk-ref reads get two rules the old loops lacked. Refs are sent once
per position: retries re-sent identical batches every two seconds into a
client that only drains them on a non-ref message, growing an unbounded
pending list. And when refs cannot advance the cursor - they report file
start minutes, which sit below the content the client was actually given,
pinning the cursor under the watermark forever during bursts - the pass
falls through to entry reads, which move the cursor by real timestamps
and double as the client's drain signal.

* fix(filer): give up on an unprovable gap instead of failing the stream

Failing after maxGapStall assumed the client could do something better,
but every consumer just reconnects at the same SinceNs and hits the same
wall, so an unprovable gap - a dead peer, or a peer whose filer store
this filer cannot read at all - turned into a permanent 15-minute
fail/reconnect loop delivering nothing. Master handled the same state by
skipping instantly and silently.

Take the middle: wait the full bound, then abandon the gap and resume at
the eviction watermark, where everything retained starts strictly after,
so the loss is exactly the range that could not be proven. The skip
shares the unproven-crossing counter and logs at error level - loss is
bounded, recorded, and the stream keeps working. A stall with nothing
evicted past the cursor loses nothing by waiting, so it restarts the
clock and keeps parking rather than skipping.

* test(filer): pin the file-skip bound through the production predicate

The spanning-file test asserted against its own copy of the arithmetic,
so a regression in the iterator - restoring the next-file-name comparison
or dropping the flush-interval term - would keep CI green while
re-introducing the silent loss the fix closed. Extract the bound into
logFileMayContainAfter, call it from the iterator, and point the test at
it; breaking the production expression now fails the test.

* test(log_buffer): pin the flush-subscriber contract

The registry the filer's gap parks wait on had no test at all. Cover the
observable contract: an append never wakes a flush subscriber, a flush
does with the watermark already stored, unregistering closes the channel
so an abandoned waiter unblocks, and double or unknown unregisters are
harmless.

The store-before-notify ordering in loopFlush is what makes the parks'
wake-up re-check sound, and it is not black-box testable - reordering
leaves a same-goroutine window of nanoseconds that hundreds of tight
round-trips never catch. Mark it load-bearing at the site instead; a
reorder now at least has to argue with the comment it deletes.

* fix(filer): a -1 SinceNs is a position, not the refs-gate sentinel

The once-per-position refs gate used -1 as "never sent", but a client may
legally subscribe with SinceNs=-1, whose cursor timestamp is exactly -1:
the very first pass then believed refs were already sent there and fell
back to streaming the whole persisted history entry by entry - the
bootstrap load chunks mode exists to avoid. Use MinInt64, which no cursor
can carry.

* refactor(filer): drop the aggregator's listener cond with no waiters left

Same shape as the FilerServer cond already removed: nothing increments
ListenersWaits and nothing ever calls Wait, so the three Broadcasts wake
nobody and the notify callback's guard is always false. Aggregated
subscribers wake through the buffer's subscriber channels now.

* fix(filer): make the gap metrics say what they count

The crossing counter's help text described only the aggregated peer case,
but give-ups increment it for local stalls too - a wedged local flush -
which sends an operator chasing peer replication when the problem is the
local store. Label it by scope and say both. The stalled gauge counted
every park, including waits with nothing evicted and nothing at risk,
while its help text promised evicted-but-unpersisted events; describe it
as what it is, a count of subscribers parked on a gap.

* test(filer): make the flush and stall tests assert what they claim

The flush-subscriber rounds were vacuous: the probe entry sat above the
round timestamps, so every round entry was collision-bumped and the
stored watermark exceeded the local value each assertion compared
against - the same bump mistake this test suite already made once. Put
the probe below the rounds and guard each round against bumping, so a
vacuous setup fails instead of passing.

The stall-outcome test wrote the reporter's park epoch directly,
bypassing the gauge Inc that gaveUp() later Decs - leaving the shared
process gauge at -1 for every test that runs after it. Park through the
real path, age the park by hand, release what the test holds, and assert
the gauge lands back where it started.

* fix(filer): finish the stream checks before marking it parked

parkOnGap stamped the reporter before waitOnGap ran its instant done
exits, so a bounded subscription completing inside a gap state was marked
parked for the microsecond before done fired - a phantom gauge blip and a
false "disconnected still behind" warning on every healthy completion.
Fold waitOnGap into parkOnGap so the done exits run first and the park
mark only ever covers a stream that actually waits.

While the park owns its timer, back the retry off as the stall ages -
2s probes growing toward one a minute - since every retry re-reads the
persisted log, and probing the store each 2s for 15 minutes per parked
subscriber during the very outage that parked them makes the bad time
worse. The subtest still named for the old fail-the-stream stall
behavior goes with the merge.

* fix(log_buffer): gate filer cursors against eviction under the read lock

The subscribe loops checked the eviction watermark and then read memory,
but a seal can land between the two: the read then served a sentinel
cursor from the earliest retained window, silently skipping the window
just evicted - the loss class this PR exists to make loud, surviving as
a race.

The only place the check is atomic with the serve decision is inside
ReadFromBuffer, under the lock seals take to evict. Rather than put the
policy back into the shared read path - which broke four message-queue
readers last time - add a new sentinel offset that opts into it:
EvictionGatedOffset reads inclusively exactly like -2, except below the
watermark it is refused to disk. The filer loops stamp it on every
cursor they hand the memory read; a refusal lands in the same gap
machinery the loop-side check feeds, so the race collapses into the
handled path. MQ cursors never carry it and keep master behavior
byte-for-byte.

* fix(filer): gate the aggregated gap on received, not bumped, timestamps

The aggregated ring rewrites an out-of-order arrival to its head plus a
nanosecond, so after any bump-heavy interval - a peer history replay
following a restart is enough - its eviction watermark lives above every
timestamp that exists on any peer's disk. Comparing a disk cursor against
it parked subscribers that had in fact drained every peer's log: a
15-minute delivery freeze ending in a give-up skip and a false loss
alarm, on a healthy cluster where master resumed instantly.

Track a second watermark in the received timestamp space - the highest
pre-bump timestamp among evicted entries - and gate the aggregated loop
on that. Disk cursors and received timestamps are the same space, so the
comparison means what it says: at or past it, every evicted entry's
original was at or below the cursor, and everything flushed of them was
already delivered. The bumped watermark keeps guarding the in-ring read
gate, whose cursors live in ring space. The local buffer is untouched:
it flushes its own bumped timestamps, so there the two spaces are one.

* fix(filer): ship each log chunk once and stop echoing ref'd files inline

Chunk mode duplicated data through two doors. Consecutive ref
collections overlap by design - the scan backs off a flush interval to
catch a spanning file, and a filer appends chunks to its newest file -
so the same file was shipped again on every pass that re-listed it: the
client re-downloaded its chunks, and a duplicated file mid-batch rewinds
timestamps inside the client's per-filer merge, which reads each stream
as sorted - transiently resurrecting deleted entries during catch-up.
Track per subscription how many chunks of each file were shipped and
send only the unsent suffix; state prunes with the scan window, so it
holds a few files per filer.

The second door was the entry fallback: when refs cannot advance the
minute-named cursor, the pass streamed the ref'd file's tail inline, and
the client applies inline events unfiltered - the same tail it already
applied from chunks. Entry passes for chunk clients now advance the
cursor without delivering; everything they skip is covered by the refs
already sent or the deltas the next collection ships.

* refactor(filer): one gap decision shared by both subscribe loops

The post-disk gap tree - guard, drain, resolve, two parks, the re-arm -
existed twice, differing only in buffer, watermark space, flush getter,
park channels, and reason strings. Four rounds of review fixes have shown
the copies drift the moment one is edited alone. gapPass now carries the
five differences and the tree lives once; the loops shrink to a
three-way switch between reading memory, restarting the pass, and ending
the stream.

* docs(filer): trim the gap-machinery comments to the why

Several blocks had grown to ten-plus lines restating what the tests
already pin or retelling one rationale at multiple sites. Keep the
non-obvious why - the load-bearing flush ordering, the two timestamp
spaces, the refusal-at-equality argument - in a few lines each.

* fix(log_buffer): credit an entry's received timestamp to its own window

The received-ts capture ran before the rollover check, so an append that
sealed the previous window stamped its timestamp onto that window and
then lost it in the reset of the new one. The eviction watermark this
feeds broke both ways: the sealed window's value was inflated by an entry
it does not contain - parking aggregated subscribers on gaps that were
drained - and the entry's real window was deflated, proving gaps empty
that still held its event on some peer's unflushed path. Credit the
timestamp only after the entry lands, when its window is known.

* fix(filer): rebase a shipped chunk suffix to logical offset zero

A grown file's delta kept the chunks' original file offsets, but the
client's chunk reader starts at logical zero and a list opening higher
reads as instant EOF - a successfully empty replay, and since chunk
clients no longer receive disk entries inline, the appended events were
silently dropped. Clone the suffix chunks with offsets rebased to zero;
the cut is record-aligned because each append is one uploaded chunk of
whole entries, so the suffix decodes as a file of its own.

* fix(filer): finish every chunk refs batch with a transition the client acts on

Both chunk consumers buffer refs until a non-ref message arrives, so a
source with historical logs and a quiet ring - a mount reconnecting
after a filer restart is the common case - shipped its backlog and then
went silent: the client sat on the refs until the next metadata mutation
anywhere in the cluster. The disk step now ends every batch with the
empty-notification marker the client already treats as a resume-cursor
advance.

The same step closes the inline replay: the cursor used to stay at the
last file's minute name, so the memory read re-delivered the retained
tail of a file the client had just read via chunks - T1..Tn applied
twice. The advance-only entry read now runs on every chunk pass, moving
the cursor to the true disk content end before memory is consulted.

Ordering inside the pass is load-bearing: the entry read can outrun the
shipped refs by a chunk appended between collection and read, and the
transition timestamp becomes the client's refs filter - stamping it past
unshipped content would silently drop that chunk's events on the next
delta. The pass therefore re-ships the delta after the entry read, so
the transition never exceeds shipped content. Bump-displaced aggregated
entries can still arrive inline above the cursor with originals below
it; that duplication is bounded and stays within the documented
at-least-once residual.

* fix(filer): prune ref state at the minute the scan actually stops at

The collector compares file names at minute granularity while the prune
used the exact-nanosecond scan bound, so for a cursor at 12:31:20 the
12-30 file was still collected but its sent state was already deleted -
the next pass reshipped the whole file, re-creating the duplicate-refs
class the state exists to prevent. Truncate the bound to the minute the
file names live in.

* fix(filer): derive the chunk cursor from the shipped refs themselves

The advance-only entry read left the three positions that must agree in
each other's blind spots. Its snapshot could trail the second delta's, so
a chunk appended between them shipped events newer than the cursor and
the memory pass sent them again. And it made the filer decode the tail
range on every pass, serialized ahead of the client's own reads by the
transition marker - re-introducing a slice of the replay work chunk mode
exists to offload.

Compute the cursor from the shipped set instead: the final entry
timestamp of each filer's last shipped chunk, decoded once through the
shared chunk cache. Refs coverage, transition marker, and memory start
are then the same number by construction - nothing is decoded twice,
nothing is dropped, and the per-pass server cost falls to one cached
chunk decode per filer. The second delta and the once-per-position refs
gate existed to patch the entry read's snapshot races, so both go with
it; the range read survives only as a fallback for legacy chunks that do
not decode standalone.

* fix(filer): keep the chunk-cursor probe inside the shipped snapshot

Three holes in the tail probe, all variations of stepping outside what
was shipped. A permanently missing chunk failed the stream before the
transition marker, so the client discarded its pending refs and
reconnected to the same failure forever - blocking all later metadata
behind one dead volume, where every other replay path (including the
client's own reader) skips such chunks; the probe now walks back to the
last readable chunk, and a filer with nothing readable simply contributes
no cursor. The legacy fallback re-listed the logs after the refs were
collected, so a concurrent append could push the range end over an
unshipped chunk and the marker past events the client never received; it
now streams the shipped chunk list itself, so no snapshot other than the
shipped one is ever consulted. And a file selected before UntilNs can
hold entries past it, which the client filters while still adopting the
marker as its checkpoint - a later bounded request then skipped them;
the marker is clamped to the bound.

* fix(filer): make the cursor probe an exact mirror of the client's reader

The probe answered from the server's view of the chunks; the marker's
correctness depends on the client's. Its backward walk found the last
readable chunk, but the client reads forward and stops at the first
unreadable one, never resuming within a file - for readable, missing,
readable the marker claimed the suffix the client never applied, losing
those entries permanently. Keeping only each filer's final file ref
discarded the progress of earlier readable files when that file was
wholly missing, rewinding the marker to the start cursor. And a torn
trailing size prefix - what a crashed writer leaves - failed the probe
where the client reads a clean end, blocking the marker forever on data
the client accepts.

The probe is now shaped like the reader it answers for: per file the
readable prefix, per filer the newest file with content, and no
condition escapes as an error - understating the marker only re-ships,
overstating loses events, and a probe failure must never block the
transition the client is waiting on. Each rule is pinned by a test that
fails against the previous shape.

* fix(filer): judge chunk readability at the volumes, not the decode cache

Two ways the probe's answer could drift from what the client experiences.
A chunk this server decoded earlier stays warm in the shared cache after
its volume dies, so the probe sailed past a chunk the direct-reading
client stops at - marker beyond the unread suffix, entries lost. Every
chunk now passes a volume lookup before the cache is consulted; the
lookup rides the master client's in-memory map, so the probe stays cheap.

And a probe stop was treated as harmless understatement, but the delta
had already marked the whole ref sent: a transient server-side failure
left the cursor stranded behind shipped content for the life of the
connection, parking aggregated streams below the watermark for data the
client already holds. The pass now rolls back the sent state of every
ref above the file that answered the probe, so unreached refs re-ship
and re-probe until the cursor gets there. Re-shipped entries at or below
the client's checkpoint are filtered client-side, and batches are
marker-separated, so a re-shipped file cannot rewind a merge mid-batch.

* test(filer): end-to-end subscribe-loop harness and wire-contract tests

Every escaped bug across this change's review rounds lived in an
interaction the unit tests could not see: the loop state machine, the
disk/memory handoff, or the server/client contract. The harness runs the
real SubscribeLocalMetadata loop against a real leveldb-backed filer,
faking only the volume layer behind the existing test hooks, and asserts
the delivered stream itself.

Eight scenarios, each pinning a class this change was reviewed for: the
headline evicted-unflushed gap parks and then delivers in full; a ring
that evicted nothing serves memory promptly; a backlog-to-live handoff
with 1ms-adjacent timestamps across every boundary delivers exactly
once; a flush-proven gap over vacuumed log files skips to the retained
ring including a single-entry window; a bounded subscription terminates
at its bound; a permanently wedged flush ends in the give-up skip with
the stream still alive; and chunk mode is checked against the real
client code - pb.ReadLogFileRefs applied to the shipped refs must cover
everything the transition marker claims, with and without a dead volume
in the middle.

Validated by re-introducing three fixed bugs: the missing eviction guard
delivers during the unproven gap, a 2ms cursor error at the handoff
drops exactly one event, and resuming at rather than below the earliest
retained window loses a single-entry window's sole event - each caught
by the scenario built for it. The gap timing knobs become vars so parks
run at test speed, a small filer hook swaps the volume-touching read
functions, and a sender test pins the refs wire rules the client
depends on: never batched, never an envelope, everything in order.

* fix(filer): re-ship a partially read answering file, pin the probe's limits

The sent-state rollback stopped at files newer than the one that answered
the probe. When the answering file itself was only prefix-readable - a
dead or transient chunk mid-file - its unread suffix stayed marked sent,
and the next append advanced the cursor past it for good. The probe now
reports whether the answering file was read through to its end, and a
prefix-limited answer re-ships that file too; a torn tail counts as
complete, since the client's read ends there as well. The rollback rules
live in one predicate with a table test - files below a complete answer
stay sent, because the client has moved past them and re-shipping cannot
rewind its filter.

Two test honesty fixes ride along. The loop harness derived its timestamp
base from time.Now() per call, so expectations recomputed across a second
boundary drifted by exactly one second; the base is now fixed per
harness. And the probe's liveness boundary is pinned as a test instead of
a comment: a volume lookup cannot see a dead needle or a stale location
inside a resolvable volume, so a warm cache can answer past a chunk the
client fails on - accepted because metadata log chunks die
volume-at-a-time and the alternative is a real read per probe, which is
what the probe exists to avoid. The test states the boundary so changing
it is a decision, not an accident.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-08-02 00:06:22 -07:00
Chris LuandGitHub e696c2585e s3 remote: honor s3.support_tagging in UpdateFileMetadata (#10532)
* 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.
2026-08-01 20:18:59 -07:00
Chris LuandGitHub 0d7173a029 remote storage: actually delete objects when a directory is removed (#10531)
* 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.
2026-08-01 20:11:41 -07:00
Chris LuandGitHub c21d92b70a test: wait for async write-budget release after pipeline shutdown (#10530)
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.
2026-08-01 20:04:40 -07:00
Chris LuandGitHub de00091765 test: random needles always carry at least one byte (#10523)
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.
2026-08-01 00:37:22 -07:00
Lisandro PinandGitHub fa9f471f56 EC scrubbing: list shards for needles failing scrubs in the result output. (#10510)
This allows to pinpoint failures to a subset of shards, which can then
be bisected and potentially reconstructed.
2026-08-01 00:27:06 -07:00
Chris LuandGitHub c2701955c7 s3: cover three untested STS paths (#10521)
* 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.
2026-07-31 23:56:04 -07:00
Chris LuandGitHub ef6a706c0e master: count only writable volumes as crowded when deciding growth (#10522)
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.
2026-07-31 19:56:45 -07:00
Chris LuandGitHub 1ce106e69d s3: audit the assumed-role principal and the STS caller (#10519)
* 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.
2026-07-31 19:51:03 -07:00
Chris LuandGitHub fa432f9a6a s3: keep an admin's role session scoped to the role (#10520)
* 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.
2026-07-31 19:50:59 -07:00
Chris LuandGitHub 82c67b5896 test: cover listings spanning a run of retracted keys (#10517)
* 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
2026-07-31 19:49:36 -07:00
Chris LuandGitHub d8d29c4ede s3: carry storage class in the cached listing metadata (#10516)
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.
2026-07-31 19:48:17 -07:00