Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub 5e2270ec25 build(deps): bump github/codeql-action from 4.38.2 to 4.38.3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.38.2 to 4.38.3.
- [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.38.2...v4.38.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-13 19:03:16 +00:00
github-actions[bot] 8093ecf4c8 docs: regenerate star history chart 2026-10-13 00:52:42 +00:00
Jay HemnaniGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Chris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
6840371774 sftp: keep a file's existing bytes when it is opened for write without truncate (#11723)
* sftp: keep a file's existing bytes when it is opened for write without truncate

* sftp: preserve sparse gaps and refuse unreadable entries when copying existing content

The copy used a single chunk-stream read, which ends at the first sparse
gap: the tail past a hole was dropped, and a remote-only entry with no
local chunks copied zero bytes, so closing the handle uploaded an empty
file. Read through PrepareStreamContent, which fills unstored regions
with zeros, and refuse entries that carry no local bytes at all.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: report a failed destination write from a completed chunk fetch

retriedStreamFetchChunkData broke out of the retry loop on a clean HTTP
end before checking writeErr, so a destination write that failed mid-
stream returned a short write and nil error. Check writeErr first.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* sftp: cover remote and all-holes sizes when copying existing content

Take the declared size from RemoteEntry when it exceeds FileSize, pad
inline content to the declared size, and treat a chunkless local file as
all holes rather than unreadable. A remote-only entry still refuses the
copy, now checked with a zero FileSize as well.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* sftp: size the existing-content copy the way filer.FileSize does

A locally shortened file still names its older, larger remote object;
taking RemoteSize unconditionally padded the copy back out to it.
filer.FileSize only honors the remote size when the remote is newer,
and also covers the chunk-extent size. A remote-only entry still
refuses the copy whenever it points at remote bytes.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-13 07:20:20 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d67fc47a61 build(deps): bump rustls from 0.23.37 to 0.23.45 in /seaweed-volume (#11707)
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.37 to 0.23.45.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.37...v/0.23.45)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.45
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 05:57:00 +08:00
d0f3fad133 ec: avoid redundant needle.GetActualSize call in LocateEcShardNeedle (#11629)
LocateEcShardNeedle passed needle.GetActualSize(size, version) to
LocateEcShardNeedleInterval, which calls needle.GetActualSize internally.
This caused header and padding to be added twice to needle size.

Fixes #3864

Co-authored-by: Morteza <m.mohammadi@rahkar.team>
2026-10-13 05:50:09 +08:00
624a13ecc6 EC worker: pin jobs to replica-host workers, late-bind local source (#11718)
* feat: prefer same-host worker and local EC replica

Admin executor pick ranked idle workers only, so a job whose volume
lived on full-03 could still run on full-01 and pull the whole .dat
across the LAN. Rank workers whose Hello host matches any replica
first (fallback to others if the local worker is full).

Detection named Sources[0] / the lowest server id; execute then
SyncAndSelectBestReplica and could switch the copy source to another
rack. After sync, if a replica on this machine has a complete file
count, copy from that URL so affinity and pull stay on one host.

* feat: pin scheduler jobs to replica-host workers until load K

Reserve prefers the least-loaded replica-host worker and waits while
that load is below 5 instead of spilling to a free remote worker.
K=0 restores immediate spill. Waterfill uses reservation load so four
idle preferred workers each get a job.

* feat: park scheduled jobs on per-worker admin queues

Assign a detection batch to the least-loaded replica-host worker in
one pass, then let ExecutionConcurrency goroutines pop a home-runnable
job or steal when that home queue reaches K. Dispatch no longer holds
a job in reserve-backoff, so a free worker is not blocked by another
home's pin. Tracked jobs stay pending/queued and record the home
worker for the admin UI.

* fix: rehome queued jobs when the home worker dies

A live home still pins below K. A home missing from live executors
is stolen immediately onto a free worker so a short orphaned queue
does not sit until the dispatch window ends.

* feat: wait before stealing a dead home worker queue

Orphaned queues are held for 30s so a worker reconnect blink does
not dump jobs onto other machines. After the grace, steal without K
as before. Zero disables the wait. A home that comes back during
grace keeps its queue.

* fix: late-bind EC from to worker hostname

After replicas are consistent (equal FileCount), rebind encode source
to a volume server on the same hostname as worker -address. Any port
on that host is local. Diverged FileCount still uses the max replica.

* feat: assign homeless jobs to global FIFO

Jobs whose replica hostname has no registered worker skip pin and orphan grace.
Free workers take own home, then steal at K/orphan, then leftover FIFO.

* fix: take homeless FIFO before steal for no-home workers

Workers without a replica-host match take leftover first; steal (K/orphan)
only when FIFO is empty. Home workers keep own-queue, steal, then leftover.

---------

Co-authored-by: Chepurnov Simon <195125934+CHEPURNOV-S-S@users.noreply.github.com>
2026-10-13 05:31:34 +08:00
Bruce ZouGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Chris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
492e700d1f Volume grow avg fill threshold (#11725)
* master: add conditional grow by average fill ratio to VolumeGrow gRPC

Callers that pre-grow volumes on fill level currently have to list all
volumes of a collection and sum sizes themselves, which is expensive on
clusters with millions of volumes. The master already tracks each
writable volume's effective size in memory, so it can answer the
question directly.

Add grow_avg_fill_threshold to VolumeGrowRequest: when set (e.g. 0.6),
growth only happens once the layout's writable volumes have reached
that average fill ratio. A layout with no writable volumes skips the
check, since that is the case growth exists to fix. VolumeGrowResponse
now reports the observed avg_fill and writable_count whether or not
growth was triggered.

* master: unit tests for WritableAvgFill and FindVolumeLayout

Covers the average-fill computation (mixed fills, no writables, a
writable volume without a size-tracking entry yet) and that
FindVolumeLayout resolves existing layouts without creating missing
collections.

* address review: report avg_fill and writable_count for every VolumeGrow

The stats were only read when grow_avg_fill_threshold was set, so a
request without a threshold got AvgFill=0 and WritableCount=0 even when
writable volumes existed. Read the layout stats unconditionally and use
the threshold only to decide whether growth happens.

* master: scope the grow fill check to the requested target

A VolumeGrow aimed at a data center, rack or node must not skip growth
because fill elsewhere keeps the layout average below the threshold.
Measure only writables serving the target; a target with none still
grows.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-13 05:30:03 +08:00
Jay HemnaniGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Chris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
b5ee5b871e sftp: fail a directory listing on a stream error, and page past hidden entries (#11724)
* sftp: fail a directory listing on a stream error, and page past hidden entries

* sftp: test a fully hidden listing page

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 19:34:46 +08:00
Alex KGitHubChris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
6fb21c9ec0 filer: copy a remote-only entry on the remote when renaming it (#11709)
* filer: copy a remote-only entry on the remote when renaming it

Renaming a file under a remote.mount whose content exists only on the
remote (never cached, or remote.uncache'd) lost it: the new entry kept
the old RemoteEntry, deleting the old entry deleted the old object, and
with no chunks nothing ever wrote the new key. filer.remote.sync could
not finish the rename either, since the old object was already gone.

moveSelfEntry now copies such an entry's object to the new key before
it touches anything, and the new entry gets the RemoteEntry of the
copy. The copy is a new optional RemoteStorageObjectCopier interface,
implemented for S3 with CopyObject up to 5 GB and UploadPartCopy above
that; the multipart copy carries the source's metadata and, where the
remote supports tagging, its tags. When an existing target is
overwritten, its delete keeps the remote object (the copy replaced it)
and its event is marked metadata-only so the sync leaves it alone too.

A remote-only rename that leaves its mount, or whose remote cannot
copy, is refused instead of deleting the only copy. Empty entries and
entries with local data rename as before.

UpdateFileMetadata builds its copy source with the same helper, which
escapes each key segment and keeps the slashes.

* filer: delete a copied rename's old object only after the commit

Review follow-ups:

- The old key of a copied remote-only entry, and the old directory of a
  rename that copied one, are now deleted once the rename has committed.
  A rename that fails after the copy leaves the source entry and its
  object in place, plus an extra object at the new key.
- Every part of a multipart copy is conditioned on the ETag the copy
  started from, so an object overwritten mid-copy fails the copy
  instead of mixing versions.
- Both copy paths keep the source's server-side encryption settings.
- filer.remote.sync no longer deletes a key that a remote-only entry
  holds by the time the delete event is processed: a file deleted and
  then replaced by a remote-only rename has its content only there.

* filer: do not sweep the old prefix of a directory rename that copied

Removing the old directory on the remote deletes everything under its
prefix. Run after the commit, that could also take an object another
rename copied in under the same path meanwhile. When a child was copied,
leave the prefix alone and delete only the copied children's old keys.

* filer: keep an old key a remote-only entry has retaken by cleanup time

A directory rename does not lock its children's paths, so between the
commit and the deferred delete of a copied child's old key another
rename can put a remote-only entry there, whose content is then only in
that object. Check the store before deleting.

* filer: keep a renamed entry's old object when the path lookup fails

The deferred delete must only run once the lookup shows no remote-only
entry at the old path. A failed lookup shows nothing, and an extra
object loses nothing.

* filer: copy over a remote-only rename target only after the commit

Copying the source object onto the target's key before the transaction
committed destroyed the target's only content, which a rolled-back
commit cannot restore. When the target is remote-only the copy now runs
after the commit: the new entry keeps the old key's stamp until the copy
lands and re-stamps it, the target's object is kept meanwhile, and a
failed copy keeps the old object so remote.sync can finish it.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* remote.sync: match the delete event's etag before deleting the object

A rename can copy another entry's content onto a key between the filer's
check and the remote delete, and the delete then removes the copy. The
delete event carries the object's etag; delete only when the live object
still matches it.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: finish a rename's remote work outside the committed transaction

The SQL stores keep the transaction in the context and refuse every
call through it once it has committed. The work that runs after
CommitTransaction used that context, so on postgres2 the deferred
old-key delete could not look up the old path and kept the object, and
finishRemoteCopies could not read or stamp the entry. Both failed with
"sql: transaction has already been committed or rolled back" on every
copied rename; remote.sync cleaned up after them.

Keep the context from before the transaction for the work after the
commit. The rename test store gets a strict mode that fails calls
through a committed transaction the way database/sql does.

* filer: carry a deferred copy's stamp in the rename event; delete orphaned uploads

- The deferred copy stamps the entry with a plain UpdateEntry, which
  publishes no event, so the queued rename event still carried the old
  key's stamp to peer filers and subscribers. Give it the copy's
  RemoteEntry before it is sent. The two notify loops share the code now.
- remote.sync kept any object whose ETag changed since the delete. With
  no entry at the path that is an upload which finished after the
  delete, and keeping it leaves it on the remote for good. Look up the
  path first and keep a changed object only when an entry holds it.

* remote.sync: keep a changed object even with no entry at its path

Deleting a changed object when no entry holds the path also deletes a
copy a directory rename has written but not yet made visible, while a
delete of that path waits in the sync; the rename then removes the old
key and the content is gone. Keep any object whose ETag changed since
the delete, as before. An upload that finishes after the delete leaks
its object instead, which is the cheaper failure.

* filer: read a pending remote rename copy from the source key

A remote-only rename over a remote-only target commits before its copy
lands, so reads on the new path served the target's old object until the
copy stamped the entry — and indefinitely when the copy failed without
remote.sync running. The committed entry now records the renamed-from
path, and remote-only reads resolve that object's key while the stamp
is pending; the copy's stamp and the sync's completion stamp both clear
the marker.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: stamp the rename event with the deferred copy's result

A remote-only rename over a remote-only target queued the new entry's
event with the source's stamp before the deferred copy ran. Peers
replaying it saved a stamp the copy had already superseded, and the
sync serving such a peer rejected the destination as a match and tried
to copy the deleted source. The event now carries the copy's result
and drops the pending-source marker once it lands.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* remote.sync: delete only the object the event's stamp describes

An object at a deleted key is not always the one the delete meant: a
rename copy can land before its filer entry is visible, and a rewrite
can upload over it. When the event recorded no remote stamp, or the
live stat cannot confirm the recorded etag, the delete now keeps the
object instead of treating it as a late upload.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: tighten the pending rename source marker

Follow-ups to reading a pending remote rename copy from its source key:

- Only the filer sets the marker. The HTTP upload and tagging handlers
  store Seaweed-* request headers as extended attributes, so they skip
  this one, and a marker that leads out of the entry's own mount is
  ignored, so it cannot point reads at another remote.
- A pending entry renamed again copies from the marker's source, not
  from its own key, which still holds the old target's bytes. A further
  deferred rename keeps naming the original source; an immediate copy
  clears the marker.
- When remote.sync finds the destination already holding the content,
  it stamps the entry (clearing the marker) before deleting the source,
  and deletes the source only once the entry no longer reads it.
- A read that resolved the source just before the copy finished and
  the source went away retries with the entry as it is now.

* filer, remote.sync: follow a pending rename's source across renames

When a remote-only entry is renamed over another remote-only file and
the deferred copy fails, it reads the source key through its marker.
Renamed again the same way, the marker keeps naming that source, but:

- remote.sync completed each rename from the event's old key. For the
  second one that is the first target's old object, which it copied
  over the destination before clearing the marker. It now copies from
  the source the entry's marker names.
- For the first rename it found no entry at the new path and deleted
  the old key, which the later entry still reads. When the event's new
  entry carried a marker and the path is gone, it now leaves the key to
  the entry that moved on.
- When the filer copies a pending entry immediately on a later rename,
  it now also releases the source the copy read, after the commit and
  with the same checks as the old key.

* remote.sync: release a pending rename's source, the filer leaves it

The filer deleted a pending entry's source after copying it on a later
rename, but that rename locks only its own paths: another rename can
copy onto the source key before its entry is visible, and the cleanup
then deleted that copy. And deleting a pending entry never released its
source at all.

The filer now leaves marked sources alone. remote.sync releases one
when the pending entry is deleted, or when a later rename was copied
right away, and only while the live object is still the one the
entry's stamp describes and no remote-only entry holds the path: a
copy that landed there since has a different ETag and is kept.

* remote.sync: release a pending source when the renamed entry is cached

A rename copied right away left its pending source to remote.sync,
which released it only while the destination was still remote-only.
Cached before the sync got there, the destination took the other path
and the source stayed on the remote for good. Release it there too,
with the same checks.

* remote.sync: release a pending source only after the destination is safe

e6d6438 released the source a renamed entry's old version read before
the destination was uploaded. A cached destination that still carried
the pending marker then had nothing behind it: had its upload failed,
remote.uncache could drop its chunks and leave it reading a deleted
source. Release the source only after the upload is stamped, and only
when the entry no longer reads through a marker.

* remote.sync: delete a rename's old key only if it is still the renamed content

completeRemoteOnlyRename deleted the old key once the destination held
the content. Another rename onto the vacated path can copy its object
there before the sync reaches the first rename, and that copy was then
deleted with it. Delete the old key only while the live object is still
the one the renamed entry's stamp describes.

Also keep the pending-source marker out of DELETE ?tagging, which
clears every Seaweed-* attribute: the marker is not a tag, and losing it
points reads back at the old target's bytes.

* remote.sync: compare a rename's old key against the source's own stamp

A retry can find the renamed entry already stamped with the copy, and a
copy of a multipart source has another ETag, so the unchanged source
was kept and stayed on the remote. Carry the stamp the event recorded
for the source (or the pending entry's, for a marked source) and compare
against that.

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 19:21:54 +08:00
ingardmGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Claude Opus 5.5Chris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
41260a55f6 master: record writableVolumeCount on HTTP /dir/assign like gRPC Assign (#11716)
* master: extract the periodic per-layout grow decision

Move the must-grow and crowded-step decision of the periodic growth scan
in ProcessGrowRequest into periodicGrowCount, a pure function of the
layout's lastGrowCount, writable and crowded counts, so it can be tested
without running the scan loop or growing real volumes.

No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* master: record writableVolumeCount on HTTP /dir/assign

The periodic growth scan (#5781) keeps a layout supplied ahead of
demand only when it knows how many writable volumes clients want:
lastGrowCount, which gRPC Assign records with SetLastGrowCount. HTTP
/dir/assign parsed writableVolumeCount but only used it to size the
emergency grow when no writable volume was left, so the scan always saw
lastGrowCount 0 for HTTP-only writers.

Since #5821 removed the per-assign threshold grow, such a layout only
grew by the rack-aware step of 2, and only once every writable volume
was crowded. When a batch of volumes filled in lockstep, those 2 took
every write and could fill before the next scan, leaving no writable
volumes until an assign-triggered emergency grow landed, with 503s for
every other assign meanwhile.

Record writableVolumeCount on HTTP assigns as gRPC Assign does. Clients
that do not send it are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* master: reject unknown rack/dataNode before recording grow targets

An assign naming a rack or data node outside the topology can never be
served, but lastGrowCount was recorded first. The periodic growth scan
drops the requested placement, so it kept topping the layout up with
volumes the requester could not use. Both assign paths now validate the
placement like they already validate the data center.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* master: scope placement existence checks to the requested path

A rack in another data center or a data node under another rack passed
validation yet could never serve the request, and the grow count was
still recorded. The rack and data-node lookups now take the requested
parents, matching the filters the placement engine applies.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 18:10:41 +08:00
Чепурнов СемёнGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Chepurnov SimonChris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
3031ab5d97 EC placer: walk extra shards on freeSlots, keep 2+2 (#11714)
* fix: rotate EC extra shards on pickTarget count ties

pickTarget used to keep the first sorted key when shard counts tied,
so 10+4 onto 4 racks always put the two fours on full-01/full-02.
Tie-break on remaining freeSlots (same idea as pickNodeInRackEligible)
so the leftover shards walk across racks over a series of volumes.

* fix: pay within-rack EC shard debt on Place

Score nodes by live EcShards plus this Place's assignments, not
this-volume counts or EC-volume presence. Allow SameRackCount+1
only after a sibling already holds a shard, so 4-on-2 becomes 3+1
while 3-on-2 stays 2+1. Rack pickTarget tie-break is unchanged.

* fix: even-spread EC disks, pay debt with remainders only

Do not pack 3+1: three shards on one disk is one disk-loss from
unrecoverable. Place this volume evenly first; leftover shards
(2+1) go to the thinner live count. 4-on-2 stays 2+2.

* fix: score Place extras by freeSlots then liveShards

Even-per-volume and SameRackCount stay; remainder prefers capacity,
then shard debt. No 3+1.

* fix: keep shard-debt fixture capacity-consistent

freeSlots = cap-shards so extras keep paying the thinner disk
across volumes instead of oscillating after the first pair.

* refactor: document dropped mFree; switch EcBalance node pick

Keep host even (mCount). Remainder is disk freeSlots then liveShards so
thin disks fill first. SameRackCount / 2+2 unchanged. pickTarget unchanged.

* test: cover Place 10+4 extras on thin disks/racks

Seed liveShards/freeSlots imbalance on 1-1-{4,6,7,8,15}, 4-2-3, 5-2-2, 6-1-2, 7-1-2. Remainder tails must miss fat first ids; remainder 0 freezes the live gap.

* fix(ecbalancer): count only matching-type slots when picking a move target

Aggregate freeSlots let a rack, machine, or node whose free capacity sat
on another disk type win placement; pickBestDiskOnNode then found no
eligible disk and the move went out with TargetDisk 0, which the copy
endpoint reads as automatic selection onto any disk.

Score candidates on freeSlotsForType — per-disk metadata when present,
the node aggregate when absent — so a wrong-type destination is never
chosen, and cover pickBestNodeForVolume's tie-breaks and the wrong-type
plan case.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ec_balance: spread free slots per disk type, keeping the remainder

Dividing the node's free slots evenly over all its non-full disks floored
a leftover slot to zero on every disk — one free slot across two disks
made the node look full. It also gave disks of other types a share of
slots counted only for the requested type. Each type's slots now spread
over that type's disks with ceil division, so the total is preserved and
every usable slot stays visible to the planner.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Chepurnov Simon <195125934+CHEPURNOV-S-S@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 18:09:53 +08:00
Joe PassananteGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Chris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
acb44a68a9 fix(volume): skip unnecessary needle data reads on delete (#11711)
* fix(volume): do not read a needle's data to delete it

A delete read the needle's whole payload from disk only to discard it.
The delete handler and BatchDelete need the needle's cookie and its
chunk-manifest flag, which live in the header and trailer, but fetched
them with a full needle read, the same call a GET uses.

readNeedle now reads the header and trailer only when MustMetaOnly is
set, whatever the needle's size, and the delete paths ask for that. The
Rust volume server honours must_meta_only the same way for its delete
paths. A compressed needle or a chunk manifest still gets the full read,
as before, because their callers need the bytes.

HEAD is unchanged. It used to set MustMetaOnly, which nothing read; it
now asks for AttemptMetaOnly alone, so it keeps reading a needle up to
PagedReadLimit whole. On a spinning disk that one read is cheaper than a
header read followed by a trailer read.

Version 1 needles are always read in full. They have no data size field
and no metadata after the data, which ReadNeedleMeta relies on, so a
metadata-only read of one failed with "invalid needle meta size". That
already happened for version 1 needles above PagedReadLimit read with
AttemptMetaOnly.

A delete therefore no longer verifies the data CRC of the needle it
removes. The cookie check is unchanged.

* fix(volume): read transformed images fully on HEAD too

A HEAD carrying resize or crop params reports the transformed length,
which a metadata read cannot produce. Go and Rust both answered such
HEADs from the source header, returning the stored size instead of the
cropped or resized one.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 18:08:19 +08:00
Chris Lu 6927de6003 seaweed-worker: mint the admin bearer token like the Go worker
The admin control stream now requires mTLS or a token signed by
jwt.filer_signing.key; the Rust worker gets --filer-signing-key and
--filer-signing-key-expires-after-seconds, mirroring the Go worker's
config names, and attaches the token to the stream metadata.
2026-10-12 03:06:57 -07:00
Chris Lu 613a286cde worker: present the admin bearer token on control streams
Workers mint a SeaweedFilerAdminClaims token when jwt.filer_signing.key
is in their configuration, matching the authentication the admin server
now requires.
2026-10-12 02:55:50 -07:00
Chris Lu 55ac46f7a9 admin: authenticate worker and plugin gRPC streams
Worker registration previously ran unauthenticated on any interface the
listener bound. The stream now requires a verified mTLS client
certificate or a Bearer token signed by jwt.filer_signing.key, and falls
back to local clients only when no key is configured.
2026-10-12 02:55:50 -07:00
Chris Lu 3f8035deb7 security: share the admin gRPC peer authentication check
The local-peer fallback and bearer-token validation used by the filer IAM
gRPC path move into weed/security so every admin-plane gRPC service can
enforce the same contract. A client certificate verified by the server's
TLS config now also counts as authenticated.
2026-10-12 02:55:50 -07:00
Chris Lu a4c8d31cb8 s3api: ignore response header overrides on anonymous requests
AWS requires response-* query parameters to be signed; an anonymous
caller must not choose how a stored object is served.
2026-10-12 02:49:55 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 6bac506842 sftp: terminate the missing-ancestor permission walk at the root
A path whose ancestors are all absent recursed on the root forever,
since Dir("/") is "/".

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 02:41:28 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2c031d9d63 sftp: check write permission on the target file, not only its parent
newFileWriter authorized uploads against the directory, so a file with
a more specific read-only ACL or mode could still be overwritten. The
same parent-directory fallback used for nonexistent paths now applies
through CheckFilePermission on the target itself.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 02:41:28 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d69f45ee83 s3: validate multipart initiate ACLs like PUT
CreateMultipartUpload accepts canned and custom ACLs on initiation;
run them through the same authorization path as PutObject so an
explicit ACL requires s3:PutObjectAcl, and persist the validated
grants on the upload entry for completion to apply.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 02:41:28 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 3aaf80b33b s3: stop trusting internal ACL headers in ParseS3Metadata
Multipart initiation stored client-supplied Seaweed-X-Amz-Acl and
Seaweed-X-Amz-Owner request headers onto the upload entry, and
CompleteMultipartUpload copied them to the finished object, letting a
caller forge ownership or a public ACL without s3:PutObjectAcl.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 02:41:28 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 85f04b54fd s3tables: check data-path ancestors for catalog markers on DeleteTable
The decoupled-delete guard only inspects the resolved data path itself,
but the catalog marker lives on a table's name path, so a location that
resolves inside another table's directory (its marker-less data
subdirectory) slips through and the recursive purge destroys the other
table's data. Walk the data path's ancestors within the bucket and
refuse when any carries a table or view catalog entry.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 02:41:28 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 6f93f87446 s3tables: confine caller-supplied metadataLocation to the table's namespace
CreateTable/RegisterTable/UpdateTable (and the view equivalents) validate
the location only for bucket and traversal, so a tenant can register a
table whose location resolves into another namespace's directory and
DeleteTable will purge it. Require the location to live under the
caller's namespace subtree, the boundary per-namespace delegation uses.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 02:41:28 -07:00
scooby87andGitHub 73949daa98 filer: keep sequential prefetch within prefetchCount chunks (#11674)
ChunkReadAt.ReadAt asks the ReaderCache to prefetch after every read,
and MaybeCache counted only the downloads it newly started toward the
window, so each call reached prefetchCount chunks further than the last.
A reader consuming a chunk in several reads, as the S3 GET path does
with a 256 KiB copy buffer over 4 MiB chunks, ran the read-ahead up to
the ReaderCache downloader limit and kept most of a large object in
memory, enough to get an S3 gateway OOM-killed mid-response.

Count every chunk in the window, downloading or not, so the read-ahead
stays at prefetchCount chunks past the reader.


Assisted-by: LLM

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-10-12 17:41:01 +08:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
603a1221a7 filer: converge peer metadata replication on restart (#11698)
* filer: skip replayed peer events older than the local entry

Replay deleted and re-inserted every received event unconditionally, so
a restarted filer that rewound its subscription offset re-applied stale
peer writes over newer local state (values reverted, deleted entries
came back and pointed at already-vacuumed chunks). Compare the event's
TsNs against the local entry's Mtime and skip per-path, so a move event
can still remove its source while keeping a newer destination.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: add replay last-writer-wins regression tests

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: stop peer subscriptions and flush offsets on shutdown

Filer.Shutdown closed the store while subscription goroutines were still
mid-stream; their deferred offset save was never guaranteed to run, so a
restart rewound each peer's stream back to the last periodic flush and
replayed stale events. MetaAggregator.Shutdown now closes every peer's
stop channel and waits for the loops so the saved offset covers what was
replayed, and the reconnect sleep is interruptible by the stop channel.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: test that shutdown stops and waits for peer loops

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: order replay by persisted per-path commit timestamps

Review feedback found the mtime comparison still lets stale replay win:
a local delete removes the entry it would have been compared against, an
entry mtime is client-controllable, and the check ran unlocked so a local
write could land between it and the store mutation.

- Record the event timestamp per mutated path in the store KV on every
  emitted mutation, plus a delete record for sources of deletes and
  renames. The record survives the entry, so a replayed create older than
  a local delete no longer resurrects it; ancestor delete records cover
  children swept without per-child events.
- Replay compares the event timestamp against that record under the same
  per-path entry locks local RPC mutations hold, falling back to entry
  mtime only for entries written before records existed. Lookup errors
  now abort the event instead of being treated as an absent entry.
- MetaAggregator.Shutdown marks the aggregator stopped under peerChansLock
  so a master update cannot start a subscription that outlives the wait,
  and waits for the loops without the five-second cap now that every
  blocking stage — signature RPC, bootstrap traversal, retry sleeps, and
  per-event processing — cancels on the stop channel.

* filer: pin peer offset at failed replay, fix commit-record context and key size

A replayed event that exhausted its retries (or was interrupted by
shutdown) was still followed by advancing the peer watermark, so the
persisted offset skipped the unapplied event forever. replicateMetadataChange
now reports the outcome: errPeerSubscriptionStopped aborts the subscription
so the deferred save keeps the last completed event, and an exhausted retry
records a barrier the persisted offset and reconnect resume point cannot
pass while the stream keeps flowing.

Commit records also failed on two fronts: NotifyUpdateEvent runs after
CommitTransaction with the same context, so SQL stores wrote MpTs/MpDl
through an already-committed *sql.Tx; and metaPathKey embedded the whole
path, overflowing the 766-char name column SQL KV stores use, which the
KvPut fallback update then silently dropped. The writes now use a fresh
context and a sha256-hashed fixed-size key.

* filer: lock HTTP delete handler and replay against ancestor paths

The HTTP DeleteHandler called DeleteEntryMetaAndData without the shared
entry lock, so a peer replay could still interleave with a local delete and
its commit record. It now holds the same lock the gRPC DeleteEntry handler
does.

Replay also locked only the exact event paths, but its ordering check
consults ancestor delete records: a local recursive delete holds only the
ancestor's lock, so a stale child create could check before the tombstone
landed and then reinsert. lockReplayPaths now locks every ancestor of both
event paths in one lexicographic order — parents sort before children, as
the rename handler's two-path order already does — while never locking the
root, which cannot be deleted and would serialize all replays.

* filer: record replay commit before the replacement insert

If the same-path update DeleteEntry succeeded but InsertEntry failed, the path sat deleted with no commit record until the retry arrived, and a stale peer create could resurrect it in the gap. The commit record now lands immediately after the delete — without a delete record, since an update is not a delete — and again after the successful insert.

* filer: lock replay ancestors shared, not exclusive

Exclusive locks on every ancestor serialized all replays under a shared
parent — unrelated objects in the same bucket could no longer apply in
parallel. Ancestors only need to exclude an exclusive holder (a local
delete or rename of the ancestor), so they now take shared locks; a path
the event changes stays exclusive even when it is also the other path's
ancestor.

* filer: order peer bootstrap on the sender's commit timestamp

traversePeerMetadata compared localCommitTsNs — a commit-clock value —
against the entry's mtime, which a client can set freely: a peer change
committed recently with an old mtime was skipped, and a stale entry
mtime-stamped in the future overwrote a newer local commit.

TraverseBfsMetadataResponse now carries the sender's per-path commit
timestamp (recorded MpTs record, entry mtime as the unrecorded fallback).
The bootstrap applies an entry only when its commit is newer than the
local one and records the applied timestamp, so replayed events order
against it — closing the gap between bootstrap content and the event
stream. A peer without the field sends 0 and keeps the mtime fallback,
preserving rolling-upgrade behavior.

* filer: keep failed commit records pending until they land

notePathChange ran after the mutation committed and only logged a KvPut
failure: a delete whose MpTs/MpDl record never landed left no tombstone,
so a stale replayed create could resurrect the path while the store was
unreachable.

Failed records now stay in metaTsPending and a repair pass retries them
until they land. localCommitTsNs, deletedAfterTsNs and CommitTsNs
consult the pending record, so ordering holds in the window between the
committed mutation and the durable record. A per-path flag bounds the
repair to one goroutine, and respawns cover merges landing between its
last pass and the flag release.

* filer: keep only unwritten work in the pending commit record

The pending record merged every obligation into one timestamp and flag,
and a later successful write left it untouched: a repair pass could then
write an older timestamp over a newer one, and a merged delete inherited
the newer change's time, which the ancestor check reads as a newer
delete. The record now holds a change and a delete timestamp of its own,
both discharged under the record's lock alongside the store writes that
cover them, so a repair writes only what is still owed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: fail traversal on a commit-record read error

CommitTsNs answered a store read failure with the entry mtime, a value
clients can set arbitrarily far ahead; a peer bootstrapping on it would
record the minted time and reject real updates below it. The lookup now
returns the error so the traversal restarts instead.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 17:39:34 +08:00
Ege ZambelliGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Ege ZambelliChris LuDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
f11cecc697 helm: keep the S3 config checksum stable across the first upgrade (fixes #11719) (#11720)
* helm: keep the S3 config checksum stable across the first upgrade (fixes #11719)

checksum/s3config hashed the rendered s3-secret.yaml. That template
keeps its generated S3 keys with lookup, so rendering it again for the
checksum drew new keys whenever lookup found nothing: on install the
filer, S3 and all-in-one pods each carried a checksum of a different
set of keys than the Secret got, and the first upgrade with unchanged
values corrected it and restarted them.

Hash the values s3-secret.yaml is built from instead: the enable and
auth switches, existingConfigSecret, credentials, the legacy-secret
settings, the audit log configs and the Secret name. Only those, so
scaling the S3 gateway or changing its resources does not restart the
filer. A change to any of them still restarts the pods. Keys that the
chart generated stay out of the hash; they only change if the Secret
is deleted by hand.

The new render test fails on the old template (two checksums for one
Secret, different on every render) and passes now; it also checks that
the checksum follows the credentials and filer.s3.auditLogConfig and
ignores s3.replicas and s3.resources. On kind, an upgrade with unchanged
values no longer replaces the filer pod.

* helm: check the S3 checksum per workload, all-in-one included

Each workload's checksum/s3config is now read on its own (--show-only),
so a workload that loses the annotation fails the test instead of being
hidden by sort -u, and the all-in-one deployment gets the same checks.
Verified against two broken copies: one without the filer annotation,
one with the old all-in-one checksum; the test fails on both.

The helper comment now says what happens when the Secret is deleted to
rotate generated keys.

* ci(helm): fail the S3 checksum test on a missing or unrendered checksum

A failed helm template or a render without the annotation produced an
empty checksum, and the inequality assertions then passed against it.
s3_checksum now returns non-zero unless it found a checksum, and the
changed-value renders are assigned before comparing so failures cannot
hide inside test.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Ege Zambelli <egezamb@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 15:43:53 +08:00
117 changed files with 8991 additions and 1544 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.38.2
uses: github/codeql-action/init@v4.38.3
# Override language selection by uncommenting this and choosing your languages
with:
languages: go
@@ -35,7 +35,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild
uses: github/codeql-action/autobuild@v4.38.2
uses: github/codeql-action/autobuild@v4.38.3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -49,4 +49,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.38.2
uses: github/codeql-action/analyze@v4.38.3
+1 -1
View File
@@ -405,7 +405,7 @@ jobs:
output: trivy-results.sarif
exit-code: '0'
- name: Upload Trivy scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v4.38.2
uses: github/codeql-action/upload-sarif@v4.38.3
if: always()
with:
sarif_file: trivy-results.sarif
@@ -460,7 +460,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v4.38.2
uses: github/codeql-action/upload-sarif@v4.38.3
with:
sarif_file: trivy-results.sarif
category: trivy-${{ matrix.variant }}
+35
View File
@@ -95,6 +95,41 @@ jobs:
grep -A 5 "^ lifecycle:" /tmp/s3-rollout.yaml | grep -q -- "- sleep"
echo "S3 rollout settings render correctly"
echo "=== Testing the S3 config checksum is stable across renders ==="
# s3-secret.yaml keeps its generated keys with lookup. Hashing the rendered
# Secret drew new keys for the checksum whenever lookup found nothing (on
# install), so the first upgrade changed the checksum and restarted the
# filer and S3 pods although nothing had changed.
s3_checksum() { # <template> <helm args...>: the checksum/s3config of one workload
local template=$1; shift
local sum
sum=$(helm template test $CHART_DIR --show-only "$template" "$@" \
| grep 'checksum/s3config:' | awk '{print $2}')
test -n "$sum" || { echo "no checksum/s3config in $template" >&2; return 1; }
echo "$sum"
}
s3_auth_args=(--set s3.enabled=true --set s3.enableAuth=true)
s3_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")
filer_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}")
test "$s3_sum" = "$filer_sum" # one Secret, one checksum
test "$s3_sum" = "$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")"
s3_creds_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}" \
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
test "$s3_sum" != "$s3_creds_sum"
filer_audit_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
--set-json 'filer.s3.auditLogConfig={"fluent_port":24224}')
test "$filer_sum" != "$filer_audit_sum"
# Settings the Secret does not read must not restart the S3 and filer pods.
test "$filer_sum" = "$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
--set s3.replicas=3 --set s3.resources.limits.cpu=1)"
all_in_one_args=(--set allInOne.enabled=true --set allInOne.s3.enabled=true --set allInOne.s3.enableAuth=true)
aio_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")
test "$aio_sum" = "$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")"
aio_creds_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}" \
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
test "$aio_sum" != "$aio_creds_sum"
echo "S3 config checksum is stable and follows only what the S3 Secret reads"
echo "=== Testing S3 credentials from an existing secret ==="
credential_args=(
--set s3.credentials.admin.existingSecret=minio-root
@@ -43,7 +43,7 @@ spec:
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace $existingS3ConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
{{- end }}
{{- $_ := set $podAnnotations "checksum/master-config" (include (print .Template.BasePath "/master/master-configmap.yaml") . | sha256sum) }}
annotations:
@@ -48,7 +48,7 @@ spec:
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.filer.s3.existingConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
{{- end }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
@@ -44,7 +44,7 @@ spec:
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.s3.existingConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
{{- end }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
@@ -692,3 +692,28 @@ Usage: {{ include "seaweedfs.service.loadBalancerFields" .Values.s3.service }}
{{- end }}
{{- end }}
{{- end -}}
{{/*
Checksum that restarts the S3-serving pods when the generated S3 config changes. It hashes the
values s3-secret.yaml is built from, not the rendered Secret: that template keeps its generated keys
with lookup, so rendering it again for a checksum draws new random keys whenever lookup finds
nothing (on install), and the first upgrade then changed the checksum and restarted the pods
although nothing had changed. Only the values that template reads are hashed, so scaling the S3
gateway or changing its resources does not restart the filer. Keep this list in step with
s3-secret.yaml.
Keys the chart generated are not part of it. Deleting the Secret to rotate them regenerates them on
the next upgrade without restarting the pods; restart them afterwards (kubectl rollout restart).
Folding the stored keys in would bring the first-upgrade restart back, since lookup finds nothing
on install.
*/}}
{{- define "seaweedfs.s3.configChecksum" -}}
{{- dict
"secretName" (include "seaweedfs.fullname" .)
"enabled" (list .Values.s3.enabled .Values.filer.s3.enabled .Values.allInOne.enabled .Values.allInOne.s3.enabled)
"enableAuth" (list .Values.s3.enableAuth .Values.filer.s3.enableAuth .Values.allInOne.s3.enableAuth)
"existingConfigSecret" (list .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret .Values.allInOne.s3.existingConfigSecret)
"credentials" .Values.s3.credentials
"legacySecret" (list .Values.s3.reuseLegacySecret .Values.s3.legacySecretName)
"auditLogConfig" (list .Values.s3.auditLogConfig .Values.filer.s3.auditLogConfig)
| toJson | sha256sum -}}
{{- end -}}
+1050 -1059
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+15 -8
View File
@@ -234,9 +234,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -244,14 +244,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -2677,6 +2678,12 @@ dependencies = [
"spki 0.7.3",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
@@ -3389,9 +3396,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -3436,9 +3443,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
+50 -1
View File
@@ -986,7 +986,12 @@ impl VolumeServer for VolumeGrpcService {
let original_cookie = n.cookie;
if !is_ec_volume {
let store = self.state.store.read().unwrap();
match store.read_volume_needle(file_id.volume_id, &mut n) {
let mut read_option = crate::server::handlers::delete_read_option();
match store.read_volume_needle_with_option(
file_id.volume_id,
&mut n,
&mut read_option,
) {
Ok(_) => {}
Err(e) => {
results.push(volume_server_pb::DeleteResult {
@@ -10997,6 +11002,50 @@ mod tests {
(r.status, r.error.clone())
}
/// A batch delete only needs the needle's cookie, which is stored in the
/// header, so it must not read the payload.
#[tokio::test]
async fn batch_delete_reads_needle_meta_only() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
const ID: u64 = 0x6e7a_0b01;
let (service, _tmp) = make_local_service_with_volume("", None);
let data = vec![7u8; 512 * 1024];
let mut n = Needle {
id: NeedleId(ID),
cookie: Cookie(0x3344),
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
service
.state
.store
.write()
.unwrap()
.write_volume_needle(VolumeId(1), &mut n, false)
.unwrap();
let reads = Arc::new(Mutex::new(Vec::new()));
let _hook = needle_read_hook::register(NeedleId(ID), {
let reads = reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let (status, _) = batch_delete_1(&service, ID, 0x3345, false).await;
assert_eq!(status, 400);
let (status, error) = batch_delete_1(&service, ID, 0x3344, false).await;
assert_eq!(status, 202, "{error}");
let reads = reads.lock().unwrap().clone();
assert!(
!reads.is_empty() && reads.iter().all(|&len| len < 4096),
"a batch delete must not read the needle's payload ({} bytes): {reads:?}",
data.len()
);
}
const GO_EC_NEEDLE_NOT_FOUND: &str =
"locate in local ec volume: FindNeedleFromEcx: needle not found";
+93 -5
View File
@@ -161,9 +161,10 @@ impl SourceReadRequest {
/// HEAD, a range, or a stream.
fn served_from_source(&self, n: &Needle) -> bool {
// HEAD is meta-only unless the payload is needed to expand a manifest.
if self.is_head {
return !(n.is_chunk_manifest() && !self.bypass_cm);
// HEAD is meta-only unless the payload is needed to expand a
// manifest or to answer an image transform's length.
return !self.has_image_ops && !(n.is_chunk_manifest() && !self.bypass_cm);
}
self.direct(n) && (self.has_range || n.data_size > STREAMING_THRESHOLD)
}
@@ -171,8 +172,10 @@ impl SourceReadRequest {
/// Meta first only if the needle could be served from source; its data
/// size is below its index size.
fn try_meta_first(&self, size: Size) -> bool {
self.is_head
|| (!self.has_image_ops && (self.has_range || size.0 > STREAMING_THRESHOLD as i32))
if self.has_image_ops {
return false;
}
self.is_head || self.has_range || size.0 > STREAMING_THRESHOLD as i32
}
}
@@ -3058,6 +3061,20 @@ struct DeleteResult {
size: i64,
}
/// Read option for deletes: read only the needle's header and trailer.
///
/// A delete needs to know that the needle exists, what its cookie is, and
/// whether it is a chunk manifest. None of that requires the payload. If the
/// needle turns out to be a chunk manifest, the read loads its data as well,
/// because the delete needs the manifest to delete the chunks.
pub(crate) fn delete_read_option() -> ReadOption {
ReadOption {
attempt_meta_only: true,
must_meta_only: true,
..ReadOption::default()
}
}
pub async fn delete_handler(
State(state): State<Arc<VolumeServerState>>,
request: Request<Body>,
@@ -3226,7 +3243,7 @@ pub async fn delete_handler(
let original_cookie = cookie;
{
let store = state.store.read().unwrap();
match store.read_volume_needle(vid, &mut n) {
match store.read_volume_needle_with_option(vid, &mut n, &mut delete_read_option()) {
Ok(_) => {}
Err(_) => {
let result = DeleteResult { size: 0 };
@@ -5523,6 +5540,17 @@ mod tests {
(status, headers, body)
}
/// The public router does not accept DELETE requests, so this calls the
/// handler directly, the same way the admin router does.
async fn send_delete(state: &Arc<VolumeServerState>, path: &str) -> StatusCode {
let request = Request::builder()
.method(Method::DELETE)
.uri(path)
.body(Body::empty())
.unwrap();
delete_handler(State(state.clone()), request).await.status()
}
/// A GET whose needle read is parked must not hold the store lock: a
/// writer, here a real append to the same volume, must get through.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -5627,6 +5655,43 @@ mod tests {
);
}
/// A delete needs the needle's cookie and its chunk-manifest flag. Both
/// are stored in the header and trailer, so the delete must not read the
/// payload.
#[tokio::test]
async fn test_delete_reads_needle_meta_only() {
use crate::storage::volume::needle_read_hook;
use std::sync::Mutex;
const ID: u64 = 0x6e7a_0a01;
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let data: Vec<u8> = (0..512 * 1024).map(|i| (i % 251) as u8).collect();
let path = put_test_needle(&state, ID, &data);
let reads = Arc::new(Mutex::new(Vec::new()));
let _hook = needle_read_hook::register(NeedleId(ID), {
let reads = reads.clone();
move |len| reads.lock().unwrap().push(len)
});
let wrong_cookie = format!("/1,{:x}{:08x}", ID, TEST_COOKIE + 1);
assert_eq!(
send_delete(&state, &wrong_cookie).await,
StatusCode::BAD_REQUEST
);
assert_eq!(send_delete(&state, &path).await, StatusCode::ACCEPTED);
let reads = reads.lock().unwrap().clone();
assert!(
!reads.is_empty() && reads.iter().all(|&len| len < 4096),
"a delete must not read the needle's payload ({} bytes): {reads:?}",
data.len()
);
let (status, _, _) = send_read(&state, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// HEAD and ranged reads keep answering from the needle meta and the
/// data file, and a missing or wrong-cookie needle is still a 404.
#[tokio::test]
@@ -6105,6 +6170,29 @@ mod tests {
assert_eq!(taken(&reads), vec![1]);
}
/// A HEAD asking for an image transform needs the payload too: a
/// meta-only read could not produce the transformed length.
#[tokio::test]
async fn test_head_with_image_op_reads_full() {
let tmp = tempfile::TempDir::new().unwrap();
let state = volume_test_state(&tmp);
let png = solid_png(8, 8);
let stored = put_test_needle(&state, 0x6e7a_0a30, &png);
let crop = format!(
"{}.png?crop_x1=0&crop_y1=0&crop_x2=2&crop_y2=2",
stored
);
let (get_status, _, cropped) = send_read(&state, Method::GET, &crop, None).await;
assert_eq!(get_status, StatusCode::OK);
assert_ne!(cropped.len(), png.len(), "the crop must change the length");
let (status, headers, body) = send_read(&state, Method::HEAD, &crop, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_empty());
assert_eq!(headers[header::CONTENT_LENGTH], cropped.len().to_string());
}
/// Go takes the transform extension from the URL first — including a fid
/// suffix like /vid,fid.png — before the resolved filename's.
#[tokio::test]
+14
View File
@@ -736,6 +736,20 @@ impl Store {
vol.read_needle(n)
}
/// Read a needle from a volume using the given read option.
///
/// On return, `read_option.is_meta_only` tells the caller whether the
/// payload was skipped.
pub fn read_volume_needle_with_option(
&self,
vid: VolumeId,
n: &mut Needle,
read_option: &mut ReadOption,
) -> Result<i32, VolumeError> {
let (_, vol) = self.find_volume(vid).ok_or(VolumeError::NotFound)?;
vol.read_needle_with_option(n, read_option)
}
/// Read a needle from a volume, optionally reading deleted needles.
pub fn read_volume_needle_opt(
&self,
+137 -6
View File
@@ -2009,12 +2009,29 @@ impl Volume {
return Ok(0);
}
match self.read_needle_data_at_unlocked(
n,
nv.offset.to_actual_offset(),
read_size,
read_option,
) {
// With `must_meta_only`, read the header and trailer first. If the
// needle is compressed or is a chunk manifest, the caller needs its
// data, so read the payload as well.
//
// A version 1 needle has no data size field and no metadata after its
// data, so the metadata reader would treat the whole payload as
// metadata. It is always read in full.
let offset = nv.offset.to_actual_offset();
// `is_meta_only` is a result: clear it in case the option is reused.
read_option.is_meta_only = false;
let meta_first = read_option.must_meta_only && self.version() != VERSION_1;
let mut read = if meta_first {
self.read_needle_meta_at_unlocked(n, offset, read_size)
} else {
Ok(())
};
if read.is_ok() && meta_first && !n.is_compressed() && !n.is_chunk_manifest() {
read_option.is_meta_only = true;
}
if read.is_ok() && !read_option.is_meta_only {
read = self.read_needle_data_at_unlocked(n, offset, read_size, read_option);
}
match read {
Ok(()) => self.check_read_write_error(None),
Err(VolumeError::Io(ref e)) => {
self.check_read_write_error(Some(e));
@@ -6080,6 +6097,120 @@ mod tests {
assert_eq!(read_n.cookie, Cookie(0x12345678));
}
/// With `must_meta_only` set, the payload is skipped whatever the needle's
/// size. This is the option a delete uses. A chunk manifest is the
/// exception: its data is still read, because a delete needs it to find
/// the chunks.
#[test]
fn test_read_needle_must_meta_only() {
let tmp = TempDir::new().unwrap();
let mut v = make_test_volume(tmp.path().to_str().unwrap());
let data = vec![7u8; 8192];
let mut plain = Needle {
id: NeedleId(1),
cookie: Cookie(0x1234),
data: data.clone(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut plain, true, false).unwrap();
let mut manifest = Needle {
id: NeedleId(2),
data: b"{}".to_vec(),
data_size: 2,
..Needle::default()
};
manifest.set_is_chunk_manifest();
v.write_needle(&mut manifest, true, false).unwrap();
let read = |id: u64, read_option: &mut ReadOption| {
let mut n = Needle {
id: NeedleId(id),
..Needle::default()
};
v.read_needle_with_option(&mut n, read_option).unwrap();
n
};
let meta_only = || ReadOption {
attempt_meta_only: true,
must_meta_only: true,
..ReadOption::default()
};
let mut read_option = meta_only();
let n = read(1, &mut read_option);
assert!(read_option.is_meta_only);
assert!(n.data.is_empty(), "{} bytes of data read", n.data.len());
assert_eq!(n.cookie, Cookie(0x1234));
assert_eq!(n.data_size as usize, data.len());
// Without `must_meta_only`, the needle is read in full.
let mut read_option = ReadOption::default();
let n = read(1, &mut read_option);
assert!(!read_option.is_meta_only);
assert_eq!(n.data, data);
let mut read_option = meta_only();
let n = read(2, &mut read_option);
assert!(!read_option.is_meta_only);
assert_eq!(n.data, b"{}");
// The option is reused: a metadata-only result from the first read
// must not carry over and skip the chunk manifest's data.
let mut read_option = meta_only();
read(1, &mut read_option);
assert!(read_option.is_meta_only);
let n = read(2, &mut read_option);
assert!(!read_option.is_meta_only);
assert_eq!(n.data, b"{}");
}
/// A version 1 needle is a header, the data and a checksum: it has no data
/// size field and no metadata after the data, so the metadata reader would
/// take the whole payload for metadata and reject anything over 128 KiB.
/// A read with `must_meta_only` must fall back to a full read.
#[test]
fn test_read_needle_must_meta_only_version_1() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let spec = VolumeSpec {
version: VERSION_1,
..VolumeSpec::default()
};
let mut v = Volume::new(dir, dir, VolumeId(1), NeedleMapKind::InMemory, &spec).unwrap();
assert_eq!(v.version(), VERSION_1);
for (id, len) in [(1u64, 11usize), (2, 256 * 1024)] {
let data: Vec<u8> = b"hello world".iter().copied().cycle().take(len).collect();
// The server only writes version 2 and 3 records, so build the
// version 1 record by hand.
let size = Size(len as i32);
let mut blob = vec![0u8; NEEDLE_HEADER_SIZE];
Cookie(0x1234).to_bytes(&mut blob[0..COOKIE_SIZE]);
NeedleId(id).to_bytes(&mut blob[COOKIE_SIZE..COOKIE_SIZE + NEEDLE_ID_SIZE]);
size.to_bytes(&mut blob[COOKIE_SIZE + NEEDLE_ID_SIZE..NEEDLE_HEADER_SIZE]);
blob.extend_from_slice(&data);
blob.extend_from_slice(&CRC::new(&data).0.to_be_bytes());
blob.resize(get_actual_size(size, VERSION_1) as usize, 0);
v.write_needle_blob_and_index(NeedleId(id), &blob, size)
.unwrap();
let mut n = Needle {
id: NeedleId(id),
..Needle::default()
};
let mut read_option = ReadOption {
attempt_meta_only: true,
must_meta_only: true,
..ReadOption::default()
};
v.read_needle_with_option(&mut n, &mut read_option).unwrap();
assert!(!read_option.is_meta_only);
assert_eq!(n.cookie, Cookie(0x1234));
assert_eq!(n.data, data, "{len} byte needle");
}
}
/// A durable write goes down the same path and lands the same data; the
/// flush is extra work, not different work.
#[test]
+11 -2
View File
@@ -485,6 +485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
"zeroize",
]
@@ -5124,7 +5125,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -5260,7 +5261,7 @@ dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
"untrusted 0.9.0",
]
[[package]]
@@ -5342,7 +5343,9 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"aws-lc-rs",
"axum",
"base64 0.22.1",
"prometheus",
"prost",
"prost-types",
@@ -6213,6 +6216,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
+4
View File
@@ -12,6 +12,10 @@ name = "seaweed_worker_core"
# Helpers the Rust volume server needs as well. A path dependency because
# the two trees are separate cargo workspaces with no common root manifest.
seaweed-common = { path = "../../../seaweed-common" }
# Already in the graph through tonic's `tls-aws-lc` feature; named here so the
# admin token can sign with the same HMAC the Go worker uses.
aws-lc-rs = "1"
base64 = "0.22"
anyhow.workspace = true
async-trait.workspace = true
prost.workspace = true
+11
View File
@@ -17,6 +17,15 @@ pub struct WorkerOptions {
/// section of security.toml. None means plaintext, which is the default the
/// Go worker also takes when no certificates are configured.
pub tls: Option<TlsOptions>,
/// The shared secret the admin stream requires when the cluster configures
/// `jwt.filer_signing.key`; mirrored from that config value. Empty means no
/// token is attached, which only works against admins that either bound
/// their worker listener to loopback or authenticate with mTLS instead.
pub signing_key: Option<String>,
/// Seconds the minted admin token lives, mirroring
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token without an
/// expiry, matching the Go default.
pub signing_key_expires_after_seconds: u64,
}
/// Certificates for the control stream. All three are required together: the
@@ -44,6 +53,8 @@ impl Default for WorkerOptions {
max_detection_concurrency: 1,
max_execution_concurrency: 1,
tls: None,
signing_key: None,
signing_key_expires_after_seconds: 0,
}
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod metrics;
pub mod registry;
pub mod senders;
pub mod stream;
pub mod token;
/// Generated plugin.proto types.
pub mod pb {
+13 -4
View File
@@ -176,10 +176,19 @@ async fn serve_once(
metadata: Default::default(),
}))?;
let mut inbound = client
.worker_stream(UnboundedReceiverStream::new(rx))
.await?
.into_inner();
let mut request = tonic::Request::new(UnboundedReceiverStream::new(rx));
if let Some(token) = crate::token::admin_token(
options.signing_key.as_deref().unwrap_or("").as_bytes(),
options.signing_key_expires_after_seconds,
) {
request.metadata_mut().insert(
"authorization",
format!("Bearer {token}")
.parse()
.context("minted admin token is not valid metadata")?,
);
}
let mut inbound = client.worker_stream(request).await?.into_inner();
let heartbeat = spawn_heartbeat(
sender.clone(),
+61
View File
@@ -0,0 +1,61 @@
use std::time::{SystemTime, UNIX_EPOCH};
use aws_lc_rs::hmac;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
/// Mints the HS256 Bearer token the admin control stream requires when
/// jwt.filer_signing.key is configured, matching the SeaweedFilerAdminClaims
/// the Go worker sends: registered claims only, no session id.
pub fn admin_token(signing_key: &[u8], expires_after_secs: u64) -> Option<String> {
if signing_key.is_empty() {
return None;
}
let claims = if expires_after_secs > 0 {
let exp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
+ expires_after_secs;
format!("{{\"exp\":{exp}}}")
} else {
"{}".to_string()
};
let signing_input = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}"),
URL_SAFE_NO_PAD.encode(claims.as_bytes())
);
let key = hmac::Key::new(hmac::HMAC_SHA256, signing_key);
let signature = hmac::sign(&key, signing_input.as_bytes());
Some(format!(
"{signing_input}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
#[cfg(test)]
mod tests {
use super::admin_token;
#[test]
fn no_key_no_token() {
assert!(admin_token(b"", 0).is_none());
}
#[test]
fn minted_token_verifies_shape() {
let token = admin_token(b"secret", 60).unwrap();
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3);
assert!(!parts.iter().any(|p| p.is_empty()));
}
#[test]
fn no_expiry_has_empty_claims() {
let token = admin_token(b"secret", 0).unwrap();
let payload = token.split('.').nth(1).unwrap();
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let claims = URL_SAFE_NO_PAD.decode(payload).unwrap();
assert_eq!(claims, b"{}");
}
}
+14
View File
@@ -61,6 +61,18 @@ struct Args {
#[arg(long)]
tls_server_name: Option<String>,
/// Shared secret for the admin control stream, the `jwt.filer_signing.key`
/// value the Go worker reads from its config file. Required by admins that
/// authenticate the worker stream with a signing key.
#[arg(long, env = "WEED_FILER_SIGNING_KEY")]
filer_signing_key: Option<String>,
/// Lifetime of the token minted from the signing key, mirroring
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token with no
/// expiry.
#[arg(long, default_value = "0")]
filer_signing_key_expires_after_seconds: u64,
/// Serve /health, /ready and /metrics on this port, the way
/// `weed worker -metricsPort` does. Zero, the default, serves nothing.
#[arg(long, default_value = "0", env = "WEED_METRICS_PORT")]
@@ -131,6 +143,8 @@ async fn main() -> Result<()> {
max_detection_concurrency: args.max_concurrency,
max_execution_concurrency: args.max_concurrency,
tls,
signing_key: args.filer_signing_key,
signing_key_expires_after_seconds: args.filer_signing_key_expires_after_seconds,
..Default::default()
};
+27
View File
@@ -3,6 +3,7 @@ package sftp
import (
"bytes"
"io"
"os"
"path"
"testing"
@@ -602,6 +603,32 @@ func TestFileContent(t *testing.T) {
sftpClient.Remove("/binary.bin")
})
t.Run("WriteAtOffsetKeepsExistingBytes", func(t *testing.T) {
file, err := sftpClient.Create("/resume.txt")
require.NoError(t, err)
_, err = file.Write([]byte("hello world"))
require.NoError(t, err)
require.NoError(t, file.Close())
// Reopen without O_TRUNC and write past the start, as a resumed upload does
file, err = sftpClient.OpenFile("/resume.txt", os.O_WRONLY)
require.NoError(t, err)
_, err = file.WriteAt([]byte("WORLD!"), 6)
require.NoError(t, err)
require.NoError(t, file.Close())
readFile, err := sftpClient.Open("/resume.txt")
require.NoError(t, err)
content, err := io.ReadAll(readFile)
require.NoError(t, err)
readFile.Close()
require.Equal(t, "hello WORLD!", string(content))
// Clean up
sftpClient.Remove("/resume.txt")
})
t.Run("EmptyFile", func(t *testing.T) {
file, err := sftpClient.Create("/empty.txt")
require.NoError(t, err)
+4
View File
@@ -188,6 +188,10 @@ func (s *WorkerGrpcServer) Stop() error {
func (s *WorkerGrpcServer) WorkerStream(stream worker_pb.WorkerService_WorkerStreamServer) error {
ctx := stream.Context()
if err := security.CheckGrpcAdminPeer(ctx, security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
return err
}
// get client address
address := findClientAddress(ctx)
+66
View File
@@ -0,0 +1,66 @@
package plugin
import (
"strings"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
// JobReplicaHosts are the volume-replica hosts a job will read from.
// Used so executor pick prefers a worker on one of those machines.
// Sources with ShardIds are EC-cleanup rows, not encode replicas.
func JobReplicaHosts(job *plugin_pb.JobSpec) []string {
if job == nil {
return nil
}
var addrs []string
addrs = append(addrs, pluginworker.ReadStringListConfig(job.Parameters, "replica_servers")...)
if src := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", "")); src != "" {
addrs = append(addrs, src)
}
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
params := &worker_pb.TaskParams{}
if err := proto.Unmarshal(payload, params); err == nil {
for _, s := range params.GetSources() {
if s == nil || s.Node == "" || len(s.ShardIds) > 0 {
continue
}
addrs = append(addrs, s.Node)
}
}
}
return util.HostKeysFromAddresses(addrs)
}
// partitionByPreferredHost splits candidates into (host matches, others).
// No preferred hosts, or no matches, returns (nil, all) so callers keep
// the existing slots+round-robin path.
func partitionByPreferredHost(candidates []*WorkerSession, preferredHosts []string) (pref, rest []*WorkerSession) {
want := util.HostKeysFromAddresses(preferredHosts)
if len(want) == 0 || len(candidates) == 0 {
return nil, candidates
}
set := make(map[string]struct{}, len(want))
for _, h := range want {
set[h] = struct{}{}
}
for _, c := range candidates {
if c == nil {
continue
}
if _, ok := set[util.HostKey(c.Address)]; ok {
pref = append(pref, c)
continue
}
rest = append(rest, c)
}
if len(pref) == 0 {
return nil, candidates
}
return pref, rest
}
+7 -1
View File
@@ -14,6 +14,8 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -267,6 +269,10 @@ func (r *Plugin) Shutdown() {
}
func (r *Plugin) WorkerStream(stream plugin_pb.PluginControlService_WorkerStreamServer) error {
if err := security.CheckGrpcAdminPeer(stream.Context(), security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
return err
}
first, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
@@ -609,7 +615,7 @@ func (r *Plugin) ExecuteJob(
releaseGate := r.acquireDetectionExecutionGate(jobType, true)
defer releaseGate()
executor, err := r.registry.PickExecutor(jobType)
executor, err := r.registry.PickExecutor(jobType, JobReplicaHosts(job)...)
if err != nil {
return nil, err
}
+31
View File
@@ -887,6 +887,37 @@ func (r *Plugin) trackExecutionQueued(job *plugin_pb.JobSpec) {
})
}
// bindQueuedJobWorker records the home worker while the job stays pending/queued.
func (r *Plugin) bindQueuedJobWorker(job *plugin_pb.JobSpec, workerID string) {
if job == nil || strings.TrimSpace(job.JobId) == "" || strings.TrimSpace(workerID) == "" {
return
}
now := time.Now().UTC()
r.jobsMu.Lock()
tracked := r.jobs[job.JobId]
if tracked == nil {
r.jobsMu.Unlock()
return
}
if isTerminalTrackedJobState(tracked.State) {
r.jobsMu.Unlock()
return
}
tracked.WorkerID = workerID
tracked.Message = "queued on worker " + workerID
tracked.UpdatedAt = timeToPtr(now)
snap := cloneTrackedJob(*tracked)
r.dirtyJobs = true
r.jobsMu.Unlock()
r.persistJobDetailSnapshot(job.JobId, func(detail *TrackedJob) {
detail.WorkerID = snap.WorkerID
detail.Message = snap.Message
detail.UpdatedAt = snap.UpdatedAt
})
}
func (r *Plugin) cancelQueuedJob(job *plugin_pb.JobSpec, cause error) {
reason := "job canceled"
if cause != nil {
+206 -125
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -28,12 +29,18 @@ const (
defaultScheduledMaxResults int32 = 1000
defaultScheduledExecutionConcurrency = 1
defaultScheduledPerWorkerConcurrency = 1
maxScheduledExecutionConcurrency = 128
defaultScheduledRetryBackoff = 5 * time.Second
defaultClusterContextTimeout = 10 * time.Second
defaultWaitingBacklogFloor = 8
defaultWaitingBacklogMultiplier = 4
maxEstimatedRuntimeCap = 8 * time.Hour
// Hold a job on replica-host workers until their reservation load
// reaches this; 0 disables the pin and spills immediately.
defaultLocalQueuePin = 5
// Wait this long after a home worker disappears before stealing
// its queue; 0 steals immediately. Covers a reconnect blink.
defaultOrphanRehomeGrace = 30 * time.Second
maxScheduledExecutionConcurrency = 128
defaultScheduledRetryBackoff = 5 * time.Second
defaultClusterContextTimeout = 10 * time.Second
defaultWaitingBacklogFloor = 8
defaultWaitingBacklogMultiplier = 4
maxEstimatedRuntimeCap = 8 * time.Hour
// How long started jobs may drain past the run window close.
scheduledExecutionDrainGrace = 30 * time.Minute
)
@@ -49,6 +56,12 @@ type schedulerPolicy struct {
PerWorkerConcurrency int
RetryLimit int
ExecutorReserveBackoff time.Duration
// LocalQueuePin is K: do not spill off replica-host workers while
// min(load(pref)) < K. Zero is the old immediate-spill path.
LocalQueuePin int
// OrphanRehomeGrace is how long a missing home's queue is held
// before steal-without-K. Zero steals as soon as the worker is gone.
OrphanRehomeGrace time.Duration
}
// laneSchedulerLoop is the main scheduling goroutine for a single lane.
@@ -466,6 +479,8 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
PerWorkerConcurrency: int(adminRuntime.PerWorkerExecutionConcurrency),
RetryLimit: int(adminRuntime.RetryLimit),
ExecutorReserveBackoff: 200 * time.Millisecond,
LocalQueuePin: defaultLocalQueuePin,
OrphanRehomeGrace: defaultOrphanRehomeGrace,
}
if policy.DetectionInterval < r.schedulerTick {
@@ -496,6 +511,9 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
if policy.ExecutionTimeout < defaultScheduledExecutionTimeout {
policy.ExecutionTimeout = defaultScheduledExecutionTimeout
}
if policy.LocalQueuePin < 0 {
policy.LocalQueuePin = 0
}
return policy, true, nil
}
@@ -813,19 +831,22 @@ func (r *Plugin) dispatchScheduledProposals(
// whole dispatch lets manual shell operations interleave between jobs.
jobsRequireLock := LaneRequiresLock(JobTypeLane(jobType))
jobQueue := make(chan *plugin_pb.JobSpec, len(proposals))
jobs := make([]*plugin_pb.JobSpec, 0, len(proposals))
for index, proposal := range proposals {
job := buildScheduledJobSpec(jobType, proposal, index)
r.trackExecutionQueued(job)
select {
case <-r.shutdownCh:
close(jobQueue)
return 0, 0, 0
for _, job := range jobs {
r.cancelQueuedJob(job, errSchedulerShutdown)
}
return 0, 0, len(jobs)
default:
jobQueue <- job
}
job := buildScheduledJobSpec(jobType, proposal, index)
r.trackExecutionQueued(job)
jobs = append(jobs, job)
}
close(jobQueue)
queues := r.assignJobsToWorkerQueues(jobType, jobs, policy)
var wg sync.WaitGroup
var statsMu sync.Mutex
@@ -842,130 +863,110 @@ func (r *Plugin) dispatchScheduledProposals(
wg.Add(1)
go func() {
defer wg.Done()
jobLoop:
for job := range jobQueue {
for {
select {
case <-r.shutdownCh:
return
default:
}
if ctx.Err() != nil {
r.cancelQueuedJob(job, ctx.Err())
statsMu.Lock()
canceledCount++
statsMu.Unlock()
return
}
if queues.isEmpty() {
return
}
item, targetID := r.takeQueuedJob(queues, jobType, policy)
if item == nil {
if queues.isEmpty() {
return
}
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
continue
}
if ctx.Err() != nil {
queues.pushFront(item)
return
}
executor, release, reserveErr := r.reserveExecutorByID(jobType, policy, targetID)
if reserveErr != nil {
queues.pushFront(item)
if ctx.Err() != nil {
return
}
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
continue
}
for {
select {
case <-r.shutdownCh:
return
default:
var releaseJobLock func()
if jobsRequireLock {
var lockErr error
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, item.job.JobId))
if lockErr != nil {
release()
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobID: item.job.JobId,
JobType: item.job.JobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
continue
}
if ctx.Err() != nil {
r.cancelQueuedJob(job, ctx.Err())
releaseJobLock()
release()
queues.pushFront(item)
return
}
}
err := r.executeScheduledJobWithExecutor(ctx, executor, item.job, clusterContext, policy)
if releaseJobLock != nil {
releaseJobLock()
}
release()
if errors.Is(err, errExecutorAtCapacity) {
queues.pushFront(item)
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
continue
}
if err != nil {
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
r.cancelQueuedJob(item.job, err)
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
executor, release, reserveErr := r.reserveScheduledExecutor(ctx, jobType, policy)
if reserveErr != nil {
if ctx.Err() != nil {
r.cancelQueuedJob(job, ctx.Err())
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobType: jobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution reservation failed: %v", reserveErr),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
break
}
var releaseJobLock func()
if jobsRequireLock {
var lockErr error
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, job.JobId))
if lockErr != nil {
release()
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobID: job.JobId,
JobType: job.JobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
break
}
// The lock may have been held by an operator for a
// while; re-check the execution window.
if ctx.Err() != nil {
releaseJobLock()
release()
r.cancelQueuedJob(job, ctx.Err())
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
}
err := r.executeScheduledJobWithExecutor(ctx, executor, job, clusterContext, policy)
if releaseJobLock != nil {
releaseJobLock()
}
release()
if errors.Is(err, errExecutorAtCapacity) {
r.trackExecutionQueued(job)
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
continue
}
if err != nil {
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
r.cancelQueuedJob(job, err)
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobID: job.JobId,
JobType: job.JobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution failed: %v", err),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
break
}
statsMu.Lock()
successCount++
errorCount++
statsMu.Unlock()
break
r.appendActivity(JobActivity{
JobID: item.job.JobId,
JobType: item.job.JobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution failed: %v", err),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
continue
}
statsMu.Lock()
successCount++
statsMu.Unlock()
}
}()
}
@@ -976,8 +977,8 @@ func (r *Plugin) dispatchScheduledProposals(
if drainErr == nil {
drainErr = errSchedulerShutdown
}
for job := range jobQueue {
r.cancelQueuedJob(job, drainErr)
for _, item := range queues.drain() {
r.cancelQueuedJob(item.job, drainErr)
canceledCount++
}
@@ -988,6 +989,7 @@ func (r *Plugin) reserveScheduledExecutor(
ctx context.Context,
jobType string,
policy schedulerPolicy,
preferredHosts []string,
) (*WorkerSession, func(), error) {
if ctx == nil {
ctx = context.Background()
@@ -1015,7 +1017,7 @@ func (r *Plugin) reserveScheduledExecutor(
return nil, nil, fmt.Errorf("timed out waiting for executor capacity for %s", jobType)
}
executors, err := r.registry.ListExecutors(jobType)
executors, err := r.registry.ListExecutors(jobType, preferredHosts...)
if err != nil {
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
if ctx.Err() != nil {
@@ -1026,11 +1028,19 @@ func (r *Plugin) reserveScheduledExecutor(
continue
}
for _, executor := range executors {
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
if !ok {
continue
pref, rest := partitionByPreferredHost(executors, preferredHosts)
lane := JobTypeLane(jobType)
loadOf := func(e *WorkerSession) int { return r.executorReservationLoad(e, lane) }
if len(pref) > 0 {
sortExecutorsByLoad(pref, loadOf)
if executor, release, ok := r.tryReserveFirst(pref, jobType, policy); ok {
return executor, release, nil
}
}
sortExecutorsByLoad(rest, loadOf)
if executor, release, ok := r.tryReserveFirst(rest, jobType, policy); ok {
return executor, release, nil
}
@@ -1051,6 +1061,75 @@ func (r *Plugin) tryReserveExecutorCapacity(
return r.tryReserveExecutorCapacityForLane(executor, jobType, policy, JobTypeLane(jobType))
}
func (r *Plugin) tryReserveFirst(executors []*WorkerSession, jobType string, policy schedulerPolicy) (*WorkerSession, func(), bool) {
for _, executor := range executors {
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
if ok {
return executor, release, true
}
}
return nil, nil, false
}
func (r *Plugin) reserveExecutorByID(jobType string, policy schedulerPolicy, workerID string) (*WorkerSession, func(), error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return nil, nil, fmt.Errorf("executor id is empty")
}
executors, err := r.registry.ListExecutors(jobType)
if err != nil {
return nil, nil, err
}
for _, executor := range executors {
if executor == nil || executor.WorkerID != workerID {
continue
}
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
if !ok {
return nil, nil, errExecutorAtCapacity
}
return executor, release, nil
}
return nil, nil, fmt.Errorf("executor %s is not available for %s", workerID, jobType)
}
func (r *Plugin) executorReservationLoad(executor *WorkerSession, lane SchedulerLane) int {
if executor == nil {
return 0
}
load := 0
if executor.Heartbeat != nil && executor.Heartbeat.ExecutionSlotsUsed > 0 {
load = int(executor.Heartbeat.ExecutionSlotsUsed)
}
workerID := strings.TrimSpace(executor.WorkerID)
if ls := r.lanes[lane]; ls != nil {
ls.execMu.Lock()
load += ls.execRes[workerID]
ls.execMu.Unlock()
return load
}
r.schedulerExecMu.Lock()
load += r.schedulerExecReservations[workerID]
r.schedulerExecMu.Unlock()
return load
}
func sortExecutorsByLoad(executors []*WorkerSession, load func(*WorkerSession) int) {
sort.SliceStable(executors, func(i, j int) bool {
if executors[i] == nil {
return false
}
if executors[j] == nil {
return true
}
li, lj := load(executors[i]), load(executors[j])
if li != lj {
return li < lj
}
return executors[i].WorkerID < executors[j].WorkerID
})
}
// tryReserveExecutorCapacityForLane reserves an execution slot on the
// per-lane reservation pool so that lanes cannot starve each other.
func (r *Plugin) tryReserveExecutorCapacityForLane(
@@ -1577,6 +1656,8 @@ func (r *Plugin) DispatchProposals(
ExecutionTimeout: defaultScheduledExecutionTimeout,
RetryBackoff: defaultScheduledRetryBackoff,
ExecutorReserveBackoff: 200 * time.Millisecond,
LocalQueuePin: defaultLocalQueuePin,
OrphanRehomeGrace: defaultOrphanRehomeGrace,
}
}
+58 -5
View File
@@ -61,6 +61,12 @@ func TestLoadSchedulerPolicyUsesAdminConfig(t *testing.T) {
if policy.JobTypeMaxRuntime != 30*time.Minute {
t.Fatalf("unexpected max runtime: got=%v", policy.JobTypeMaxRuntime)
}
if policy.LocalQueuePin != defaultLocalQueuePin {
t.Fatalf("unexpected local queue pin: got=%d", policy.LocalQueuePin)
}
if policy.OrphanRehomeGrace != defaultOrphanRehomeGrace {
t.Fatalf("unexpected orphan rehome grace: got=%v", policy.OrphanRehomeGrace)
}
}
func TestLoadSchedulerPolicyUsesDescriptorDefaultsWhenConfigMissing(t *testing.T) {
@@ -138,13 +144,13 @@ func TestReserveScheduledExecutorRespectsPerWorkerLimit(t *testing.T) {
ExecutorReserveBackoff: time.Millisecond,
}
executor1, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
executor1, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
if err != nil {
t.Fatalf("reserve executor 1: %v", err)
}
defer release1()
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
if err != nil {
t.Fatalf("reserve executor 2: %v", err)
}
@@ -266,7 +272,7 @@ func TestReserveScheduledExecutorTimesOutWhenNoExecutor(t *testing.T) {
start := time.Now()
pluginSvc.Shutdown()
_, _, err = pluginSvc.reserveScheduledExecutor(context.Background(), "missing-job-type", policy)
_, _, err = pluginSvc.reserveScheduledExecutor(context.Background(), "missing-job-type", policy, nil)
if err == nil {
t.Fatalf("expected reservation shutdown error")
}
@@ -297,7 +303,7 @@ func TestReserveScheduledExecutorWaitsForWorkerCapacity(t *testing.T) {
ExecutorReserveBackoff: 5 * time.Millisecond,
}
_, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
_, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
if err != nil {
t.Fatalf("reserve executor 1: %v", err)
}
@@ -308,7 +314,7 @@ func TestReserveScheduledExecutorWaitsForWorkerCapacity(t *testing.T) {
}
secondReserveCh := make(chan reserveResult, 1)
go func() {
_, release2, reserveErr := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
_, release2, reserveErr := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
if release2 != nil {
release2()
}
@@ -992,3 +998,50 @@ func TestLaneStatus_LifecycleNextDetectionSurvivesDefaultLanePrune(t *testing.T)
status.NextDetectionAt, expected)
}
}
func TestReserveWaterfillsPreferredHosts(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
ids := []string{"worker-a", "worker-b", "worker-c", "worker-d"}
for _, id := range ids {
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
WorkerId: id,
Address: "10.0.0.1:9334",
Capabilities: []*plugin_pb.JobTypeCapability{
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
},
})
}
policy := schedulerPolicy{
PerWorkerConcurrency: 8,
ExecutorReserveBackoff: time.Millisecond,
LocalQueuePin: 5,
}
preferred := []string{"10.0.0.1:8080"}
seen := map[string]int{}
var releases []func()
defer func() {
for _, release := range releases {
release()
}
}()
for i := 0; i < 4; i++ {
exec, release, err := pluginSvc.reserveScheduledExecutor(context.Background(), "erasure_coding", policy, preferred)
if err != nil {
t.Fatalf("reserve %d: %v", i, err)
}
releases = append(releases, release)
seen[exec.WorkerID]++
}
if len(seen) != 4 {
t.Fatalf("want one job per worker, got %v", seen)
}
}
+23 -5
View File
@@ -277,14 +277,24 @@ func (r *Registry) PickDetector(jobType string) (*WorkerSession, error) {
}
// PickExecutor picks one executor worker for a job type.
func (r *Registry) PickExecutor(jobType string) (*WorkerSession, error) {
return r.pickByKind(jobType, false)
// preferredHosts, when set, ranks workers whose Hello Address host matches a
// replica/source host ahead of others (same-machine EC pull). Empty means the
// previous slots+round-robin order.
func (r *Registry) PickExecutor(jobType string, preferredHosts ...string) (*WorkerSession, error) {
list, err := r.ListExecutors(jobType, preferredHosts...)
if err != nil {
return nil, err
}
return list[0], nil
}
// ListExecutors returns sorted executor candidates for one job type.
// Ordering is by most available execution slots, then lexical worker ID.
// The top tie group is rotated round-robin to prevent sticky assignment.
func (r *Registry) ListExecutors(jobType string) ([]*WorkerSession, error) {
// preferredHosts, when any worker matches, are listed first (then the rest);
// rotation applies only inside the preferred group so a local worker is not
// rotated away in favor of a remote one.
func (r *Registry) ListExecutors(jobType string, preferredHosts ...string) ([]*WorkerSession, error) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -293,8 +303,16 @@ func (r *Registry) ListExecutors(jobType string) ([]*WorkerSession, error) {
return nil, fmt.Errorf("no executor worker available for job_type=%s", jobType)
}
sortByKind(candidates, jobType, false)
r.rotateTopCandidatesLocked(candidates, jobType, false)
pref, rest := partitionByPreferredHost(candidates, preferredHosts)
if len(pref) > 0 {
sortByKind(pref, jobType, false)
r.rotateTopCandidatesLocked(pref, jobType, false)
sortByKind(rest, jobType, false)
candidates = append(pref, rest...)
} else {
sortByKind(candidates, jobType, false)
r.rotateTopCandidatesLocked(candidates, jobType, false)
}
out := make([]*WorkerSession, 0, len(candidates))
for _, candidate := range candidates {
+67
View File
@@ -401,3 +401,70 @@ func TestRegistryHasCapableWorkerIgnoresNilCapability(t *testing.T) {
t.Fatalf("expected HasCapableWorker to return false when capability has no detect or execute")
}
}
func TestListExecutorsPrefersMatchingReplicaHost(t *testing.T) {
t.Parallel()
r := NewRegistry()
r.UpsertFromHello(&plugin_pb.WorkerHello{
WorkerId: "worker-remote",
Address: "10.87.0.4:33646",
Capabilities: []*plugin_pb.JobTypeCapability{
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
},
})
r.UpsertFromHello(&plugin_pb.WorkerHello{
WorkerId: "worker-local",
Address: "10.87.0.3:33646",
Capabilities: []*plugin_pb.JobTypeCapability{
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 1},
},
})
r.UpdateHeartbeat("worker-remote", &plugin_pb.WorkerHeartbeat{
WorkerId: "worker-remote", ExecutionSlotsUsed: 0, ExecutionSlotsTotal: 8,
})
r.UpdateHeartbeat("worker-local", &plugin_pb.WorkerHeartbeat{
WorkerId: "worker-local", ExecutionSlotsUsed: 0, ExecutionSlotsTotal: 1,
})
// Without preference the emptier remote worker wins on slots.
plain, err := r.ListExecutors("erasure_coding")
if err != nil {
t.Fatalf("ListExecutors: %v", err)
}
if plain[0].WorkerID != "worker-remote" {
t.Fatalf("without preference want remote first, got %s", plain[0].WorkerID)
}
// Replica on :8080 of the same host as worker-local.
pref, err := r.ListExecutors("erasure_coding", "10.87.0.3:8080")
if err != nil {
t.Fatalf("ListExecutors preferred: %v", err)
}
if pref[0].WorkerID != "worker-local" {
t.Fatalf("preferred host want worker-local first, got %s", pref[0].WorkerID)
}
if pref[1].WorkerID != "worker-remote" {
t.Fatalf("remote should still be fallback, got %s", pref[1].WorkerID)
}
}
func TestJobReplicaHostsFromSourcesAndReplicaServers(t *testing.T) {
t.Parallel()
job := &plugin_pb.JobSpec{
Parameters: map[string]*plugin_pb.ConfigValue{
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "10.87.0.1:8080"},
},
"replica_servers": {
Kind: &plugin_pb.ConfigValue_StringList{
StringList: &plugin_pb.StringList{Values: []string{"10.87.0.1:8080", "10.87.0.2:8081"}},
},
},
},
}
got := JobReplicaHosts(job)
if len(got) != 2 {
t.Fatalf("got %v", got)
}
}
+317
View File
@@ -0,0 +1,317 @@
package plugin
import (
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// queuedJob is one scheduled spec parked on a home worker's admin-side queue.
type queuedJob struct {
job *plugin_pb.JobSpec
home string
}
// workerJobQueues holds per-worker FIFOs for one dispatch batch.
// Home assignment is done once up front; run goroutines only pop/steal.
// leftover is the homeless FIFO: replica hostnames with no currently
// registered worker. Orphan grace does not apply here.
type workerJobQueues struct {
mu sync.Mutex
q map[string][]*queuedJob
leftover []*queuedJob
remaining int
missingSince map[string]time.Time
homes map[string]struct{}
}
func newWorkerJobQueues() *workerJobQueues {
return &workerJobQueues{
q: make(map[string][]*queuedJob),
missingSince: make(map[string]time.Time),
homes: make(map[string]struct{}),
}
}
func (qs *workerJobQueues) hasHome(workerID string) bool {
if qs == nil || workerID == "" {
return false
}
_, ok := qs.homes[workerID]
return ok
}
func (qs *workerJobQueues) push(home string, job *plugin_pb.JobSpec) {
if qs == nil || job == nil {
return
}
item := &queuedJob{job: job, home: home}
qs.mu.Lock()
defer qs.mu.Unlock()
qs.pushLocked(item, false)
}
func (qs *workerJobQueues) pushFront(item *queuedJob) {
if qs == nil || item == nil {
return
}
qs.mu.Lock()
defer qs.mu.Unlock()
qs.pushLocked(item, true)
}
func (qs *workerJobQueues) pushLocked(item *queuedJob, front bool) {
qs.remaining++
if item.home == "" {
if front {
qs.leftover = append([]*queuedJob{item}, qs.leftover...)
return
}
qs.leftover = append(qs.leftover, item)
return
}
if front {
qs.q[item.home] = append([]*queuedJob{item}, qs.q[item.home]...)
return
}
qs.q[item.home] = append(qs.q[item.home], item)
}
func (qs *workerJobQueues) isEmpty() bool {
if qs == nil {
return true
}
qs.mu.Lock()
defer qs.mu.Unlock()
return qs.remaining == 0
}
func (qs *workerJobQueues) queueLen(workerID string) int {
if qs == nil {
return 0
}
qs.mu.Lock()
defer qs.mu.Unlock()
return len(qs.q[workerID])
}
func (qs *workerJobQueues) leftoverLen() int {
if qs == nil {
return 0
}
qs.mu.Lock()
defer qs.mu.Unlock()
return len(qs.leftover)
}
func (qs *workerJobQueues) drain() []*queuedJob {
if qs == nil {
return nil
}
qs.mu.Lock()
defer qs.mu.Unlock()
out := make([]*queuedJob, 0, qs.remaining)
for id, qq := range qs.q {
out = append(out, qq...)
qs.q[id] = nil
}
out = append(out, qs.leftover...)
qs.leftover = nil
qs.remaining = 0
return out
}
func popFront(qq []*queuedJob) (*queuedJob, []*queuedJob) {
item := qq[0]
if len(qq) == 1 {
return item, nil
}
return item, qq[1:]
}
// assignJobsToWorkerQueues parks each job on the least-loaded replica-host
// worker. A home exists only if a currently registered worker's -address
// hostname matches a replica host. Otherwise the job is homeless: global
// leftover FIFO, no fake home, no K, no orphan grace. Single-threaded.
func (r *Plugin) assignJobsToWorkerQueues(
jobType string,
jobs []*plugin_pb.JobSpec,
_ schedulerPolicy,
) *workerJobQueues {
qs := newWorkerJobQueues()
executors, err := r.registry.ListExecutors(jobType)
lane := JobTypeLane(jobType)
queued := make(map[string]int, len(executors))
for _, job := range jobs {
if job == nil {
continue
}
if err != nil || len(executors) == 0 {
qs.push("", job)
continue
}
pref, _ := partitionByPreferredHost(executors, JobReplicaHosts(job))
if len(pref) == 0 {
// Never registered for that hostname: homeless, not an orphan.
qs.push("", job)
continue
}
for _, e := range pref {
if e != nil && e.WorkerID != "" {
qs.homes[e.WorkerID] = struct{}{}
}
}
sortExecutorsByLoad(pref, func(e *WorkerSession) int {
return r.executorReservationLoad(e, lane) + queued[e.WorkerID]
})
home := pref[0].WorkerID
qs.push(home, job)
queued[home]++
r.bindQueuedJobWorker(job, home)
}
return qs
}
// takeQueuedJob pops a job for a free worker.
// Home worker (hostname matched a replica / has a home-queue identity):
// 1. own home queue
// 2. steal from another home if len>=K, or orphan past OrphanRehomeGrace
// 3. homeless leftover FIFO
// No-home worker (no matching replica, no home-queue identity):
// 1. homeless leftover FIFO
// 2. only if leftover empty: steal (K / orphan), same rules
func (r *Plugin) takeQueuedJob(
qs *workerJobQueues,
jobType string,
policy schedulerPolicy,
) (item *queuedJob, targetID string) {
if qs == nil {
return nil, ""
}
executors, err := r.registry.ListExecutors(jobType)
if err != nil || len(executors) == 0 {
return nil, ""
}
lane := JobTypeLane(jobType)
pin := policy.LocalQueuePin
defer func() {
if item != nil && targetID != "" {
r.bindQueuedJobWorker(item.job, targetID)
}
}()
qs.mu.Lock()
defer qs.mu.Unlock()
if qs.remaining == 0 {
return nil, ""
}
var bestHome *WorkerSession
bestLoad := 0
for _, exec := range executors {
if len(qs.q[exec.WorkerID]) == 0 {
continue
}
limit := schedulerWorkerExecutionLimit(exec, jobType, policy)
if limit <= 0 {
continue
}
load := r.executorReservationLoad(exec, lane)
if load >= limit {
continue
}
if bestHome == nil || load < bestLoad || (load == bestLoad && exec.WorkerID < bestHome.WorkerID) {
bestHome = exec
bestLoad = load
}
}
if bestHome != nil {
id := bestHome.WorkerID
item, qs.q[id] = popFront(qs.q[id])
qs.remaining--
return item, id
}
var thief *WorkerSession
for _, exec := range executors {
limit := schedulerWorkerExecutionLimit(exec, jobType, policy)
if limit <= 0 {
continue
}
if r.executorReservationLoad(exec, lane) >= limit {
continue
}
if thief == nil || exec.WorkerID < thief.WorkerID {
thief = exec
}
}
if thief == nil {
return nil, ""
}
takeLeftover := func() (*queuedJob, string) {
if len(qs.leftover) == 0 {
return nil, ""
}
item, qs.leftover = popFront(qs.leftover)
qs.remaining--
return item, thief.WorkerID
}
if !qs.hasHome(thief.WorkerID) {
if item, id := takeLeftover(); item != nil {
return item, id
}
}
live := make(map[string]struct{}, len(executors))
for _, exec := range executors {
live[exec.WorkerID] = struct{}{}
delete(qs.missingSince, exec.WorkerID)
}
now := time.Now()
grace := policy.OrphanRehomeGrace
victimID := ""
victimLen := 0
victimOrphan := false
for id, qq := range qs.q {
if len(qq) == 0 {
delete(qs.missingSince, id)
continue
}
_, isLive := live[id]
orphan := !isLive
if isLive {
if pin > 0 && len(qq) < pin {
continue
}
} else {
if _, ok := qs.missingSince[id]; !ok {
qs.missingSince[id] = now
}
if grace > 0 && now.Sub(qs.missingSince[id]) < grace {
continue
}
}
better := victimID == ""
if !better && orphan != victimOrphan {
better = orphan
}
if !better && orphan == victimOrphan {
better = len(qq) > victimLen || (len(qq) == victimLen && id < victimID)
}
if better {
victimID = id
victimLen = len(qq)
victimOrphan = orphan
}
}
if victimID != "" {
item, qs.q[victimID] = popFront(qs.q[victimID])
qs.remaining--
return item, thief.WorkerID
}
return takeLeftover()
}
+600
View File
@@ -0,0 +1,600 @@
package plugin
import (
"fmt"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
func replicaJob(id, source string) *plugin_pb.JobSpec {
return &plugin_pb.JobSpec{
JobId: id,
JobType: "erasure_coding",
Summary: id,
DedupeKey: id,
Parameters: map[string]*plugin_pb.ConfigValue{
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: source},
},
},
}
}
func registerECWorkers(t *testing.T, pluginSvc *Plugin, workers map[string]string) {
t.Helper()
for id, addr := range workers {
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
WorkerId: id,
Address: addr,
Capabilities: []*plugin_pb.JobTypeCapability{
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
},
})
}
}
func TestAssignJobsWaterfillsPreferredWorkers(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-a": "10.0.0.1:9334",
"worker-b": "10.0.0.2:9334",
"worker-c": "10.0.0.3:9334",
"worker-d": "10.0.0.4:9334",
})
jobs := []*plugin_pb.JobSpec{
replicaJob("j1", "10.0.0.1:8080"),
replicaJob("j2", "10.0.0.1:8080"),
replicaJob("j3", "10.0.0.1:8080"),
replicaJob("j4", "10.0.0.1:8080"),
}
// All four replica IPs listed so every worker is preferred.
for _, job := range jobs {
job.Parameters["replica_servers"] = &plugin_pb.ConfigValue{
Kind: &plugin_pb.ConfigValue_StringList{StringList: &plugin_pb.StringList{
Values: []string{"10.0.0.1:8080", "10.0.0.2:8080", "10.0.0.3:8080", "10.0.0.4:8080"},
}},
}
}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, schedulerPolicy{LocalQueuePin: 5})
if qs.queueLen("worker-a")+qs.queueLen("worker-b")+qs.queueLen("worker-c")+qs.queueLen("worker-d") != 4 {
t.Fatalf("expected 4 assigned jobs")
}
for _, id := range []string{"worker-a", "worker-b", "worker-c", "worker-d"} {
if qs.queueLen(id) != 1 {
t.Fatalf("want 1 job on %s, got %d", id, qs.queueLen(id))
}
}
}
func TestAssignAndTakeDoesNotStarveOtherHomes(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := []*plugin_pb.JobSpec{
replicaJob("local-1", "10.0.0.1:8080"),
replicaJob("local-2", "10.0.0.1:8080"),
replicaJob("remote-1", "10.0.0.2:8080"),
}
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-local") != 2 || qs.queueLen("worker-remote") != 1 {
t.Fatalf("homes local=%d remote=%d", qs.queueLen("worker-local"), qs.queueLen("worker-remote"))
}
_, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
_, release1, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, target1)
if err != nil {
t.Fatalf("reserve 1: %v", err)
}
defer release1()
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item2 == nil || target2 != "worker-remote" {
t.Fatalf("second take should start remote home, got job=%v target=%s", item2, target2)
}
}
func TestTakeStealsOnlyWhenHomeQueueReachesK(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := make([]*plugin_pb.JobSpec, 0, 6)
for i := 0; i < 6; i++ {
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
}
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-local") != 6 {
t.Fatalf("all jobs should home on local, got %d", qs.queueLen("worker-local"))
}
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if target != "worker-local" {
t.Fatalf("first take want local, got %s", target)
}
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
if err != nil {
t.Fatalf("reserve local: %v", err)
}
defer release()
// 5 still queued on local (>=K) and local has no slot → steal to remote.
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if stolen == nil || stealTarget != "worker-remote" {
t.Fatalf("expected steal to remote, got job=%v target=%s", stolen, stealTarget)
}
if qs.queueLen("worker-local") != 4 {
t.Fatalf("after steal want 4 left on local, got %d", qs.queueLen("worker-local"))
}
// 4 < K: do not steal again.
if item, tgt := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
t.Fatalf("pin should hold remaining jobs, got target=%s", tgt)
}
}
func TestTakeStealsImmediatelyWhenPinDisabled(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := []*plugin_pb.JobSpec{
replicaJob("a", "10.0.0.1:8080"),
replicaJob("b", "10.0.0.1:8080"),
}
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 0}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if target != "worker-local" {
t.Fatalf("first take want local, got %s", target)
}
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
if err != nil {
t.Fatalf("reserve local: %v", err)
}
defer release()
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if stolen == nil || stealTarget != "worker-remote" {
t.Fatalf("K=0 should steal immediately, got job=%v target=%s", stolen, stealTarget)
}
}
func TestTakeStealsOrphanedHomeWithoutK(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := []*plugin_pb.JobSpec{
replicaJob("o1", "10.0.0.1:8080"),
replicaJob("o2", "10.0.0.1:8080"),
}
policy := schedulerPolicy{
PerWorkerConcurrency: 1,
LocalQueuePin: 5,
OrphanRehomeGrace: 30 * time.Second,
}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-local") != 2 {
t.Fatalf("want 2 on local, got %d", qs.queueLen("worker-local"))
}
pluginSvc.registry.Remove("worker-local")
if item, tgt := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
t.Fatalf("within grace should hold orphan queue, got target=%s", tgt)
}
if qs.queueLen("worker-local") != 2 {
t.Fatalf("held queue changed: %d", qs.queueLen("worker-local"))
}
qs.mu.Lock()
qs.missingSince["worker-local"] = time.Now().Add(-time.Minute)
qs.mu.Unlock()
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if stolen == nil || stealTarget != "worker-remote" {
t.Fatalf("after grace should steal without K, got job=%v target=%s", stolen, stealTarget)
}
if qs.queueLen("worker-local") != 1 {
t.Fatalf("want 1 orphan left, got %d", qs.queueLen("worker-local"))
}
stolen2, stealTarget2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if stolen2 == nil || stealTarget2 != "worker-remote" {
t.Fatalf("second orphan should also move, got job=%v target=%s", stolen2, stealTarget2)
}
}
func TestTakeKeepsOrphanWhenHomeReconnectsDuringGrace(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := []*plugin_pb.JobSpec{replicaJob("blink", "10.0.0.1:8080")}
policy := schedulerPolicy{
PerWorkerConcurrency: 1,
LocalQueuePin: 5,
OrphanRehomeGrace: 30 * time.Second,
}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
pluginSvc.registry.Remove("worker-local")
if item, _ := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy); item != nil {
t.Fatal("expected hold during grace")
}
registerECWorkers(t, pluginSvc, map[string]string{"worker-local": "10.0.0.1:9334"})
item, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item == nil || target != "worker-local" {
t.Fatalf("reconnected home should run its queue, got job=%v target=%s", item, target)
}
}
func TestTakeStealsOrphanImmediatelyWhenGraceDisabled(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := []*plugin_pb.JobSpec{replicaJob("o1", "10.0.0.1:8080")}
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5, OrphanRehomeGrace: 0}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
pluginSvc.registry.Remove("worker-local")
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if stolen == nil || stealTarget != "worker-remote" {
t.Fatalf("grace=0 should steal immediately, got job=%v target=%s", stolen, stealTarget)
}
}
func TestBindQueuedJobWorkerKeepsPendingState(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
job := replicaJob("bind-1", "10.0.0.1:8080")
pluginSvc.trackExecutionQueued(job)
pluginSvc.bindQueuedJobWorker(job, "worker-local")
pluginSvc.jobsMu.Lock()
tracked := pluginSvc.jobs[job.JobId]
pluginSvc.jobsMu.Unlock()
if tracked == nil {
t.Fatal("missing tracked job")
}
if tracked.State != "job_state_pending" {
t.Fatalf("state=%s", tracked.State)
}
if tracked.Stage != "queued" {
t.Fatalf("stage=%s", tracked.Stage)
}
if tracked.WorkerID != "worker-local" {
t.Fatalf("worker=%s", tracked.WorkerID)
}
}
func TestHomelessAllRemoteWorkersUseFIFOWithoutPinOrGrace(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
// Workers never registered on the replica hostname — homeless, not orphan.
registerECWorkers(t, pluginSvc, map[string]string{
"worker-a": "10.0.0.5:9334",
"worker-b": "10.0.0.6:9334",
})
jobs := []*plugin_pb.JobSpec{
replicaJob("h1", "10.0.0.1:8080"),
replicaJob("h2", "10.0.0.1:8080"),
replicaJob("h3", "10.0.0.1:8080"),
}
policy := schedulerPolicy{
PerWorkerConcurrency: 1,
LocalQueuePin: 5,
OrphanRehomeGrace: 30 * time.Second,
}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-a") != 0 || qs.queueLen("worker-b") != 0 {
t.Fatalf("homeless jobs must not get a fake home, a=%d b=%d", qs.queueLen("worker-a"), qs.queueLen("worker-b"))
}
if qs.leftoverLen() != 3 {
t.Fatalf("want 3 homeless FIFO, got %d", qs.leftoverLen())
}
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item1 == nil || item1.job.JobId != "h1" {
t.Fatalf("FIFO first want h1, got %v", item1)
}
_, release1, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, target1)
if err != nil {
t.Fatalf("reserve 1: %v", err)
}
defer release1()
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item2 == nil || item2.job.JobId != "h2" {
t.Fatalf("FIFO second want h2 immediately, got %v", item2)
}
if target2 == "" || target2 == target1 {
t.Fatalf("second take should hit the other idle worker, t1=%s t2=%s", target1, target2)
}
if qs.leftoverLen() != 1 {
t.Fatalf("want 1 homeless left, got %d", qs.leftoverLen())
}
}
func TestMixedReplicaLocalJobsStayHomeBeforeHomeless(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := []*plugin_pb.JobSpec{
replicaJob("local-1", "10.0.0.1:8080"),
replicaJob("local-2", "10.0.0.1:8080"),
replicaJob("homeless-1", "10.0.0.9:8080"),
replicaJob("homeless-2", "10.0.0.9:8080"),
}
policy := schedulerPolicy{
PerWorkerConcurrency: 1,
LocalQueuePin: 5,
OrphanRehomeGrace: 30 * time.Second,
}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-local") != 2 || qs.queueLen("worker-remote") != 0 || qs.leftoverLen() != 2 {
t.Fatalf("homes local=%d remote=%d leftover=%d", qs.queueLen("worker-local"), qs.queueLen("worker-remote"), qs.leftoverLen())
}
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item1 == nil || item1.job.JobId != "local-1" || target1 != "worker-local" {
t.Fatalf("first take want local-1 on home, got job=%v target=%s", item1, target1)
}
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
if err != nil {
t.Fatalf("reserve local: %v", err)
}
defer release()
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item2 == nil || item2.job.JobId != "homeless-1" || target2 != "worker-remote" {
t.Fatalf("remote should take homeless, not steal pinned home, got job=%v target=%s", item2, target2)
}
if qs.queueLen("worker-local") != 1 {
t.Fatalf("remaining replica-local job must stay home, got %d", qs.queueLen("worker-local"))
}
if qs.leftoverLen() != 1 {
t.Fatalf("one homeless should remain, leftover=%d", qs.leftoverLen())
}
}
func TestIdleHomeWorkerTakesHomelessAfterEmptyHomeQueue(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
})
jobs := []*plugin_pb.JobSpec{
replicaJob("home-1", "10.0.0.1:8080"),
replicaJob("homeless-1", "10.0.0.9:8080"),
}
policy := schedulerPolicy{
PerWorkerConcurrency: 1,
LocalQueuePin: 5,
OrphanRehomeGrace: 30 * time.Second,
}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-local") != 1 || qs.leftoverLen() != 1 {
t.Fatalf("home=%d leftover=%d", qs.queueLen("worker-local"), qs.leftoverLen())
}
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item1 == nil || item1.job.JobId != "home-1" || target1 != "worker-local" {
t.Fatalf("first take want home-1, got job=%v target=%s", item1, target1)
}
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item2 == nil || item2.job.JobId != "homeless-1" || target2 != "worker-local" {
t.Fatalf("idle home with empty queue may take homeless, got job=%v target=%s", item2, target2)
}
if qs.leftoverLen() != 0 {
t.Fatalf("homeless should be gone, leftover=%d", qs.leftoverLen())
}
}
func TestHomelessWorkerTakesFIFOBeforeStealableHome(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-remote": "10.0.0.2:9334",
})
jobs := make([]*plugin_pb.JobSpec, 0, 7)
for i := 0; i < 6; i++ {
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
}
jobs = append(jobs, replicaJob("homeless-1", "10.0.0.9:8080"))
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-local") != 6 || qs.leftoverLen() != 1 {
t.Fatalf("home=%d leftover=%d", qs.queueLen("worker-local"), qs.leftoverLen())
}
if qs.hasHome("worker-remote") {
t.Fatal("remote must have no home identity")
}
_, target := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if target != "worker-local" {
t.Fatalf("first take want local, got %s", target)
}
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
if err != nil {
t.Fatalf("reserve local: %v", err)
}
defer release()
item, takeTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item == nil || takeTarget != "worker-remote" || item.job.JobId != "homeless-1" {
t.Fatalf("remote must take homeless FIFO before K-steal, got job=%v target=%s", item, takeTarget)
}
if qs.leftoverLen() != 0 {
t.Fatalf("homeless should be gone, leftover=%d", qs.leftoverLen())
}
if qs.queueLen("worker-local") != 5 {
t.Fatalf("stealable home must stay until FIFO empty, got %d", qs.queueLen("worker-local"))
}
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if stolen == nil || stealTarget != "worker-remote" || stolen.job.JobId == "homeless-1" {
t.Fatalf("after FIFO empty remote may steal >=K, got job=%v target=%s", stolen, stealTarget)
}
}
func TestHomeWorkerStealsBeforeHomelessAfterOwnQueueEmpty(t *testing.T) {
t.Parallel()
pluginSvc, err := New(Options{})
if err != nil {
t.Fatalf("New: %v", err)
}
defer pluginSvc.Shutdown()
registerECWorkers(t, pluginSvc, map[string]string{
"worker-local": "10.0.0.1:9334",
"worker-other": "10.0.0.2:9334",
})
jobs := make([]*plugin_pb.JobSpec, 0, 8)
for i := 0; i < 6; i++ {
jobs = append(jobs, replicaJob(fmt.Sprintf("j%d", i), "10.0.0.1:8080"))
}
jobs = append(jobs, replicaJob("other-1", "10.0.0.2:8080"))
jobs = append(jobs, replicaJob("homeless-1", "10.0.0.9:8080"))
policy := schedulerPolicy{PerWorkerConcurrency: 1, LocalQueuePin: 5}
qs := pluginSvc.assignJobsToWorkerQueues("erasure_coding", jobs, policy)
if qs.queueLen("worker-local") != 6 || qs.queueLen("worker-other") != 1 || qs.leftoverLen() != 1 {
t.Fatalf("local=%d other=%d leftover=%d", qs.queueLen("worker-local"), qs.queueLen("worker-other"), qs.leftoverLen())
}
if !qs.hasHome("worker-other") {
t.Fatal("other must keep home identity")
}
item1, target1 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item1 == nil || target1 != "worker-local" {
t.Fatalf("first take want local home, got job=%v target=%s", item1, target1)
}
_, release, err := pluginSvc.reserveExecutorByID("erasure_coding", policy, "worker-local")
if err != nil {
t.Fatalf("reserve local: %v", err)
}
defer release()
item2, target2 := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if item2 == nil || item2.job.JobId != "other-1" || target2 != "worker-other" {
t.Fatalf("other should drain own home first, got job=%v target=%s", item2, target2)
}
stolen, stealTarget := pluginSvc.takeQueuedJob(qs, "erasure_coding", policy)
if stolen == nil || stealTarget != "worker-other" || stolen.job.JobId == "homeless-1" {
t.Fatalf("home worker with empty own queue still steals before homeless, got job=%v target=%s", stolen, stealTarget)
}
if qs.leftoverLen() != 1 {
t.Fatalf("homeless should still wait, leftover=%d", qs.leftoverLen())
}
}
+191 -18
View File
@@ -195,7 +195,7 @@ func (option *RemoteSyncOptions) makeEventProcessor(remoteStorage *remote_pb.Rem
return updateLocalEntry(option, message.NewParentPath, message.NewEntry, remoteEntry)
}
if filer_pb.IsDelete(resp) {
return processDeleteEvent(client, mountedDir, remoteStorageMountLocation, resp)
return processDeleteEvent(filerSource, client, mountedDir, remoteStorageMountLocation, resp)
}
if message.OldEntry != nil && message.NewEntry != nil {
return processUpdateEvent(option, filerSource, *option.storageClass, client, mountedDir, remoteStorageMountLocation, resp)
@@ -212,8 +212,11 @@ func (option *RemoteSyncOptions) makeEventProcessor(remoteStorage *remote_pb.Rem
// event the inline delete already handled), and GCS reports that case as
// ErrRemoteObjectNotFound where S3 and Azure answer an idempotent success.
// Returning the error would pin the sync offset on an event that has nothing
// left to do.
// left to do. A file whose path a remote-only entry has taken since keeps its
// object: a rename copied that entry's content to this key, and it exists
// nowhere else.
func processDeleteEvent(
filerSource filer_pb.FilerClient,
client remote_storage.RemoteStorageClient,
mountedDir string,
remoteStorageMountLocation *remote_pb.RemoteStorageLocation,
@@ -232,6 +235,36 @@ func processDeleteEvent(
}
glog.V(2).Infof("delete: %+v", resp)
dest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(resp.Directory, message.OldEntry.Name), remoteStorageMountLocation)
if !message.OldEntry.IsDirectory {
// a deleted entry whose rename copy was still pending read its
// content from the source its marker names, which nothing else
// releases
if err := releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry); err != nil {
return err
}
// The object at the key may have been replaced since the delete: a
// rename copies another entry's content onto it, a rewrite uploads
// over it, and a copy can land before its entry is visible. Delete
// only an object the event's stamp still describes.
if live, statErr := client.StatFile(dest); statErr == nil {
if oldRemote := message.OldEntry.GetRemoteEntry(); oldRemote == nil ||
!describes(oldRemote, live) ||
oldRemote.RemoteETag != "" && live.RemoteETag == "" {
glog.V(0).Infof("keep %s: the object changed since the delete", remote_storage.FormatLocation(dest))
return nil
}
} else if !errors.Is(statErr, remote_storage.ErrRemoteObjectNotFound) {
return statErr
}
current, err := currentEntry(filerSource, resp.Directory, message.OldEntry.Name)
if err != nil {
return err
}
if isRemoteOnly(current) {
glog.V(0).Infof("keep %s: a remote-only entry has taken its path since", remote_storage.FormatLocation(dest))
return nil
}
}
if message.OldEntry.IsDirectory {
glog.V(0).Infof("rmdir %s", remote_storage.FormatLocation(dest))
return client.RemoveDirectory(dest)
@@ -240,6 +273,41 @@ func processDeleteEvent(
return deleteRemoteFile(client, dest)
}
// releasePendingSource deletes the source a pending-rename marker on entry
// names, once nothing needs it. The filer never deletes a marked source: a
// concurrent rename can copy onto that key before its entry is visible. So
// the object goes only while it is still the one entry's stamp describes, and
// no remote-only entry holds its path.
func releasePendingSource(filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, mountedDir string, remoteStorageMountLocation *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry) error {
src := util.FullPath(entry.GetExtended()[filer.ExtRemoteRenameSourceKey])
if src == "" || !strings.HasPrefix(string(src), strings.TrimSuffix(mountedDir, "/")+"/") {
return nil
}
srcDest := toRemoteStorageLocation(util.FullPath(mountedDir), src, remoteStorageMountLocation)
srcDir, srcName := src.DirAndName()
current, err := currentEntry(filerSource, srcDir, srcName)
if err != nil {
return err
}
if isRemoteOnly(current) {
glog.V(0).Infof("keep %s: a remote-only entry holds the pending rename's source", remote_storage.FormatLocation(srcDest))
return nil
}
live, err := client.StatFile(srcDest)
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
return nil
}
if err != nil {
return err
}
if stamp := entry.GetRemoteEntry(); !describes(stamp, live) || stamp.RemoteETag != "" && live.RemoteETag == "" {
glog.V(0).Infof("keep %s: the pending rename's source changed since", remote_storage.FormatLocation(srcDest))
return nil
}
glog.V(0).Infof("delete %s, the source of a pending rename", remote_storage.FormatLocation(srcDest))
return deleteRemoteFile(client, srcDest)
}
// deleteRemoteFile deletes the object and treats an already-absent object as
// deleted.
func deleteRemoteFile(client remote_storage.RemoteStorageClient, dest *remote_pb.RemoteStorageLocation) error {
@@ -268,6 +336,17 @@ func processUpdateEvent(
}
oldDest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(resp.Directory, message.OldEntry.Name), remoteStorageMountLocation)
dest := toRemoteStorageLocation(util.FullPath(mountedDir), util.NewFullPath(message.NewParentPath, message.NewEntry.Name), remoteStorageMountLocation)
eventSource := renameSource{path: util.NewFullPath(resp.Directory, message.OldEntry.Name), loc: oldDest, stamp: message.OldEntry.GetRemoteEntry()}
// sourceOf is where a remote-only entry's content is: the source its
// pending-rename marker names, which may be an earlier rename's old key,
// or else the event's old key
sourceOf := func(entry *filer_pb.Entry, fallback renameSource) renameSource {
if src := string(entry.Extended[filer.ExtRemoteRenameSourceKey]); strings.HasPrefix(src, strings.TrimSuffix(mountedDir, "/")+"/") {
// a pending entry carries the stamp of the source it reads
return renameSource{path: util.FullPath(src), loc: toRemoteStorageLocation(util.FullPath(mountedDir), util.FullPath(src), remoteStorageMountLocation), stamp: entry.GetRemoteEntry()}
}
return fallback
}
if proto.Equal(oldDest, dest) && !shouldSendToRemote(message.NewEntry) {
glog.V(2).Infof("skipping updating: %+v", resp)
return nil
@@ -307,7 +386,23 @@ func processUpdateEvent(
return err
}
if isRemoteOnly(current) {
return completeRemoteOnlyRename(filerClient, client, message.NewParentPath, current, oldDest, dest, storageClass)
source := sourceOf(current, eventSource)
if err := completeRemoteOnlyRename(filerClient, client, message.NewParentPath, current, source, dest, storageClass); err != nil {
return err
}
// a pending entry the filer copied on this rename leaves the
// source its marker named
if pending := sourceOf(message.OldEntry, renameSource{}); pending.loc != nil && !proto.Equal(pending.loc, source.loc) {
return releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry)
}
return nil
}
if current == nil && len(message.NewEntry.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
// the rename's copy was pending and the entry has moved on: a
// later rename carries the source in its own marker and finishes
// or releases it, so this one leaves it
glog.V(0).Infof("keep %s: the pending rename moved on", remote_storage.FormatLocation(oldDest))
return nil
}
glog.V(0).Infof("delete %s", remote_storage.FormatLocation(oldDest))
if err := client.DeleteFile(oldDest); err != nil {
@@ -323,14 +418,43 @@ func processUpdateEvent(
if errors.Is(writeErr, errSuperseded) {
glog.Errorf("skipping %s: %v", remote_storage.FormatLocation(dest), writeErr)
if !proto.Equal(oldDest, dest) {
return uploadCurrentEntry(filerClient, filerSource, client, message.NewParentPath, message.NewEntry.Name, oldDest, dest, storageClass)
if err := uploadCurrentEntry(filerClient, filerSource, client, message.NewParentPath, message.NewEntry.Name, eventSource, dest, storageClass, sourceOf); err != nil {
return err
}
return releaseRenamedSource(filerSource, client, mountedDir, remoteStorageMountLocation, message)
}
return nil
}
if writeErr != nil {
return writeErr
}
return updateLocalEntry(filerClient, message.NewParentPath, message.NewEntry, remoteEntry)
if err := updateLocalEntry(filerClient, message.NewParentPath, message.NewEntry, remoteEntry); err != nil {
return err
}
if !proto.Equal(oldDest, dest) {
return releaseRenamedSource(filerSource, client, mountedDir, remoteStorageMountLocation, message)
}
return nil
}
// releaseRenamedSource releases a pending source the renamed entry's old
// version read, once the destination holds the content on the remote under
// its own key: only after the upload and its stamp, which clears the marker,
// and only if the entry does not read through a marker any more. Released
// earlier, an upload that fails followed by remote.uncache would leave the
// entry reading a deleted source.
func releaseRenamedSource(filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, mountedDir string, remoteStorageMountLocation *remote_pb.RemoteStorageLocation, message *filer_pb.EventNotification) error {
if len(message.OldEntry.GetExtended()[filer.ExtRemoteRenameSourceKey]) == 0 {
return nil
}
current, err := currentEntry(filerSource, message.NewParentPath, message.NewEntry.Name)
if err != nil {
return err
}
if current != nil && len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
return nil
}
return releasePendingSource(filerSource, client, mountedDir, remoteStorageMountLocation, message.OldEntry)
}
// currentEntry returns what the filer holds at dir/name now, nil when the
@@ -359,11 +483,24 @@ func isRemoteOnly(entry *filer_pb.Entry) bool {
// copied over the destination, the entry is stamped with the copy, and the
// old key goes. With neither object present the content is lost: the event
// fails and holds the offset for recovery.
func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir string, current *filer_pb.Entry, oldDest, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir string, current *filer_pb.Entry, source renameSource, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
oldDest := source.loc
// the source's recorded stamp; a retry can find the entry already
// stamped with the copy, whose ETag a multipart source does not share
sourceStamp := source.stamp
if sourceStamp == nil {
sourceStamp = proto.Clone(current.RemoteEntry).(*filer_pb.RemoteEntry)
}
if existing, err := client.StatFile(dest); err == nil {
if describes(current.RemoteEntry, existing) {
glog.V(0).Infof("%s already holds the renamed content", remote_storage.FormatLocation(dest))
return deleteRemoteFile(client, oldDest)
if len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
// reads still go to the source until the stamp clears the marker
if err := updateLocalEntry(filerClient, dir, current, existing); err != nil {
return err
}
}
return deleteRenameSource(filerClient, client, dir, current.Name, source, sourceStamp)
}
glog.V(0).Infof("%s holds an object the entry does not describe (size %d, want %d); replacing it from %s", remote_storage.FormatLocation(dest), existing.RemoteSize, current.RemoteEntry.RemoteSize, remote_storage.FormatLocation(oldDest))
} else if !errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
@@ -389,7 +526,42 @@ func completeRemoteOnlyRename(filerClient filer_pb.FilerClient, client remote_st
if err := updateLocalEntry(filerClient, dir, current, remoteEntry); err != nil {
return err
}
return deleteRemoteFile(client, oldDest)
return deleteRenameSource(filerClient, client, dir, current.Name, source, sourceStamp)
}
// renameSource is where a renamed remote-only entry's content was read
// from: the path, the object it maps to, and the stamp describing it.
type renameSource struct {
path util.FullPath
loc *remote_pb.RemoteStorageLocation
stamp *filer_pb.RemoteEntry
}
// deleteRenameSource deletes a rename's old key once the renamed entry no
// longer reads it: an entry still carrying the pending-source marker (its
// stamp lost a race) keeps the source, and the event is retried. The key is
// left alone when the object there is no longer the one stamp describes, as
// when another rename copied onto it, visible yet or not.
func deleteRenameSource(filerClient filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, source renameSource, stamp *filer_pb.RemoteEntry) error {
current, err := currentEntry(filerClient, dir, name)
if err != nil {
return err
}
if current != nil && len(current.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
return fmt.Errorf("%s still reads %s", util.NewFullPath(dir, name), remote_storage.FormatLocation(source.loc))
}
live, err := client.StatFile(source.loc)
if errors.Is(err, remote_storage.ErrRemoteObjectNotFound) {
return nil
}
if err != nil {
return err
}
if !describes(stamp, live) || stamp.GetRemoteETag() != "" && live.RemoteETag == "" {
glog.V(0).Infof("keep %s: it is no longer the renamed content", remote_storage.FormatLocation(source.loc))
return nil
}
return deleteRemoteFile(client, source.loc)
}
// describes reports whether the object a stat returned is the one the entry's
@@ -417,15 +589,15 @@ func openRemoteObject(client remote_storage.RemoteStorageClient, loc *remote_pb.
}
// uploadCurrentEntry uploads what the filer holds at dir/name now, when the
// event that superseded this rename will not. A superseded rename has
// already deleted the old key; the rewrite behind it in the log uploads the
// destination itself whenever shouldSendToRemote sees it as unsynced, so
// only a current entry that reads synced is uploaded here and the content
// goes up once. A remote-only entry is finished the way
// completeRemoteOnlyRename finishes a rename: its stamp can already describe
// the destination (a sync plus remote.uncache in the meantime), and only
// then is it complete.
func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, oldDest, dest *remote_pb.RemoteStorageLocation, storageClass string) error {
// event that superseded this rename will not. A rename whose snapshot is
// superseded has already deleted the old key; the rewrite behind it in the
// log uploads the destination itself unless shouldSendToRemote skips it on
// the inherited RemoteEntry, whose RemoteMtime can equal the rewrite's mtime
// within the same second. Only that case uploads here, so the content goes
// up once. A remote-only entry is finished the way completeRemoteOnlyRename
// finishes a rename: its stamp can already describe the destination (a sync
// plus remote.uncache in the meantime), and only then is it complete.
func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.FilerClient, client remote_storage.RemoteStorageClient, dir, name string, oldSource renameSource, dest *remote_pb.RemoteStorageLocation, storageClass string, sourceOf func(*filer_pb.Entry, renameSource) renameSource) error {
current, _, _, err := filer_pb.GetEntry(context.Background(), filerSource, util.NewFullPath(dir, name))
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
@@ -437,7 +609,7 @@ func uploadCurrentEntry(filerClient filer_pb.FilerClient, filerSource filer_pb.F
return nil
}
if isRemoteOnly(current) {
return completeRemoteOnlyRename(filerClient, client, dir, current, oldDest, dest, storageClass)
return completeRemoteOnlyRename(filerClient, client, dir, current, sourceOf(current, oldSource), dest, storageClass)
}
if shouldSendToRemote(current) {
glog.V(0).Infof("leaving %s to the rewrite that superseded the rename", remote_storage.FormatLocation(dest))
@@ -634,6 +806,7 @@ func updateLocalEntry(filerClient filer_pb.FilerClient, dir string, entry *filer
remoteEntry.LastLocalSyncTsNs = time.Now().UnixNano()
expected := proto.Clone(entry).(*filer_pb.Entry)
entry.RemoteEntry = remoteEntry
delete(entry.Extended, filer.ExtRemoteRenameSourceKey)
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
_, err := client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
Directory: dir,
+375 -7
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"strings"
"testing"
"time"
@@ -686,12 +687,20 @@ type recordingRemote struct {
// chunk ids of each written entry: which version of the file went up
writtenChunks [][]string
deleteErr error
writeErr error
// objects present on the remote, by path: StatFile and ReadFile answer
// from it, everything else is ErrRemoteObjectNotFound.
objects map[string][]byte
// statETag is the ETag StatFile reports for a present object, unless
// statETags names one for its path.
statETag string
statETags map[string]string
}
func (r *recordingRemote) WriteFile(loc *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry, reader io.Reader) (*filer_pb.RemoteEntry, error) {
if r.writeErr != nil {
return nil, r.writeErr
}
r.writes = append(r.writes, loc)
var ids []string
for _, c := range entry.GetChunks() {
@@ -714,7 +723,11 @@ func (r *recordingRemote) StatFile(loc *remote_pb.RemoteStorageLocation) (*filer
if !ok {
return nil, remote_storage.ErrRemoteObjectNotFound
}
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteSize: int64(len(body))}, nil
etag := r.statETag
if e, ok := r.statETags[loc.Path]; ok {
etag = e
}
return &filer_pb.RemoteEntry{StorageName: loc.Name, RemoteSize: int64(len(body)), RemoteETag: etag}, nil
}
func (r *recordingRemote) ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) ([]byte, error) {
@@ -1008,7 +1021,7 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
t.Run("absent object", func(t *testing.T) {
remote := &recordingRemote{deleteErr: remote_storage.ErrRemoteObjectNotFound}
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err != nil {
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err != nil {
t.Fatalf("err = %v, want nil: deleting an absent object is complete", err)
}
if len(remote.deletes) != 1 || !proto.Equal(remote.deletes[0], wantDelete) {
@@ -1020,7 +1033,7 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
marked := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
marked.EventNotification.OldEntry.Extended = map[string][]byte{filer.ExtKeepRemoteObjectKey: []byte("true")}
remote := &recordingRemote{}
if err := processDeleteEvent(remote, mountedDir, mountLoc, marked); err != nil {
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, marked); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 0 {
@@ -1028,9 +1041,108 @@ func TestDeleteEventAbsentRemoteObjectIsSuccess(t *testing.T) {
}
})
t.Run("a remote-only entry that took the path keeps the object", func(t *testing.T) {
// deleted, then a remote-only file renamed onto the path: the rename
// copied its content to this key before the sync got to the delete
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: &filer_pb.Entry{
Name: "state.db.tmp",
Attributes: &filer_pb.FuseAttributes{FileSize: 10},
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 10},
}}
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, resp); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want none", remote.deletes)
}
})
t.Run("a replaced object is kept", func(t *testing.T) {
// the delete recorded the old object's etag; a rename copied another
// entry's content onto the key since, and it must not go with it
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 10}
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
filerClient := &stubFilerClient{entry: &filer_pb.Entry{Name: "state.db.tmp", Content: []byte("copied")}}
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, deleted); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want none", remote.deletes)
}
})
t.Run("a changed object with no entry at the path yet is kept", func(t *testing.T) {
// a directory rename copies a child onto the key before its new entry
// becomes visible; the copy may be the content's only remaining home
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 10}
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want none", remote.deletes)
}
})
t.Run("an unchanged object still goes", func(t *testing.T) {
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 3}
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("old")}, statETag: `"orig"`}
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 1 {
t.Errorf("deletes = %+v, want one", remote.deletes)
}
})
t.Run("a copy that landed before its entry is kept", func(t *testing.T) {
// the deleted entry was never replicated, so its event records no
// stamp to match the fresh rename copy waiting for its filer entry
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("copied")}, statETag: `"copy"`}
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want none", remote.deletes)
}
})
t.Run("an unverifiable object is kept", func(t *testing.T) {
// the delete recorded an etag the live stat does not report; the
// object cannot be confirmed as the one the delete meant
deleted := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
deleted.EventNotification.OldEntry.RemoteEntry = &filer_pb.RemoteEntry{RemoteETag: `"orig"`, RemoteSize: 3}
remote := &recordingRemote{objects: map[string][]byte{"/b/vol/state.db.tmp": []byte("old")}}
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want none", remote.deletes)
}
})
t.Run("an entry with local data at the path does not stop the delete", func(t *testing.T) {
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: &filer_pb.Entry{
Name: "state.db.tmp",
Content: []byte("local"),
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 10},
}}
if err := processDeleteEvent(filerClient, remote, mountedDir, mountLoc, resp); err != nil {
t.Fatalf("err = %v", err)
}
if len(remote.deletes) != 1 {
t.Errorf("deletes = %+v, want one", remote.deletes)
}
})
t.Run("other failure still fails", func(t *testing.T) {
remote := &recordingRemote{deleteErr: errors.New("AccessDenied: Access Denied")}
if err := processDeleteEvent(remote, mountedDir, mountLoc, resp); err == nil {
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, resp); err == nil {
t.Fatal("err = nil, want the delete failure")
}
})
@@ -1281,7 +1393,7 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 1024})
remote := &recordingRemote{}
filerClient := &stubFilerClient{entry: current}
err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, "")
err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", renameSource{loc: wantDelete}, wantWrite, "", eventSource)
if err == nil || !strings.Contains(err.Error(), "neither") {
t.Errorf("err = %v, want the lost-content failure", err)
}
@@ -1292,9 +1404,9 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
t.Run("uncached with the destination already synced: complete without a write", func(t *testing.T) {
current := entryWith("b.txt", &filer_pb.RemoteEntry{StorageName: "gcs", RemoteMtime: 1786096669, RemoteSize: 7})
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/b.txt": []byte("payload")}}
remote := &recordingRemote{objects: map[string][]byte{"/b/dir/a.txt": []byte("payload"), "/b/dir/b.txt": []byte("payload")}}
filerClient := &stubFilerClient{entry: current}
if err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", wantDelete, wantWrite, ""); err != nil {
if err := uploadCurrentEntry(filerClient, filerClient, remote, "/buckets/b/dir", "b.txt", renameSource{loc: wantDelete}, wantWrite, "", eventSource); err != nil {
t.Fatalf("err = %v, want nil: the destination object matches the entry's stamp", err)
}
if len(remote.writes) != 0 {
@@ -1306,6 +1418,228 @@ func TestSupersededRenameUploadsCurrentEntry(t *testing.T) {
})
}
func TestCompleteRemoteOnlyRenameClearsThePendingSourceFirst(t *testing.T) {
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/src/a.jpg"}
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/dst/a.jpg"}
pending := func() *filer_pb.Entry {
return &filer_pb.Entry{
Name: "a.jpg",
Attributes: &filer_pb.FuseAttributes{FileSize: 4},
RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"copy"`, RemoteSize: 4},
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/src/a.jpg")},
}
}
objects := func() map[string][]byte {
return map[string][]byte{"/b/src/a.jpg": []byte("data"), "/b/dst/a.jpg": []byte("data")}
}
t.Run("stamped, then the source goes", func(t *testing.T) {
// the destination already holds the copy; the entry still reads the
// source until its stamp clears the marker
current := pending()
filerClient := &stubFilerClient{entry: current}
remote := &recordingRemote{objects: objects(), statETag: `"copy"`}
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b/dst", current, renameSource{path: "/buckets/b/src/a.jpg", loc: oldDest}, dest, ""); err != nil {
t.Fatalf("err = %v", err)
}
if filerClient.updates != 1 {
t.Errorf("updates = %d, want the stamp that clears the marker", filerClient.updates)
}
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/src/a.jpg" {
t.Errorf("deletes = %+v, want the source", remote.deletes)
}
})
t.Run("a marker still in place keeps the source", func(t *testing.T) {
// the stamp did not land: the filer still has the marker
filerClient := &stubFilerClient{entry: pending()}
remote := &recordingRemote{objects: objects(), statETag: `"copy"`}
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b/dst", pending(), renameSource{path: "/buckets/b/src/a.jpg", loc: oldDest}, dest, ""); err == nil {
t.Fatal("err = nil, want the event retried")
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want none while the entry reads the source", remote.deletes)
}
})
}
// eventSource resolves a rename's source to the event's old key.
func eventSource(_ *filer_pb.Entry, source renameSource) renameSource {
return source
}
func TestRenameOfAPendingEntryFollowsItsSource(t *testing.T) {
// A was renamed over B and B over C, both copies failing: C reads A's
// object through the marker, and B's key holds the old target's bytes
const mountedDir = "/buckets"
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
remoteOnly := &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}
pendingC := func() *filer_pb.Entry {
return &filer_pb.Entry{
Name: "c.jpg",
Attributes: &filer_pb.FuseAttributes{FileSize: 6},
RemoteEntry: remoteOnly,
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")},
}
}
event := func(oldName, newName string) *filer_pb.SubscribeMetadataResponse {
newEntry := pendingC()
newEntry.Name = newName
return &filer_pb.SubscribeMetadataResponse{
Directory: "/buckets/b",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: oldName, RemoteEntry: remoteOnly},
NewParentPath: "/buckets/b",
NewEntry: newEntry,
},
}
}
objects := func() map[string][]byte {
return map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("old-c")}
}
t.Run("the first rename leaves the source to the entry that moved on", func(t *testing.T) {
remote := &recordingRemote{objects: objects()}
filerClient := &stubFilerClient{} // nothing at b.jpg any more
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event("a.jpg", "b.jpg")); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 0 || len(remote.writes) != 0 {
t.Errorf("deletes = %+v, writes = %+v, want neither: c.jpg still reads a.jpg", remote.deletes, remote.writes)
}
})
t.Run("the second rename copies from the marker's source", func(t *testing.T) {
current := pendingC()
remote := &recordingRemote{objects: objects(), statETag: `"a"`}
filerClient := &stubFilerClient{entry: current}
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event("b.jpg", "c.jpg")); err != nil {
t.Fatal(err)
}
if len(remote.written) != 1 || string(remote.written[0]) != "data-a" {
t.Errorf("written = %q, want a.jpg's content", remote.written)
}
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/a.jpg" {
t.Errorf("deletes = %+v, want the source once the stamp cleared the marker", remote.deletes)
}
})
}
func TestPendingRenameSourceIsReleasedOnlyWhileUnchanged(t *testing.T) {
const mountedDir = "/buckets"
mountLoc := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/"}
// b.jpg's copy failed after it replaced a target: it read a.jpg's object
pendingB := func() *filer_pb.Entry {
return &filer_pb.Entry{
Name: "b.jpg",
RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6},
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")},
}
}
deleted := func() *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{OldEntry: pendingB()}}
}
deletedPaths := func(r *recordingRemote) (paths []string) {
for _, d := range r.deletes {
paths = append(paths, d.Path)
}
return
}
t.Run("deleting the pending entry releases its source", func(t *testing.T) {
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a")}, statETag: `"a"`}
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted()); err != nil {
t.Fatal(err)
}
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
t.Errorf("deletes = %v, want the source", got)
}
})
t.Run("a source replaced since is kept", func(t *testing.T) {
// another rename copied onto a.jpg before its entry became visible
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-d")}, statETag: `"d"`}
if err := processDeleteEvent(&stubFilerClient{}, remote, mountedDir, mountLoc, deleted()); err != nil {
t.Fatal(err)
}
if got := deletedPaths(remote); slices.Contains(got, "/b/a.jpg") {
t.Errorf("deletes = %v, want the replaced source kept", got)
}
})
t.Run("a destination cached since releases the source too", func(t *testing.T) {
// c.jpg was cached before the sync reached the rename: it is no
// longer remote-only, and a.jpg is still left over
cached := &filer_pb.Entry{Name: "c.jpg", Content: []byte("data-a"), Attributes: &filer_pb.FuseAttributes{FileSize: 6}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}}
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("data-a")}, statETag: `"a"`}
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: cached,
}}
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": cached}}
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event); err != nil {
t.Fatal(err)
}
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
t.Errorf("deletes = %v, want the pending source released", got)
}
})
t.Run("a cached entry still reading the source keeps it until its upload lands", func(t *testing.T) {
// c.jpg was cached through its marker; until the upload is stamped
// and the marker cleared, a.jpg is what remote.uncache would fall
// back to
marked := func() *filer_pb.Entry {
return &filer_pb.Entry{Name: "c.jpg", Content: []byte("data-a"), Attributes: &filer_pb.FuseAttributes{FileSize: 6},
RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6},
Extended: map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/a.jpg")}}
}
for name, writeErr := range map[string]error{"upload fails": errors.New("upload failed"), "stamp does not land": nil} {
t.Run(name, func(t *testing.T) {
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b")}, statETag: `"a"`, writeErr: writeErr}
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: marked(),
}}
// the filer keeps the marker: the stub does not store updates
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": marked()}}
_ = processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event)
if got := deletedPaths(remote); slices.Contains(got, "/b/a.jpg") {
t.Errorf("deletes = %v, want the source kept while the entry reads it", got)
}
})
}
})
t.Run("a later rename the filer copied releases the source", func(t *testing.T) {
// b.jpg was renamed to c.jpg and the filer copied a.jpg there at once
copied := &filer_pb.Entry{Name: "c.jpg", Attributes: &filer_pb.FuseAttributes{FileSize: 6}, RemoteEntry: &filer_pb.RemoteEntry{StorageName: "b2", RemoteETag: `"a"`, RemoteSize: 6}}
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("old-b"), "/b/c.jpg": []byte("data-a")}, statETag: `"a"`}
event := &filer_pb.SubscribeMetadataResponse{Directory: "/buckets/b", EventNotification: &filer_pb.EventNotification{
OldEntry: pendingB(), NewParentPath: "/buckets/b", NewEntry: copied,
}}
filerClient := &pathFilerClient{entries: map[string]*filer_pb.Entry{"/buckets/b/c.jpg": copied}}
if err := processUpdateEvent(filerClient, filerClient, "", remote, mountedDir, mountLoc, event); err != nil {
t.Fatal(err)
}
if got := deletedPaths(remote); !slices.Contains(got, "/b/a.jpg") {
t.Errorf("deletes = %v, want the pending source released", got)
}
})
}
// pathFilerClient serves entries by path; a path it lacks is not found.
type pathFilerClient struct {
stubFilerClient
entries map[string]*filer_pb.Entry
}
func (c *pathFilerClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
return &filer_pb.LookupDirectoryEntryResponse{Entry: c.entries[req.Directory+"/"+req.Name]}, nil
}
func (c *pathFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return fn(c)
}
func TestShouldSendToRemote(t *testing.T) {
const mtime = 1786096669
@@ -1383,3 +1717,37 @@ func TestMetadataOnlyUpdateRestampsSyncTs(t *testing.T) {
t.Error("the restamped entry should read synced")
}
}
func TestCompleteRemoteOnlyRenameKeepsAReusedOldKey(t *testing.T) {
// A was renamed to B, then D to A before the sync got to the first
// rename: the object at A is now D's, and the only copy of it
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/a.jpg"}
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/b.jpg"}
current := &filer_pb.Entry{Name: "b.jpg", RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"a"`, RemoteSize: 6}}
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-d"), "/b/b.jpg": []byte("data-a")}, statETag: `"a"`}
remote.statETags = map[string]string{"/b/a.jpg": `"d"`}
filerClient := &stubFilerClient{entry: current}
if err := completeRemoteOnlyRename(filerClient, remote, "/buckets/b", current, renameSource{path: "/buckets/b/a.jpg", loc: oldDest}, dest, ""); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 0 {
t.Errorf("deletes = %+v, want the reused key kept", remote.deletes)
}
}
func TestCompleteRemoteOnlyRenameRetryUsesTheSourceStamp(t *testing.T) {
// an earlier run copied a multipart source and stamped the entry with
// the copy's ETag, then failed to delete the source
oldDest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/a.jpg"}
dest := &remote_pb.RemoteStorageLocation{Name: "b2", Bucket: "bucket", Path: "/b/b.jpg"}
current := &filer_pb.Entry{Name: "b.jpg", RemoteEntry: &filer_pb.RemoteEntry{RemoteETag: `"copy"`, RemoteSize: 6}}
remote := &recordingRemote{objects: map[string][]byte{"/b/a.jpg": []byte("data-a"), "/b/b.jpg": []byte("data-a")},
statETags: map[string]string{"/b/a.jpg": `"a-2"`, "/b/b.jpg": `"copy"`}}
source := renameSource{path: "/buckets/b/a.jpg", loc: oldDest, stamp: &filer_pb.RemoteEntry{RemoteETag: `"a-2"`, RemoteSize: 6}}
if err := completeRemoteOnlyRename(&stubFilerClient{entry: current}, remote, "/buckets/b", current, source, dest, ""); err != nil {
t.Fatal(err)
}
if len(remote.deletes) != 1 || remote.deletes[0].Path != "/b/a.jpg" {
t.Errorf("deletes = %+v, want the unchanged source deleted on the retry", remote.deletes)
}
}
+8 -2
View File
@@ -82,7 +82,7 @@ func runPluginWorkerWithOptions(options pluginWorkerRunOptions) bool {
return false
}
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption, options.MaxExecute, options.WorkingDir)
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption, options.MaxExecute, options.WorkingDir, options.Address)
if err != nil {
glog.Errorf("Failed to build plugin worker handlers: %v", err)
return false
@@ -141,7 +141,7 @@ func resolvePluginWorkerID(explicitID string, workingDir string) (string, error)
// buildPluginWorkerHandlers resolves the comma-separated jobTypes string
// (which may contain category names like "all", "default", "heavy" and/or
// explicit job type names/aliases) into a deduplicated slice of JobHandlers.
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string) ([]pluginworker.JobHandler, error) {
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string, workerAddress ...string) ([]pluginworker.JobHandler, error) {
jobTypes = strings.TrimSpace(jobTypes)
if jobTypes == "" {
jobTypes = defaultPluginWorkerJobTypes
@@ -152,10 +152,16 @@ func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxE
return nil, err
}
addr := ""
if len(workerAddress) > 0 {
addr = strings.TrimSpace(workerAddress[0])
}
opts := pluginworker.HandlerBuildOptions{
GrpcDialOption: dialOption,
MaxExecute: maxExecute,
WorkingDir: workingDir,
WorkerAddress: addr,
}
handlers := make([]pluginworker.JobHandler, 0, len(factories))
+3 -3
View File
@@ -458,13 +458,13 @@ func retriedStreamFetchChunkData(ctx context.Context, writer io.Writer, urlStrin
localProcessed += writtenCount
totalWritten += writtenCount
})
if !shouldRetry {
break
}
if writeErr != nil {
err = writeErr
break
}
if !shouldRetry {
break
}
if err != nil {
failed = true
glog.V(0).InfofCtx(ctx, "read %s failed, err: %v", urlString, err)
+20
View File
@@ -787,3 +787,23 @@ func TestFetchWholeChunkUsesProvidedFilerJwt(t *testing.T) {
return ""
}))
}
// A completed fetch whose destination write failed is a failed copy, not a
// short success: the caller must see the write error, or a temp disk filling
// mid-copy would still upload the truncated file.
func TestRetriedStreamFetchChunkDataReportsWriteError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("0123456789"))
}))
t.Cleanup(srv.Close)
written, err := retriedStreamFetchChunkData(context.Background(), &failOnWrite{}, []string{srv.URL + "/5,abc"}, "", nil, false, true, 0, 0, nil)
assert.Error(t, err)
assert.Zero(t, written)
}
type failOnWrite struct{}
func (w *failOnWrite) Write(p []byte) (int, error) {
return 0, fmt.Errorf("no space left")
}
+18
View File
@@ -82,6 +82,11 @@ type Filer struct {
persistedLogCache *persistedLogCache
metaLogInflight metaLogInflight
remoteTombstones *remoteDeletionTombstones
// EntryLockTable serializes mutations per entry path. The filer server
// holds it across local read-modify-write mutations and the meta
// aggregator across a replayed peer change's check-and-apply, so the two
// cannot interleave on the same path.
EntryLockTable *util.LockTable[util.FullPath]
// remoteTombstonesDone, when non-nil, is closed once the startup tombstone
// rebuild finishes; lazy remote reads wait on it so a pending delete
// cannot resurrect in the gap.
@@ -110,6 +115,12 @@ type Filer struct {
// overwriting the unread ledger with a partial set.
deletionLedgerBlocked atomic.Bool
deletionLedgerFlush chan struct{}
// metaTsPending holds per-path commit records whose KV write failed,
// keyed by path; ordering reads consult it until the retry lands them
// (see meta_path_ts.go). metaTsRepairing marks paths with a live
// repair goroutine so failures neither strand a record nor pile on.
metaTsPending sync.Map
metaTsRepairing sync.Map
// flushCtx is the context metadata-log flushes append under. It stays
// live for the process lifetime and is cancelled once during Shutdown,
// bounding every retry - in-flight and queued alike - to one deadline.
@@ -133,6 +144,7 @@ func NewFiler(masters pb.ServerDiscovery, grpcDialOption grpc.DialOption, filerH
DeletionRetryQueue: NewDeletionRetryQueue(),
persistedLogCache: newPersistedLogCache(persistedLogCacheMaxBytes),
remoteTombstones: newRemoteDeletionTombstones(),
EntryLockTable: util.NewLockTable[util.FullPath](),
}
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
if f.UniqueFilerId < 0 {
@@ -913,6 +925,12 @@ func (f *Filer) Shutdown() {
f.LocalMetaLogBuffer.ShutdownLogBuffer()
// The final metadata-log flush still needs the store to append its entry.
f.LocalMetaLogBuffer.WaitForShutdown()
// Stop peer subscriptions and let each persist its last applied offset;
// the replay on the next start otherwise resumes from a stale offset and
// applies those old events over newer local state.
if f.MetaAggregator != nil {
f.MetaAggregator.Shutdown()
}
// Persist the deletion ledger one last time before the store closes, so a
// clean shutdown leaves the recovery set exactly consistent with reality.
f.snapshotDeletionLedger()
+94
View File
@@ -225,3 +225,97 @@ func (f *Filer) maybeDeleteFromRemote(ctx context.Context, entry *Entry) (bool,
glog.V(3).InfofCtx(ctx, "maybeDeleteFromRemote: deleted %s from remote", entry.FullPath)
return true, nil
}
// DeleteFromRemote deletes the object, or for a directory every object, the
// entry maps to on its mounted remote storage.
func (f *Filer) DeleteFromRemote(ctx context.Context, entry *Entry) error {
_, err := f.maybeDeleteFromRemote(ctx, entry)
return err
}
// ExtRemoteRenameSourceKey marks a remote-only entry whose rename committed
// while its copy to the new key was still pending: it holds the path whose
// object remote-only reads must serve until the copy stamps the entry.
const ExtRemoteRenameSourceKey = "Seaweed-X-Remote-Rename-Source"
// RemoteSourcePath returns the path whose remote object holds the content of
// the remote-only entry at p: the renamed-from path a pending deferred copy
// still reads, or p itself. A marker leading out of p's mount is ignored, so
// whoever can set extended attributes cannot point reads at another mount.
func (f *Filer) RemoteSourcePath(entry *Entry, p util.FullPath) util.FullPath {
src := util.FullPath(entry.Extended[ExtRemoteRenameSourceKey])
if src == "" || f.RemoteStorage == nil {
return p
}
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(p)
if remoteLoc == nil {
return p
}
if srcMountDir, _ := f.RemoteStorage.FindMountDirectory(src); srcMountDir != mountDir {
return p
}
return src
}
// RemoteReadPath returns the dir and name whose object a remote-only read of
// the entry at dir/name serves.
func (f *Filer) RemoteReadPath(entry *Entry, dir, name string) (string, string) {
return f.RemoteSourcePath(entry, util.NewFullPath(dir, name)).DirAndName()
}
// CopyRemoteOnlyEntry copies the remote object of a renamed entry whose
// content exists only on the remote to the key of newPath, and returns the
// RemoteEntry of the copy. The rename deletes the old key along with the old
// entry, and with no local data nothing would write the new key. It returns
// nil when no copy is needed: the entry has local data, which the remote sync
// uploads to the new key, it is empty, which the sync writes as an empty
// object, or it is not under a remote mount. A rename that
// moves such an entry out of its mount, or onto a remote that cannot copy, is
// refused, since it would delete the only copy of the content.
func (f *Filer) CopyRemoteOnlyEntry(ctx context.Context, entry *Entry, oldPath, newPath util.FullPath) (*filer_pb.RemoteEntry, error) {
src, dst, copier, err := f.RemoteCopyLocations(ctx, entry, oldPath, newPath)
if err != nil || copier == nil {
return nil, err
}
remoteEntry, err := copier.CopyFile(src, dst)
if err != nil {
return nil, fmt.Errorf("copy remote %s to %s: %w", remote_storage.FormatLocation(src), remote_storage.FormatLocation(dst), err)
}
glog.V(3).InfofCtx(ctx, "CopyRemoteOnlyEntry: copied %s to %s", remote_storage.FormatLocation(src), remote_storage.FormatLocation(dst))
return remoteEntry, nil
}
// RemoteCopyLocations resolves the object locations a remote-only rename copy
// of entry between oldPath and newPath would use, and the client able to copy
// it. A nil copier means the entry needs no copy; an error means it needs one
// that cannot be served, which a rename must refuse before committing.
func (f *Filer) RemoteCopyLocations(ctx context.Context, entry *Entry, oldPath, newPath util.FullPath) (src, dst *remote_pb.RemoteStorageLocation, copier remote_storage.RemoteStorageObjectCopier, err error) {
if f.RemoteStorage == nil || entry.IsDirectory() || !entry.IsInRemoteOnly() || len(entry.Content) > 0 {
return nil, nil, nil, nil
}
mountDir, remoteLoc := f.RemoteStorage.FindMountDirectory(oldPath)
if remoteLoc == nil {
return nil, nil, nil, nil
}
if newMountDir, _ := f.RemoteStorage.FindMountDirectory(newPath); newMountDir != mountDir {
return nil, nil, nil, fmt.Errorf("%s exists only on the remote of mount %s and cannot be renamed out of it; cache it first", oldPath, mountDir)
}
remoteConf, found := f.RemoteStorage.GetRemoteStorageConf(remoteLoc.Name)
if !found {
return nil, nil, nil, fmt.Errorf("resolve remote storage client for %s: not found", oldPath)
}
client, clientErr := f.buildRemoteStorageClient(ctx, remoteConf)
if clientErr != nil {
return nil, nil, nil, fmt.Errorf("resolve remote storage client for %s: %w", oldPath, clientErr)
}
copier, ok := client.(remote_storage.RemoteStorageObjectCopier)
if !ok {
return nil, nil, nil, fmt.Errorf("%s exists only on the remote and remote storage type %s cannot copy it to the new name; cache it first", oldPath, remoteConf.Type)
}
src = MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, f.RemoteSourcePath(entry, oldPath))
dst = MapFullPathToRemoteStorageLocation(mountDir, remoteLoc, newPath)
return src, dst, copier, nil
}
+10
View File
@@ -35,6 +35,7 @@ type stubFilerStore struct {
kv map[string][]byte
insertErr error
findErr error
kvPutErr error
kvGetErr error
kvDeleteErr error
kvGetHook func(key []byte)
@@ -49,6 +50,12 @@ func newStubFilerStore() *stubFilerStore {
}
}
func (s *stubFilerStore) setKvPutErr(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.kvPutErr = err
}
func (s *stubFilerStore) GetName() string { return "stub" }
func (s *stubFilerStore) Initialize(util.Configuration, string) error { return nil }
func (s *stubFilerStore) Shutdown() {}
@@ -60,6 +67,9 @@ func (s *stubFilerStore) RollbackTransaction(context.Context) error { return nil
func (s *stubFilerStore) KvPut(_ context.Context, key []byte, value []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.kvPutErr != nil {
return s.kvPutErr
}
s.kv[string(key)] = append([]byte(nil), value...)
return nil
}
+4
View File
@@ -71,6 +71,10 @@ func (f *Filer) notifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry
}
f.logMetaEvent(ctx, event)
// Record the commit timestamp so a peer replay ordered by the entry lock
// this mutation may still be under sees the local change, including as a
// tombstone after the entry itself is gone.
f.noteCommittedEvent(event)
if sink := metadataEventSinkFromContext(ctx); sink != nil {
sink.Record(event)
}
+196 -54
View File
@@ -63,6 +63,11 @@ type MetaAggregator struct {
flushAdvanced chan struct{}
remotePeerArrived chan struct{}
peerWatermarksLock sync.Mutex
peerWg sync.WaitGroup
// stopped is set under peerChansLock by Shutdown; OnPeerUpdate then
// rejects adds so no subscription can outlive the shutdown wait and run
// against a store Filer.Shutdown has already closed.
stopped bool
}
// MetaAggregator only aggregates data "on the fly". The logs are not re-persisted to disk.
@@ -87,6 +92,13 @@ func (ma *MetaAggregator) OnPeerUpdate(update *master_pb.ClusterNodeUpdate, star
ma.peerChansLock.Lock()
defer ma.peerChansLock.Unlock()
// After Shutdown the master connection may still deliver an update; a
// subscription started now would have no stop signal and could run past
// the store close.
if ma.stopped {
return
}
address := pb.ServerAddress(update.Address)
if update.IsAdd {
// the peer is already followed, restarting would only lose the events
@@ -102,7 +114,11 @@ func (ma *MetaAggregator) OnPeerUpdate(update *master_pb.ClusterNodeUpdate, star
// Account for the peer before its stream signals; keep prior values
// on reconnect.
ma.initPeerWatermark(address)
go ma.loopSubscribeToOneFiler(ma.filer, ma.self, address, startFrom, stopChan)
ma.peerWg.Add(1)
go func() {
defer ma.peerWg.Done()
ma.loopSubscribeToOneFiler(ma.filer, ma.self, address, startFrom, stopChan)
}()
} else {
if prevChan, found := ma.peerChans[address]; found {
close(prevChan)
@@ -344,6 +360,42 @@ func (ma *MetaAggregator) HasPeer(address pb.ServerAddress) bool {
return false
}
// Shutdown stops every peer subscription and waits for each to return,
// letting the deferred offset save persist the last applied position before
// the store closes. Without it a graceful restart resumes from the last
// periodically-saved offset — up to a minute stale — and replays those
// events over newer local state.
func (ma *MetaAggregator) Shutdown() {
ma.peerChansLock.Lock()
ma.stopped = true
chans := ma.peerChans
ma.peerChans = make(map[pb.ServerAddress]chan struct{})
for _, stopChan := range chans {
close(stopChan)
}
ma.peerChansLock.Unlock()
// Wait without a cap: every blocking point in the subscribe loop is
// stop-aware, so a peer still running here is genuinely wedged and
// returning anyway would let Filer.Shutdown close the store under it and
// skip its deferred offset save.
done := make(chan struct{})
go func() {
ma.peerWg.Wait()
close(done)
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
glog.Warningf("meta aggregator shutdown: peer subscriptions still stopping")
}
}
}
func (ma *MetaAggregator) loopSubscribeToOneFiler(f *Filer, self pb.ServerAddress, peer pb.ServerAddress, startFrom time.Time, stopChan chan struct{}) {
lastTsNs := startFrom.UnixNano()
for {
@@ -368,7 +420,12 @@ func (ma *MetaAggregator) loopSubscribeToOneFiler(f *Filer, self pb.ServerAddres
if lastTsNs < nextLastTsNs {
lastTsNs = nextLastTsNs
}
time.Sleep(1733 * time.Millisecond)
select {
case <-stopChan:
glog.V(0).Infof("stop subscribing peer %s meta change", peer)
return
case <-time.After(1733 * time.Millisecond):
}
}
}
@@ -384,11 +441,34 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
*/
var maybeReplicateMetadataChange func(*filer_pb.SubscribeMetadataResponse)
var maybeReplicateMetadataChange func(*filer_pb.SubscribeMetadataResponse) error
lastPersistTime := time.Now()
lastTsNs := startFrom
// replayFloorTsNs is the earliest event this session gave up replaying.
// The persisted offset and the reconnect resume point must stay behind
// it so the event is redelivered on the next subscribe instead of being
// skipped forever.
var replayFloorTsNs int64
persistableTsNs := func(tsNs int64) int64 {
if replayFloorTsNs > 0 && replayFloorTsNs-1 < tsNs {
return replayFloorTsNs - 1
}
return tsNs
}
peerSignature, err := ma.readFilerStoreSignature(peer)
// Everything below derives from ctx, cancelled when the peer is removed
// or the aggregator shuts down, so no stage can block past its stop.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-stopChan:
cancel()
case <-ctx.Done():
}
}()
peerSignature, err := ma.readFilerStoreSignature(ctx, peer)
if err != nil {
return lastTsNs, fmt.Errorf("connecting to peer filer %s: %v", peer, err)
}
@@ -408,7 +488,7 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
// different concept and can be set to arbitrary values.
preTraverseTime := time.Now()
glog.V(0).Infof("no previous offset for peer %s, starting full metadata sync", peer)
if traverseErr := ma.traversePeerMetadata(f, peer); traverseErr != nil {
if traverseErr := ma.traversePeerMetadata(ctx, f, peer); traverseErr != nil {
return lastTsNs, fmt.Errorf("initial metadata sync from %s: %v", peer, traverseErr)
}
lastTsNs = preTraverseTime.Add(-time.Minute).UnixNano()
@@ -420,11 +500,12 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
return lastTsNs, fmt.Errorf("read offset for peer %s: %w", peer, err)
}
defer func(prevTsNs int64) {
if lastTsNs != prevTsNs && lastTsNs != lastPersistTime.UnixNano() {
if err := ma.updateOffset(f, peer, peerSignature, lastTsNs); err == nil {
glog.V(0).Infof("last sync time with %s at %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
saveTsNs := persistableTsNs(lastTsNs)
if saveTsNs != prevTsNs && lastTsNs != lastPersistTime.UnixNano() {
if err := ma.updateOffset(f, peer, peerSignature, saveTsNs); err == nil {
glog.V(0).Infof("last sync time with %s at %v (%d)", peer, time.Unix(0, saveTsNs), saveTsNs)
} else {
glog.Errorf("failed to save last sync time with %s at %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
glog.Errorf("failed to save last sync time with %s at %v (%d)", peer, time.Unix(0, saveTsNs), saveTsNs)
}
}
}(lastTsNs)
@@ -432,11 +513,23 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
glog.V(0).Infof("follow peer: %v, last %v (%d)", peer, time.Unix(0, lastTsNs), lastTsNs)
var counter int64
var synced bool
maybeReplicateMetadataChange = func(event *filer_pb.SubscribeMetadataResponse) {
replicateMetadataChange(f.Store, peer, event)
maybeReplicateMetadataChange = func(event *filer_pb.SubscribeMetadataResponse) error {
if err := replicateMetadataChange(f, peer, event, stopChan); err != nil {
if errors.Is(err, errPeerSubscriptionStopped) {
return err
}
// A failed event becomes a barrier the persisted offset and
// the reconnect resume point cannot pass, while the stream
// keeps flowing: blocking on an event that can never replay
// would stall every later event from this peer.
if replayFloorTsNs == 0 || event.TsNs < replayFloorTsNs {
replayFloorTsNs = event.TsNs
}
return nil
}
counter++
if lastPersistTime.Add(time.Minute).Before(time.Now()) {
if err := ma.updateOffset(f, peer, peerSignature, event.TsNs); err == nil {
if err := ma.updateOffset(f, peer, peerSignature, persistableTsNs(event.TsNs)); err == nil {
if event.TsNs < time.Now().Add(-2*time.Minute).UnixNano() {
glog.V(0).Infof("sync with %s progressed to: %v %0.2f/sec", peer, time.Unix(0, event.TsNs), float64(counter)/60.0)
} else if !synced {
@@ -449,6 +542,7 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
glog.V(0).Infof("failed to update offset for %v: %v", peer, err)
}
}
return nil
}
}
@@ -465,7 +559,9 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
return err
}
if maybeReplicateMetadataChange != nil {
maybeReplicateMetadataChange(event)
if err := maybeReplicateMetadataChange(event); err != nil {
return err
}
}
return nil
}
@@ -476,17 +572,8 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
glog.V(0).Infof("subscribing remote %s meta change: %v, clientId:%d", peer, time.Unix(0, lastTsNs), ma.filer.UniqueFilerId)
err = pb.WithFilerClient(true, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Stop the stream promptly on removal; the blocking Recv below would
// otherwise only notice when the stream errors on its own.
go func() {
select {
case <-stopChan:
cancel()
case <-ctx.Done():
}
}()
// ctx is cancelled on stopChan by the watch above; the blocking Recv
// below stops promptly on removal or shutdown.
atomic.AddInt32(&ma.filer.UniqueFilerEpoch, 1)
// Construct a log file reader that reads chunks via the peer filer's LookupVolume.
lookupFn := LookupFn(filerClient{client})
@@ -511,6 +598,14 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
}
processOne := func(event *filer_pb.SubscribeMetadataResponse) error {
// Bail promptly on stop; a cancelled stream may still deliver
// queued events while unwinding, and replaying them now only
// delays the offset save the shutdown is waiting on.
select {
case <-stopChan:
return errPeerSubscriptionStopped
default:
}
if err := processEventFn(event); err != nil {
glog.V(0).Infof("SubscribeLocalMetadata process %v: %v", event, err)
return fmt.Errorf("process %v: %w", event, err)
@@ -587,19 +682,37 @@ func (ma *MetaAggregator) doSubscribeToOneFiler(f *Filer, self pb.ServerAddress,
}
}
})
return lastTsNs, err
return persistableTsNs(lastTsNs), err
}
// replicateMetadataChange retries transient Replay failures with bounded
// backoff. A failure that outlives the retry budget is counted and logged,
// then skipped: blocking on an event that can never replay would stall every
// later event from this peer, which is worse than one entry staying stale.
func replicateMetadataChange(store FilerStore, peer pb.ServerAddress, event *filer_pb.SubscribeMetadataResponse) {
err := util.Retry("replicate metadata change from "+string(peer), func() error {
return Replay(store, event)
})
if err == nil {
return
// backoff and reports the outcome. errPeerSubscriptionStopped is returned
// when the subscription stops mid-retry so the caller aborts and the offset
// save keeps the last completed event. A failure that outlives the retry
// budget is counted and logged, then returned: the caller pins the peer's
// offset at the event so a restart replays it rather than skipping it.
func replicateMetadataChange(f *Filer, peer pb.ServerAddress, event *filer_pb.SubscribeMetadataResponse, stopChan <-chan struct{}) error {
waitTime := time.Second
var err error
for waitTime < util.RetryWaitTime {
select {
case <-stopChan:
return errPeerSubscriptionStopped
default:
}
if err = f.Replay(event); err == nil {
return nil
}
if !util.IsTransientError(err) {
break
}
glog.V(0).Infof("retry replicate metadata change from %s: %v", peer, err)
select {
case <-stopChan:
return errPeerSubscriptionStopped
case <-time.After(waitTime):
}
waitTime += waitTime / 2
}
stats.FilerMetaAggregatorReplayFailures.WithLabelValues(string(peer)).Inc()
name := event.GetEventNotification().GetNewEntry().GetName()
@@ -607,15 +720,20 @@ func replicateMetadataChange(store FilerStore, peer pb.ServerAddress, event *fil
name = event.GetEventNotification().GetOldEntry().GetName()
}
glog.Errorf("giving up replicating metadata change from %s for %s/%s (ts=%d): %v", peer, event.Directory, name, event.TsNs, err)
return err
}
// errPeerSubscriptionStopped ends event processing once the subscription's
// stop channel closed.
var errPeerSubscriptionStopped = errors.New("peer subscription stopped")
// traversePeerMetadata does a full BFS traversal of a peer filer's metadata
// and inserts all entries into the local store. This is used when a filer
// connects to a peer for the first time and needs to bootstrap pre-existing data.
func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress) error {
// The ctx cancels on the peer's stop channel so a shutdown does not have to
// out-wait a long traversal.
func (ma *MetaAggregator) traversePeerMetadata(ctx context.Context, f *Filer, peer pb.ServerAddress) error {
return pb.WithFilerClient(true, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := client.TraverseBfsMetadata(ctx, &filer_pb.TraverseBfsMetadataRequest{
Directory: "/",
ExcludedPrefixes: []string{SystemLogDir},
@@ -635,22 +753,17 @@ func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress)
if resp.Entry == nil {
continue
}
fullpath := util.Join(resp.Directory, resp.Entry.Name)
entry := FromPbEntry(resp.Directory, resp.Entry)
if insertErr := f.Store.InsertEntry(context.Background(), entry); insertErr != nil {
// Entry may already exist (root dir, or partial previous bootstrap).
existing, findErr := f.Store.FindEntry(context.Background(), entry.FullPath)
if findErr != nil {
return fmt.Errorf("insert entry %s: %w", fullpath, insertErr)
}
// Only overwrite if the peer's entry is newer.
if entry.Attr.Mtime.After(existing.Attr.Mtime) {
if updateErr := f.Store.UpdateEntry(context.Background(), entry); updateErr != nil {
return fmt.Errorf("update entry %s: %w", fullpath, updateErr)
}
} else {
glog.V(1).Infof("skip older peer entry %s (peer mtime %v <= local mtime %v)", fullpath, entry.Attr.Mtime, existing.Attr.Mtime)
}
// Order on the sender's per-path commit timestamp, not the
// entry's mtime — mtime is client-settable and does not track
// when the peer committed the change. Peers that predate the
// field send 0 and fall back to mtime.
peerTsNs := resp.EntryTsNs
if peerTsNs == 0 {
peerTsNs = entry.Attr.Mtime.UnixNano()
}
if applyErr := f.applyTraversedEntry(ctx, entry, peerTsNs); applyErr != nil {
return applyErr
}
count++
if count%10000 == 0 {
@@ -662,9 +775,38 @@ func (ma *MetaAggregator) traversePeerMetadata(f *Filer, peer pb.ServerAddress)
})
}
func (ma *MetaAggregator) readFilerStoreSignature(peer pb.ServerAddress) (sig int32, err error) {
// applyTraversedEntry merges one bootstrap entry under the path's replay
// locks, keeping local state when its commit is newer than the sender's —
// most importantly a delete, whose tombstone must not let the bootstrap
// resurrect the path. An applied entry records the sender's commit time so a
// later peer replay orders against it rather than the entry's mtime.
func (f *Filer) applyTraversedEntry(ctx context.Context, entry *Entry, peerTsNs int64) error {
unlock := f.lockReplayPaths(entry.FullPath, "")
defer unlock()
localTsNs, err := f.localCommitTsNs(ctx, entry.FullPath)
if err != nil {
return fmt.Errorf("check local state for %s: %w", entry.FullPath, err)
}
if localTsNs >= peerTsNs {
return nil
}
if insertErr := f.Store.InsertEntry(ctx, entry); insertErr != nil {
// Entry already exists (root dir, or partial previous bootstrap);
// the lock kept the check above current, so the peer commit is newer.
if _, findErr := f.Store.FindEntry(ctx, entry.FullPath); findErr != nil {
return fmt.Errorf("insert entry %s: %w", entry.FullPath, insertErr)
}
if updateErr := f.Store.UpdateEntry(ctx, entry); updateErr != nil {
return fmt.Errorf("update entry %s: %w", entry.FullPath, updateErr)
}
}
f.notePathChange(entry.FullPath, peerTsNs, false)
return nil
}
func (ma *MetaAggregator) readFilerStoreSignature(ctx context.Context, peer pb.ServerAddress) (sig int32, err error) {
err = pb.WithFilerClient(false, 0, peer, ma.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
resp, err := client.GetFilerConfiguration(ctx, &filer_pb.GetFilerConfigurationRequest{})
if err != nil {
return err
}
+601 -2
View File
@@ -15,6 +15,14 @@ import (
"github.com/seaweedfs/seaweedfs/weed/util"
)
// newReplayTestFiler builds the minimum Filer Replay needs over a stub store.
func newReplayTestFiler(store FilerStore) *Filer {
return &Filer{
Store: NewFilerStoreWrapper(store),
EntryLockTable: util.NewLockTable[util.FullPath](),
}
}
// flakyReplayStore fails InsertEntry for its first failUntil calls, then
// delegates to the embedded stubFilerStore.
type flakyReplayStore struct {
@@ -73,8 +81,11 @@ func TestReplicateMetadataChangeRetriesTransientFailure(t *testing.T) {
err: errors.New("i/o timeout talking to store"),
}
peer := pb.ServerAddress("peer-transient:1")
f := newReplayTestFiler(store)
replicateMetadataChange(store, peer, quotaChangeEvent("my-bucket", wantQuota))
if err := replicateMetadataChange(f, peer, quotaChangeEvent("my-bucket", wantQuota), nil); err != nil {
t.Fatalf("replicateMetadataChange: %v", err)
}
inserted, err := store.FindEntry(context.Background(), util.NewFullPath("/buckets", "my-bucket"))
if err != nil {
@@ -97,10 +108,14 @@ func TestReplicateMetadataChangeGivesUpLoudlyOnPermanentFailure(t *testing.T) {
err: errors.New("entry checksum mismatch"),
}
peer := pb.ServerAddress("peer-permanent:1")
f := newReplayTestFiler(store)
before := replayFailureCount(t, peer)
replicateMetadataChange(store, peer, quotaChangeEvent("poison-bucket", 65536<<20))
err := replicateMetadataChange(f, peer, quotaChangeEvent("poison-bucket", 65536<<20), nil)
if err == nil {
t.Fatal("expected the exhausted replay error so the caller can pin the peer offset at the failed event")
}
if got := store.attemptCount(); got != 1 {
t.Fatalf("attempts = %d, want exactly 1: a non-transient error must fail fast, not retry", got)
@@ -109,3 +124,587 @@ func TestReplicateMetadataChangeGivesUpLoudlyOnPermanentFailure(t *testing.T) {
t.Fatalf("FilerMetaAggregatorReplayFailures[%s] = %v, want %v", peer, got, before+1)
}
}
func overwriteEvent(dir, name string, tsNs int64, content string) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: dir,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: name},
NewEntry: &filer_pb.Entry{Name: name, Content: []byte(content)},
},
TsNs: tsNs,
}
}
func deleteEvent(dir, name string, tsNs int64) *filer_pb.SubscribeMetadataResponse {
return &filer_pb.SubscribeMetadataResponse{
Directory: dir,
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: name},
},
TsNs: tsNs,
}
}
func TestReplaySkipsEventOlderThanLocalEntry(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now},
Content: []byte("newer local write"),
}
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "old replayed version")); err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "newer local write" {
t.Fatalf("content = %q, want local write kept", entry.Content)
}
}
func TestReplayAppliesEventNewerThanLocalEntry(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Minute)},
Content: []byte("old local write"),
}
if err := f.Replay(overwriteEvent("/dir", "file", now.UnixNano(), "newer peer write")); err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "newer peer write" {
t.Fatalf("content = %q, want peer write applied", entry.Content)
}
}
func TestReplaySkipsDeleteOlderThanLocalEntry(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
if err := f.Replay(deleteEvent("/dir", "file", now.Add(-time.Minute).UnixNano())); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err != nil {
t.Fatalf("newer local entry must survive a stale delete: %v", err)
}
}
func TestReplayAppliesNewerDelete(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now.Add(-time.Minute)}}
if err := f.Replay(deleteEvent("/dir", "file", now.UnixNano())); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("newer delete must remove the local entry")
}
}
// A move event touches two paths; the local check applies to each side
// independently, so a newer destination is kept while the source is removed.
func TestReplayMoveChecksEachPathSeparately(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
oldPath := util.NewFullPath("/a", "file")
newPath := util.NewFullPath("/b", "file")
store.entries[string(oldPath)] = &Entry{FullPath: oldPath, Attr: Attr{Mtime: now.Add(-time.Minute)}}
store.entries[string(newPath)] = &Entry{FullPath: newPath, Attr: Attr{Mtime: now}, Content: []byte("kept")}
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/a",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "file"},
NewEntry: &filer_pb.Entry{Name: "file"},
NewParentPath: "/b",
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), oldPath); err == nil {
t.Fatal("old source entry should be deleted")
}
entry, err := store.FindEntry(context.Background(), newPath)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "kept" {
t.Fatalf("content = %q, want newer destination kept", entry.Content)
}
}
// A local delete leaves a recorded tombstone, so a replayed create older than
// the delete must not resurrect the path even though no entry remains to
// compare against.
func TestReplaySkipsCreateOlderThanLocalDelete(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
// The delete removed the entry; its commit record survives.
f.notePathChange(path, now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(createEvent); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("stale replayed create resurrected a locally deleted entry")
}
}
// A deleted directory covers its children: a bulk drop emits no per-child
// events, so the tombstone on the ancestor is what a replayed child create
// must check.
func TestReplaySkipsCreateUnderDeletedAncestor(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
f.notePathChange("/dir", now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "child", Content: []byte("stale")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(createEvent); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), "/dir/child"); err == nil {
t.Fatal("stale replayed create resurrected a child of a deleted directory")
}
}
// A recorded commit timestamp orders replay, not the entry's mtime: a rename
// keeps the source's mtime on the destination, and a mounted client can set
// mtime arbitrarily, so an entry whose mtime postdates the event must still
// be overwritten when its commit record is older.
func TestReplayAppliesWhenCommitRecordIsOlderThanFutureMtime(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/b", "x")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set or kept from a rename source
Content: []byte("old destination"),
}
// The last committed change predates the event.
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/a",
EventNotification: &filer_pb.EventNotification{
OldEntry: &filer_pb.Entry{Name: "x"},
NewEntry: &filer_pb.Entry{Name: "x", Content: []byte("renamed in")},
NewParentPath: "/b",
},
TsNs: now.UnixNano(),
}
if err := f.Replay(event); err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if string(entry.Content) != "renamed in" {
t.Fatalf("content = %q, want the rename destination applied", entry.Content)
}
}
// A store read failure is not a not-found: the replayed change must not be
// applied on the guess that no newer local entry exists.
func TestReplayPropagatesLookupError(t *testing.T) {
store := newStubFilerStore()
store.findErr = errors.New("store read failed")
f := newReplayTestFiler(store)
err := f.Replay(overwriteEvent("/dir", "file", time.Now().UnixNano(), "v"))
if err == nil {
t.Fatal("expected Replay to propagate the FindEntry failure")
}
}
// The check and the mutation share the per-path entry lock, so a local write
// holding that lock cannot be split by a replayed change.
func TestReplaySerializesWithEntryLock(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{FullPath: path, Attr: Attr{Mtime: now}}
// A local mutation holding the path lock; a replay started behind it
// sees the recorded commit and skips.
held := f.EntryLockTable.AcquireLock("test local write", path, util.ExclusiveLock)
f.notePathChange(path, now.UnixNano(), false)
done := make(chan error, 1)
go func() {
done <- f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale"))
}()
select {
case <-done:
t.Fatal("replay finished while the path lock was still held")
case <-time.After(50 * time.Millisecond):
}
f.EntryLockTable.ReleaseLock(path, held)
if err := <-done; err != nil {
t.Fatalf("Replay: %v", err)
}
entry, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("FindEntry: %v", err)
}
if entry.Content != nil {
t.Fatalf("content = %q, want local entry kept", entry.Content)
}
}
// A closed stop channel must abort the retry loop with
// errPeerSubscriptionStopped so the subscription tears down and the offset
// save keeps the last completed event instead of skipping this one.
func TestReplicateMetadataChangeAbortsOnStop(t *testing.T) {
store := &flakyReplayStore{
stubFilerStore: newStubFilerStore(),
failUntil: 1 << 30,
err: errors.New("i/o timeout talking to store"),
}
peer := pb.ServerAddress("peer-stopped:1")
f := newReplayTestFiler(store)
stopChan := make(chan struct{})
close(stopChan)
err := replicateMetadataChange(f, peer, quotaChangeEvent("stuck-bucket", 1<<20), stopChan)
if !errors.Is(err, errPeerSubscriptionStopped) {
t.Fatalf("err = %v, want errPeerSubscriptionStopped", err)
}
if got := store.attemptCount(); got != 0 {
t.Fatalf("attempts = %d, want 0: a stopped subscription must not replay", got)
}
}
// Commit-record keys must stay small: SQL KV stores keep the key suffix in a
// VARCHAR(766) column, so a raw path over ~578 bytes silently drops the
// record (the insert fails and the fallback update touches no row).
func TestMetaPathKeyFixedSizeForLongPaths(t *testing.T) {
path := util.NewFullPath("/", string(make([]rune, 600)))
for _, p := range []util.FullPath{"/a", path} {
if got := len(metaPathKey(metaPathTsPrefix, p)); got != len(metaPathTsPrefix)+16 {
t.Fatalf("metaPathKey(%d-byte path) = %d bytes, want %d", len(p), got, len(metaPathTsPrefix)+16)
}
}
}
// Exact-path locking alone lets a replayed child create slip between a local
// ancestor delete's tombstone check and the sweep: replay locks every
// ancestor too, so it waits for the delete's lock and then sees the
// tombstone.
func TestReplaySerializesWithAncestorLock(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
child := util.NewFullPath("/dir", "file")
held := f.EntryLockTable.AcquireLock("test local delete", "/dir", util.ExclusiveLock)
f.notePathChange("/dir", now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
done := make(chan error, 1)
go func() {
done <- f.Replay(createEvent)
}()
select {
case <-done:
t.Fatal("replay finished while the ancestor lock was still held")
case <-time.After(50 * time.Millisecond):
}
f.EntryLockTable.ReleaseLock("/dir", held)
if err := <-done; err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), child); err == nil {
t.Fatal("stale replayed create resurrected a child of a locally deleted directory")
}
}
// Bootstrap ordering uses the sender's commit timestamp: a local commit
// newer than the traversed entry keeps its state even when the peer entry's
// mtime is far in the future.
func TestBootstrapSkipsPeerEntryBehindLocalDelete(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
f.notePathChange(path, now.UnixNano(), true)
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(time.Hour)}, // client-settable, must not win
Content: []byte("stale peer entry"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.Add(-time.Minute).UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("bootstrap resurrected a path newer local delete covered")
}
}
// The bug this guards: a peer entry committed recently but stamped with an
// old client mtime must still apply over an absent-or-older local commit.
func TestBootstrapAppliesPeerCommitNewerThanLocal(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Hour)}, // old client-set mtime
Content: []byte("fresh peer write"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
got, err := store.FindEntry(context.Background(), path)
if err != nil {
t.Fatalf("peer entry with newer commit must bootstrap: %v", err)
}
if string(got.Content) != "fresh peer write" {
t.Fatalf("content = %q, want peer entry applied", got.Content)
}
}
// A bootstrap-applied entry records the sender's commit timestamp, so a
// replayed event older than it — but newer than the entry's mtime — is
// still skipped.
func TestBootstrapAppliedCommitBlocksStaleReplay(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Hour)},
Content: []byte("fresh peer write"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
if err := f.Replay(overwriteEvent("/dir", "file", now.Add(-time.Minute).UnixNano(), "stale replay")); err != nil {
t.Fatalf("Replay: %v", err)
}
got, _ := store.FindEntry(context.Background(), path)
if string(got.Content) != "fresh peer write" {
t.Fatalf("content = %q, stale replay must not overwrite the bootstrap entry", got.Content)
}
}
// An existing local entry is replaced only when the peer commit is newer —
// not when its mtime happens to be.
func TestBootstrapUpdatesExistingByCommitTime(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
now := time.Now()
path := util.NewFullPath("/dir", "file")
store.entries[string(path)] = &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(time.Hour)}, // future mtime, e.g. client-set
Content: []byte("old local write"),
}
f.notePathChange(path, now.Add(-time.Minute).UnixNano(), false)
entry := &Entry{
FullPath: path,
Attr: Attr{Mtime: now.Add(-time.Hour)},
Content: []byte("newer peer commit"),
}
if err := f.applyTraversedEntry(context.Background(), entry, now.UnixNano()); err != nil {
t.Fatalf("applyTraversedEntry: %v", err)
}
got, _ := store.FindEntry(context.Background(), path)
if string(got.Content) != "newer peer commit" {
t.Fatalf("content = %q, newer peer commit must update despite the future local mtime", got.Content)
}
}
// A delete whose commit record failed to persist still blocks a stale
// replayed create: the pending record stands in until the retry lands it.
func TestPendingDeleteRecordBlocksStaleReplay(t *testing.T) {
store := newStubFilerStore()
store.setKvPutErr(errors.New("kv store unreachable"))
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
now := time.Now()
path := util.NewFullPath("/dir", "file")
// The delete already committed; only its ordering record failed.
f.notePathChange(path, now.UnixNano(), true)
createEvent := &filer_pb.SubscribeMetadataResponse{
Directory: "/dir",
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file", Content: []byte("stale resurrect")},
},
TsNs: now.Add(-time.Minute).UnixNano(),
}
if err := f.Replay(createEvent); err != nil {
t.Fatalf("Replay: %v", err)
}
if _, err := store.FindEntry(context.Background(), path); err == nil {
t.Fatal("stale create resurrected a path whose delete record is still pending")
}
}
// The failed record write retries until it lands, then leaves the pending set.
func TestPendingCommitRecordRepairs(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
path := util.NewFullPath("/dir", "file")
store.setKvPutErr(errors.New("kv store unreachable"))
f.notePathChange(path, 100, true)
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
t.Fatal("failed record write must stay pending")
}
store.setKvPutErr(nil)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path))
if err != nil || !found || ts != 100 {
t.Fatalf("delete record after repair: ts=%v found=%v err=%v", ts, found, err)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("pending record was not repaired")
}
// CommitTsNs reports the recorded commit time over the entry mtime, and a
// still-pending record over both.
func TestCommitTsNsPrefersRecordAndPending(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
path := util.NewFullPath("/dir", "file")
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 7 {
t.Fatalf("no record: CommitTsNs = %d, %v; want fallback 7", got, err)
}
f.notePathChange(path, 50, false)
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 50 {
t.Fatalf("recorded: CommitTsNs = %d, %v; want 50", got, err)
}
pending := &metaTsPendingRec{}
pending.changeTsNs.Store(80)
f.metaTsPending.Store(string(path), pending)
if got, err := f.CommitTsNs(context.Background(), path, 7); err != nil || got != 80 {
t.Fatalf("pending: CommitTsNs = %d, %v; want 80", got, err)
}
}
// A successful write discharges what the pending record owed before it, so a
// repair pass can never put an older timestamp back over it.
func TestPendingCommitRecordDischargedByNewerWrite(t *testing.T) {
store := newStubFilerStore()
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
path := util.NewFullPath("/dir", "file")
store.setKvPutErr(errors.New("kv store unreachable"))
f.notePathChange(path, 100, false)
store.setKvPutErr(nil)
f.notePathChange(path, 200, false)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
break
}
time.Sleep(10 * time.Millisecond)
}
ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path))
if err != nil || !found || ts != 200 {
t.Fatalf("MpTs after repair = %v, %v, %v; want 200", ts, found, err)
}
}
// A failed delete record keeps its own time even when a later failed change
// merges into the pending record: the repair must not stamp the delete at
// the newer change's time.
func TestPendingDeleteRecordKeepsItsTime(t *testing.T) {
store := newStubFilerStore()
store.setKvPutErr(errors.New("kv store unreachable"))
f := newReplayTestFiler(store)
f.flushCtx, f.flushCancel = context.WithCancel(context.Background())
defer f.flushCancel()
path := util.NewFullPath("/dir", "file")
f.notePathChange(path, 100, true)
f.notePathChange(path, 200, false)
store.setKvPutErr(nil)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, loaded := f.metaTsPending.Load(string(path)); !loaded {
break
}
time.Sleep(10 * time.Millisecond)
}
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathTsPrefix, path)); err != nil || !found || ts != 200 {
t.Fatalf("MpTs = %v, %v, %v; want 200", ts, found, err)
}
if ts, found, err := f.kvReadTsNs(context.Background(), metaPathKey(metaPathDelPrefix, path)); err != nil || !found || ts != 100 {
t.Fatalf("MpDl = %v, %v, %v; want 100", ts, found, err)
}
}
+52
View File
@@ -34,3 +34,55 @@ func TestOnPeerUpdateRepeatedAdd(t *testing.T) {
t.Fatal("expecting the subscription to be removed")
}
}
// Shutdown must close every peer's stopChan and wait for the loops, which is
// what lets each loop's deferred offset save run before the store closes.
func TestMetaAggregatorShutdownStopsPeerLoops(t *testing.T) {
ma := NewMetaAggregator(nil, pb.ServerAddress("127.0.0.1:2"), nil)
stopChan := make(chan struct{})
peer := pb.ServerAddress("127.0.0.1:1")
ma.peerChans[peer] = stopChan
ma.peerWg.Add(1)
exited := make(chan struct{})
go func() {
defer ma.peerWg.Done()
<-stopChan
close(exited)
}()
done := make(chan struct{})
go func() {
ma.Shutdown()
close(done)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Shutdown did not return")
}
select {
case <-exited:
default:
t.Fatal("peer loop was not stopped")
}
}
// After Shutdown begins, a peer-add update arriving on the still-connected
// master watch must not start a subscription: it would have no stop signal
// and could run past the store close.
func TestOnPeerUpdateAfterShutdownRejected(t *testing.T) {
ma := NewMetaAggregator(nil, pb.ServerAddress("127.0.0.1:2"), nil)
ma.Shutdown()
peer := pb.ServerAddress("127.0.0.1:1")
ma.OnPeerUpdate(&master_pb.ClusterNodeUpdate{Address: string(peer), IsAdd: true}, time.Now())
ma.peerChansLock.Lock()
_, found := ma.peerChans[peer]
ma.peerChansLock.Unlock()
if found {
t.Fatal("peer subscription started after shutdown")
}
}
+324
View File
@@ -0,0 +1,324 @@
package filer
import (
"context"
"crypto/sha256"
"errors"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
const (
// metaPathTsPrefix keys per-path commit timestamps in the filer store KV:
// the newest event timestamp this filer committed or applied for the
// path. Written for every emitted mutation event and kept after the entry
// is deleted, so a replayed peer event can still be ordered against a
// change that left no entry behind.
metaPathTsPrefix = "MpTs"
// metaPathDelPrefix keys delete-only timestamps. A replayed event checks
// the path's ancestors too, so a directory delete also covers children
// swept without per-child events (a dropped bucket's whole-store delete,
// or a walk aborted partway).
metaPathDelPrefix = "MpDl"
)
// metaPathKey hashes the path into a fixed-size key. SQL-backed KV stores
// split the key at byte 8 and store the remainder base64-encoded in a
// VARCHAR(766) name column: a raw path over ~578 bytes overflows it, the
// KvPut insert fails, and the fallback update touches no row yet returns no
// error — the commit record would never land and stale replay could restore
// the path.
func metaPathKey(prefix string, path util.FullPath) []byte {
sum := sha256.Sum256([]byte(path))
key := make([]byte, len(prefix)+16)
copy(key, prefix)
copy(key[len(prefix):], sum[:16])
return key
}
// kvReadTsNs returns the timestamp recorded for key, or found=false when the
// key is absent or malformed. Any other store read failure is an error: a
// caller deciding whether a replayed change is stale must not guess.
func (f *Filer) kvReadTsNs(ctx context.Context, key []byte) (tsNs int64, found bool, err error) {
value, err := f.Store.KvGet(ctx, key)
if err != nil {
if errors.Is(err, ErrKvNotFound) {
return 0, false, nil
}
return 0, false, err
}
if len(value) < 8 {
return 0, false, nil
}
return int64(util.BytesToUint64(value)), true, nil
}
// notePathChange persists tsNs as path's newest committed change. Local event
// timestamps are stamped monotonically (metaLogInflight.stamp), so a plain
// put cannot regress an earlier local record; a higher peer record it may
// overwrite was already newer under last-writer-wins ordering anyway. A
// delete additionally writes a delete record the ancestor lookup consults.
//
// The write uses a fresh context: callers notify after CommitTransaction,
// and SQL stores keep the already-committed *sql.Tx in the request context —
// a record written through it would fail and the path would lose ordering.
//
// A failed write is kept in metaTsPending and retried in the background: the
// mutation the record orders has already committed, so dropping it would let
// a stale replayed change overwrite or resurrect the path while the store is
// unreachable. Ordering reads consult the pending record until the retry
// lands it.
func (f *Filer) notePathChange(path util.FullPath, tsNs int64, deleted bool) {
if f.Store == nil {
return
}
ctx := context.Background()
key := string(path)
rec := f.pendingRec(key)
defer rec.mu.Unlock()
value := make([]byte, 8)
util.Uint64toBytes(value, uint64(tsNs))
if err := f.Store.KvPut(ctx, metaPathKey(metaPathTsPrefix, path), value); err != nil {
glog.WarningfCtx(ctx, "record meta change time %s: %v", path, err)
if tsNs > rec.changeTsNs.Load() {
rec.changeTsNs.Store(tsNs)
}
} else if rec.changeTsNs.Load() <= tsNs {
// This write covers every change record owed so far.
rec.changeTsNs.Store(0)
}
if deleted {
if err := f.Store.KvPut(ctx, metaPathKey(metaPathDelPrefix, path), value); err != nil {
glog.WarningfCtx(ctx, "record meta delete time %s: %v", path, err)
if tsNs > rec.deleteTsNs.Load() {
rec.deleteTsNs.Store(tsNs)
}
} else if rec.deleteTsNs.Load() <= tsNs {
rec.deleteTsNs.Store(0)
}
}
if rec.changeTsNs.Load() == 0 && rec.deleteTsNs.Load() == 0 {
f.metaTsPending.CompareAndDelete(key, rec)
return
}
if _, running := f.metaTsRepairing.LoadOrStore(key, struct{}{}); !running {
go f.repairMetaPathTs(path)
}
}
// metaTsPendingRec is a commit record still on its way to the store, holding
// only work not yet written: changeTsNs is the newest committed change the
// MpTs record lacks and deleteTsNs the newest delete the MpDl record lacks —
// the two keep their own times so a later update cannot masquerade as the
// delete. Store writes and the bookkeeping they discharge share mu, so a
// repair pass can never write an older record over a newer one that already
// landed; readers take the atomic fields directly.
type metaTsPendingRec struct {
mu sync.Mutex
changeTsNs atomic.Int64
deleteTsNs atomic.Int64
}
// pendingRec returns the live pending record for key, locked for mutation,
// recreating it when a repair already dropped the previous one.
func (f *Filer) pendingRec(key string) *metaTsPendingRec {
for {
cur, _ := f.metaTsPending.LoadOrStore(key, &metaTsPendingRec{})
rec := cur.(*metaTsPendingRec)
rec.mu.Lock()
if live, ok := f.metaTsPending.Load(key); ok && live == cur {
return rec
}
rec.mu.Unlock()
}
}
// repairMetaPathTs retries a pending commit record until it lands. Each pass
// writes only what the record still owes, so a merge by a later failure is
// picked up without another goroutine and a landed write is never rewritten.
// The retry is unbounded — the mutation is already committed and only the
// record preserves its ordering.
func (f *Filer) repairMetaPathTs(path util.FullPath) {
key := string(path)
ctx := f.flushCtx
if ctx == nil {
ctx = context.Background()
}
defer func() {
f.metaTsRepairing.Delete(key)
// A failure landing between the last pass and the flag release
// still needs a repair pass — unless the retry context is done.
if ctx.Err() != nil {
return
}
if _, ok := f.metaTsPending.Load(key); ok {
if _, running := f.metaTsRepairing.LoadOrStore(key, struct{}{}); !running {
go f.repairMetaPathTs(path)
}
}
}()
for {
if _, ok := f.metaTsPending.Load(key); !ok {
return
}
rec := f.pendingRec(key)
var tsErr, delErr error
if tsNs := rec.changeTsNs.Load(); tsNs > 0 {
value := make([]byte, 8)
util.Uint64toBytes(value, uint64(tsNs))
if tsErr = f.Store.KvPut(ctx, metaPathKey(metaPathTsPrefix, path), value); tsErr == nil {
rec.changeTsNs.Store(0)
}
}
if tsNs := rec.deleteTsNs.Load(); tsNs > 0 {
value := make([]byte, 8)
util.Uint64toBytes(value, uint64(tsNs))
if delErr = f.Store.KvPut(ctx, metaPathKey(metaPathDelPrefix, path), value); delErr == nil {
rec.deleteTsNs.Store(0)
}
}
done := rec.changeTsNs.Load() == 0 && rec.deleteTsNs.Load() == 0
if done {
f.metaTsPending.CompareAndDelete(key, rec)
}
rec.mu.Unlock()
if !done {
glog.V(0).InfofCtx(ctx, "retry record meta change time %s: %v %v", path, tsErr, delErr)
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
}
}
}
// pendingPathTsNs returns the newest pending commit timestamp for path and
// the newest pending delete timestamp.
func (f *Filer) pendingPathTsNs(path util.FullPath) (changeTsNs, deleteTsNs int64) {
cur, ok := f.metaTsPending.Load(string(path))
if !ok {
return 0, 0
}
rec := cur.(*metaTsPendingRec)
return rec.changeTsNs.Load(), rec.deleteTsNs.Load()
}
// CommitTsNs returns the newest commit timestamp known for path — the
// recorded event time, or a still-pending one — and fallbackTsNs when none
// exists. Peers stamp traversal entries with it so a bootstrap subscriber
// orders each entry on the sender's commit clock rather than the entry's
// client-settable mtime. A store read failure is an error, not a fallback:
// the mtime it would mint could outdate real commits.
func (f *Filer) CommitTsNs(ctx context.Context, path util.FullPath, fallbackTsNs int64) (int64, error) {
if f.Store == nil {
return fallbackTsNs, nil
}
tsNs := fallbackTsNs
recTs, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathTsPrefix, path))
if err != nil {
return 0, err
}
if found {
tsNs = recTs
}
if pendTs, _ := f.pendingPathTsNs(path); pendTs > tsNs {
tsNs = pendTs
}
return tsNs, nil
}
// noteCommittedEvent records the event's commit timestamp for every path it
// touches: the source of a delete or rename, and the target of a create,
// update, or rename. It runs after the local mutation commits while the
// caller still holds the path's entry lock (for filer-server mutations), so
// a peer replay ordered by the same lock sees either the whole local change
// or none of it.
func (f *Filer) noteCommittedEvent(event *filer_pb.SubscribeMetadataResponse) {
message := event.EventNotification
if message == nil {
return
}
var source, target util.FullPath
if message.OldEntry != nil {
source = util.FullPath(filer_pb.MetadataEventSourceFullPath(event))
}
if message.NewEntry != nil {
target = util.FullPath(filer_pb.MetadataEventTargetFullPath(event))
}
// A delete or a rename removes the source path; an update reports the
// same path on both sides and counts once.
if source != "" && source != target {
f.notePathChange(source, event.TsNs, true)
}
if target != "" {
f.notePathChange(target, event.TsNs, false)
}
}
// deletedAfterTsNs returns the newest recorded delete timestamp among path
// and its ancestors.
func (f *Filer) deletedAfterTsNs(ctx context.Context, path util.FullPath) (int64, error) {
var tsNs int64
for p := string(path); p != "" && p != "/"; {
ts, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathDelPrefix, util.FullPath(p)))
if err != nil {
return 0, err
}
if found && ts > tsNs {
tsNs = ts
}
if _, pendDel := f.pendingPathTsNs(util.FullPath(p)); pendDel > tsNs {
tsNs = pendDel
}
i := strings.LastIndexByte(p, '/')
if i <= 0 {
break
}
p = p[:i]
}
return tsNs, nil
}
// localCommitTsNs returns the newest timestamp this filer is known to have
// committed for path: the recorded event time when one exists — it is not
// client-controllable the way an entry's mtime is — else the entry's mtime
// for entries written before recording existed, plus any recorded delete of
// the path or an ancestor.
func (f *Filer) localCommitTsNs(ctx context.Context, path util.FullPath) (int64, error) {
if f.Store == nil {
return 0, nil
}
var tsNs int64
recTs, found, err := f.kvReadTsNs(ctx, metaPathKey(metaPathTsPrefix, path))
if err != nil {
return 0, err
}
if found {
tsNs = recTs
} else if entry, findErr := f.Store.FindEntry(ctx, path); findErr != nil {
if !errors.Is(findErr, filer_pb.ErrNotFound) {
return 0, findErr
}
} else if entry != nil {
tsNs = entry.Mtime.UnixNano()
}
if pendTs, _ := f.pendingPathTsNs(path); pendTs > tsNs {
tsNs = pendTs
}
delTs, err := f.deletedAfterTsNs(ctx, path)
if err != nil {
return 0, err
}
if delTs > tsNs {
tsNs = delTs
}
return tsNs, nil
}
+151 -14
View File
@@ -2,6 +2,8 @@ package filer
import (
"context"
"slices"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -9,31 +11,166 @@ import (
)
// Replay applies the delete and the insert as separate, non-transactional
// store calls, so retrying it after a partial failure is not atomic.
func Replay(filerStore FilerStore, resp *filer_pb.SubscribeMetadataResponse) error {
// store calls, so retrying it after a partial failure is not atomic; both
// operations are idempotent and the timestamp check tolerates re-delivery.
//
// A replayed event can be older than local state: a peer resuming from a
// stale offset replays everything since the offset, and two writes through
// different filers race within the replication lag. Apply each path's change
// only when no newer local change is recorded, so a late event cannot revert
// or resurrect entries.
//
// Ordering uses the per-path commit timestamp recorded for every emitted
// mutation (meta_path_ts.go), which survives deletes as a tombstone and is
// not client-controllable the way entry mtime is — a mounted client can set
// mtime into the future, and a rename keeps the source file's mtime on the
// destination entry. Entries written before recording existed fall back to
// their mtime.
//
// The check and the mutation run under the same per-path entry lock the
// filer server's local writes hold, so they cannot be split by a concurrent
// local update or by another peer's replay.
func (f *Filer) Replay(resp *filer_pb.SubscribeMetadataResponse) error {
message := resp.EventNotification
var oldPath util.FullPath
var newEntry *Entry
dir := resp.Directory
if message.NewParentPath != "" {
dir = message.NewParentPath
}
var oldPath, newPath util.FullPath
if message.OldEntry != nil {
oldPath = util.NewFullPath(resp.Directory, message.OldEntry.Name)
glog.V(4).Infof("deleting %v", oldPath)
if err := filerStore.DeleteEntry(context.Background(), oldPath); err != nil {
}
if message.NewEntry != nil {
newPath = util.NewFullPath(dir, message.NewEntry.Name)
}
ctx := context.Background()
unlock := f.lockReplayPaths(oldPath, newPath)
defer unlock()
samePath := oldPath != "" && oldPath == newPath
if samePath {
newer, err := f.replayBlockedByLocal(ctx, oldPath, resp.TsNs)
if err != nil || newer {
return err
}
glog.V(4).Infof("deleting %v", oldPath)
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
return err
}
// Record the commit before the insert too: if the insert fails and
// the event is retried later, the path sits deleted in between —
// without the record a stale peer create could resurrect it in the
// gap. The entry still exists afterward, so no delete record.
f.notePathChange(oldPath, resp.TsNs, false)
glog.V(4).Infof("creating %v", newPath)
newEntry := FromPbEntry(dir, message.NewEntry)
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
return err
}
f.notePathChange(oldPath, resp.TsNs, false)
return nil
}
if message.OldEntry != nil {
newer, err := f.replayBlockedByLocal(ctx, oldPath, resp.TsNs)
if err != nil {
return err
}
if !newer {
glog.V(4).Infof("deleting %v", oldPath)
if err := f.Store.DeleteEntry(ctx, oldPath); err != nil {
return err
}
f.notePathChange(oldPath, resp.TsNs, true)
}
}
if message.NewEntry != nil {
dir := resp.Directory
if message.NewParentPath != "" {
dir = message.NewParentPath
}
key := util.NewFullPath(dir, message.NewEntry.Name)
glog.V(4).Infof("creating %v", key)
newEntry = FromPbEntry(dir, message.NewEntry)
if err := filerStore.InsertEntry(context.Background(), newEntry); err != nil {
newer, err := f.replayBlockedByLocal(ctx, newPath, resp.TsNs)
if err != nil {
return err
}
if !newer {
glog.V(4).Infof("creating %v", newPath)
newEntry := FromPbEntry(dir, message.NewEntry)
if err := f.Store.InsertEntry(ctx, newEntry); err != nil {
return err
}
f.notePathChange(newPath, resp.TsNs, false)
}
}
return nil
}
// replayBlockedByLocal reports whether the local store already holds a
// version of path committed after the event's timestamp. Lookup failures are
// propagated: a replayed change must not be applied on the guess that the
// path was simply absent, or a transient read error could let a stale event
// overwrite a newer entry past the point of no return.
func (f *Filer) replayBlockedByLocal(ctx context.Context, path util.FullPath, tsNs int64) (bool, error) {
if tsNs <= 0 {
return false, nil
}
localTsNs, err := f.localCommitTsNs(ctx, path)
if err != nil {
return false, err
}
return localTsNs > tsNs, nil
}
// lockReplayPaths holds the shared entry locks for every path the event
// touches, in the same sorted order the rename handler takes them.
// lockReplayPaths takes the per-path entry locks for the event's paths and
// every ancestor of them, in lexicographic order — parents sort before
// children, the same ordering the local rename handler's two-path lock uses,
// so lock acquisition is deadlock-free across operations. Ancestors are
// included because the replay check consults ancestor delete records: a
// local recursive delete or directory rename holds only the ancestor's lock,
// and without contending on it a replay could reinsert a stale child after
// its check. Ancestors are locked shared — they only need to exclude an
// exclusive holder, not each other, or every replay under a busy parent
// would serialize — while a path the event actually changes stays exclusive
// even when it is also the other path's ancestor. The root is never locked:
// it cannot be deleted, and a shared root lock would still be excluded by
// every top-level delete.
func (f *Filer) lockReplayPaths(oldPath, newPath util.FullPath) func() {
lt := f.EntryLockTable
if lt == nil {
return func() {}
}
var paths []util.FullPath
addAncestors := func(p util.FullPath) {
for i := strings.LastIndexByte(string(p), '/'); i > 0; i = strings.LastIndexByte(string(p), '/') {
p = p[:i]
paths = append(paths, p)
}
}
addAncestors(oldPath)
addAncestors(newPath)
if oldPath != "" {
paths = append(paths, oldPath)
}
if newPath != "" {
paths = append(paths, newPath)
}
if len(paths) == 0 {
return func() {}
}
slices.Sort(paths)
paths = slices.Compact(paths)
locks := make([]*util.ActiveLock, len(paths))
for i, p := range paths {
lockType := util.SharedLock
if p == oldPath || p == newPath {
lockType = util.ExclusiveLock
}
locks[i] = lt.AcquireLock("MetaReplay", p, lockType)
}
return func() {
for i := len(paths) - 1; i >= 0; i-- {
lt.ReleaseLock(paths[i], locks[i])
}
}
}
@@ -0,0 +1,74 @@
package filer
import (
"context"
"fmt"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/seaweedfs/seaweedfs/weed/util/chunk_cache"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
// A sequential reader that consumes a chunk in several small ReadAt calls, as
// the S3 GET path does with its 256 KiB copy buffer over 4 MiB chunks, must
// keep its read-ahead at prefetchCount chunks. Each ReadAt triggers a
// prefetch; if chunks already being fetched are not counted toward the
// window, every call starts prefetchCount more downloads and the read-ahead
// runs to the ReaderCache downloader limit, buffering most of the object.
func TestChunkReadAtSequentialPrefetchStaysWithinWindow(t *testing.T) {
const chunkSize = 64 << 10
const chunkCount = 64
const sliceSize = 16 << 10
var readerChunk atomic.Int64
var maxLead atomic.Int64
rc := NewReaderCache(256, (*chunk_cache.TieredChunkCache)(nil), func(context.Context, string) ([]string, error) {
return []string{"unused"}, nil
}, nil)
defer rc.destroy()
rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, fileId string, _ util_http.RefreshUrlsFunc) (int, error) {
idx, err := strconv.Atoi(strings.TrimPrefix(fileId, "chunk"))
if err != nil {
return 0, err
}
lead := int64(idx) - readerChunk.Load()
for {
cur := maxLead.Load()
if lead <= cur || maxLead.CompareAndSwap(cur, lead) {
break
}
}
return len(buffer), nil
}
views := NewIntervalList[*ChunkView]()
for i := 0; i < chunkCount; i++ {
views.AppendInterval(&Interval[*ChunkView]{
StartOffset: int64(i * chunkSize),
StopOffset: int64((i + 1) * chunkSize),
Value: &ChunkView{
FileId: fmt.Sprintf("chunk%d", i),
ViewSize: chunkSize,
ViewOffset: int64(i * chunkSize),
ChunkSize: chunkSize,
},
})
}
reader := NewChunkReaderAtFromClient(context.Background(), rc, views, chunkSize*chunkCount, DefaultPrefetchCount)
defer reader.ReleaseStream()
buf := make([]byte, sliceSize)
for off := int64(0); off < chunkSize*chunkCount; off += sliceSize {
readerChunk.Store(off / chunkSize)
if _, err := reader.ReadAt(buf, off); err != nil && off+sliceSize < chunkSize*chunkCount {
t.Fatalf("ReadAt(%d): %v", off, err)
}
}
if got := maxLead.Load(); got > DefaultPrefetchCount {
t.Fatalf("read-ahead reached %d chunks past the reader, want at most %d", got, DefaultPrefetchCount)
}
}
+4 -3
View File
@@ -92,8 +92,10 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
return
}
cached := 0
for x := chunkViews; x != nil && cached < count; x = x.Next {
// The window is the next count chunks, whether or not a download for them
// is already running: counting only newly started downloads would push the
// window count chunks further on every call.
for x, i := chunkViews, 0; x != nil && i < count; x, i = x.Next, i+1 {
chunkView := x.Value
if _, found := rc.downloaders[chunkView.FileId]; found {
continue
@@ -124,7 +126,6 @@ func (rc *ReaderCache) MaybeCache(chunkViews *Interval[*ChunkView], count int) {
rc.downloaders[chunkView.FileId] = cacher
go cacher.startCaching()
started = append(started, cacher)
cached++
}
return
+4
View File
@@ -714,6 +714,10 @@ message TraverseBfsMetadataRequest {
message TraverseBfsMetadataResponse {
string directory = 1;
Entry entry = 2;
// The sender's per-path commit timestamp (see meta_path_ts.go): the newest
// event time this filer committed for the path, 0 when unrecorded so
// subscribers fall back to the entry's mtime.
int64 entry_ts_ns = 3;
}
message LogEntry {
+17 -5
View File
@@ -4777,9 +4777,13 @@ func (x *TraverseBfsMetadataRequest) GetExcludedPrefixes() []string {
}
type TraverseBfsMetadataResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
Entry *Entry `protobuf:"bytes,2,opt,name=entry,proto3" json:"entry,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
Entry *Entry `protobuf:"bytes,2,opt,name=entry,proto3" json:"entry,omitempty"`
// The sender's per-path commit timestamp (see meta_path_ts.go): the newest
// event time this filer committed for the path, 0 when unrecorded so
// subscribers fall back to the entry's mtime.
EntryTsNs int64 `protobuf:"varint,3,opt,name=entry_ts_ns,json=entryTsNs,proto3" json:"entry_ts_ns,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4828,6 +4832,13 @@ func (x *TraverseBfsMetadataResponse) GetEntry() *Entry {
return nil
}
func (x *TraverseBfsMetadataResponse) GetEntryTsNs() int64 {
if x != nil {
return x.EntryTsNs
}
return 0
}
type LogEntry struct {
state protoimpl.MessageState `protogen:"open.v1"`
TsNs int64 `protobuf:"varint,1,opt,name=ts_ns,json=tsNs,proto3" json:"ts_ns,omitempty"`
@@ -7688,10 +7699,11 @@ const file_filer_proto_rawDesc = "" +
"\bfiler_id\x18\x03 \x01(\tR\afilerId\"g\n" +
"\x1aTraverseBfsMetadataRequest\x12\x1c\n" +
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12+\n" +
"\x11excluded_prefixes\x18\x02 \x03(\tR\x10excludedPrefixes\"b\n" +
"\x11excluded_prefixes\x18\x02 \x03(\tR\x10excludedPrefixes\"\x82\x01\n" +
"\x1bTraverseBfsMetadataResponse\x12\x1c\n" +
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12%\n" +
"\x05entry\x18\x02 \x01(\v2\x0f.filer_pb.EntryR\x05entry\"\x8b\x01\n" +
"\x05entry\x18\x02 \x01(\v2\x0f.filer_pb.EntryR\x05entry\x12\x1e\n" +
"\ventry_ts_ns\x18\x03 \x01(\x03R\tentryTsNs\"\x8b\x01\n" +
"\bLogEntry\x12\x13\n" +
"\x05ts_ns\x18\x01 \x01(\x03R\x04tsNs\x12,\n" +
"\x12partition_key_hash\x18\x02 \x01(\x05R\x10partitionKeyHash\x12\x12\n" +
+27
View File
@@ -4379,6 +4379,11 @@ func (m *TraverseBfsMetadataResponse) MarshalToSizedBufferVT(dAtA []byte) (int,
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if m.EntryTsNs != 0 {
i = protohelpers.EncodeVarint(dAtA, i, uint64(m.EntryTsNs))
i--
dAtA[i] = 0x18
}
if m.Entry != nil {
size, err := m.Entry.MarshalToSizedBufferVT(dAtA[:i])
if err != nil {
@@ -8267,6 +8272,9 @@ func (m *TraverseBfsMetadataResponse) SizeVT() (n int) {
l = m.Entry.SizeVT()
n += 1 + l + protohelpers.SizeOfVarint(uint64(l))
}
if m.EntryTsNs != 0 {
n += 1 + protohelpers.SizeOfVarint(uint64(m.EntryTsNs))
}
n += len(m.unknownFields)
return n
}
@@ -20865,6 +20873,25 @@ func (m *TraverseBfsMetadataResponse) UnmarshalVT(dAtA []byte) error {
return err
}
iNdEx = postIndex
case 3:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field EntryTsNs", wireType)
}
m.EntryTsNs = 0
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
m.EntryTsNs |= int64(b&0x7F) << shift
if b < 0x80 {
break
}
}
default:
iNdEx = preIndex
skippy, err := protohelpers.Skip(dAtA[iNdEx:])
+10
View File
@@ -305,6 +305,11 @@ message VolumeGrowRequest {
string data_node = 7;
uint32 memory_map_max_size_mb = 8;
string disk_type = 9;
// If > 0, grow only when the average fill ratio of the layout's writable
// volumes has reached this threshold (e.g. 0.6). Lets callers pre-grow on
// fill level with one call, instead of listing volumes to compute the
// ratio themselves. Ignored when there are no writable volumes.
float grow_avg_fill_threshold = 10;
}
message AssignResponse {
@@ -611,4 +616,9 @@ message RaftLeadershipTransferResponse {
}
message VolumeGrowResponse {
// Average fill ratio of the layout's writable volumes at request time
// (0 when there are none). Reported whether or not growth was triggered.
float avg_fill = 1;
// Number of writable volumes at request time.
uint32 writable_count = 2;
}
+42 -7
View File
@@ -1739,8 +1739,13 @@ type VolumeGrowRequest struct {
DataNode string `protobuf:"bytes,7,opt,name=data_node,json=dataNode,proto3" json:"data_node,omitempty"`
MemoryMapMaxSizeMb uint32 `protobuf:"varint,8,opt,name=memory_map_max_size_mb,json=memoryMapMaxSizeMb,proto3" json:"memory_map_max_size_mb,omitempty"`
DiskType string `protobuf:"bytes,9,opt,name=disk_type,json=diskType,proto3" json:"disk_type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// If > 0, grow only when the average fill ratio of the layout's writable
// volumes has reached this threshold (e.g. 0.6). Lets callers pre-grow on
// fill level with one call, instead of listing volumes to compute the
// ratio themselves. Ignored when there are no writable volumes.
GrowAvgFillThreshold float32 `protobuf:"fixed32,10,opt,name=grow_avg_fill_threshold,json=growAvgFillThreshold,proto3" json:"grow_avg_fill_threshold,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VolumeGrowRequest) Reset() {
@@ -1836,6 +1841,13 @@ func (x *VolumeGrowRequest) GetDiskType() string {
return ""
}
func (x *VolumeGrowRequest) GetGrowAvgFillThreshold() float32 {
if x != nil {
return x.GrowAvgFillThreshold
}
return 0
}
type AssignResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Fid string `protobuf:"bytes,1,opt,name=fid,proto3" json:"fid,omitempty"`
@@ -4659,7 +4671,12 @@ func (x *RaftLeadershipTransferResponse) GetNewLeader() string {
}
type VolumeGrowResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
state protoimpl.MessageState `protogen:"open.v1"`
// Average fill ratio of the layout's writable volumes at request time
// (0 when there are none). Reported whether or not growth was triggered.
AvgFill float32 `protobuf:"fixed32,1,opt,name=avg_fill,json=avgFill,proto3" json:"avg_fill,omitempty"`
// Number of writable volumes at request time.
WritableCount uint32 `protobuf:"varint,2,opt,name=writable_count,json=writableCount,proto3" json:"writable_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4694,6 +4711,20 @@ func (*VolumeGrowResponse) Descriptor() ([]byte, []int) {
return file_master_proto_rawDescGZIP(), []int{68}
}
func (x *VolumeGrowResponse) GetAvgFill() float32 {
if x != nil {
return x.AvgFill
}
return 0
}
func (x *VolumeGrowResponse) GetWritableCount() uint32 {
if x != nil {
return x.WritableCount
}
return 0
}
type SuperBlockExtra_ErasureCoding struct {
state protoimpl.MessageState `protogen:"open.v1"`
Data uint32 `protobuf:"varint,1,opt,name=data,proto3" json:"data,omitempty"`
@@ -5229,7 +5260,7 @@ const file_master_proto_rawDesc = "" +
"\x15writable_volume_count\x18\t \x01(\rR\x13writableVolumeCount\x12\x1b\n" +
"\tdisk_type\x18\n" +
" \x01(\tR\bdiskType\x12,\n" +
"\x12expected_data_size\x18\v \x01(\x04R\x10expectedDataSize\"\xbe\x02\n" +
"\x12expected_data_size\x18\v \x01(\x04R\x10expectedDataSize\"\xf5\x02\n" +
"\x11VolumeGrowRequest\x122\n" +
"\x15writable_volume_count\x18\x01 \x01(\rR\x13writableVolumeCount\x12 \n" +
"\vreplication\x18\x02 \x01(\tR\vreplication\x12\x1e\n" +
@@ -5242,7 +5273,9 @@ const file_master_proto_rawDesc = "" +
"\x04rack\x18\x06 \x01(\tR\x04rack\x12\x1b\n" +
"\tdata_node\x18\a \x01(\tR\bdataNode\x122\n" +
"\x16memory_map_max_size_mb\x18\b \x01(\rR\x12memoryMapMaxSizeMb\x12\x1b\n" +
"\tdisk_type\x18\t \x01(\tR\bdiskType\"\xc4\x01\n" +
"\tdisk_type\x18\t \x01(\tR\bdiskType\x125\n" +
"\x17grow_avg_fill_threshold\x18\n" +
" \x01(\x02R\x14growAvgFillThreshold\"\xc4\x01\n" +
"\x0eAssignResponse\x12\x10\n" +
"\x03fid\x18\x01 \x01(\tR\x03fid\x12\x14\n" +
"\x05count\x18\x04 \x01(\x04R\x05count\x12\x14\n" +
@@ -5477,8 +5510,10 @@ const file_master_proto_rawDesc = "" +
"\x1eRaftLeadershipTransferResponse\x12'\n" +
"\x0fprevious_leader\x18\x01 \x01(\tR\x0epreviousLeader\x12\x1d\n" +
"\n" +
"new_leader\x18\x02 \x01(\tR\tnewLeader\"\x14\n" +
"\x12VolumeGrowResponse2\xf1\x12\n" +
"new_leader\x18\x02 \x01(\tR\tnewLeader\"V\n" +
"\x12VolumeGrowResponse\x12\x19\n" +
"\bavg_fill\x18\x01 \x01(\x02R\aavgFill\x12%\n" +
"\x0ewritable_count\x18\x02 \x01(\rR\rwritableCount2\xf1\x12\n" +
"\aSeaweed\x12I\n" +
"\rSendHeartbeat\x12\x14.master_pb.Heartbeat\x1a\x1c.master_pb.HeartbeatResponse\"\x00(\x010\x01\x12X\n" +
"\rKeepConnected\x12\x1f.master_pb.KeepConnectedRequest\x1a .master_pb.KeepConnectedResponse\"\x00(\x010\x01\x12Q\n" +
+3
View File
@@ -37,6 +37,9 @@ type HandlerBuildOptions struct {
GrpcDialOption grpc.DialOption
MaxExecute int
WorkingDir string
// WorkerAddress is the advertised weed worker -address (host:port).
// It is not a volume-server URL; handlers match replicas by hostname.
WorkerAddress string
}
var (
+9
View File
@@ -14,7 +14,10 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -204,6 +207,12 @@ func (w *Worker) runOnce(ctx context.Context, adminAddress string) error {
connCtx, cancelConn := context.WithCancel(ctx)
defer cancelConn()
if md := security.GrpcAdminPeerCredentials(
security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key")),
util.GetViper().GetInt("jwt.filer_signing.expires_after_seconds")); len(md) > 0 {
connCtx = metadata.NewOutgoingContext(connCtx, md)
}
stream, err := client.WorkerStream(connCtx)
if err != nil {
return fmt.Errorf("open worker stream: %w", err)
+8
View File
@@ -110,6 +110,14 @@ type RemoteStorageStreamReader interface {
ReadFileAsStream(ctx context.Context, loc *remote_pb.RemoteStorageLocation, offset int64, size int64) (reader io.ReadCloser, err error)
}
// RemoteStorageObjectCopier is an optional interface for remote storage clients
// that copy an object to another key within the storage, without the content
// passing through the client. A rename of an entry whose content exists only
// on the remote needs it.
type RemoteStorageObjectCopier interface {
CopyFile(src *remote_pb.RemoteStorageLocation, dst *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error)
}
// CacheWaitTimeout is how long a read of an uncached remote-only object waits
// for the local cache before serving another way: small files wait longer since
// their cache completes quickly, large files fail fast for better TTFB. A mount
+163 -1
View File
@@ -610,7 +610,7 @@ func (s *s3RemoteStorageClient) UpdateFileMetadata(loc *remote_pb.RemoteStorageL
copyInput := &s3.CopyObjectInput{
Bucket: aws.String(loc.Bucket),
Key: aws.String(key),
CopySource: aws.String(url.PathEscape(loc.Bucket + "/" + key)),
CopySource: aws.String(s3CopySource(loc.Bucket, key)),
MetadataDirective: aws.String(s3.MetadataDirectiveReplace),
Metadata: headOut.Metadata,
ContentType: headOut.ContentType,
@@ -663,6 +663,168 @@ func (s *s3RemoteStorageClient) UpdateFileMetadata(loc *remote_pb.RemoteStorageL
}
return
}
// s3CopyPartSize is the smallest part a multipart copy uses; parts grow so
// the largest object stays within the 10000-part limit
const s3CopyPartSize = 512 * 1024 * 1024
// CopyFile copies the object at src to dst on the server side, with a single
// CopyObject up to the 5 GB limit and a multipart copy above it. The copy
// keeps the source's metadata.
func (s *s3RemoteStorageClient) CopyFile(src *remote_pb.RemoteStorageLocation, dst *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
srcKey, dstKey := src.Path[1:], dst.Path[1:]
headOut, err := s.conn.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(src.Bucket),
Key: aws.String(srcKey),
})
if err != nil {
if reqErr, ok := err.(awserr.RequestFailure); ok && reqErr.StatusCode() == http.StatusNotFound {
return nil, remote_storage.ErrRemoteObjectNotFound
}
return nil, fmt.Errorf("stat s3 %s/%s before copy: %w", src.Bucket, srcKey, err)
}
copySource := aws.String(s3CopySource(src.Bucket, srcKey))
var storageClass *string
if s.conf.S3StorageClass != "" {
storageClass = aws.String(s.conf.S3StorageClass)
}
if size := aws.Int64Value(headOut.ContentLength); size <= s3CopyObjectSizeLimit {
// a copy takes the bucket's default encryption unless told otherwise
if _, err = s.conn.CopyObject(&s3.CopyObjectInput{
Bucket: aws.String(dst.Bucket),
Key: aws.String(dstKey),
CopySource: copySource,
StorageClass: storageClass,
ServerSideEncryption: headOut.ServerSideEncryption,
SSEKMSKeyId: headOut.SSEKMSKeyId,
}); err != nil {
return nil, fmt.Errorf("copy s3 %s/%s to %s/%s: %w", src.Bucket, srcKey, dst.Bucket, dstKey, err)
}
} else if err = s.multipartCopy(headOut, src, copySource, size, dst.Bucket, dstKey, storageClass); err != nil {
return nil, fmt.Errorf("copy s3 %s/%s to %s/%s: %w", src.Bucket, srcKey, dst.Bucket, dstKey, err)
}
return s.readFileRemoteEntry(dst)
}
// s3CopySource escapes each segment of the copy source and keeps the slashes,
// which every S3 implementation reads back
func s3CopySource(bucket, key string) string {
segments := strings.Split(bucket+"/"+key, "/")
for i, segment := range segments {
segments[i] = url.PathEscape(segment)
}
return strings.Join(segments, "/")
}
// s3TaggingHeader encodes tags as the query string x-amz-tagging takes, with
// spaces as %20: a + is read back as a space by some implementations and
// literally by others
func s3TaggingHeader(tagSet []*s3.Tag) string {
escape := func(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
pairs := make([]string, 0, len(tagSet))
for _, tag := range tagSet {
pairs = append(pairs, escape(aws.StringValue(tag.Key))+"="+escape(aws.StringValue(tag.Value)))
}
return strings.Join(pairs, "&")
}
// multipartCopy copies an object above the CopyObject limit part by part. A
// multipart upload does not inherit the source's metadata or tags, so they are
// carried over like CopyObject would: metadata from the source's HeadObject,
// tags when the remote supports tagging.
func (s *s3RemoteStorageClient) multipartCopy(headOut *s3.HeadObjectOutput, src *remote_pb.RemoteStorageLocation, copySource *string, size int64, bucket, key string, storageClass *string) error {
createInput := &s3.CreateMultipartUploadInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Metadata: headOut.Metadata,
ContentType: headOut.ContentType,
ContentEncoding: headOut.ContentEncoding,
CacheControl: headOut.CacheControl,
ContentDisposition: headOut.ContentDisposition,
ContentLanguage: headOut.ContentLanguage,
WebsiteRedirectLocation: headOut.WebsiteRedirectLocation,
ServerSideEncryption: headOut.ServerSideEncryption,
SSEKMSKeyId: headOut.SSEKMSKeyId,
StorageClass: storageClass,
}
if headOut.Expires != nil {
if expires, parseErr := http.ParseTime(*headOut.Expires); parseErr == nil {
createInput.Expires = aws.Time(expires)
}
}
if s.conf.S3SupportTagging {
tagOut, tagErr := s.conn.GetObjectTagging(&s3.GetObjectTaggingInput{
Bucket: aws.String(src.Bucket),
Key: aws.String(src.Path[1:]),
})
if tagErr != nil {
return fmt.Errorf("read tags: %w", tagErr)
}
if len(tagOut.TagSet) > 0 {
createInput.Tagging = aws.String(s3TaggingHeader(tagOut.TagSet))
}
}
createOut, err := s.conn.CreateMultipartUpload(createInput)
if err != nil {
return err
}
partSize := int64(s3CopyPartSize)
for partSize*10000 < size {
partSize *= 2
}
var parts []*s3.CompletedPart
for offset, partNumber := int64(0), int64(1); offset < size; offset, partNumber = offset+partSize, partNumber+1 {
partOut, partErr := s.conn.UploadPartCopy(&s3.UploadPartCopyInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
UploadId: createOut.UploadId,
PartNumber: aws.Int64(partNumber),
CopySource: copySource,
CopySourceRange: aws.String(fmt.Sprintf("bytes=%d-%d", offset, min(offset+partSize, size)-1)),
// every part from the object the HeadObject saw, not a mix of
// versions if it is overwritten mid-copy
CopySourceIfMatch: headOut.ETag,
})
if partErr == nil && partOut.CopyPartResult == nil {
partErr = errors.New("no part result")
}
if partErr != nil {
s.abortMultipartUpload(bucket, key, createOut.UploadId)
return fmt.Errorf("copy part %d: %w", partNumber, partErr)
}
parts = append(parts, &s3.CompletedPart{
ETag: partOut.CopyPartResult.ETag,
PartNumber: aws.Int64(partNumber),
})
}
if _, err = s.conn.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
UploadId: createOut.UploadId,
MultipartUpload: &s3.CompletedMultipartUpload{Parts: parts},
}); err != nil {
s.abortMultipartUpload(bucket, key, createOut.UploadId)
return err
}
return nil
}
func (s *s3RemoteStorageClient) abortMultipartUpload(bucket, key string, uploadId *string) {
if _, err := s.conn.AbortMultipartUpload(&s3.AbortMultipartUploadInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
UploadId: uploadId,
}); err != nil {
glog.Warningf("abort multipart copy to s3 %s/%s: %v", bucket, key, err)
}
}
func (s *s3RemoteStorageClient) DeleteFile(loc *remote_pb.RemoteStorageLocation) (err error) {
_, err = s.conn.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(loc.Bucket),
@@ -0,0 +1,169 @@
package s3
import (
"errors"
"net/http"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
awss3 "github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3iface"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/stretchr/testify/require"
)
// copyObjectMock serves HeadObject for a source of size bytes and records the
// copy calls.
type copyObjectMock struct {
s3iface.S3API
size int64
missing bool
partErr error
copyInputs []*awss3.CopyObjectInput
partInputs []*awss3.UploadPartCopyInput
createInput *awss3.CreateMultipartUploadInput
tagInputs []*awss3.GetObjectTaggingInput
completed *awss3.CompleteMultipartUploadInput
aborted bool
}
func (m *copyObjectMock) HeadObject(input *awss3.HeadObjectInput) (*awss3.HeadObjectOutput, error) {
if m.missing {
return nil, awserr.NewRequestFailure(awserr.New("NotFound", "not found", nil), http.StatusNotFound, "")
}
return &awss3.HeadObjectOutput{
ContentLength: aws.Int64(m.size),
ContentType: aws.String("image/jpeg"),
Expires: aws.String("Wed, 21 Oct 2026 07:28:00 GMT"),
WebsiteRedirectLocation: aws.String("/elsewhere"),
ServerSideEncryption: aws.String("aws:kms"),
SSEKMSKeyId: aws.String("key-1"),
ETag: aws.String(`"etag-` + aws.StringValue(input.Key) + `"`),
}, nil
}
func (m *copyObjectMock) GetObjectTagging(input *awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
m.tagInputs = append(m.tagInputs, input)
return &awss3.GetObjectTaggingOutput{TagSet: []*awss3.Tag{
{Key: aws.String("album"), Value: aws.String("2026 & co")},
{Key: aws.String("a+b"), Value: aws.String("x=y")},
}}, nil
}
func (m *copyObjectMock) CopyObject(input *awss3.CopyObjectInput) (*awss3.CopyObjectOutput, error) {
m.copyInputs = append(m.copyInputs, input)
return &awss3.CopyObjectOutput{}, nil
}
func (m *copyObjectMock) CreateMultipartUpload(input *awss3.CreateMultipartUploadInput) (*awss3.CreateMultipartUploadOutput, error) {
m.createInput = input
return &awss3.CreateMultipartUploadOutput{UploadId: aws.String("upload-1")}, nil
}
func (m *copyObjectMock) UploadPartCopy(input *awss3.UploadPartCopyInput) (*awss3.UploadPartCopyOutput, error) {
m.partInputs = append(m.partInputs, input)
if m.partErr != nil && len(m.partInputs) == 2 {
return nil, m.partErr
}
return &awss3.UploadPartCopyOutput{CopyPartResult: &awss3.CopyPartResult{ETag: aws.String(`"part"`)}}, nil
}
func (m *copyObjectMock) CompleteMultipartUpload(input *awss3.CompleteMultipartUploadInput) (*awss3.CompleteMultipartUploadOutput, error) {
m.completed = input
return &awss3.CompleteMultipartUploadOutput{}, nil
}
func (m *copyObjectMock) AbortMultipartUpload(*awss3.AbortMultipartUploadInput) (*awss3.AbortMultipartUploadOutput, error) {
m.aborted = true
return &awss3.AbortMultipartUploadOutput{}, nil
}
func newCopyTestClient(mock *copyObjectMock) *s3RemoteStorageClient {
return &s3RemoteStorageClient{conf: &remote_pb.RemoteConf{Name: "test"}, conn: mock}
}
var (
copySrc = &remote_pb.RemoteStorageLocation{Name: "test", Bucket: "bucket", Path: "/src/a b.jpg"}
copyDst = &remote_pb.RemoteStorageLocation{Name: "test", Bucket: "bucket", Path: "/dst/a b.jpg"}
)
func TestS3ClientImplementsObjectCopier(t *testing.T) {
var _ remote_storage.RemoteStorageObjectCopier = &s3RemoteStorageClient{}
}
func TestS3CopyFileUsesCopyObjectUpToTheLimit(t *testing.T) {
mock := &copyObjectMock{size: s3CopyObjectSizeLimit}
remoteEntry, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
require.NoError(t, err)
require.Len(t, mock.copyInputs, 1)
require.Equal(t, "bucket/src/a%20b.jpg", aws.StringValue(mock.copyInputs[0].CopySource))
require.Equal(t, "dst/a b.jpg", aws.StringValue(mock.copyInputs[0].Key))
require.Equal(t, "aws:kms", aws.StringValue(mock.copyInputs[0].ServerSideEncryption))
require.Equal(t, "key-1", aws.StringValue(mock.copyInputs[0].SSEKMSKeyId))
require.Nil(t, mock.createInput)
// the RemoteEntry describes the destination
require.Equal(t, `"etag-dst/a b.jpg"`, remoteEntry.RemoteETag)
require.Equal(t, int64(s3CopyObjectSizeLimit), remoteEntry.RemoteSize)
}
func TestS3CopyFileCopiesLargeObjectsInParts(t *testing.T) {
size := int64(s3CopyObjectSizeLimit) + 1
mock := &copyObjectMock{size: size}
_, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
require.NoError(t, err)
require.Empty(t, mock.copyInputs)
require.NotNil(t, mock.createInput)
require.Equal(t, "image/jpeg", aws.StringValue(mock.createInput.ContentType))
require.Equal(t, "/elsewhere", aws.StringValue(mock.createInput.WebsiteRedirectLocation))
require.Equal(t, "aws:kms", aws.StringValue(mock.createInput.ServerSideEncryption))
require.Equal(t, "key-1", aws.StringValue(mock.createInput.SSEKMSKeyId))
require.Equal(t, int64(1792567680), aws.TimeValue(mock.createInput.Expires).Unix())
// tags only where the remote supports tagging
require.Empty(t, mock.tagInputs)
require.Nil(t, mock.createInput.Tagging)
require.Len(t, mock.partInputs, 11)
require.Equal(t, "bytes=0-536870911", aws.StringValue(mock.partInputs[0].CopySourceRange))
require.Equal(t, "bytes=5368709120-5368709120", aws.StringValue(mock.partInputs[10].CopySourceRange))
for _, part := range mock.partInputs {
require.Equal(t, `"etag-src/a b.jpg"`, aws.StringValue(part.CopySourceIfMatch))
}
require.Len(t, mock.completed.MultipartUpload.Parts, 11)
require.Equal(t, int64(11), aws.Int64Value(mock.completed.MultipartUpload.Parts[10].PartNumber))
require.False(t, mock.aborted)
}
func TestS3CopyFileCarriesTagsIntoAMultipartCopy(t *testing.T) {
mock := &copyObjectMock{size: s3CopyObjectSizeLimit + 1}
client := newCopyTestClient(mock)
client.conf.S3SupportTagging = true
_, err := client.CopyFile(copySrc, copyDst)
require.NoError(t, err)
require.Len(t, mock.tagInputs, 1)
require.Equal(t, "src/a b.jpg", aws.StringValue(mock.tagInputs[0].Key))
require.Equal(t, "album=2026%20%26%20co&a%2Bb=x%3Dy", aws.StringValue(mock.createInput.Tagging))
}
func TestS3CopyFileAbortsAFailedMultipartCopy(t *testing.T) {
mock := &copyObjectMock{size: 2 * s3CopyObjectSizeLimit, partErr: errors.New("part failed")}
_, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
require.ErrorContains(t, err, "part failed")
require.True(t, mock.aborted)
require.Nil(t, mock.completed)
}
func TestS3CopyFileMissingSource(t *testing.T) {
mock := &copyObjectMock{missing: true}
_, err := newCopyTestClient(mock).CopyFile(copySrc, copyDst)
require.ErrorIs(t, err, remote_storage.ErrRemoteObjectNotFound)
require.Empty(t, mock.copyInputs)
}
+1
View File
@@ -110,6 +110,7 @@ func (s3a *S3ApiServer) createMultipartUpload(r *http.Request, input *s3.CreateM
if input.ContentType != nil {
entry.Attributes.Mime = *input.ContentType
}
applyPutObjectACL(r, entry)
// Prepare and apply encryption configuration within directory creation
// This ensures encryption resources are only allocated if directory creation succeeds
@@ -0,0 +1,126 @@
package s3api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/require"
)
func TestParseS3MetadataIgnoresInternalAclHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "http://s3/b/o?uploads", nil)
req.Header.Set(s3_constants.ExtAmzAclKey, `[{"Grantee":{"Type":"Group","URI":"http://acs.amazonaws.com/groups/global/AllUsers"},"Permission":"READ"}]`)
req.Header.Set(s3_constants.ExtAmzOwnerKey, "forged-owner")
req.Header.Set("X-Amz-Meta-Color", "red")
metadata, code := ParseS3Metadata(req, nil, false)
require.Equal(t, s3err.ErrNone, code)
require.NotContains(t, metadata, s3_constants.ExtAmzAclKey)
require.NotContains(t, metadata, s3_constants.ExtAmzOwnerKey)
require.Equal(t, "red", string(metadata["X-Amz-Meta-Color"]))
}
// Multipart initiation previously trusted the internal Seaweed-X-Amz-*
// headers, so a caller could forge ownership or a public ACL onto the
// completed object without s3:PutObjectAcl. Initiate must run the same ACL
// validation as PUT and store only the validated grants.
func TestNewMultipartUploadInitiateACL(t *testing.T) {
const bucket, object, writer, bucketOwner = "mpu-bucket", "dir/blob.bin", "mpu-writer", "bucket-owner"
forgedACL := `[{"Grantee":{"Type":"Group","URI":"http://acs.amazonaws.com/groups/global/AllUsers"},"Permission":"READ"}]`
setup := func(t *testing.T, writeAcp bool) (*S3ApiServer, *ambiguousPutFiler) {
volume := startFakeVolumeServer(t)
f := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, f))
s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory")
t.Cleanup(s3a.iam.Shutdown)
s3a.iam.isAuthEnabled = true
account := &Account{Id: writer, DisplayName: writer}
identity := &Identity{Name: "mpu-acl-test", Account: account, IsStatic: true,
Actions: []Action{"Write:" + bucket + "/*"},
Credentials: []*Credential{{AccessKey: routingTestAccessKey, SecretKey: routingTestSecretKey}}}
if writeAcp {
identity.Actions = append(identity.Actions, Action("WriteAcp:"+bucket+"/*"))
}
s3a.iam.accessKeyIdent[routingTestAccessKey] = identity
s3a.iam.nameToIdentity[identity.Name] = identity
s3a.iam.accounts[writer] = account
s3a.iam.accounts[bucketOwner] = &Account{Id: bucketOwner, DisplayName: bucketOwner}
bucketEntry := &filer_pb.Entry{Name: bucket, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte(bucketOwner)}}
bucketEntry.Extended[s3_constants.ExtOwnershipKey] = []byte(s3_constants.OwnershipObjectWriter)
f.entries["/buckets/"+bucket] = bucketEntry
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: s3_constants.OwnershipObjectWriter})
return s3a, f
}
initiate := func(t *testing.T, s3a *S3ApiServer, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "http://s3/"+bucket+"/"+object+"?uploads", nil)
req = mux.SetURLVars(req, map[string]string{"bucket": bucket, "object": object})
for k, v := range headers {
req.Header.Set(k, v)
}
signRoutingTestRequest(t, req, "", "s3")
rr := httptest.NewRecorder()
s3a.iam.Auth(s3a.NewMultipartUploadHandler, s3_constants.ACTION_WRITE)(rr, req)
return rr
}
uploadEntry := func(t *testing.T, f *ambiguousPutFiler) *filer_pb.Entry {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
prefix := "/buckets/" + bucket + "/" + s3_constants.MultipartUploadsFolder + "/"
for key, entry := range f.entries {
if strings.HasPrefix(key, prefix) {
return entry
}
}
t.Fatalf("no upload entry recorded under %s", prefix)
return nil
}
t.Run("forged internal headers are ignored", func(t *testing.T) {
s3a, f := setup(t, false)
rr := initiate(t, s3a, map[string]string{
s3_constants.ExtAmzAclKey: forgedACL,
s3_constants.ExtAmzOwnerKey: "forged-owner",
})
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
entry := uploadEntry(t, f)
require.Equal(t, writer, string(entry.Extended[s3_constants.ExtAmzOwnerKey]))
require.NotEqual(t, forgedACL, string(entry.Extended[s3_constants.ExtAmzAclKey]))
for _, grant := range GetAcpGrants(entry.Extended) {
require.Nil(t, grant.Grantee.URI)
}
})
t.Run("explicit acl requires write acp", func(t *testing.T) {
s3a, f := setup(t, false)
rr := initiate(t, s3a, map[string]string{s3_constants.AmzCannedAcl: "public-read"})
require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
require.Empty(t, f.entries["/buckets/"+bucket+"/"+s3_constants.MultipartUploadsFolder])
})
t.Run("explicit acl lands on upload entry", func(t *testing.T) {
s3a, f := setup(t, true)
rr := initiate(t, s3a, map[string]string{s3_constants.AmzCannedAcl: "public-read"})
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
grants := GetAcpGrants(uploadEntry(t, f).Extended)
var publicRead bool
for _, grant := range grants {
if grant.Grantee.URI != nil && *grant.Grantee.URI == "http://acs.amazonaws.com/groups/global/AllUsers" && grant.Permission != nil && *grant.Permission == "READ" {
publicRead = true
}
}
require.True(t, publicRead, "public-read grants should persist on the upload entry: %s", string(uploadEntry(t, f).Extended[s3_constants.ExtAmzAclKey]))
})
}
-12
View File
@@ -94,18 +94,6 @@ func ParseS3Metadata(r *http.Request, existing map[string][]byte, isReplace bool
metadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(keyMD5)
}
// ACL owner
acpOwner := r.Header.Get(s3_constants.ExtAmzOwnerKey)
if len(acpOwner) > 0 {
metadata[s3_constants.ExtAmzOwnerKey] = []byte(acpOwner)
}
// ACL grants
acpGrants := r.Header.Get(s3_constants.ExtAmzAclKey)
if len(acpGrants) > 0 {
metadata[s3_constants.ExtAmzAclKey] = []byte(acpGrants)
}
return metadata, s3err.ErrNone
}
+4 -2
View File
@@ -2430,8 +2430,10 @@ func (s3a *S3ApiServer) setResponseHeaders(w http.ResponseWriter, r *http.Reques
// Apply S3 passthrough headers from query parameters
// AWS S3 supports overriding response headers via query parameters like:
// ?response-cache-control=no-cache&response-content-type=application/json
// This allows presigned URLs to control how browsers handle the downloaded content
if r != nil {
// This allows presigned URLs to control how browsers handle the downloaded content.
// AWS requires the request to be signed; anonymous requests must not influence
// how the stored object is served.
if r != nil && getRequestAuthType(r) != authTypeAnonymous {
for queryParam, headerValue := range r.URL.Query() {
if normalizedHeader, ok := s3_constants.PassThroughHeaders[strings.ToLower(queryParam)]; ok && len(headerValue) > 0 && headerValue[0] != "" {
w.Header().Set(normalizedHeader, headerValue[0])
@@ -238,3 +238,30 @@ func TestRemoteObjectETagFallback(t *testing.T) {
t.Errorf("local object ETag = %q", got)
}
}
func TestSetResponseHeadersIgnoresOverridesForAnonymous(t *testing.T) {
s3a := &S3ApiServer{}
entry := &filer_pb.Entry{
Name: "object",
Attributes: &filer_pb.FuseAttributes{FileSize: 8, Mtime: 100},
}
for name, url := range map[string]string{
"anonymous": "/bucket/object?response-content-type=text%2Fhtml&response-content-disposition=inline",
"signed": "/bucket/object?response-content-type=text%2Fhtml&response-content-disposition=inline",
"presigned": "/bucket/object?X-Amz-Credential=k&response-content-type=text%2Fhtml",
} {
r := httptest.NewRequest(http.MethodGet, url, nil)
if name == "signed" {
r.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=k")
}
w := httptest.NewRecorder()
s3a.setResponseHeaders(w, r, entry, 8)
if name == "anonymous" {
if got := w.Header().Get("Content-Disposition"); got != "" {
t.Errorf("anonymous request must not override response headers, got Content-Disposition %q", got)
}
} else if got := w.Header().Get("Content-Type"); got != "text/html" {
t.Errorf("%s request should apply response overrides, got Content-Type %q", name, got)
}
}
}
@@ -97,6 +97,15 @@ func (s3a *S3ApiServer) NewMultipartUploadHandler(w http.ResponseWriter, r *http
}
}
// An explicit ACL on initiate applies to the completed object and requires
// s3:PutObjectAcl, same as PUT. The validated grants ride on the upload
// entry so CompleteMultipartUpload cannot be handed forged internal headers.
r, aclCode := s3a.preparePutObjectACL(r, bucket)
if aclCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, aclCode)
return
}
createMultipartUploadInput := &s3.CreateMultipartUploadInput{
Bucket: aws.String(bucket),
Key: objectKey(aws.String(object)),
+1 -42
View File
@@ -2,7 +2,6 @@ package s3api
import (
"context"
"net"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
@@ -10,7 +9,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/security"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
@@ -56,46 +54,7 @@ func (s3a *S3ApiServer) checkAdminAuth(ctx context.Context) error {
}
func checkLocalPeer(ctx context.Context) error {
if ctx == nil {
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
}
pr, ok := peer.FromContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
}
if _, isUnix := pr.Addr.(*net.UnixAddr); isUnix {
return nil
}
var ip net.IP
if tcpAddr, ok := pr.Addr.(*net.TCPAddr); ok {
ip = tcpAddr.IP
} else if h, _, err := net.SplitHostPort(pr.Addr.String()); err == nil {
ip = net.ParseIP(h)
} else {
ip = net.ParseIP(pr.Addr.String())
}
if ip != nil && (ip.IsLoopback() || isLocalAddress(ip)) {
return nil
}
glog.V(1).Infof("rejected unauthenticated admin gRPC call from %s: no jwt.filer_signing.key configured", pr.Addr)
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
}
// isLocalAddress enumerates interfaces per call: only reachable for
// non-loopback TCP peers on the no-key admin path, which is low-volume —
// and a cached set would keep trusting an address after it is removed from
// the host and reassigned.
func isLocalAddress(ip net.IP) bool {
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, a := range addrs {
if ipNet, ok := a.(*net.IPNet); ok && ipNet.IP.Equal(ip) {
return true
}
}
return false
return security.CheckLocalGrpcPeer(ctx)
}
func (s3a *S3ApiServer) PutIdentity(ctx context.Context, req *iam_pb.PutIdentityRequest) (*iam_pb.PutIdentityResponse, error) {
@@ -3,6 +3,7 @@ package s3tables
import (
"context"
"encoding/json"
"path"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
@@ -120,3 +121,50 @@ func TestDeleteTableRefusesLiveSiblingDataPath(t *testing.T) {
assert.NotNil(t, fs.Get(GetTablePath(renameTestBucket, "ns", "t"), "data"),
"the targeted table's data must survive")
}
// A location inside another table's directory carries no catalog marker of its
// own — the marker lives on the name path. Dropping a table whose
// MetadataLocation resolves into nsa/vtab/data must be refused by the ancestor
// check, or the victim's marker-less data subdirectory is purged wholesale.
func TestDeleteTableRefusesNestedSiblingDataPath(t *testing.T) {
fs, m := startRenameManager(t)
nsaMeta, _ := json.Marshal(namespaceMetadata{Namespace: []string{"nsa"}, OwnerAccountID: DefaultAccountID})
fs.Put(GetTableBucketPath(renameTestBucket), "nsa", map[string][]byte{ExtendedKeyMetadata: nsaMeta})
victimMeta, _ := json.Marshal(tableMetadataInternal{
Name: "vtab",
Namespace: "nsa",
Format: "ICEBERG",
OwnerAccountID: DefaultAccountID,
MetadataLocation: "s3://" + renameTestBucket + "/nsa/vtab/metadata/v1.metadata.json",
})
victimTablePath := GetTablePath(renameTestBucket, "nsa", "vtab")
fs.Put(GetNamespacePath(renameTestBucket, "nsa"), "vtab", map[string][]byte{
ExtendedKeyMetadata: victimMeta,
ExtendedKeyEntryType: []byte(EntryTypeTable),
})
fs.Put(victimTablePath, "data", nil)
fs.Put(path.Join(victimTablePath, "data"), "important-0001.parquet", nil)
evilMeta, _ := json.Marshal(tableMetadataInternal{
Name: "evil",
Namespace: "attackerns2",
Format: "ICEBERG",
OwnerAccountID: DefaultAccountID,
MetadataLocation: "s3://" + renameTestBucket + "/nsa/vtab/data/metadata/v1.metadata.json",
})
nsMeta, _ := json.Marshal(namespaceMetadata{Namespace: []string{"attackerns2"}, OwnerAccountID: DefaultAccountID})
fs.Put(GetTableBucketPath(renameTestBucket), "attackerns2", map[string][]byte{ExtendedKeyMetadata: nsMeta})
fs.Put(GetNamespacePath(renameTestBucket, "attackerns2"), "evil", map[string][]byte{
ExtendedKeyMetadata: evilMeta,
ExtendedKeyEntryType: []byte(EntryTypeTable),
})
require.Error(t, runDeleteTable(t, m, fs, "attackerns2", "evil"))
assert.NotNil(t, fs.Get(path.Join(victimTablePath, "data"), "important-0001.parquet"),
"data inside another table's directory must survive")
assert.NotNil(t, fs.Get(GetNamespacePath(renameTestBucket, "nsa"), "vtab"),
"the victim table's catalog entry must survive")
}
+24 -3
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
@@ -100,7 +101,7 @@ func (h *S3TablesHandler) handleCreateTable(w http.ResponseWriter, r *http.Reque
return err
}
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
@@ -233,7 +234,7 @@ func (h *S3TablesHandler) handleRegisterTable(w http.ResponseWriter, r *http.Req
}
bucketName, namespaceName, tableName := target.bucketName, target.namespaceName, target.tableName
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
@@ -996,6 +997,26 @@ func (h *S3TablesHandler) handleDeleteTable(w http.ResponseWriter, r *http.Reque
// still a live catalog entry. Only this table's authorization was
// checked; a decoupled location has had its catalog attributes
// stripped, so a surviving marker means the path belongs elsewhere.
// The marker lives on the entry's name path, so ancestors of the
// data path are checked as well: a location inside another table's
// directory resolves above any marker the data path itself would
// carry.
for dir := path.Dir(dataPath); dir != GetTableBucketPath(bucketName) && strings.HasPrefix(dir, GetTableBucketPath(bucketName)+"/"); dir = path.Dir(dir) {
ancestor, lookupErr := h.lookupEntry(r.Context(), client, dir)
if lookupErr != nil {
if errors.Is(lookupErr, filer_pb.ErrNotFound) {
continue
}
return fmt.Errorf("refusing to delete table %s: cannot read data path %q: %w", tableName, dataPath, lookupErr)
}
if _, ok := ancestor.Extended[ExtendedKeyEntryType]; ok {
return fmt.Errorf("refusing to delete table %s: data path %q is inside another catalog entry", tableName, dataPath)
}
var ownerMeta tableMetadataInternal
if data := ancestor.Extended[ExtendedKeyMetadata]; json.Unmarshal(data, &ownerMeta) == nil && ownerMeta.Name != "" {
return fmt.Errorf("refusing to delete table %s: data path %q is inside another catalog entry", tableName, dataPath)
}
}
switch _, markerErr := h.getExtendedAttribute(r.Context(), client, dataPath, ExtendedKeyMetadata); {
case markerErr == nil:
return fmt.Errorf("refusing to delete table %s: data path %q is another catalog entry", tableName, dataPath)
@@ -1494,7 +1515,7 @@ func (h *S3TablesHandler) handleUpdateTable(w http.ResponseWriter, r *http.Reque
return err
}
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
+2 -2
View File
@@ -121,7 +121,7 @@ func (h *S3TablesHandler) handleCreateView(w http.ResponseWriter, r *http.Reques
return err
}
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
@@ -410,7 +410,7 @@ func (h *S3TablesHandler) handleUpdateView(w http.ResponseWriter, r *http.Reques
return err
}
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName); err != nil {
if err := ValidateMetadataLocation(req.MetadataLocation, bucketName, namespaceName); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
+10 -5
View File
@@ -127,11 +127,13 @@ func TableDataDirFromMetadataLocation(metadataLocation string) string {
}
// ValidateMetadataLocation checks that an s3:// metadata location stays within
// the authorized table bucket and rejects traversal segments that path.Join
// would collapse to escape the bucket directory. Empty locations are allowed
// (the catalog derives one). A non-empty location must include a table path so
// its metadata directory is table-specific, not shared at the bucket level.
func ValidateMetadataLocation(metadataLocation, bucketName string) error {
// the authorized table bucket and inside the caller's own namespace subtree,
// the boundary per-tenant policies are delegated on. Without the namespace
// check a caller could register a location inside another tenant's data
// directory and DeleteTable would purge it. Empty locations are allowed (the
// catalog derives one). A non-empty location must include a table path so its
// metadata directory is table-specific, not shared at the bucket level.
func ValidateMetadataLocation(metadataLocation, bucketName, namespace string) error {
if metadataLocation == "" {
return nil
}
@@ -155,6 +157,9 @@ func ValidateMetadataLocation(metadataLocation, bucketName string) error {
if !hasSegment {
return fmt.Errorf("metadata location must include a table path")
}
if !strings.HasPrefix(tablePath, namespace+"/") {
return fmt.Errorf("metadata location must be within namespace %s", namespace)
}
return nil
}
+25 -18
View File
@@ -4,29 +4,36 @@ import "testing"
func TestValidateMetadataLocation(t *testing.T) {
tests := []struct {
name string
loc string
bucket string
wantErr bool
name string
loc string
bucket string
namespace string
wantErr bool
}{
{"empty allowed", "", "bkt", false},
{"same bucket", "s3://bkt/ns/t/metadata/v1.metadata.json", "bkt", false},
{"cross bucket rejected", "s3://other/ns/t/metadata/v1.metadata.json", "bkt", true},
{"bucket only rejected", "s3://bkt", "bkt", true},
{"bucket with trailing slash rejected", "s3://bkt/", "bkt", true},
{"slash-only path rejected", "s3://bkt///", "bkt", true},
{"dotdot in path rejected", "s3://bkt/../victim/metadata/v1.metadata.json", "bkt", true},
{"dotdot mid path rejected", "s3://bkt/ns/../../victim/metadata/v1.metadata.json", "bkt", true},
{"dot segment rejected", "s3://bkt/ns/./t/metadata/v1.metadata.json", "bkt", true},
{"backslash segment rejected", "s3://bkt/ns/\\t/metadata/v1.metadata.json", "bkt", true},
{"non-s3 scheme rejected", "file:///bkt/ns/t", "bkt", true},
{"empty location string", "s3://", "bkt", true},
{"empty allowed", "", "bkt", "ns", false},
{"own namespace", "s3://bkt/ns/t/metadata/v1.metadata.json", "bkt", "ns", false},
{"own namespace arbitrary layout", "s3://bkt/ns/warehouse/t/metadata/v1.metadata.json", "bkt", "ns", false},
{"cross bucket rejected", "s3://other/ns/t/metadata/v1.metadata.json", "bkt", "ns", true},
{"cross namespace rejected", "s3://bkt/nsa/t/metadata/v1.metadata.json", "bkt", "ns", true},
{"cross namespace data dir rejected", "s3://bkt/nsa/vtab/data/metadata/v1.metadata.json", "bkt", "ns", true},
{"namespace prefix rejected", "s3://bkt/nsa/vtab/metadata/v1.metadata.json", "bkt", "ns", true},
{"outside namespace tree rejected", "s3://bkt/shared/t/metadata/v1.metadata.json", "bkt", "ns", true},
{"namespace dir without table path rejected", "s3://bkt/ns", "bkt", "ns", true},
{"bucket only rejected", "s3://bkt", "bkt", "ns", true},
{"bucket with trailing slash rejected", "s3://bkt/", "bkt", "ns", true},
{"slash-only path rejected", "s3://bkt///", "bkt", "ns", true},
{"dotdot in path rejected", "s3://bkt/../victim/metadata/v1.metadata.json", "bkt", "ns", true},
{"dotdot mid path rejected", "s3://bkt/ns/../../victim/metadata/v1.metadata.json", "bkt", "ns", true},
{"dot segment rejected", "s3://bkt/ns/./t/metadata/v1.metadata.json", "bkt", "ns", true},
{"backslash segment rejected", "s3://bkt/ns/\\t/metadata/v1.metadata.json", "bkt", "ns", true},
{"non-s3 scheme rejected", "file:///bkt/ns/t", "bkt", "ns", true},
{"empty location string", "s3://", "bkt", "ns", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateMetadataLocation(tt.loc, tt.bucket)
err := ValidateMetadataLocation(tt.loc, tt.bucket, tt.namespace)
if (err != nil) != tt.wantErr {
t.Fatalf("ValidateMetadataLocation(%q, %q) err = %v, wantErr = %v", tt.loc, tt.bucket, err, tt.wantErr)
t.Fatalf("ValidateMetadataLocation(%q, %q, %q) err = %v, wantErr = %v", tt.loc, tt.bucket, tt.namespace, err, tt.wantErr)
}
})
}
+113
View File
@@ -0,0 +1,113 @@
package security
import (
"context"
"net"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
// CheckGrpcAdminPeer authenticates the peer of an admin-plane gRPC handler:
// a client certificate verified by the server's TLS config, or a Bearer token
// signed by signingKey (jwt.filer_signing.key). With no key configured remote
// callers cannot be told apart, so only local clients (unix socket, loopback,
// or an address assigned to this host) are allowed.
func CheckGrpcAdminPeer(ctx context.Context, signingKey SigningKey) error {
if isMtlsVerifiedPeer(ctx) {
return nil
}
if len(signingKey) == 0 {
return CheckLocalGrpcPeer(ctx)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "missing metadata")
}
authHeaders := md.Get("authorization")
if len(authHeaders) == 0 {
return status.Error(codes.Unauthenticated, "missing authorization metadata")
}
raw := strings.TrimSpace(authHeaders[0])
parts := strings.Fields(raw)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
return status.Error(codes.Unauthenticated, "authorization header must use Bearer scheme")
}
parsed, err := DecodeJwt(signingKey, EncodedJwt(parts[1]), &SeaweedFilerAdminClaims{})
if err != nil || parsed == nil || !parsed.Valid {
return status.Error(codes.Unauthenticated, "invalid admin token")
}
if claims, ok := parsed.Claims.(*SeaweedFilerAdminClaims); !ok || claims.SessionId != "" {
return status.Error(codes.Unauthenticated, "invalid admin token")
}
return nil
}
// GrpcAdminPeerCredentials returns outgoing metadata carrying a Bearer token
// for admin-plane gRPC handlers, or nil when no signing key is configured.
func GrpcAdminPeerCredentials(signingKey SigningKey, expiresAfterSec int) metadata.MD {
token := GenJwtForFilerAdmin(signingKey, expiresAfterSec)
if token == "" {
return nil
}
return metadata.Pairs("authorization", "Bearer "+string(token))
}
func isMtlsVerifiedPeer(ctx context.Context) bool {
pr, ok := peer.FromContext(ctx)
if !ok {
return false
}
tlsInfo, ok := pr.AuthInfo.(credentials.TLSInfo)
return ok && len(tlsInfo.State.VerifiedChains) > 0
}
// CheckLocalGrpcPeer admits only clients on this host: unix sockets, loopback,
// or an address assigned to a local interface.
func CheckLocalGrpcPeer(ctx context.Context) error {
if ctx == nil {
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
}
pr, ok := peer.FromContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
}
if _, isUnix := pr.Addr.(*net.UnixAddr); isUnix {
return nil
}
var ip net.IP
if tcpAddr, ok := pr.Addr.(*net.TCPAddr); ok {
ip = tcpAddr.IP
} else if h, _, err := net.SplitHostPort(pr.Addr.String()); err == nil {
ip = net.ParseIP(h)
} else {
ip = net.ParseIP(pr.Addr.String())
}
if ip != nil && (ip.IsLoopback() || isLocalAddress(ip)) {
return nil
}
glog.V(1).Infof("rejected unauthenticated admin gRPC call from %s: no jwt.filer_signing.key configured", pr.Addr)
return status.Error(codes.Unauthenticated, "admin gRPC calls require jwt.filer_signing.key or a local client")
}
// isLocalAddress enumerates interfaces per call: only reachable for
// non-loopback TCP peers on the no-key admin path, which is low-volume —
// and a cached set would keep trusting an address after it is removed from
// the host and reassigned.
func isLocalAddress(ip net.IP) bool {
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, a := range addrs {
if ipNet, ok := a.(*net.IPNet); ok && ipNet.IP.Equal(ip) {
return true
}
}
return false
}
+68
View File
@@ -0,0 +1,68 @@
package security
import (
"context"
"crypto/tls"
"crypto/x509"
"net"
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
func peerCtx(addr net.Addr) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{Addr: addr})
}
func bearerCtx(addr net.Addr, signingKey SigningKey) context.Context {
ctx := peerCtx(addr)
token := GenJwtForFilerAdmin(signingKey, 0)
return metadata.NewIncomingContext(ctx, metadata.Pairs("authorization", "Bearer "+string(token)))
}
func TestCheckGrpcAdminPeer(t *testing.T) {
key := SigningKey("test-signing-key-0123456789abcdef")
loopback := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5000}
remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.9"), Port: 5000}
for name, tc := range map[string]struct {
ctx context.Context
signingKey SigningKey
wantCode codes.Code
}{
"no peer info, no key": {context.Background(), nil, codes.Unauthenticated},
"loopback, no key": {peerCtx(loopback), nil, codes.OK},
"remote, no key": {peerCtx(remote), nil, codes.Unauthenticated},
"remote + valid token": {bearerCtx(remote, key), key, codes.OK},
"remote + no metadata, key set": {peerCtx(remote), key, codes.Unauthenticated},
"remote + bad token": {metadata.NewIncomingContext(peerCtx(remote), metadata.Pairs("authorization", "Bearer wrong")), key, codes.Unauthenticated},
"unix socket, no key": {peerCtx(&net.UnixAddr{Name: "/tmp/s.sock"}), nil, codes.OK},
"mtls verified peer": {peer.NewContext(context.Background(), &peer.Peer{
Addr: remote,
AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{}}}},
}), nil, codes.OK},
} {
err := CheckGrpcAdminPeer(tc.ctx, tc.signingKey)
got := codes.OK
if err != nil {
got = status.Code(err)
}
if got != tc.wantCode {
t.Errorf("%s: got %v want %v (err=%v)", name, got, tc.wantCode, err)
}
}
}
func TestGrpcAdminPeerCredentials(t *testing.T) {
if md := GrpcAdminPeerCredentials(nil, 0); len(md) != 0 {
t.Errorf("expected nil metadata without signing key, got %v", md)
}
md := GrpcAdminPeerCredentials(SigningKey("key"), 60)
if len(md.Get("authorization")) != 1 {
t.Fatalf("expected one authorization entry, got %v", md)
}
}
+2 -1
View File
@@ -96,7 +96,8 @@ func (fs *FilerServer) doCacheRemoteObjectToLocalCluster(ctx context.Context, re
glog.V(1).Infof("CacheRemoteObjectToLocalCluster: caching %s/%s (remote size: %d)", req.Directory, req.Name, entry.Remote.RemoteSize)
storageConf, remoteLocation, err := fs.resolveMountedRemote(ctx, req.Directory, req.Name)
readDir, readName := fs.filer.RemoteReadPath(entry, req.Directory, req.Name)
storageConf, remoteLocation, err := fs.resolveMountedRemote(ctx, readDir, readName)
if err != nil {
return nil, err
}
+188 -15
View File
@@ -2,7 +2,9 @@ package weed_server
import (
"context"
"errors"
"fmt"
"maps"
"path/filepath"
"time"
@@ -45,6 +47,9 @@ func (fs *FilerServer) AtomicRenameEntry(ctx context.Context, req *filer_pb.Atom
defer fs.acquireRenamePathLocks("AtomicRenameEntry", oldParent.Child(req.OldName), newParent.Child(req.NewName))()
// a SQL store keeps the transaction in the context and refuses calls
// through it once committed; the work after the commit uses this one
afterCommitCtx := ctx
ctx, err := fs.filer.BeginTransaction(ctx)
if err != nil {
return nil, err
@@ -58,7 +63,8 @@ func (fs *FilerServer) AtomicRenameEntry(ctx context.Context, req *filer_pb.Atom
var metadataEvents []metadataEvent
var pendingChunkDeletes []*filer_pb.FileChunk
moveErr := fs.moveEntry(ctx, nil, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes)
var pendingRemoteCopies []*pendingRemoteCopy
moveErr := fs.moveEntry(ctx, nil, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes, &pendingRemoteCopies)
if moveErr != nil {
fs.filer.RollbackTransaction(ctx)
return nil, fmt.Errorf("%s/%s move error: %v", req.OldDirectory, req.OldName, moveErr)
@@ -75,8 +81,10 @@ func (fs *FilerServer) AtomicRenameEntry(ctx context.Context, req *filer_pb.Atom
if len(pendingChunkDeletes) > 0 {
fs.filer.DeleteChunksNotRecursive(pendingChunkDeletes)
}
fs.finishRemoteCopies(afterCommitCtx, pendingRemoteCopies)
applyRemoteCopies(metadataEvents, pendingRemoteCopies)
for _, event := range metadataEvents {
event.notify(fs.filer, ctx, req.Signatures)
event.notify(fs.filer, afterCommitCtx, req.Signatures)
}
return &filer_pb.AtomicRenameEntryResponse{}, nil
@@ -97,6 +105,7 @@ func (fs *FilerServer) StreamRenameEntry(req *filer_pb.StreamRenameEntryRequest,
ctx := context.Background()
afterCommitCtx := ctx
ctx, err = fs.filer.BeginTransaction(ctx)
if err != nil {
return err
@@ -125,7 +134,8 @@ func (fs *FilerServer) StreamRenameEntry(req *filer_pb.StreamRenameEntryRequest,
var metadataEvents []metadataEvent
var pendingChunkDeletes []*filer_pb.FileChunk
moveErr := fs.moveEntry(ctx, stream, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes)
var pendingRemoteCopies []*pendingRemoteCopy
moveErr := fs.moveEntry(ctx, stream, oldParent, oldEntry, newParent, req.NewName, req.Signatures, false, &metadataEvents, &pendingChunkDeletes, &pendingRemoteCopies)
if moveErr != nil {
fs.filer.RollbackTransaction(ctx)
return fmt.Errorf("%s/%s move error: %v", req.OldDirectory, req.OldName, moveErr)
@@ -138,8 +148,10 @@ func (fs *FilerServer) StreamRenameEntry(req *filer_pb.StreamRenameEntryRequest,
if len(pendingChunkDeletes) > 0 {
fs.filer.DeleteChunksNotRecursive(pendingChunkDeletes)
}
fs.finishRemoteCopies(afterCommitCtx, pendingRemoteCopies)
applyRemoteCopies(metadataEvents, pendingRemoteCopies)
for _, event := range metadataEvents {
event.notify(fs.filer, ctx, req.Signatures)
event.notify(fs.filer, afterCommitCtx, req.Signatures)
}
return nil
@@ -149,29 +161,119 @@ type metadataEvent struct {
oldEntry *filer.Entry
newEntry *filer.Entry
deleteChunks bool
// the old entry's remote object is deleted once the rename has committed:
// it was copied to the new key, and a rename that fails must not lose it
deleteOldRemote bool
}
// pendingRemoteCopy is a remote-only rename over a remote-only target whose
// copy runs after the commit: the store transaction cannot roll a remote
// write back, so the new entry keeps the old key's stamp until the copy lands.
type pendingRemoteCopy struct {
source *filer.Entry
oldPath util.FullPath
newPath util.FullPath
failed bool
copied *filer_pb.RemoteEntry
}
// finishRemoteCopies runs the deferred remote copies after the commit. A
// failure leaves the new entry pointing at the old key, whose object the
// caller then keeps: remote.sync completes the copy the same way it does a
// rename whose event it finds unfinished.
func (fs *FilerServer) finishRemoteCopies(ctx context.Context, pendings []*pendingRemoteCopy) {
for _, pending := range pendings {
remoteEntry, err := fs.filer.CopyRemoteOnlyEntry(ctx, pending.source, pending.oldPath, pending.newPath)
if err != nil {
glog.WarningfCtx(ctx, "remote rename copy of %s: %v", pending.newPath, err)
pending.failed = true
continue
}
entry, findErr := fs.filer.FindEntry(ctx, pending.newPath)
if findErr != nil {
glog.WarningfCtx(ctx, "stamp remote rename copy of %s: %v", pending.newPath, findErr)
pending.failed = true
continue
}
entry.Remote = remoteEntry
delete(entry.Extended, filer.ExtRemoteRenameSourceKey)
if updateErr := fs.filer.UpdateEntry(ctx, entry, entry, false); updateErr != nil {
glog.WarningfCtx(ctx, "stamp remote rename copy of %s: %v", pending.newPath, updateErr)
pending.failed = true
continue
}
pending.copied = remoteEntry
}
}
// applyRemoteCopies brings the queued rename events in line with the deferred
// copies. The stamp is written with a plain UpdateEntry, which publishes no
// event, so the rename event carries it, without the pending-source marker,
// to peer filers and subscribers. A failed copy keeps the old object, which
// the entry still reads through the marker.
func applyRemoteCopies(events []metadataEvent, pendings []*pendingRemoteCopy) {
for i := range events {
event := &events[i]
for _, pending := range pendings {
if event.oldEntry.FullPath != pending.oldPath {
continue
}
if pending.failed {
event.deleteOldRemote = false
} else if pending.copied != nil && event.newEntry != nil {
newEntry := event.newEntry.ShallowClone()
newEntry.Remote = pending.copied
newEntry.Extended = maps.Clone(newEntry.Extended)
delete(newEntry.Extended, filer.ExtRemoteRenameSourceKey)
event.newEntry = newEntry
}
}
}
}
func (event metadataEvent) notify(f *filer.Filer, ctx context.Context, signatures []int32) {
if event.deleteOldRemote {
deleteRenamedRemoteObject(f, ctx, event.oldEntry)
}
f.NotifyUpdateEvent(ctx, event.oldEntry, event.newEntry, event.deleteChunks, false, signatures)
}
func (fs *FilerServer) moveEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk) error {
// deleteRenamedRemoteObject deletes the remote object a committed rename no
// longer needs. A child path is not locked by its directory's rename, so
// another rename may have put a remote-only entry at the key meanwhile; the
// store is asked directly, a lazy fetch would revive the key. When in doubt
// the object stays: an extra object loses nothing.
func deleteRenamedRemoteObject(f *filer.Filer, ctx context.Context, entry *filer.Entry) {
current, findErr := f.Store.FindEntry(ctx, entry.FullPath)
switch {
case findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound):
glog.WarningfCtx(ctx, "keep remote object of renamed %s: look up its old path: %v", entry.FullPath, findErr)
case findErr == nil && current.IsInRemoteOnly():
glog.V(0).InfofCtx(ctx, "keep remote object of renamed %s: a remote-only entry has taken its path", entry.FullPath)
default:
if err := f.DeleteFromRemote(ctx, entry); err != nil {
glog.WarningfCtx(ctx, "delete remote object of renamed %s: %v", entry.FullPath, err)
}
}
}
func (fs *FilerServer) moveEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk, pendingRemoteCopies *[]*pendingRemoteCopy) error {
if err := fs.moveSelfEntry(ctx, stream, oldParent, entry, newParent, newName, func() error {
if entry.IsDirectory() {
if err := fs.moveFolderSubEntries(ctx, stream, oldParent, entry, newParent, newName, signatures, metadataEvents, pendingChunkDeletes); err != nil {
if err := fs.moveFolderSubEntries(ctx, stream, oldParent, entry, newParent, newName, signatures, metadataEvents, pendingChunkDeletes, pendingRemoteCopies); err != nil {
return err
}
}
return nil
}, signatures, skipTargetLookup, metadataEvents, pendingChunkDeletes); err != nil {
}, signatures, skipTargetLookup, metadataEvents, pendingChunkDeletes, pendingRemoteCopies); err != nil {
return fmt.Errorf("fail to move %s => %s: %v", oldParent.Child(entry.Name()), newParent.Child(newName), err)
}
return nil
}
func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk) error {
func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, signatures []int32, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk, pendingRemoteCopies *[]*pendingRemoteCopy) error {
currentDirPath := oldParent.Child(entry.Name())
newDirPath := newParent.Child(newName)
@@ -194,7 +296,7 @@ func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb
// println("processing", lastFileName)
newChildPath := newDirPath.Child(item.Name())
skipTarget := fs.filer.Store.SameActualStore(newDirPath, newChildPath)
err := fs.moveEntry(ctx, stream, currentDirPath, item, newDirPath, item.Name(), signatures, skipTarget, metadataEvents, pendingChunkDeletes)
err := fs.moveEntry(ctx, stream, currentDirPath, item, newDirPath, item.Name(), signatures, skipTarget, metadataEvents, pendingChunkDeletes, pendingRemoteCopies)
if err != nil {
return err
}
@@ -206,7 +308,7 @@ func (fs *FilerServer) moveFolderSubEntries(ctx context.Context, stream filer_pb
return nil
}
func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, moveFolderSubEntries func() error, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk) error {
func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.SeaweedFiler_StreamRenameEntryServer, oldParent util.FullPath, entry *filer.Entry, newParent util.FullPath, newName string, moveFolderSubEntries func() error, signatures []int32, skipTargetLookup bool, metadataEvents *[]metadataEvent, pendingChunkDeletes *[]*filer_pb.FileChunk, pendingRemoteCopies *[]*pendingRemoteCopy) error {
oldPath, newPath := oldParent.Child(entry.Name()), newParent.Child(newName)
@@ -235,8 +337,48 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee
case !existingTarget.IsDirectory() && entry.IsDirectory():
return fmt.Errorf("%s: %w", existingTarget.FullPath, filer_pb.ErrExistingIsFile)
}
}
// a remote-only entry is copied on the remote before anything changes:
// deleting the old entry deletes the old object. Over a remote-only
// target the copy runs after the commit instead — it overwrites the
// target's only content, which a rolled-back commit cannot restore —
// and the new entry reads the old key until the copy stamps it.
deferredCopy := false
var copiedRemote *filer_pb.RemoteEntry
if existingTarget != nil && existingTarget.IsInRemoteOnly() && entry.IsInRemoteOnly() {
if _, _, copier, copyErr := fs.filer.RemoteCopyLocations(ctx, entry, oldPath, newPath); copyErr != nil {
return copyErr
} else if copier != nil {
deferredCopy = true
*pendingRemoteCopies = append(*pendingRemoteCopies, &pendingRemoteCopy{source: sourceEntry, oldPath: oldPath, newPath: newPath})
}
}
if !deferredCopy {
var copyErr error
copiedRemote, copyErr = fs.filer.CopyRemoteOnlyEntry(ctx, entry, oldPath, newPath)
if copyErr != nil {
return copyErr
}
}
remoteEntry := entry.Remote
if copiedRemote != nil {
remoteEntry = copiedRemote
}
if existingTarget != nil {
targetCtx := filer.WithSuppressedMetadataEvents(ctx)
if copiedRemote != nil || deferredCopy {
// the copy has replaced or still needs the target's object; keep it
targetCtx = filer.WithKeepRemoteObject(targetCtx)
existingTarget.Extended = maps.Clone(existingTarget.Extended)
if existingTarget.Extended == nil {
existingTarget.Extended = map[string][]byte{}
}
existingTarget.Extended[filer.ExtKeepRemoteObjectKey] = []byte("true")
}
if deleteErr := fs.filer.DeleteEntryMetaAndData(
filer.WithSuppressedMetadataEvents(ctx),
targetCtx,
newPath,
false,
false,
@@ -258,9 +400,23 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee
Content: entry.Content,
HardLinkCounter: entry.HardLinkCounter,
HardLinkId: entry.HardLinkId,
Remote: entry.Remote,
Remote: remoteEntry,
Quota: entry.Quota,
}
if deferredCopy {
// a pending entry renamed again still reads its original source
newEntry.Extended = maps.Clone(newEntry.Extended)
if newEntry.Extended == nil {
newEntry.Extended = map[string][]byte{}
}
newEntry.Extended[filer.ExtRemoteRenameSourceKey] = []byte(fs.filer.RemoteSourcePath(entry, oldPath))
} else if copiedRemote != nil && len(newEntry.Extended[filer.ExtRemoteRenameSourceKey]) > 0 {
// the copy came from the pending source; the new key holds it now.
// The source itself is left to remote.sync, which deletes it only
// while it is still the object the rename event recorded
newEntry.Extended = maps.Clone(newEntry.Extended)
delete(newEntry.Extended, filer.ExtRemoteRenameSourceKey)
}
if skipTargetLookup {
if newEntry.FullPath.IsLongerFileName(fs.filer.MaxFilenameLength) {
return filer_pb.ErrEntryNameTooLong
@@ -311,19 +467,36 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee
})
}
*metadataEvents = append(*metadataEvents, metadataEvent{
oldEntry: sourceEntry,
newEntry: newEntry,
oldEntry: sourceEntry,
newEntry: newEntry,
deleteOldRemote: copiedRemote != nil || deferredCopy,
})
selfEvent := len(*metadataEvents) - 1
keepOldRemote := copiedRemote != nil || deferredCopy
if moveFolderSubEntries != nil {
if moveChildrenErr := moveFolderSubEntries(); moveChildrenErr != nil {
return moveChildrenErr
}
// removing the old directory on the remote removes every object under
// its prefix, the copied children's old keys included, which must wait
// for the commit. Those are deleted one by one after it instead, and
// nothing else under the prefix is touched.
for _, event := range (*metadataEvents)[selfEvent+1:] {
if event.deleteOldRemote {
keepOldRemote = true
break
}
}
}
// delete old entry
ctx = context.WithValue(ctx, "OP", "MV")
deleteErr := fs.filer.DeleteEntryMetaAndData(filer.WithSuppressedMetadataEvents(ctx), oldPath, false, false, false, false, signatures, 0)
deleteCtx := filer.WithSuppressedMetadataEvents(ctx)
if keepOldRemote {
deleteCtx = filer.WithKeepRemoteObject(deleteCtx)
}
deleteErr := fs.filer.DeleteEntryMetaAndData(deleteCtx, oldPath, false, false, false, false, signatures, 0)
if deleteErr != nil {
return deleteErr
}
@@ -0,0 +1,430 @@
package weed_server
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
// renameRemoteClient records the remote calls a rename makes. It cannot copy;
// copyingRenameRemoteClient adds CopyFile.
type renameRemoteClient struct {
remote_storage.RemoteStorageClient
mu sync.Mutex
calls []string
copyErr error
}
func (c *renameRemoteClient) record(call string) {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = append(c.calls, call)
}
func (c *renameRemoteClient) recorded() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.calls...)
}
func (c *renameRemoteClient) StatFile(*remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
return nil, remote_storage.ErrRemoteObjectNotFound
}
func (c *renameRemoteClient) ListDirectory(context.Context, *remote_pb.RemoteStorageLocation, remote_storage.VisitFunc) error {
return nil
}
func (c *renameRemoteClient) DeleteFile(loc *remote_pb.RemoteStorageLocation) error {
c.record("delete " + loc.Bucket + loc.Path)
return nil
}
func (c *renameRemoteClient) RemoveDirectory(loc *remote_pb.RemoteStorageLocation) error {
c.record("rmdir " + loc.Bucket + loc.Path)
return nil
}
type copyingRenameRemoteClient struct {
*renameRemoteClient
}
func (c copyingRenameRemoteClient) CopyFile(src, dst *remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
if c.copyErr != nil {
return nil, c.copyErr
}
c.record("copy " + src.Bucket + src.Path + " " + dst.Bucket + dst.Path)
return &filer_pb.RemoteEntry{StorageName: "r1", RemoteSize: 10, RemoteMtime: 1800000000, RemoteETag: `"copy"`}, nil
}
// newRenameRemoteTestServer mounts /buckets/b, /buckets/c and /data/m on r1,
// through client.
func newRenameRemoteTestServer(t *testing.T, client remote_storage.RemoteStorageClient) (*FilerServer, *renameTestStore) {
t.Helper()
store := newRenameTestStore()
f := newRenameTestFiler(t, store)
f.BuildGuardedRemoteClient = func(context.Context, *remote_pb.RemoteConf, bool) (remote_storage.RemoteStorageClient, error) {
return client, nil
}
mapping, err := proto.Marshal(&remote_pb.RemoteStorageMapping{Mappings: map[string]*remote_pb.RemoteStorageLocation{
"/buckets/b": {Name: "r1", Bucket: "origin", Path: "/"},
"/buckets/c": {Name: "r1", Bucket: "other", Path: "/"},
"/data/m": {Name: "r1", Bucket: "origin", Path: "/m"},
}})
require.NoError(t, err)
conf, err := proto.Marshal(&remote_pb.RemoteConf{Name: "r1", Type: "s3"})
require.NoError(t, err)
for name, content := range map[string][]byte{filer.REMOTE_STORAGE_MOUNT_FILE: mapping, "r1" + filer.REMOTE_STORAGE_CONF_SUFFIX: conf} {
entry := newFileEntry(filer.DirectoryEtcRemote+"/"+name, 1)
entry.Content = content
store.entries[string(entry.FullPath)] = entry
}
require.NoError(t, f.RemoteStorage.LoadRemoteStorageConfigurationsAndMapping(f))
for _, dir := range []string{"/buckets", "/buckets/b", "/buckets/b/src", "/buckets/b/dst", "/buckets/c", "/data", "/data/m"} {
store.entries[dir] = newDirectoryEntry(dir, 2)
}
return &FilerServer{filer: f, option: &FilerOption{}, entryLockTable: util.NewLockTable[util.FullPath]()}, store
}
func remoteOnlyRenameEntry(path string, inode uint64) *filer.Entry {
entry := newFileEntry(path, inode)
entry.FileSize = 10
entry.Remote = &filer_pb.RemoteEntry{StorageName: "r1", RemoteSize: 10, RemoteMtime: 1700000000, RemoteETag: `"orig"`}
return entry
}
func renameFile(server *FilerServer, oldPath, newPath util.FullPath) error {
oldDir, oldName := oldPath.DirAndName()
newDir, newName := newPath.DirAndName()
_, err := server.AtomicRenameEntry(context.Background(), &filer_pb.AtomicRenameEntryRequest{
OldDirectory: oldDir,
OldName: oldName,
NewDirectory: newDir,
NewName: newName,
})
return err
}
func TestRenameRemoteOnlyEntryCopiesBeforeDeletingTheOldObject(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
assert.Equal(t, []string{"copy origin/src/a.jpg origin/dst/a.jpg", "delete origin/src/a.jpg"}, client.recorded())
_, err := store.FindEntry(context.Background(), "/buckets/b/src/a.jpg")
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
require.NoError(t, err)
assert.Equal(t, `"copy"`, dst.Remote.RemoteETag)
assert.Equal(t, uint64(101), dst.Attr.Inode)
}
func TestRenameRemoteOnlyEntryOverTargetKeepsTheCopy(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
queue := &captureQueue{}
swapNotificationQueue(t, queue)
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
// the target's delete must not take the copy that replaced its object
assert.Equal(t, []string{"copy origin/src/a.jpg origin/dst/a.jpg", "delete origin/src/a.jpg"}, client.recorded())
events := queue.snapshot()
require.Len(t, events, 2)
assert.True(t, filer.IsMetadataOnlyDelete(events[0].notification.OldEntry), "the target's delete event must leave the remote object alone")
assert.False(t, filer.IsMetadataOnlyDelete(events[1].notification.OldEntry))
// the copy lands after the commit; the rename event still carries its stamp
assert.Equal(t, `"copy"`, events[1].notification.NewEntry.RemoteEntry.GetRemoteETag())
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
require.NoError(t, err)
assert.Equal(t, uint64(101), dst.Attr.Inode)
assert.Equal(t, `"copy"`, dst.Remote.RemoteETag)
assert.Empty(t, dst.Extended[filer.ExtRemoteRenameSourceKey])
// peers replay the post-copy state, not the source's stamp the entry
// committed with
newEvent := events[1].notification.NewEntry
assert.Equal(t, `"copy"`, newEvent.RemoteEntry.RemoteETag)
assert.Empty(t, newEvent.Extended[filer.ExtRemoteRenameSourceKey])
}
func TestRenameRemoteOnlyEntryCopyFailureKeepsTheOldObject(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{copyErr: errors.New("copy refused")}}
server, store := newRenameRemoteTestServer(t, client)
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
// the copy runs after the commit over a remote-only target, so its
// failure cannot roll anything back: the entry still stamps the old
// object, which is kept for remote.sync to finish the copy
assert.Empty(t, client.recorded())
_, err := store.FindEntry(context.Background(), "/buckets/b/src/a.jpg")
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
require.NoError(t, err)
assert.Equal(t, `"orig"`, dst.Remote.RemoteETag)
// reads keep resolving the source's key until the copy stamps the entry:
// its own key still holds the target's old object
assert.Equal(t, "/buckets/b/src/a.jpg", string(dst.Extended[filer.ExtRemoteRenameSourceKey]))
readDir, readName := server.filer.RemoteReadPath(dst, "/buckets/b/dst", "a.jpg")
assert.Equal(t, "/buckets/b/src", readDir)
assert.Equal(t, "a.jpg", readName)
}
func TestRenameRemoteOnlyEntryWithoutCopierIsRefused(t *testing.T) {
client := &renameRemoteClient{}
server, store := newRenameRemoteTestServer(t, client)
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
err := renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg")
require.ErrorContains(t, err, "cannot copy")
assert.Empty(t, client.recorded())
_, err = store.FindEntry(context.Background(), "/buckets/b/src/a.jpg")
require.NoError(t, err)
_, err = store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
}
func TestRenameRemoteOnlyEntryOutOfItsMountIsRefused(t *testing.T) {
for _, paths := range [][2]util.FullPath{
{"/buckets/b/src/a.jpg", "/buckets/c/a.jpg"},
{"/data/m/a.jpg", "/data/a.jpg"},
} {
t.Run(string(paths[1]), func(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
store.entries[string(paths[0])] = remoteOnlyRenameEntry(string(paths[0]), 101)
err := renameFile(server, paths[0], paths[1])
require.ErrorContains(t, err, "cannot be renamed out of it")
assert.Empty(t, client.recorded())
_, err = store.FindEntry(context.Background(), paths[0])
require.NoError(t, err)
_, err = store.FindEntry(context.Background(), paths[1])
assert.ErrorIs(t, err, filer_pb.ErrNotFound)
})
}
}
func TestRenameEmptyRemoteEntryDoesNotCopy(t *testing.T) {
// nothing to lose: no copy, and no refusal even out of the mount or
// without a copier; the sync writes an empty object at a new key
client := &renameRemoteClient{}
server, store := newRenameRemoteTestServer(t, client)
for _, path := range []string{"/buckets/b/src/empty", "/data/m/empty"} {
entry := remoteOnlyRenameEntry(path, 101)
entry.FileSize = 0
entry.Remote.RemoteSize = 0
store.entries[path] = entry
}
require.NoError(t, renameFile(server, "/buckets/b/src/empty", "/buckets/b/dst/empty"))
require.NoError(t, renameFile(server, "/data/m/empty", "/data/empty"))
assert.Equal(t, []string{"delete origin/src/empty", "delete origin/m/empty"}, client.recorded())
}
func TestRenameRemoteOnlyEntryFailingAfterTheCopyKeepsTheSource(t *testing.T) {
for _, src := range []util.FullPath{"/buckets/b/src/a.jpg", "/buckets/b/src"} {
t.Run(string(src), func(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
store.commitErr = errors.New("commit failed")
err := renameFile(server, src, "/buckets/b/moved")
require.ErrorContains(t, err, "commit failed")
// the old object, and the old directory holding it, are deleted
// only once the rename has committed
dst := "origin/moved"
if src == "/buckets/b/src" {
dst = "origin/moved/a.jpg"
}
assert.Equal(t, []string{"copy origin/src/a.jpg " + dst}, client.recorded())
})
}
}
func TestRenameEntryWithLocalDataDoesNotCopy(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
entry := remoteOnlyRenameEntry("/data/m/a.jpg", 101)
entry.Chunks = []*filer_pb.FileChunk{{FileId: "1,0123456789", Size: 10}}
store.entries["/data/m/a.jpg"] = entry
// even out of the mount: the old object goes, the content stays local
require.NoError(t, renameFile(server, "/data/m/a.jpg", "/data/a.jpg"))
assert.Equal(t, []string{"delete origin/m/a.jpg"}, client.recorded())
dst, err := store.FindEntry(context.Background(), "/data/a.jpg")
require.NoError(t, err)
assert.Equal(t, `"orig"`, dst.Remote.RemoteETag)
assert.Len(t, dst.GetChunks(), 1)
}
func TestRenameDirectoryCopiesRemoteOnlyChildren(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
local := remoteOnlyRenameEntry("/buckets/b/src/b.jpg", 102)
local.Content = []byte("0123456789")
store.entries["/buckets/b/src/b.jpg"] = local
require.NoError(t, renameFile(server, "/buckets/b/src", "/buckets/b/moved"))
// b.jpg's old object goes with its entry, a.jpg's waits for the commit,
// and the old prefix is not swept: it could hold a newer object by then
assert.Equal(t, []string{
"copy origin/src/a.jpg origin/moved/a.jpg",
"delete origin/src/b.jpg",
"delete origin/src/a.jpg",
}, client.recorded())
moved, err := store.FindEntry(context.Background(), "/buckets/b/moved/a.jpg")
require.NoError(t, err)
assert.Equal(t, `"copy"`, moved.Remote.RemoteETag)
moved, err = store.FindEntry(context.Background(), "/buckets/b/moved/b.jpg")
require.NoError(t, err)
assert.Equal(t, `"orig"`, moved.Remote.RemoteETag)
}
func TestDeferredOldKeyDeleteKeepsAKeyRetakenSince(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
event := metadataEvent{
oldEntry: remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101),
newEntry: remoteOnlyRenameEntry("/buckets/b/moved/a.jpg", 101),
deleteOldRemote: true,
}
// another rename put a remote-only entry at the old path after the commit
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 303)
event.notify(server.filer, context.Background(), nil)
assert.Empty(t, client.recorded())
// a lookup that fails says nothing about the path
delete(store.entries, "/buckets/b/src/a.jpg")
store.findErr = errors.New("store unavailable")
event.notify(server.filer, context.Background(), nil)
assert.Empty(t, client.recorded())
store.findErr = nil
event.notify(server.filer, context.Background(), nil)
assert.Equal(t, []string{"delete origin/src/a.jpg"}, client.recorded())
}
func TestRenameRemoteOnlyEntryFinishesAfterTheCommitOnASQLStore(t *testing.T) {
// a SQL store keeps the transaction in the context and refuses calls
// through it once committed, so the work after the commit must not use it
for _, overTarget := range []bool{false, true} {
t.Run(map[bool]string{false: "new path", true: "over a remote-only target"}[overTarget], func(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{}}
server, store := newRenameRemoteTestServer(t, client)
store.strictTx = true
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
if overTarget {
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
}
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
// the copy is stamped and the old key deleted by the filer itself
assert.Equal(t, []string{"copy origin/src/a.jpg origin/dst/a.jpg", "delete origin/src/a.jpg"}, client.recorded())
dst, err := store.FindEntry(context.Background(), "/buckets/b/dst/a.jpg")
require.NoError(t, err)
assert.Equal(t, `"copy"`, dst.Remote.RemoteETag)
})
}
}
func TestRenamePendingEntryAgainCopiesFromItsSource(t *testing.T) {
client := copyingRenameRemoteClient{&renameRemoteClient{copyErr: errors.New("copy refused")}}
server, store := newRenameRemoteTestServer(t, client)
store.entries["/buckets/b/src/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/src/a.jpg", 101)
store.entries["/buckets/b/dst/a.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/a.jpg", 202)
store.entries["/buckets/b/dst/d.jpg"] = remoteOnlyRenameEntry("/buckets/b/dst/d.jpg", 303)
// the deferred copy fails: dst/a.jpg reads src/a.jpg's object
require.NoError(t, renameFile(server, "/buckets/b/src/a.jpg", "/buckets/b/dst/a.jpg"))
// renamed again over another remote-only file, failing again: the marker
// keeps naming the original source, not dst/a.jpg's old object
require.NoError(t, renameFile(server, "/buckets/b/dst/a.jpg", "/buckets/b/dst/d.jpg"))
d, err := store.FindEntry(context.Background(), "/buckets/b/dst/d.jpg")
require.NoError(t, err)
assert.Equal(t, "/buckets/b/src/a.jpg", string(d.Extended[filer.ExtRemoteRenameSourceKey]))
// renamed to a new path, the copy comes from the source and clears the marker
client.copyErr = nil
require.NoError(t, renameFile(server, "/buckets/b/dst/d.jpg", "/buckets/b/dst/c.jpg"))
assert.Contains(t, client.recorded(), "copy origin/src/a.jpg origin/dst/c.jpg")
// the source is left to remote.sync: a concurrent rename may have copied
// onto that key before its entry is visible
assert.NotContains(t, client.recorded(), "delete origin/src/a.jpg")
c, err := store.FindEntry(context.Background(), "/buckets/b/dst/c.jpg")
require.NoError(t, err)
assert.Empty(t, c.Extended[filer.ExtRemoteRenameSourceKey])
assert.Equal(t, `"copy"`, c.Remote.RemoteETag)
}
func TestRemoteSourcePathStaysInTheMount(t *testing.T) {
server, _ := newRenameRemoteTestServer(t, &renameRemoteClient{})
entry := remoteOnlyRenameEntry("/buckets/b/x.jpg", 101)
for marker, want := range map[string]util.FullPath{
"/buckets/b/src/y.jpg": "/buckets/b/src/y.jpg", // same mount: the pending source
"/buckets/c/y.jpg": "/buckets/b/x.jpg", // another mount: ignored
"/tmp/y.jpg": "/buckets/b/x.jpg", // no mount: ignored
} {
entry.Extended = map[string][]byte{filer.ExtRemoteRenameSourceKey: []byte(marker)}
assert.Equal(t, want, server.filer.RemoteSourcePath(entry, "/buckets/b/x.jpg"), marker)
}
}
func TestTaggingCannotSetThePendingSourceMarker(t *testing.T) {
server, store := newRenameRemoteTestServer(t, &renameRemoteClient{})
store.entries["/buckets/b/x.jpg"] = remoteOnlyRenameEntry("/buckets/b/x.jpg", 101)
r := httptest.NewRequest(http.MethodPut, "/buckets/b/x.jpg?tagging", nil)
r.Header.Set("Seaweed-X-Remote-Rename-Source", "/buckets/b/other.jpg")
r.Header.Set("Seaweed-Album", "2026")
server.PutTaggingHandler(httptest.NewRecorder(), r)
x, err := store.FindEntry(context.Background(), "/buckets/b/x.jpg")
require.NoError(t, err)
assert.Equal(t, "2026", string(x.Extended["Seaweed-Album"]))
assert.Empty(t, x.Extended[filer.ExtRemoteRenameSourceKey], "only the filer sets the pending source")
}
func TestDeletingTagsKeepsThePendingSourceMarker(t *testing.T) {
server, store := newRenameRemoteTestServer(t, &renameRemoteClient{})
entry := remoteOnlyRenameEntry("/buckets/b/x.jpg", 101)
entry.Extended = map[string][]byte{"Seaweed-Album": []byte("2026"), filer.ExtRemoteRenameSourceKey: []byte("/buckets/b/src/x.jpg")}
store.entries["/buckets/b/x.jpg"] = entry
// no tag names: delete every tag
server.DeleteTaggingHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/buckets/b/x.jpg?tagging", nil))
x, err := store.FindEntry(context.Background(), "/buckets/b/x.jpg")
require.NoError(t, err)
assert.Empty(t, x.Extended["Seaweed-Album"])
assert.Equal(t, "/buckets/b/src/x.jpg", string(x.Extended[filer.ExtRemoteRenameSourceKey]), "the marker is not a tag")
}
+49 -6
View File
@@ -31,6 +31,21 @@ type renameTestStore struct {
deleteErr error
listErr error // simulates a transient store/RPC failure from a directory listing
findDelay time.Duration // optional: widen check-then-act windows in tests
findErr error // simulates a failing entry lookup
strictTx bool // like a SQL store: calls through a committed transaction fail
}
type renameTestTxKey struct{}
type renameTestTx struct{ done bool }
// txDone reports a call through a transaction that has already committed or
// rolled back, which a SQL store refuses.
func (s *renameTestStore) txDone(ctx context.Context) error {
if tx, ok := ctx.Value(renameTestTxKey{}).(*renameTestTx); ok && tx.done {
return errors.New("sql: transaction has already been committed or rolled back")
}
return nil
}
func newRenameTestStore() *renameTestStore {
@@ -44,10 +59,23 @@ func (s *renameTestStore) GetName() string { return
func (s *renameTestStore) Initialize(util.Configuration, string) error { return nil }
func (s *renameTestStore) Shutdown() {}
func (s *renameTestStore) BeginTransaction(ctx context.Context) (context.Context, error) {
if s.strictTx {
return context.WithValue(ctx, renameTestTxKey{}, &renameTestTx{}), nil
}
return ctx, nil
}
func (s *renameTestStore) CommitTransaction(context.Context) error { return s.commitErr }
func (s *renameTestStore) RollbackTransaction(context.Context) error { return nil }
func (s *renameTestStore) CommitTransaction(ctx context.Context) error {
if tx, ok := ctx.Value(renameTestTxKey{}).(*renameTestTx); ok {
tx.done = true
}
return s.commitErr
}
func (s *renameTestStore) RollbackTransaction(ctx context.Context) error {
if tx, ok := ctx.Value(renameTestTxKey{}).(*renameTestTx); ok {
tx.done = true
}
return nil
}
func (s *renameTestStore) KvPut(context.Context, []byte, []byte) error {
return nil
}
@@ -56,27 +84,39 @@ func (s *renameTestStore) KvGet(context.Context, []byte) ([]byte, error) {
}
func (s *renameTestStore) KvDelete(context.Context, []byte) error { return nil }
func (s *renameTestStore) InsertEntry(_ context.Context, entry *filer.Entry) error {
func (s *renameTestStore) InsertEntry(ctx context.Context, entry *filer.Entry) error {
if err := s.txDone(ctx); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.entries[string(entry.FullPath)] = entry.ShallowClone()
return nil
}
func (s *renameTestStore) UpdateEntry(_ context.Context, entry *filer.Entry) error {
func (s *renameTestStore) UpdateEntry(ctx context.Context, entry *filer.Entry) error {
if err := s.txDone(ctx); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.entries[string(entry.FullPath)] = entry.ShallowClone()
return nil
}
func (s *renameTestStore) FindEntry(_ context.Context, p util.FullPath) (*filer.Entry, error) {
func (s *renameTestStore) FindEntry(ctx context.Context, p util.FullPath) (*filer.Entry, error) {
if err := s.txDone(ctx); err != nil {
return nil, err
}
if s.findDelay > 0 {
time.Sleep(s.findDelay)
}
s.mu.Lock()
defer s.mu.Unlock()
s.findCalls[string(p)]++
if s.findErr != nil {
return nil, s.findErr
}
entry, found := s.entries[string(p)]
if !found {
return nil, filer_pb.ErrNotFound
@@ -84,7 +124,10 @@ func (s *renameTestStore) FindEntry(_ context.Context, p util.FullPath) (*filer.
return entry.ShallowClone(), nil
}
func (s *renameTestStore) DeleteEntry(_ context.Context, p util.FullPath) error {
func (s *renameTestStore) DeleteEntry(ctx context.Context, p util.FullPath) error {
if err := s.txDone(ctx); err != nil {
return err
}
if s.deleteErr != nil {
return s.deleteErr
}
@@ -42,9 +42,14 @@ func (fs *FilerServer) TraverseBfsMetadata(req *filer_pb.TraverseBfsMetadataRequ
// the filer on large trees (e.g. during a peer's first-time bootstrap).
sendEntry := func(entry *filer.Entry) error {
parent, _ := entry.FullPath.DirAndName()
entryTsNs, err := fs.filer.CommitTsNs(ctx, entry.FullPath, entry.Mtime.UnixNano())
if err != nil {
return fmt.Errorf("commit timestamp of %s: %w", entry.FullPath, err)
}
if err := stream.Send(&filer_pb.TraverseBfsMetadataResponse{
Directory: parent,
Entry: entry.ToProtoEntry(),
EntryTsNs: entryTsNs,
}); err != nil {
return fmt.Errorf("send traverse bfs metadata response: %w", err)
}
@@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/stretchr/testify/assert"
@@ -37,8 +38,9 @@ func TestPtrie(t *testing.T) {
type captureTraverseStream struct {
grpc.ServerStream
ctx context.Context
visited []string
ctx context.Context
visited []string
entryTsNs map[string]int64
}
func (s *captureTraverseStream) Context() context.Context { return s.ctx }
@@ -48,7 +50,12 @@ func (s *captureTraverseStream) Send(resp *filer_pb.TraverseBfsMetadataResponse)
if dir != "/" {
dir += "/"
}
s.visited = append(s.visited, dir+resp.Entry.Name)
path := dir + resp.Entry.Name
s.visited = append(s.visited, path)
if s.entryTsNs == nil {
s.entryTsNs = make(map[string]int64)
}
s.entryTsNs[path] = resp.EntryTsNs
return nil
}
@@ -89,6 +96,16 @@ func TestTraverseBfsMetadata(t *testing.T) {
}
seen[path] = true
}
// Every response carries a commit timestamp for ordering; with no
// recorded commit it falls back to the entry's mtime.
for path, tsNs := range stream.entryTsNs {
if path == "/" {
assert.Equal(t, filer.Root.Attr.Mtime.UnixNano(), tsNs)
continue
}
assert.Equalf(t, store.entries[path].Attr.Mtime.UnixNano(), tsNs, "entry_ts_ns for %s", path)
}
}
var _ filer_pb.SeaweedFiler_TraverseBfsMetadataServer = (*captureTraverseStream)(nil)
+3
View File
@@ -268,6 +268,9 @@ func NewFilerServer(defaultMux, readonlyMux *http.ServeMux, option *FilerOption)
maxFilenameLength := v.GetUint32("filer.options.max_file_name_length")
glog.V(0).Infof("max_file_name_length %d", maxFilenameLength)
fs.filer = filer.NewFiler(*option.Masters, fs.grpcDialOption, option.Host, option.FilerGroup, option.Collection, option.DefaultReplication, option.DataCenter, maxFilenameLength, nil)
// Peer metadata replay takes the same per-path locks, so a replayed change
// cannot split a local mutation's read-modify-write.
fs.entryLockTable = fs.filer.EntryLockTable
fs.filer.Cipher = option.Cipher
fs.filer.DefaultDiskType = option.DiskType
fs.filer.BuildGuardedRemoteClient = BuildGuardedRemoteStorageClient
+23 -10
View File
@@ -206,15 +206,18 @@ func (fs *FilerServer) GetOrHeadHandler(w http.ResponseWriter, r *http.Request)
// Every part of a multipart Range is prepared on its own, so the origin
// preflight below is memoized to one stat per request.
statOrigin := sync.OnceValue(func() error {
dir, name := entry.FullPath.DirAndName()
client, remoteLocation, err := fs.mountedRemoteClient(ctx, dir, name)
if err != nil {
statOriginAt := func(readDir, readName string) func() error {
return sync.OnceValue(func() error {
client, remoteLocation, err := fs.mountedRemoteClient(ctx, readDir, readName)
if err != nil {
return err
}
_, err = client.StatFile(remoteLocation)
return err
}
_, err = client.StatFile(remoteLocation)
return err
})
})
}
entryDir, entryName := entry.FullPath.DirAndName()
statOrigin := statOriginAt(fs.filer.RemoteReadPath(entry, entryDir, entryName))
ProcessRangeRequest(r, w, totalSize, mimeType, func(offset int64, size int64) (filer.DoStreamContent, error) {
if offset+size <= int64(len(entry.Content)) {
@@ -230,13 +233,23 @@ func (fs *FilerServer) GetOrHeadHandler(w http.ResponseWriter, r *http.Request)
chunks := entry.GetChunks()
if entry.IsInRemoteOnly() {
dir, name := entry.FullPath.DirAndName()
readDir, readName := fs.filer.RemoteReadPath(entry, dir, name)
var mountedLocation *remote_pb.RemoteStorageLocation
if fs.filer.RemoteStorage != nil {
_, mountedLocation = fs.filer.RemoteStorage.FindMountDirectory(entry.FullPath)
}
cacheWait := remote_storage.CacheWaitTimeout(entry.Remote.RemoteSize, mountedLocation)
if cacheWait <= 0 {
return fs.streamFromRemoteOnly(ctx, r, dir, name, offset, size, statOrigin)
streamFn, err := fs.streamFromRemoteOnly(ctx, r, readDir, readName, offset, size, statOrigin)
if errors.Is(err, filer_pb.ErrNotFound) && (readDir != dir || readName != name) {
// the pending copy finished and its source went away since
// the entry was read; serve what the entry names now
if fresh, findErr := fs.filer.FindEntry(ctx, entry.FullPath); findErr == nil {
freshDir, freshName := fs.filer.RemoteReadPath(fresh, dir, name)
return fs.streamFromRemoteOnly(ctx, r, freshDir, freshName, offset, size, statOriginAt(freshDir, freshName))
}
}
return streamFn, err
}
// Bounded wait: a large download outlasts any client timeout, so
// serve straight from the origin once the wait expires while the
@@ -264,7 +277,7 @@ func (fs *FilerServer) GetOrHeadHandler(w http.ResponseWriter, r *http.Request)
// when a later prepare fails; keep those on the 503 retry path.
if !strings.Contains(r.Header.Get("Range"), ",") {
glog.V(1).InfofCtx(ctx, "stream %s from remote while caching: %v", entry.FullPath, err)
if streamFn, remoteErr := fs.streamFromRemote(ctx, dir, name, offset, size); remoteErr == nil {
if streamFn, remoteErr := fs.streamFromRemote(ctx, readDir, readName, offset, size); remoteErr == nil {
return streamFn, nil
} else {
glog.WarningfCtx(ctx, "stream %s from remote: %v", entry.FullPath, remoteErr)
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
@@ -35,7 +36,7 @@ func (fs *FilerServer) PutTaggingHandler(w http.ResponseWriter, r *http.Request)
}
for header, values := range r.Header {
if strings.HasPrefix(header, needle.PairNamePrefix) {
if strings.HasPrefix(header, needle.PairNamePrefix) && header != filer.ExtRemoteRenameSourceKey {
for _, value := range values {
existingEntry.Extended[header] = []byte(value)
}
@@ -89,7 +90,7 @@ func (fs *FilerServer) DeleteTaggingHandler(w http.ResponseWriter, r *http.Reque
// delete all tags or specific tags
hasDeletion := false
for header, _ := range existingEntry.Extended {
if strings.HasPrefix(header, needle.PairNamePrefix) {
if strings.HasPrefix(header, needle.PairNamePrefix) && header != filer.ExtRemoteRenameSourceKey {
if len(deletions) == 0 {
delete(existingEntry.Extended, header)
hasDeletion = true
+9 -1
View File
@@ -255,7 +255,15 @@ func (fs *FilerServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
return
}
err = fs.filer.DeleteEntryMetaAndData(context.Background(), util.FullPath(objectPath), isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, 0)
// The delete and the commit record it emits must serialize with peer
// replay of this path, same as the gRPC DeleteEntry handler.
fullPath := util.FullPath(objectPath)
if fs.entryLockTable != nil {
pathLock := fs.entryLockTable.AcquireLock("DeleteHandler", fullPath, util.ExclusiveLock)
defer fs.entryLockTable.ReleaseLock(fullPath, pathLock)
}
err = fs.filer.DeleteEntryMetaAndData(context.Background(), fullPath, isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, 0)
if err != nil && err != filer_pb.ErrNotFound {
glog.V(1).Infoln("deleting", objectPath, ":", err.Error())
writeJsonError(w, r, http.StatusInternalServerError, err)
@@ -468,6 +468,9 @@ func (fs *FilerServer) saveMetaData(ctx context.Context, r *http.Request, fileNa
}
for k, v := range r.Header {
if len(v) > 0 && len(v[0]) > 0 {
if k == filer.ExtRemoteRenameSourceKey {
continue // set by the filer only: it picks the remote key reads serve
}
if strings.HasPrefix(k, needle.PairNamePrefix) || k == "Cache-Control" || k == "Expires" || k == "Content-Disposition" {
entry.Extended[k] = []byte(v[0])
}
+6
View File
@@ -92,6 +92,12 @@ func (ms *MasterServer) Assign(ctx context.Context, req *master_pb.AssignRequest
if !ms.Topo.DataCenterExists(option.DataCenter) {
return nil, fmt.Errorf("data center %v not found in topology", option.DataCenter)
}
if !ms.Topo.RackExists(option.DataCenter, option.Rack) {
return nil, fmt.Errorf("rack %v not found in topology", option.Rack)
}
if !ms.Topo.DataNodeExists(option.DataCenter, option.Rack, option.DataNode) {
return nil, fmt.Errorf("data node %v not found in topology", option.DataNode)
}
vl := ms.Topo.GetVolumeLayout(option.Collection, option.ReplicaPlacement, option.Ttl, option.DiskType)
if req.DiskType == "" {
+47 -6
View File
@@ -45,6 +45,23 @@ func (ms *MasterServer) DoAutomaticVolumeGrow(req *topology.VolumeGrowRequest) {
ms.broadcastVolumeLocationsToClients(newVidLocations)
}
// periodicGrowCount decides how many volumes the periodic growth scan adds to
// a layout ahead of its rack-aware pass, from the writableVolumeCount clients
// last asked for (lastGrowCount) and the layout's writable and crowded counts.
// mustGrow reports a refill back up to lastGrowCount writable volumes, which
// the caller only grows when there is room for all of it. A layout that never
// received a writableVolumeCount (lastGrowCount 0) is left to the rack-aware
// pass.
func periodicGrowCount(lastGrowCount uint32, writable, crowded int) (count uint32, mustGrow bool) {
if n := int(lastGrowCount) - writable; n > 0 {
return uint32(n), true
}
if lastGrowCount > 0 && writable < int(lastGrowCount*2) && float64(crowded+volumeGrowStepCount) > float64(writable)*topology.VolumeGrowStrategy.Threshold {
return volumeGrowStepCount, false
}
return 0, false
}
func (ms *MasterServer) ProcessGrowRequest() {
go func() {
ctx := context.Background()
@@ -67,25 +84,25 @@ func (ms *MasterServer) ProcessGrowRequest() {
continue
}
writable, crowded := vl.GetWritableVolumeCount()
mustGrow := int(lastGrowCount) - writable
vgr := vlc.ToVolumeGrowRequest()
underReplicated := vl.CountUnderReplicatedVolumes()
stats.MasterVolumeLayoutWritable.WithLabelValues(vlc.Collection, vgr.DiskType, vgr.Replication, vgr.Ttl).Set(float64(writable))
stats.MasterVolumeLayoutCrowded.WithLabelValues(vlc.Collection, vgr.DiskType, vgr.Replication, vgr.Ttl).Set(float64(crowded))
stats.MasterUnderReplicatedVolumes.WithLabelValues(vlc.Collection, vgr.DiskType, vgr.Replication, vgr.Ttl).Set(float64(underReplicated))
growCount, mustGrow := periodicGrowCount(lastGrowCount, writable, crowded)
switch {
case mustGrow > 0:
case mustGrow:
if rp, rpErr := super_block.NewReplicaPlacementFromString(vgr.Replication); rpErr != nil {
glog.V(0).Infof("failed to parse replica placement %s: %v", vgr.Replication, rpErr)
} else {
vgr.WritableVolumeCount = uint32(mustGrow)
vgr.WritableVolumeCount = growCount
if ms.Topo.AvailableSpaceFor(&topology.VolumeGrowOption{DiskType: types.ToDiskType(vgr.DiskType)}) >= int64(vgr.WritableVolumeCount*uint32(rp.GetCopyCount())) {
_, err = ms.VolumeGrow(ctx, vgr)
}
}
case lastGrowCount > 0 && writable < int(lastGrowCount*2) && float64(crowded+volumeGrowStepCount) > float64(writable)*topology.VolumeGrowStrategy.Threshold:
vgr.WritableVolumeCount = volumeGrowStepCount
case growCount > 0:
vgr.WritableVolumeCount = growCount
_, err = ms.VolumeGrow(ctx, vgr)
}
if err != nil {
@@ -492,6 +509,27 @@ func (ms *MasterServer) VolumeGrow(ctx context.Context, req *master_pb.VolumeGro
Force: true,
Reason: "grpc volume grow",
}
// Report the layout's fill observed at request time whether or not
// growth was triggered, so callers can tell an empty layout from a
// skipped one. A missing collection/layout reports zeros.
var avgFill float64
var writableCount int
if vl := ms.Topo.FindVolumeLayout(req.Collection, replicaPlacement, ttl, types.ToDiskType(req.DiskType)); vl != nil {
avgFill, writableCount = vl.WritableAvgFill(&volumeGrowOption)
}
// Conditional grow: with a threshold set, grow only when the layout's
// writable volumes have reached this average fill. A layout with no
// writable volumes (none yet, or all just filled) skips the check — that
// is the case growth exists to fix.
if req.GrowAvgFillThreshold > 0 && writableCount > 0 && avgFill < float64(req.GrowAvgFillThreshold) {
return &master_pb.VolumeGrowResponse{
AvgFill: float32(avgFill),
WritableCount: uint32(writableCount),
}, nil
}
replicaCount := int64(req.WritableVolumeCount * uint32(replicaPlacement.GetCopyCount()))
if ms.Topo.AvailableSpaceFor(&volumeGrowOption) < replicaCount {
@@ -500,5 +538,8 @@ func (ms *MasterServer) VolumeGrow(ctx context.Context, req *master_pb.VolumeGro
ms.DoAutomaticVolumeGrow(&volumeGrowRequest)
return &master_pb.VolumeGrowResponse{}, nil
return &master_pb.VolumeGrowResponse{
AvgFill: float32(avgFill),
WritableCount: uint32(writableCount),
}, nil
}
+16
View File
@@ -190,6 +190,22 @@ func (ms *MasterServer) dirAssignHandler(w http.ResponseWriter, r *http.Request)
})
return
}
if !ms.Topo.RackExists(option.DataCenter, option.Rack) {
writeJsonQuiet(w, r, http.StatusBadRequest, operation.AssignResult{
Error: fmt.Sprintf("rack %v not found in topology", option.Rack),
})
return
}
if !ms.Topo.DataNodeExists(option.DataCenter, option.Rack, option.DataNode) {
writeJsonQuiet(w, r, http.StatusBadRequest, operation.AssignResult{
Error: fmt.Sprintf("data node %v not found in topology", option.DataNode),
})
return
}
// As in gRPC Assign: the periodic growth scan keeps this layout topped up
// to the writableVolumeCount clients ask for. 0 (not sent) is ignored.
vl.SetLastGrowCount(uint32(writableVolumeCount))
for time.Since(startTime) < maxTimeout {
fid, count, dnList, shouldGrow, err := ms.Topo.PickForWrite(requestedCount, option, vl, expectedDataSize)
@@ -0,0 +1,209 @@
package weed_server
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/sequence"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/topology"
)
const assignTestVolumeSizeLimit = 1 << 30
// assignTestCluster is a leader master over one data center with one volume
// server per rack, like a production cluster spreading "010" replicas.
type assignTestCluster struct {
ms *MasterServer
nodes []*topology.DataNode
rp *super_block.ReplicaPlacement
nextVid uint32
}
func newAssignTestCluster(t *testing.T, racks int, replication string) *assignTestCluster {
t.Helper()
rp, err := super_block.NewReplicaPlacementFromString(replication)
require.NoError(t, err)
c := &assignTestCluster{ms: newLeaderMaster(), rp: rp}
// Room for the per-assign size estimate, which would fill newLeaderMaster's
// tiny volumes on the first assign.
c.ms.Topo = topology.NewTopology("test", sequence.NewMemorySequencer(), assignTestVolumeSizeLimit, 5, false)
c.ms.Topo.RaftServer = leaderRaftServer{}
dc := c.ms.Topo.GetOrCreateDataCenter("dc1")
for i := 0; i < racks; i++ {
c.nodes = append(c.nodes, dc.GetOrCreateRack(fmt.Sprintf("rack%d", i)).
GetOrCreateDataNode("127.0.0.1", 8080+i, 18080+i, "127.0.0.1", fmt.Sprintf("dn%d", i), map[string]uint32{"": 1000}))
}
return c
}
// grow registers n new empty volumes, each with its copies on consecutive
// racks, the way a finished volume grow lands in the topology.
func (c *assignTestCluster) grow(collection string, n uint32) {
for i := uint32(0); i < n; i++ {
c.nextVid++
v := storage.VolumeInfo{
Id: needle.VolumeId(c.nextVid),
Collection: collection,
ReplicaPlacement: c.rp,
Ttl: needle.EMPTY_TTL,
Version: needle.GetCurrentVersion(),
}
for r := 0; r < c.rp.GetCopyCount(); r++ {
dn := c.nodes[(int(c.nextVid)+r)%len(c.nodes)]
dn.AddOrUpdateVolume(v)
c.ms.Topo.RegisterVolumeLayout(v, dn)
}
}
}
func (c *assignTestCluster) layout(collection string) *topology.VolumeLayout {
return c.ms.Topo.GetVolumeLayout(collection, c.rp, needle.EMPTY_TTL, "")
}
func (c *assignTestCluster) httpAssign(t *testing.T, query string) {
t.Helper()
w := httptest.NewRecorder()
c.ms.dirAssignHandler(w, httptest.NewRequest(http.MethodGet, "/dir/assign?"+query, nil))
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
// A client's writableVolumeCount is what the periodic growth scan keeps a
// layout topped up to. gRPC Assign has recorded it since the scan was added;
// HTTP /dir/assign parsed it but dropped it, so HTTP-only writers never got
// proactive growth. Both entry points must leave the layout in the same state.
func TestDirAssignRecordsWritableVolumeCountLikeGrpcAssign(t *testing.T) {
tests := []struct {
name string
counts []uint32 // writableVolumeCount per assign, 0 = not sent
want uint32
}{
{name: "sent", counts: []uint32{30}, want: 30},
{name: "not sent", counts: []uint32{0}, want: 0},
{name: "later assign without it keeps the last value", counts: []uint32{30, 0}, want: 30},
{name: "latest value wins", counts: []uint32{30, 12}, want: 12},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpCluster := newAssignTestCluster(t, 1, "000")
httpCluster.grow("c", 1)
for _, n := range tt.counts {
query := "collection=c&replication=000"
if n > 0 {
query += fmt.Sprintf("&writableVolumeCount=%d", n)
}
httpCluster.httpAssign(t, query)
}
grpcCluster := newAssignTestCluster(t, 1, "000")
grpcCluster.grow("c", 1)
for _, n := range tt.counts {
_, err := grpcCluster.ms.Assign(context.Background(), &master_pb.AssignRequest{
Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: n,
})
require.NoError(t, err)
}
assert.Equal(t, tt.want, grpcCluster.layout("c").GetLastGrowCount(), "gRPC Assign")
assert.Equal(t, tt.want, httpCluster.layout("c").GetLastGrowCount(), "HTTP /dir/assign")
})
}
}
// An assign naming a placement the topology cannot serve must fail before it
// records a growth target: the periodic scan drops the requested placement, so
// the volumes it would keep growing could never serve the requester.
func TestDirAssignRejectsUnknownPlacementBeforeGrowCount(t *testing.T) {
c := newAssignTestCluster(t, 1, "000")
c.grow("c", 1)
vl := c.layout("c")
// A rack and data node that exist, but in a different data center: the
// requester named dc1, so these placements can never be served either.
c.ms.Topo.GetOrCreateDataCenter("dc2").GetOrCreateRack("rackX").
GetOrCreateDataNode("127.0.0.2", 8090, 18090, "127.0.0.2", "dnX", map[string]uint32{"": 1000})
for _, query := range []string{
"collection=c&replication=000&writableVolumeCount=30&rack=missing",
"collection=c&replication=000&writableVolumeCount=30&dataNode=missing",
"collection=c&replication=000&writableVolumeCount=30&dataCenter=dc1&rack=rackX",
"collection=c&replication=000&writableVolumeCount=30&dataCenter=dc1&dataNode=dnX",
"collection=c&replication=000&writableVolumeCount=30&dataCenter=dc1&rack=rack0&dataNode=dnX",
} {
w := httptest.NewRecorder()
c.ms.dirAssignHandler(w, httptest.NewRequest(http.MethodGet, "/dir/assign?"+query, nil))
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
}
assert.Zero(t, vl.GetLastGrowCount())
for _, req := range []*master_pb.AssignRequest{
{Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: 30, Rack: "missing"},
{Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: 30, DataCenter: "dc1", Rack: "rackX"},
{Count: 1, Collection: "c", Replication: "000", WritableVolumeCount: 30, DataCenter: "dc1", Rack: "rack0", DataNode: "dnX"},
} {
_, err := c.ms.Assign(context.Background(), req)
assert.Error(t, err)
}
assert.Zero(t, vl.GetLastGrowCount())
}
// Replays the outage of an HTTP-only writer with writableVolumeCount=30 on a
// "010" collection across 5 racks. Writes are spread by remaining capacity, so
// the 30 volumes fill in lockstep. Before the fix the periodic scan only ever
// saw lastGrowCount 0: it added the rack-aware step of 2 when everything was
// crowded and nothing more while those 2 stayed uncrowded. When the old batch
// went full, the 2 volumes took every write, filled before the next scan, and
// assigns failed until an emergency grow landed.
func TestPeriodicGrowthKeepsHTTPAssignLayoutWritable(t *testing.T) {
const n = 30
c := newAssignTestCluster(t, 5, "010")
c.grow("photo", n)
vl := c.layout("photo")
c.httpAssign(t, fmt.Sprintf("collection=photo&replication=010&writableVolumeCount=%d", n))
const limit = int64(assignTestVolumeSizeLimit)
batch := vl.CloneWritableVolumes()
writeTo := func(vids []needle.VolumeId, size int64) {
for _, vid := range vids {
vl.RecordAssign(vid, size)
}
}
// scan runs the per-layout steps of ProcessGrowRequest, with a grow that
// lands at once.
scan := func() {
writable, crowded := vl.GetWritableVolumeCount()
count, _ := periodicGrowCount(vl.GetLastGrowCount(), writable, crowded)
c.grow("photo", count)
for _, plan := range vl.PlanRackAwareGrowth(c.ms.Topo.ListDCAndRacks(), vl.GetLastGrowCount(), volumeGrowStepCount) {
c.grow("photo", plan.WritableVolumeCount)
}
writable, _ = vl.GetWritableVolumeCount()
require.LessOrEqual(t, writable, 2*n, "growth must stay bounded by twice the requested writable count")
}
// The batch crosses the crowded threshold together, and stays crowded
// over two scans.
writeTo(batch, limit*95/100)
_, crowded := vl.GetWritableVolumeCount()
require.Equal(t, n, crowded)
scan()
scan()
// The batch goes full; whatever the scans added is all that is left.
writeTo(batch, limit)
scan()
writable, crowded := vl.GetWritableVolumeCount()
assert.Zero(t, crowded)
assert.GreaterOrEqual(t, writable, n,
"after the batch went full the scan must restore the %d writable volumes the client asked for, not leave %d to take every write", n, writable)
}
+1 -1
View File
@@ -60,7 +60,7 @@ func (vs *VolumeServer) BatchDelete(ctx context.Context, req *volume_server_pb.B
if !req.SkipCookieCheck {
cookie := n.Cookie
if !isEcVolume {
if _, err := vs.store.ReadVolumeNeedle(volumeId, n, nil, nil); err != nil {
if _, err := vs.store.ReadVolumeNeedle(volumeId, n, deleteReadOption(), nil); err != nil {
resp.Results = append(resp.Results, &volume_server_pb.DeleteResult{
FileId: fid,
Status: http.StatusNotFound,
+8 -3
View File
@@ -308,14 +308,19 @@ func shouldAttemptStreamWrite(hasLocalVolume bool, ext string, r *http.Request)
if len(ext) > 0 {
ext = strings.ToLower(ext)
}
if r.Method == http.MethodHead {
return true, true
}
_, _, _, shouldResize := shouldResizeImages(ext, r)
_, _, _, _, shouldCrop := shouldCropImages(ext, r)
if shouldResize || shouldCrop {
// A transformed image's length is not the needle's; the payload is
// needed to answer — also true for HEAD, which reports it.
return false, false
}
if r.Method == http.MethodHead {
// Attempt only, so a needle up to PagedReadLimit is still read whole:
// on a spinning disk one read is cheaper than a header read followed
// by a trailer read.
return true, false
}
return true, false
}
@@ -0,0 +1,35 @@
package weed_server
import (
"net/http"
"net/http/httptest"
"testing"
)
// A HEAD only attempts a metadata-only read, like a GET: a needle up to
// PagedReadLimit is read whole. Demanding metadata only would turn that one
// read into a header read followed by a trailer read.
func TestShouldAttemptStreamWrite(t *testing.T) {
for _, tc := range []struct {
name string
method string
target string
ext string
hasVolume bool
wantAttempt bool
wantMustMeta bool
}{
{"HEAD", http.MethodHead, "/1,abc.jpg", ".jpg", true, true, false},
{"GET", http.MethodGet, "/1,abc.jpg", ".jpg", true, true, false},
{"GET of a resized image", http.MethodGet, "/1,abc.jpg?width=100", ".jpg", true, false, false},
{"HEAD of a resized image", http.MethodHead, "/1,abc.jpg?width=100", ".jpg", true, false, false},
{"HEAD of a cropped image", http.MethodHead, "/1,abc.jpg?crop_x1=0&crop_y1=0&crop_x2=100&crop_y2=100", ".jpg", true, false, false},
{"HEAD without a local volume", http.MethodHead, "/1,abc.jpg", ".jpg", false, false, false},
} {
r := httptest.NewRequest(tc.method, tc.target, nil)
attempt, mustMeta := shouldAttemptStreamWrite(tc.hasVolume, tc.ext, r)
if attempt != tc.wantAttempt || mustMeta != tc.wantMustMeta {
t.Errorf("%s: attempt %v, must be metadata only %v; want %v, %v", tc.name, attempt, mustMeta, tc.wantAttempt, tc.wantMustMeta)
}
}
}
+12 -1
View File
@@ -115,7 +115,7 @@ func (vs *VolumeServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
return
}
_, ok := vs.store.ReadVolumeNeedle(volumeId, n, nil, nil)
_, ok := vs.store.ReadVolumeNeedle(volumeId, n, deleteReadOption(), nil)
if ok != nil {
m := make(map[string]uint32)
m["size"] = 0
@@ -161,6 +161,17 @@ func (vs *VolumeServer) DeleteHandler(w http.ResponseWriter, r *http.Request) {
}
// deleteReadOption returns the read option used by deletes: read only the
// needle's header and trailer.
//
// A delete needs to know that the needle exists, what its cookie is, and
// whether it is a chunk manifest. None of that requires the payload. If the
// needle turns out to be a chunk manifest, the read loads its data as well,
// because the delete needs the manifest to delete the chunks.
func deleteReadOption() *storage.ReadOption {
return &storage.ReadOption{AttemptMetaOnly: true, MustMetaOnly: true}
}
func writeDeleteResult(err error, count int64, w http.ResponseWriter, r *http.Request) {
if err == nil {
m := make(map[string]int64)
+210
View File
@@ -0,0 +1,210 @@
package sftpd
import (
"bytes"
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
type copyTestFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
entries map[string]*filer_pb.Entry
volumeUrl string
}
func (f *copyTestFiler) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
if entry, ok := f.entries[req.Directory+"/"+req.Name]; ok {
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
}
return nil, status.Error(codes.NotFound, "no entry is found")
}
func (f *copyTestFiler) LookupVolume(_ context.Context, req *filer_pb.LookupVolumeRequest) (*filer_pb.LookupVolumeResponse, error) {
resp := &filer_pb.LookupVolumeResponse{LocationsMap: make(map[string]*filer_pb.Locations)}
for _, vid := range req.VolumeIds {
resp.LocationsMap[vid] = &filer_pb.Locations{Locations: []*filer_pb.Location{{Url: f.volumeUrl}}}
}
return resp, nil
}
func newCopyTestServer(t *testing.T, volumeUrl string, entries map[string]*filer_pb.Entry) *SftpServer {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
grpcServer := grpc.NewServer()
filer_pb.RegisterSeaweedFilerServer(grpcServer, &copyTestFiler{entries: entries, volumeUrl: volumeUrl})
go func() { _ = grpcServer.Serve(lis) }()
t.Cleanup(grpcServer.Stop)
_, port, _ := net.SplitHostPort(lis.Addr().String())
return &SftpServer{
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
user: &user.User{Username: "alice", Uid: 1000, HomeDir: "/"},
}
}
func chunkedEntry(size int64, chunks ...*filer_pb.FileChunk) *filer_pb.Entry {
return &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(size)},
Chunks: chunks,
IsDirectory: false,
}
}
func fileChunk(fileId string, offset, size int64) *filer_pb.FileChunk {
return &filer_pb.FileChunk{FileId: fileId, Offset: offset, Size: uint64(size), ModifiedTsNs: 1}
}
// A non-truncating write must keep untouched bytes, including a sparse file's
// zeros between chunks. Stopping at the first gap would drop the tail.
func TestCopyExistingContentFillsSparseGaps(t *testing.T) {
chunkA := []byte("AAAAAAAAAA")
chunkB := []byte("BBBBBBBBBB")
objects := map[string][]byte{"3,01": chunkA, "3,02": chunkB}
volume := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, ok := objects[r.URL.Path[1:]]
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
_, _ = w.Write(data)
}))
t.Cleanup(volume.Close)
entries := map[string]*filer_pb.Entry{
"/home/sparse.bin": chunkedEntry(100, fileChunk("3,01", 0, 10), fileChunk("3,02", 90, 10)),
}
fs := newCopyTestServer(t, volume.Listener.Addr().String(), entries)
var dst bytes.Buffer
if err := fs.copyExistingContent("/home/sparse.bin", &dst); err != nil {
t.Fatalf("copyExistingContent: %v", err)
}
want := append(append(append([]byte{}, chunkA...), make([]byte, 80)...), chunkB...)
if !bytes.Equal(dst.Bytes(), want) {
t.Fatalf("copied %d bytes, want %d with a zero-filled gap; tail present: %v", dst.Len(), len(want), bytes.Contains(dst.Bytes(), chunkB))
}
}
// A remote-only file has no local chunks: copying nothing would upload an
// empty replacement on close. Refusing the read keeps the remote object.
func TestCopyExistingContentRejectsUnreadableEntry(t *testing.T) {
remoteOnly := chunkedEntry(0)
remoteOnly.RemoteEntry = &filer_pb.RemoteEntry{RemoteSize: 100}
entries := map[string]*filer_pb.Entry{
"/home/remote.bin": remoteOnly,
}
fs := newCopyTestServer(t, "", entries)
var dst bytes.Buffer
if err := fs.copyExistingContent("/home/remote.bin", &dst); err == nil {
t.Fatal("expected refusal when no local bytes exist")
}
}
// A locally shortened file still names its older, larger remote object. Once
// the local entry is newer the remote size is stale: copying must not pad the
// file back out to it.
func TestCopyExistingContentIgnoresStaleRemoteSize(t *testing.T) {
chunkA := []byte("AAAAAAAAAA")
volume := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/3,01" {
http.Error(w, "not found", http.StatusNotFound)
return
}
_, _ = w.Write(chunkA)
}))
t.Cleanup(volume.Close)
entries := map[string]*filer_pb.Entry{
"/home/short.bin": {
Attributes: &filer_pb.FuseAttributes{FileSize: 10, Mtime: 200},
Chunks: []*filer_pb.FileChunk{fileChunk("3,01", 0, 10)},
RemoteEntry: &filer_pb.RemoteEntry{RemoteSize: 100, RemoteMtime: 100},
},
}
fs := newCopyTestServer(t, volume.Listener.Addr().String(), entries)
var dst bytes.Buffer
if err := fs.copyExistingContent("/home/short.bin", &dst); err != nil {
t.Fatalf("copyExistingContent: %v", err)
}
if !bytes.Equal(dst.Bytes(), chunkA) {
t.Fatalf("copied %d bytes, want the %d local bytes only", dst.Len(), len(chunkA))
}
}
// A file whose size is all holes still keeps its zeros.
func TestCopyExistingContentAllHoles(t *testing.T) {
entries := map[string]*filer_pb.Entry{
"/home/holes.bin": chunkedEntry(10),
}
fs := newCopyTestServer(t, "", entries)
var dst bytes.Buffer
if err := fs.copyExistingContent("/home/holes.bin", &dst); err != nil {
t.Fatalf("all-holes file: %v", err)
}
if dst.Len() != 10 || !bytes.Equal(dst.Bytes(), make([]byte, 10)) {
t.Fatalf("wrote %d bytes, want 10 zeros", dst.Len())
}
}
func TestCopyExistingContentInlineAndMissing(t *testing.T) {
entries := map[string]*filer_pb.Entry{
"/home/inline.txt": {Attributes: &filer_pb.FuseAttributes{FileSize: 5}, Content: []byte("hello")},
}
fs := newCopyTestServer(t, "", entries)
var dst bytes.Buffer
if err := fs.copyExistingContent("/home/inline.txt", &dst); err != nil {
t.Fatalf("inline content: %v", err)
}
if got := dst.String(); got != "hello" {
t.Fatalf("inline content = %q, want %q", got, "hello")
}
dst.Reset()
if err := fs.copyExistingContent("/home/missing.txt", &dst); err != nil || dst.Len() != 0 {
t.Fatalf("missing file: err=%v written=%d", err, dst.Len())
}
}
func TestCopyExistingContentTrailingGap(t *testing.T) {
chunkA := []byte("AAAAAAAAAA")
volume := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/3,01" {
http.Error(w, "not found", http.StatusNotFound)
return
}
_, _ = w.Write(chunkA)
}))
t.Cleanup(volume.Close)
entries := map[string]*filer_pb.Entry{
"/home/trailing.bin": chunkedEntry(50, fileChunk("3,01", 0, 10)),
}
fs := newCopyTestServer(t, volume.Listener.Addr().String(), entries)
var dst bytes.Buffer
if err := fs.copyExistingContent("/home/trailing.bin", &dst); err != nil {
t.Fatalf("copyExistingContent: %v", err)
}
want := append(append([]byte{}, chunkA...), make([]byte, 40)...)
if !bytes.Equal(dst.Bytes(), want) {
t.Fatalf("copied %d bytes, want %d with a zero-filled tail", dst.Len(), len(want))
}
}
+95 -9
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -23,6 +24,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
)
@@ -150,9 +152,8 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
if err != nil {
return nil, err
}
dir, _ := util.FullPath(absPath).DirAndName()
if err := fs.checkFilePermission(dir, "write"); err != nil {
glog.Errorf("Permission denied for %s", dir)
if err := fs.checkFilePermission(absPath, "write"); err != nil {
glog.Errorf("Permission denied for %s", absPath)
return nil, err
}
// Create a temporary file to buffer writes
@@ -160,6 +161,14 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
// Without O_TRUNC the client writes into the existing file, so start from its current bytes.
if !r.Pflags().Trunc {
if err := fs.copyExistingContent(absPath, tmpFile); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return nil, err
}
}
return &SeaweedSftpFileWriter{
fs: *fs,
@@ -173,6 +182,75 @@ func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
}, nil
}
func (fs *SftpServer) copyExistingContent(absPath string, dst io.Writer) error {
entry, err := fs.getEntry(absPath)
if err == os.ErrNotExist {
return nil
}
if err != nil {
return err
}
if entry.IsDirectory {
return nil
}
size := int64(filer.FileSize(entry))
remote := entry.GetRemoteEntry()
if len(entry.Content) == 0 && len(entry.GetChunks()) == 0 {
if remote != nil && remote.RemoteSize > 0 {
return fmt.Errorf("existing content of %s is not readable", absPath)
}
if size == 0 {
return nil
}
return writeZeros(dst, size)
}
if size == 0 {
return nil
}
if len(entry.Content) > 0 {
if _, err := dst.Write(entry.Content); err != nil {
return err
}
if remaining := size - int64(len(entry.Content)); remaining > 0 {
return writeZeros(dst, remaining)
}
return nil
}
streamFn, err := filer.PrepareStreamContent(lookupFileIdFn(filer.LookupFn(fs)), filer.JwtForVolumeServer, entry.GetChunks(), 0, size)
if err != nil {
return fmt.Errorf("read existing content of %s: %w", absPath, err)
}
if err := streamFn(dst); err != nil {
return fmt.Errorf("read existing content of %s: %w", absPath, err)
}
return nil
}
// lookupFileIdFn adapts a filer LookupFn to the chunk streamer's provider
// interface. PrepareStreamContent fills unstored gaps with zeros, so a sparse
// file's tail survives the copy.
type lookupFileIdFn wdclient.LookupFileIdFunctionType
func (fn lookupFileIdFn) GetLookupFileIdFunction() wdclient.LookupFileIdFunctionType {
return wdclient.LookupFileIdFunctionType(fn)
}
func writeZeros(w io.Writer, size int64) error {
zeros := make([]byte, 8192)
for size > 0 {
n := int64(len(zeros))
if size < n {
n = size
}
written, err := w.Write(zeros[:n])
size -= int64(written)
if err != nil {
return err
}
}
return nil
}
func (fs *SftpServer) removeEntry(absPath string) error {
return fs.deleteEntry(absPath, false)
}
@@ -247,21 +325,24 @@ func (fs *SftpServer) listAllPages(dirPath string) (sftp.ListerAt, error) {
var all []os.FileInfo
last := ""
for {
page, err := fs.fetchDirectoryPage(dirPath, last)
page, lastName, received, err := fs.fetchDirectoryPage(dirPath, last)
if err != nil {
return nil, err
}
all = append(all, page...)
if len(page) < defaultListLimit {
if received < defaultListLimit {
break
}
last = page[len(page)-1].Name()
last = lastName
}
return listerat(all), nil
}
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, error) {
// fetchDirectoryPage returns the entries the user may list, plus the last name and count the filer sent.
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, string, int, error) {
var list []os.FileInfo
lastName := ""
received := 0
err := fs.callWithClient(true, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{Directory: dirPath, StartFromFileName: start, Limit: defaultListLimit})
if err != nil {
@@ -272,9 +353,14 @@ func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo,
if err == io.EOF {
break
}
if err != nil || r.Entry == nil {
if err != nil {
return err
}
if r.Entry == nil {
continue
}
received++
lastName = r.Entry.Name
p := path.Join(dirPath, r.Entry.Name)
if err := fs.checkFilePermission(p, "list"); err != nil {
continue
@@ -283,7 +369,7 @@ func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo,
}
return nil
})
return list, err
return list, lastName, received, err
}
// makeDir creates a new directory with proper permissions.
+141
View File
@@ -1,12 +1,24 @@
package sftpd
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
// TestPutFileEscapesQueryInjection ensures a filename containing "?" cannot be
@@ -37,3 +49,132 @@ func TestPutFileEscapesQueryInjection(t *testing.T) {
t.Errorf("path not delivered literally: got %q, want %q", gotPath, malicious)
}
}
type fakeListFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
names []string
failFrom int // ListEntries fails after sending this many entries, when > 0
}
func (f *fakeListFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
sent := 0
for _, name := range f.names {
if name <= req.StartFromFileName || sent >= int(req.Limit) {
continue
}
if f.failFrom > 0 && sent == f.failFrom {
return status.Error(codes.Unavailable, "filer went away")
}
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}}); err != nil {
return err
}
sent++
}
return nil
}
func (f *fakeListFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
return &filer_pb.LookupDirectoryEntryResponse{Entry: &filer_pb.Entry{Name: req.Name, Attributes: &filer_pb.FuseAttributes{}}}, nil
}
func newListTestServer(t *testing.T, filer *fakeListFiler) *SftpServer {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := grpc.NewServer()
filer_pb.RegisterSeaweedFilerServer(srv, filer)
go srv.Serve(lis)
t.Cleanup(srv.Stop)
return &SftpServer{
filerAddr: pb.ServerAddress(fmt.Sprintf("127.0.0.1:1.%d", lis.Addr().(*net.TCPAddr).Port)),
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
user: &user.User{
Username: "alice",
HomeDir: "/",
Uid: 1001,
Gid: 1001,
// every entry under /d is listable except f00000
Permissions: map[string][]string{"/d": {"list"}, "/d/f00000": {}},
},
}
}
func listNames(t *testing.T, fs *SftpServer) ([]string, error) {
t.Helper()
type result struct {
lister sftp.ListerAt
err error
}
done := make(chan result, 1)
go func() {
lister, err := fs.listAllPages("/d")
done <- result{lister, err}
}()
select {
case r := <-done:
if r.err != nil {
return nil, r.err
}
infos := make([]os.FileInfo, 10000)
n, _ := r.lister.ListAt(infos, 0)
var names []string
for _, fi := range infos[:n] {
names = append(names, fi.Name())
}
return names, nil
case <-time.After(10 * time.Second):
t.Fatal("listing did not return")
return nil, nil
}
}
func fileNames(n int) []string {
names := make([]string, n)
for i := range names {
names[i] = fmt.Sprintf("f%05d", i)
}
return names
}
func TestListAllPagesReturnsStreamError(t *testing.T) {
fs := newListTestServer(t, &fakeListFiler{names: fileNames(10), failFrom: 3})
_, err := listNames(t, fs)
if status.Code(err) != codes.Unavailable {
t.Fatalf("expected the stream error, got %v", err)
}
}
func TestListAllPagesContinuesPastHiddenEntries(t *testing.T) {
fs := newListTestServer(t, &fakeListFiler{names: fileNames(defaultListLimit + 500)})
names, err := listNames(t, fs)
if err != nil {
t.Fatal(err)
}
if len(names) != defaultListLimit+499 {
t.Fatalf("expected %d entries, got %d", defaultListLimit+499, len(names))
}
if names[0] != "f00001" || names[len(names)-1] != fmt.Sprintf("f%05d", defaultListLimit+499) {
t.Fatalf("unexpected first/last entries %q, %q", names[0], names[len(names)-1])
}
}
func TestListAllPagesContinuesPastFullyHiddenPage(t *testing.T) {
const tail = 10
fs := newListTestServer(t, &fakeListFiler{names: fileNames(defaultListLimit + tail)})
// The whole first page is hidden from the user; only the tail is listable.
for i := 0; i < defaultListLimit; i++ {
fs.user.Permissions[fmt.Sprintf("/d/f%05d", i)] = []string{}
}
names, err := listNames(t, fs)
if err != nil {
t.Fatal(err)
}
if len(names) != tail {
t.Fatalf("expected the %d entries after the hidden page, got %d", tail, len(names))
}
if names[0] != fmt.Sprintf("f%05d", defaultListLimit) {
t.Fatalf("unexpected first entry %q", names[0])
}
}
+3
View File
@@ -67,6 +67,9 @@ func (fs *SftpServer) CheckFilePermission(path string, perm string) error {
// check permissions on the parent directory instead
if err == os.ErrNotExist {
parentPath := stdpath.Dir(path)
if parentPath == path {
return os.ErrPermission
}
// Check if user can write to the parent directory
return fs.CheckFilePermission(parentPath, perm)
}
+107
View File
@@ -1,9 +1,19 @@
package sftpd
import (
"context"
"net"
"os"
"testing"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
func TestPathWithin(t *testing.T) {
@@ -62,3 +72,100 @@ func TestIsPathInHomeDirectorySiblingPrefix(t *testing.T) {
t.Error("home /tenants/alice must not match sibling /tenants/alice-archive")
}
}
// newFileWriter must honor a more specific ACL on the target file, not just
// the parent directory's: a file the user cannot write stays read-only even
// in a writable directory. Missing paths still defer to the parent directory.
func TestNewFileWriterChecksTargetFile(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{
"/": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
"/home": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: 0755}},
"/home/alice": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
"/home/alice/locked.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
"/home/alice/ok.txt": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
"/home/alice/nested": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0755}},
"/home/alice/nested/ok.f": {Attributes: &filer_pb.FuseAttributes{Uid: 1000, FileMode: 0644}},
}}
grpcServer := grpc.NewServer()
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
go func() { _ = grpcServer.Serve(lis) }()
t.Cleanup(grpcServer.Stop)
_, port, _ := net.SplitHostPort(lis.Addr().String())
fs := &SftpServer{
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
user: &user.User{
Username: "alice", Uid: 1000, Gid: 1000, HomeDir: "/home/alice",
Permissions: map[string][]string{
"/home/alice": {"readwrite"},
"/home/alice/locked.txt": {"read"},
},
},
}
write := func(path string) error {
w, err := fs.newFileWriter(&sftp.Request{Filepath: path})
if writer, ok := w.(*SeaweedSftpFileWriter); ok {
_ = os.Remove(writer.tmpFile.Name())
_ = writer.tmpFile.Close()
}
return err
}
if err := write("/locked.txt"); err == nil {
t.Error("expected write denial on a file with an explicit read-only ACL")
}
if err := write("/ok.txt"); err != nil {
t.Errorf("expected write under the directory ACL, got %v", err)
}
if err := write("/new.txt"); err != nil {
t.Errorf("expected write on a new file in a writable dir, got %v", err)
}
if err := write("/nested/new.txt"); err != nil {
t.Errorf("expected write on a new file under a missing intermediate check, got %v", err)
}
delete(filer.entries, "/home/alice/nested")
delete(filer.entries, "/home/alice/nested/ok.f")
if err := write("/gone/deeper.txt"); err != nil {
t.Errorf("expected write when ancestors resolve to the writable dir, got %v", err)
}
}
func TestCheckFilePermissionMissingAncestorsTerminates(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
filer := &permissionTestFiler{entries: map[string]*filer_pb.Entry{}}
grpcServer := grpc.NewServer()
filer_pb.RegisterSeaweedFilerServer(grpcServer, filer)
go func() { _ = grpcServer.Serve(lis) }()
t.Cleanup(grpcServer.Stop)
_, port, _ := net.SplitHostPort(lis.Addr().String())
fs := &SftpServer{
filerAddr: pb.ServerAddress("127.0.0.1:0." + port),
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
user: &user.User{Username: "alice", Uid: 1000, HomeDir: "/home/alice"},
}
if err := fs.checkFilePermission("/a/b/c", "write"); err == nil {
t.Error("expected denial when no ancestor exists")
}
}
type permissionTestFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
entries map[string]*filer_pb.Entry
}
func (f *permissionTestFiler) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
if entry, ok := f.entries[req.Directory+"/"+req.Name]; ok {
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
}
return nil, status.Error(codes.NotFound, "no entry is found")
}
+1 -1
View File
@@ -982,7 +982,7 @@ func (ev *EcVolume) LocateEcShardNeedle(needleId types.NeedleId, version needle.
return types.Offset{}, 0, nil, fmt.Errorf("FindNeedleFromEcx: %w", err)
}
intervals = ev.LocateEcShardNeedleInterval(version, offset.ToActualOffset(), types.Size(needle.GetActualSize(size, version)))
intervals = ev.LocateEcShardNeedleInterval(version, offset.ToActualOffset(), size)
return
}
@@ -256,3 +256,30 @@ func TestEcVolumeDestroySweepsBothDirs(t *testing.T) {
}
}
}
func TestLocateEcShardNeedleMatchesInterval(t *testing.T) {
ecxFile, err := os.OpenFile("./test_files/389.ecx", os.O_RDONLY, 0)
assert.NoError(t, err)
defer ecxFile.Close()
stat, err := ecxFile.Stat()
assert.NoError(t, err)
fileSize := stat.Size()
ev := &EcVolume{
VolumeId: needle.VolumeId(389),
ecxFile: ecxFile,
ecxFileSize: fileSize,
Shards: []*EcVolumeShard{
{ecdFileSize: 1118830592},
},
ECContext: NewDefaultECContext("", 389),
}
needleId, _ := types.ParseNeedleId("0f0edb92")
offset, size, intervals, err := ev.LocateEcShardNeedle(needleId, needle.GetCurrentVersion())
assert.NoError(t, err)
expectedIntervals := ev.LocateEcShardNeedleInterval(needle.GetCurrentVersion(), offset.ToActualOffset(), size)
assert.Equal(t, expectedIntervals, intervals)
}
@@ -504,10 +504,11 @@ func balanceShardTypeAcrossRacks(vk volKey, nodes map[string]*Node, racks map[st
var moves []*move
for _, pm := range toMove {
hasRoom := func(r string) bool {
return r != pm.src.rack && racks[r].freeSlots > 0 &&
return r != pm.src.rack && rackFreeForType(racks[r], diskType) > 0 &&
(!pm.avoidDataRack || !antiAffinity[r])
}
destRack, ok := pickTarget(rackKeys, shardsPerRack, maxPerRack, antiAffinity, hasRoom, withinLimit)
rackFree := func(r string) int { return rackFreeForType(racks[r], diskType) }
destRack, ok := pickTarget(rackKeys, shardsPerRack, maxPerRack, antiAffinity, hasRoom, withinLimit, rackFree)
if !ok && totalCap > 0 && rackShardCount[pm.src.rack] > totalCap {
// No rack is under the per-type cap. A shard must not stay on a rack
// above the total cap while another rack is under it, so relax the
@@ -515,12 +516,12 @@ func balanceShardTypeAcrossRacks(vk volKey, nodes map[string]*Node, racks map[st
// inside pickTarget. Only a source above the total cap may relax:
// otherwise the destination just takes over the per-type overflow
// and the next Plan moves it back.
destRack, ok = pickTarget(rackKeys, shardsPerRack, totalCap, antiAffinity, hasRoom, withinLimit)
destRack, ok = pickTarget(rackKeys, shardsPerRack, totalCap, antiAffinity, hasRoom, withinLimit, rackFree)
}
if !ok {
continue
}
destNode := pickNodeInRack(racks[destRack], vk, rp)
destNode := pickNodeInRack(racks[destRack], vk, rp, diskType)
if destNode == nil {
continue
}
@@ -553,15 +554,55 @@ func balanceShardTypeAcrossRacks(vk volKey, nodes map[string]*Node, racks map[st
return moves
}
func pickNodeInRack(r *rack, vk volKey, rp *super_block.ReplicaPlacement) *Node {
return pickBestNodeForVolume(sortedNodeSlice(r.nodes), vk, rp)
func pickNodeInRack(r *rack, vk volKey, rp *super_block.ReplicaPlacement, diskType string) *Node {
return pickBestNodeForVolumeOnDisk(sortedNodeSlice(r.nodes), vk, rp, diskType)
}
// pickBestNodeForVolume returns the node with the fewest shards of the volume that
// has a free slot and is under the SameRackCount cap, or nil.
// pickBestNodeForVolumeOnDisk is pickBestNodeForVolume restricted to nodes with
// free capacity on a disk of diskType, so the pick cannot land on a node whose
// room sits on another type.
func pickBestNodeForVolumeOnDisk(nodes []*Node, vk volKey, rp *super_block.ReplicaPlacement, diskType string) *Node {
eligible := make([]*Node, 0, len(nodes))
for _, node := range nodes {
if freeSlotsForType(node, diskType) > 0 {
eligible = append(eligible, node)
}
}
return pickBestNodeForVolume(eligible, vk, rp)
}
// freeSlotsForType is the node's free EC slots usable by diskType. Per-disk
// metadata is authoritative when present; without it the node's aggregate
// stands in so the volume server's own disk selection can place the shard.
func freeSlotsForType(n *Node, diskType string) int {
if len(n.disks) == 0 {
return n.freeSlots
}
free := 0
for _, d := range n.disks {
if d.freeSlots > 0 && (diskType == "" || d.diskType == diskType) {
free += d.freeSlots
}
}
return free
}
func rackFreeForType(r *rack, diskType string) int {
free := 0
for _, n := range r.nodes {
free += freeSlotsForType(n, diskType)
}
return free
}
// pickBestNodeForVolume spreads this volume evenly first. Remainders: higher
// freeSlots, then lower liveShards+assignedThisPlace. SameRackCount is a hard
// cap (no +1 for debt).
func pickBestNodeForVolume(nodes []*Node, vk volKey, rp *super_block.ReplicaPlacement) *Node {
var best *Node
bestCount := -1
bestFree := -1
bestScore := -1
for _, node := range nodes {
if node.freeSlots <= 0 {
continue
@@ -570,8 +611,20 @@ func pickBestNodeForVolume(nodes []*Node, vk volKey, rp *super_block.ReplicaPlac
if rp != nil && rp.SameRackCount > 0 && count >= rp.SameRackCount {
continue
}
if best == nil || count < bestCount {
best, bestCount = node, count
score := nodeLiveShardCount(node)
better := false
switch {
case best == nil:
better = true
case count != bestCount:
better = count < bestCount
case node.freeSlots != bestFree:
better = node.freeSlots > bestFree
default:
better = score < bestScore
}
if better {
best, bestCount, bestFree, bestScore = node, count, node.freeSlots, score
}
}
return best
@@ -662,12 +715,21 @@ func withinRackMachineSpread(vk volKey, r *rack, machines map[string][]*Node, di
// shard (free slot, under SameRackCount), so a capped machine is skipped
// rather than settled on and the move dropped.
destHost, ok := pickTarget(machineKeys, shardsPerMachine, maxPerMachine, nil,
func(h string) bool { return h != pm.src.host && pickBestNodeForVolume(machines[h], vk, rp) != nil },
func(string) bool { return true })
func(h string) bool {
return h != pm.src.host && pickBestNodeForVolumeOnDisk(machines[h], vk, rp, diskType) != nil
},
func(string) bool { return true },
func(h string) int {
n := 0
for _, node := range machines[h] {
n += freeSlotsForType(node, diskType)
}
return n
})
if !ok {
continue
}
destNode := pickBestNodeForVolume(machines[destHost], vk, rp)
destNode := pickBestNodeForVolumeOnDisk(machines[destHost], vk, rp, diskType)
if destNode == nil {
continue
}
@@ -749,13 +811,14 @@ func balanceShardTypeAcrossNodes(vk volKey, r *rack, diskType string, dataShards
var moves []*move
for _, pm := range toMove {
destID, ok := pickTarget(nodeKeys, shardsPerNode, maxPerNode, antiAffinity,
func(n string) bool { return n != pm.src.id && r.nodes[n].freeSlots > 0 },
func(n string) bool { return n != pm.src.id && freeSlotsForType(r.nodes[n], diskType) > 0 },
func(n string) bool {
if rp != nil && rp.SameRackCount > 0 {
return nodeShardCount[n] < rp.SameRackCount
}
return true
})
},
func(n string) int { return freeSlotsForType(r.nodes[n], diskType) })
if !ok {
continue
}
@@ -967,12 +1030,17 @@ func shardsByGroup(vk volKey, nodes map[string]*Node, dataShards int, key func(*
// pickTarget selects a destination key with room for another shard of a type, in
// two passes: first excluding anti-affinity targets, then any valid target. Among
// valid targets it prefers the fewest shards of this type; ties break on sorted
// key order, so selection is deterministic.
func pickTarget(candidates []string, shardsPerTarget map[string][]int, maxPerTarget int, antiAffinity map[string]bool, hasFreeSlots, withinLimit func(string) bool) (string, bool) {
// valid targets it prefers the fewest shards of this type (even spread). Count
// ties break on remaining freeSlots, not sorted key order: otherwise 14/4
// leftovers always land on the first rack/machine/node ids and the extra shards
// of 4+4+3+3 never walk. Equal freeSlots still keep the first seen key so a
// single run stays deterministic. Node-in-rack Place remainders (after even) are
// freeSlots then liveShards — see pickNodeInRackEligible.
func pickTarget(candidates []string, shardsPerTarget map[string][]int, maxPerTarget int, antiAffinity map[string]bool, hasFreeSlots, withinLimit func(string) bool, freeSlots func(string) int) (string, bool) {
try := func(skipAnti bool) (string, bool) {
best := ""
bestCount := maxPerTarget + 1
bestFree := -1
for _, c := range candidates {
if skipAnti && antiAffinity[c] {
continue
@@ -986,8 +1054,13 @@ func pickTarget(candidates []string, shardsPerTarget map[string][]int, maxPerTar
if !withinLimit(c) {
continue
}
if cnt := len(shardsPerTarget[c]); cnt < bestCount {
best, bestCount = c, cnt
cnt := len(shardsPerTarget[c])
free := 0
if freeSlots != nil {
free = freeSlots(c)
}
if best == "" || cnt < bestCount || (cnt == bestCount && free > bestFree) {
best, bestCount, bestFree = c, cnt, free
}
}
return best, best != ""
@@ -1169,6 +1242,19 @@ func volumeShardCount(node *Node, vk volKey) int {
return 0
}
// nodeLiveShardCount is the disk-level EC shard cardinality (master EcShards),
// not the UI "EC volumes" count (len of the volume map).
func nodeLiveShardCount(n *Node) int {
if n == nil {
return 0
}
total := 0
for _, d := range n.disks {
total += d.shardCount
}
return total
}
func nodeInRackHoldingShard(nodes map[string]*Node, rackID string, vk volKey, shardID int) *Node {
var inRack []*Node
for _, id := range sortedNodeKeys(nodes) {
@@ -90,6 +90,71 @@ func TestPickBestDiskOnNode(t *testing.T) {
})
}
func TestPickBestNodeForVolume(t *testing.T) {
const vid = uint32(100)
vk := volKey{collection: "c", vid: vid}
t.Run("more free slots wins the count tie", func(t *testing.T) {
topo := NewTopology()
thin := topo.AddNode("n1", "dc1", "dc1:r1", 5)
thin.AddDisk(1, "", 5, 0)
fat := topo.AddNode("n2", "dc1", "dc1:r1", 50)
fat.AddDisk(1, "", 50, 0)
if got := pickBestNodeForVolume([]*Node{thin, fat}, vk, nil); got != fat {
t.Errorf("got %v, want n2 (more free slots)", got.id)
}
})
t.Run("fewer live shards wins the free tie", func(t *testing.T) {
topo := NewTopology()
idle := topo.AddNode("n1", "dc1", "dc1:r1", 10)
idle.AddDisk(1, "", 10, 0)
busy := topo.AddNode("n2", "dc1", "dc1:r1", 10)
busy.AddDisk(1, "", 10, 9)
if got := pickBestNodeForVolume([]*Node{busy, idle}, vk, nil); got != idle {
t.Errorf("got %v, want n1 (fewer live shards)", got.id)
}
})
t.Run("volume spread beats free slots", func(t *testing.T) {
topo := NewTopology()
holding := topo.AddNode("n1", "dc1", "dc1:r1", 100)
holding.AddDisk(1, "", 100, 1)
holding.AddShards(vid, "c", 1, bits(0))
empty := topo.AddNode("n2", "dc1", "dc1:r1", 1)
empty.AddDisk(1, "", 1, 0)
if got := pickBestNodeForVolume([]*Node{holding, empty}, vk, nil); got != empty {
t.Errorf("got %v, want n2 (volume spread first)", got.id)
}
})
}
func TestPlanSkipsDestinationsWithoutMatchingDiskType(t *testing.T) {
topo := NewTopology()
src := topo.AddNode("node1", "dc1", "dc1:rack1", 0)
src.AddDisk(0, "ssd", 0, 14)
src.AddShards(100, "col1", 0, bits(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13))
// Aggregate capacity alone would make the hdd-only node look like the
// roomiest destination; none of its slots can take an ssd shard.
wrong := topo.AddNode("node2", "dc1", "dc1:rack2", 600)
wrong.AddDisk(1, "hdd", 600, 0)
right := topo.AddNode("node3", "dc1", "dc1:rack3", 600)
right.AddDisk(1, "ssd", 600, 0)
moves := Plan(topo, Options{ImbalanceThreshold: 0.01, DiskType: "ssd", Ratio: ratio(10, 4)})
if len(moves) == 0 {
t.Fatal("expected moves")
}
for _, m := range moves {
if m.TargetNode == wrong.id {
t.Errorf("shard %d targeted the hdd-only node", m.ShardID)
}
if m.TargetNode == right.id && m.TargetDisk != 1 {
t.Errorf("shard %d landed on disk %d, want 1", m.ShardID, m.TargetDisk)
}
}
}
func TestPlanSourceDiskAttribution(t *testing.T) {
shardsByDisk := map[uint32][]int{0: {0, 1, 2}, 1: {3, 4, 5}, 2: {6, 7}, 3: {8, 9}, 4: {10, 11}, 5: {12, 13}}
shardToDisk := map[int]uint32{}

Some files were not shown because too many files have changed in this diff Show More