s3api: preserve remote object ETags across cache fills (#11704)

* s3api: preserve remote object ETags across cache fills

* s3api: align remote copy validators and reject stale ETags

* s3api: keep remote ETag through metadata touches, drop it after resizes

The remote-ETag check required Mtime to match RemoteMtime, so a utimens
on a cold remote object returned an empty ETag until a later cache fill
restored the remote value. It also trusted LastLocalSyncTsNs alone, so a
setattr resize that leaves chunks and the stamp untouched kept serving
the old remote ETag for different local bytes.

Require FileSize to match RemoteSize for both the synced and remote-only
paths and stop comparing modification times: content changes already
surface through cleared sync stamps, chunks, or inline content, while a
metadata-only touch leaves the remote bytes authoritative. Regression
tests cover touched cold and cached entries and resized cached entries.

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
This commit is contained in:
Zhe ChenandChris Lu authored and GitHub committed 2026-10-10 23:58:22 +08:00
1 parent 17ba8b3c7c
commit 547befc070
3 files changed
+200 -22

No files matched your search

+19 -1
View File
@@ -2594,6 +2594,10 @@ func parseConditionalHeaders(r *http.Request) (conditionalHeaders, s3err.ErrorCo
// S3ApiServer implements EntryGetter interface
func (s3a *S3ApiServer) getObjectETag(entry *filer_pb.Entry) string {
return objectETag(entry)
}
func objectETag(entry *filer_pb.Entry) string {
// Try to get ETag from Extended attributes first
if etagBytes, hasETag := entry.Extended[s3_constants.ExtETagKey]; hasETag {
etag := string(etagBytes)
@@ -2605,13 +2609,27 @@ func (s3a *S3ApiServer) getObjectETag(entry *filer_pb.Entry) string {
}
// Empty stored ETag — fall through to Md5/chunk-based calculation
}
// The remote ETag still describes this entry only while the local copy
// holds the remote bytes: a synced cache fill, or a remote-only entry
// with no local content. Content changes made through the filer clear
// the sync stamp (Filer.UpdateEntry) or add chunks or inline content;
// resizes without new bytes show up in FileSize. Metadata-only updates
// like utimens keep the remote bytes, so Mtime is not part of the check.
if remote := entry.RemoteEntry; remote != nil && remote.RemoteETag != "" &&
entry.GetAttributes().GetFileSize() == uint64(remote.RemoteSize) &&
(remote.LastLocalSyncTsNs > 0 || (len(entry.Chunks) == 0 && len(entry.Content) == 0)) {
return quoteETag(remote.RemoteETag)
}
// Check for Md5 in Attributes (matches filer.ETag behavior)
// Note: len(nil slice) == 0 in Go, so no need for explicit nil check
if entry.Attributes != nil && len(entry.Attributes.Md5) > 0 {
return fmt.Sprintf("\"%x\"", entry.Attributes.Md5)
}
// Fallback: calculate ETag from chunks
return s3a.calculateETagFromChunks(entry.Chunks)
if len(entry.Chunks) == 0 {
return `""`
}
return quoteETag(filer.ETagChunks(entry.Chunks))
}
func (s3a *S3ApiServer) etagMatches(headerValue, objectETag string) bool {