filer: support nanosecond timestamps for conditional delete (#11339) (#12071)

* filer: support nanosecond timestamps for conditional delete (#11339)

* shell: fs.verify prune carries the entry mtime in nanoseconds

IfNotModifiedAfter now resolves to sub-second precision on the filer,
so pass the looked-up mtime_ns along with mtime; the re-lookup check
catches same-second rewrites that share the second, and the test mock
mirrors the filer's seconds/nanoseconds cutoff.

* shell: volume.fsck guards empty-directory deletes with mtime nanoseconds

The looked-up mtime_ns joins the seconds in IfNotModifiedAfter so a
directory rewritten within the same second is not dropped; the stub
filer mirrors the filer's scale heuristic and the race fixtures use a
realistic mtime so the nanosecond branch engages.

* filer: cover the seconds guard and equality boundary

Test second-resolution cutoffs on both sides of the entry's second and
the exact-equality nanosecond guard that still deletes.

* filer: compare the nanosecond guard as a time, not UnixNano

UnixNano overflows for mtimes outside 1678-2262, letting a far-future
entry slip past the guard; time.After compares exactly at every epoch.
The seconds branch keeps Unix() so a same-second cutoff still deletes.

* filer_pb: add explicit if_not_modified_after_ns to DeleteEntryRequest

* filer: prefer explicit nanoseconds guard, keep if_not_modified_after as seconds

* shell: send seconds and nanoseconds delete cutoffs, skip unexpressible mtimes

* filer: merge conditional-delete guards into one nanosecond cutoff

DeleteEntryMetaAndData carried both the seconds and nanoseconds guards
even though the precedence between them is fixed, so every caller passed
a dead parameter. The gRPC handler now folds if_not_modified_after into
nanoseconds at the boundary, where (sec+1)*1e9-1 keeps the whole-second
cutoff exact, and the filer takes a single ifNotModifiedAfterNs.

* filer: hold the conditional-delete cutoff as a time, not int64 nanoseconds

A seconds-only guard at or past year 2262 saturated to MaxInt64
nanoseconds (~2262), so a delete whose cutoff matched a far-future mtime
was skipped while the request still reported success, and any entry
between 2262 and the real cutoff survived pruning. The merged cutoff is
now a time.Time, where a whole-second guard maps to the last nanosecond
of its second for any int64 second.

---------

Co-authored-by: user.email <chrislusf@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-26 07:51:33 -07:00
1 parent b85281f0da
commit a6be3c89c2
25 files changed
+326 -100

No files matched your search

+31 -13
View File
@@ -163,8 +163,7 @@ type ItemEntry struct {
chunks []*filer_pb.FileChunk
rawChunks []*filer_pb.FileChunk
path util.FullPath
mtimeSec int64
md5 []byte
attr *filer_pb.FuseAttributes
}
// resolveAndVerify expands chunk manifests and verifies the entry's needles.
@@ -220,8 +219,7 @@ func (c *commandFsVerify) verifyProcessMetadata(path string, wg *sync.WaitGroup)
if *c.pruneEntries && hasMissingNeedles {
pruned, pruneErr := c.pruneEntry(
util.NewFullPath(message.NewParentPath, message.NewEntry.Name),
message.NewEntry.Attributes.Mtime,
message.NewEntry.Attributes.Md5,
message.NewEntry.Attributes,
message.NewEntry.Chunks)
if pruneErr != nil {
fmt.Fprintf(c.writer, "prune %s failed: %v\n", entryPath, pruneErr)
@@ -371,13 +369,28 @@ func (c *commandFsVerify) verifyEntry(path string, chunks []*filer_pb.FileChunk,
return itemIsVerifed.Load(), hasMissingNeedlesFlag.Load()
}
// deleteEntryCutoff splits an entry mtime into the conditional-delete fields:
// seconds keep older filers enforcing the guard, nanoseconds keep same-second
// precision where int64 can hold them. ok is false when neither field can
// express the cutoff and the delete must be skipped rather than sent unguarded.
func deleteEntryCutoff(mtimeSec int64, mtimeNsec int32) (sec, ns int64, ok bool) {
sec = mtimeSec
if sec <= 0 {
return 0, 0, false
}
if nsec := int64(mtimeNsec); nsec >= 0 && nsec < 1e9 && sec <= (math.MaxInt64-nsec)/1e9 {
ns = sec*1e9 + nsec
}
return sec, ns, true
}
// pruneEntry deletes an entry whose needles are gone, unless the entry
// changed since verification. Three guards protect a concurrent repair:
// re-lookup with mtime/md5 comparison, an identity check of the chunk set
// (an append or rewrite in the same second as the original mtime changes the
// chunks but not the timestamps), and IfNotModifiedAfter on the delete. The
// entry is only counted as pruned when a follow-up lookup confirms it is gone.
func (c *commandFsVerify) pruneEntry(path util.FullPath, mtimeSec int64, md5 []byte, expectedChunks []*filer_pb.FileChunk) (pruned bool, err error) {
func (c *commandFsVerify) pruneEntry(path util.FullPath, attr *filer_pb.FuseAttributes, expectedChunks []*filer_pb.FileChunk) (pruned bool, err error) {
dir, name := path.DirAndName()
expectedIds := chunkFingerprints(expectedChunks)
lookupErr := c.env.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
@@ -392,11 +405,11 @@ func (c *commandFsVerify) pruneEntry(path util.FullPath, mtimeSec int64, md5 []b
return err
}
att := lookupResp.Entry.GetAttributes()
if att.GetMtime() != mtimeSec {
if att.GetMtime() != attr.GetMtime() || att.GetMtimeNs() != attr.GetMtimeNs() {
fmt.Fprintf(c.writer, "skip pruning %s: entry changed since verification\n", path)
return nil
}
if len(md5) > 0 && !bytes.Equal(att.GetMd5(), md5) {
if md5 := attr.GetMd5(); len(md5) > 0 && !bytes.Equal(att.GetMd5(), md5) {
fmt.Fprintf(c.writer, "skip pruning %s: entry changed since verification\n", path)
return nil
}
@@ -404,10 +417,16 @@ func (c *commandFsVerify) pruneEntry(path util.FullPath, mtimeSec int64, md5 []b
fmt.Fprintf(c.writer, "skip pruning %s: entry changed since verification\n", path)
return nil
}
sec, ns, ok := deleteEntryCutoff(attr.GetMtime(), attr.GetMtimeNs())
if !ok {
fmt.Fprintf(c.writer, "skip pruning %s: mtime cannot set a delete condition\n", path)
return nil
}
deleteResp, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
Directory: dir,
Name: name,
IfNotModifiedAfter: mtimeSec,
Directory: dir,
Name: name,
IfNotModifiedAfter: sec,
IfNotModifiedAfterNs: ns,
})
if err != nil {
return err
@@ -487,8 +506,7 @@ func (c *commandFsVerify) verifyTraverseBfs(path string) (fileCount uint64, errC
chunks: dataChunks,
rawChunks: entry.Entry.GetChunks(),
path: util.NewFullPath(entry.Dir, entry.Entry.Name),
mtimeSec: entry.Entry.GetAttributes().GetMtime(),
md5: entry.Entry.GetAttributes().GetMd5(),
attr: entry.Entry.GetAttributes(),
}:
case <-ctx.Done():
return ctx.Err()
@@ -508,7 +526,7 @@ func (c *commandFsVerify) verifyTraverseBfs(path string) (fileCount uint64, errC
}
fileCount++
} else if *c.pruneEntries && hasMissingNeedles {
pruned, pruneErr := c.pruneEntry(i.path, i.mtimeSec, i.md5, i.rawChunks)
pruned, pruneErr := c.pruneEntry(i.path, i.attr, i.rawChunks)
if pruneErr != nil {
fmt.Fprintf(c.writer, "prune %s failed: %v\n", itemPath, pruneErr)
} else if pruned {