diff --git a/other/java/client/src/main/proto/filer.proto b/other/java/client/src/main/proto/filer.proto index 42f772a57a..f0bf1d6371 100644 --- a/other/java/client/src/main/proto/filer.proto +++ b/other/java/client/src/main/proto/filer.proto @@ -524,8 +524,9 @@ message DeleteEntryRequest { bool ignore_recursive_error = 6; bool is_from_other_cluster = 7; repeated int32 signatures = 8; - int64 if_not_modified_after = 9; + int64 if_not_modified_after = 9; // unix time in seconds bool keep_remote_object = 10; + int64 if_not_modified_after_ns = 11; // unix time in nanoseconds; overrides if_not_modified_after when set } message DeleteEntryResponse { diff --git a/weed/filer/empty_folder_cleanup/empty_folder_cleaner.go b/weed/filer/empty_folder_cleanup/empty_folder_cleaner.go index b85b94d20f..a917515ff9 100644 --- a/weed/filer/empty_folder_cleanup/empty_folder_cleaner.go +++ b/weed/filer/empty_folder_cleanup/empty_folder_cleaner.go @@ -52,7 +52,7 @@ type deletedFolder struct { // FilerOperations defines the filer operations needed by EmptyFolderCleaner type FilerOperations interface { CountDirectoryEntries(ctx context.Context, dirPath util.FullPath, limit int) (count int, err error) - DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isFromOtherCluster bool, signatures []int32, ifNotModifiedAfter int64) error + DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isFromOtherCluster bool, signatures []int32, notModifiedAfter time.Time) error GetEntryAttributes(ctx context.Context, p util.FullPath) (attributes map[string][]byte, err error) IsDirectoryKeyObject(ctx context.Context, p util.FullPath) (bool, error) DirectoryAttributes(ctx context.Context, p util.FullPath) (DirectoryAttributes, error) @@ -656,7 +656,7 @@ func (efc *EmptyFolderCleaner) countItems(ctx context.Context, folder string) (i // deleteFolder deletes an empty folder func (efc *EmptyFolderCleaner) deleteFolder(ctx context.Context, folder string) error { - return efc.filer.DeleteEntryMetaAndData(ctx, util.FullPath(folder), false, false, false, false, nil, 0) + return efc.filer.DeleteEntryMetaAndData(ctx, util.FullPath(folder), false, false, false, false, nil, time.Time{}) } func (efc *EmptyFolderCleaner) getBucketCleanupPolicy(ctx context.Context, folder string) (bucketPath string, autoRemove bool, source string, attrValue string, err error) { diff --git a/weed/filer/empty_folder_cleanup/empty_folder_cleaner_test.go b/weed/filer/empty_folder_cleanup/empty_folder_cleaner_test.go index bf673b630e..6e9aabcde3 100644 --- a/weed/filer/empty_folder_cleanup/empty_folder_cleaner_test.go +++ b/weed/filer/empty_folder_cleanup/empty_folder_cleaner_test.go @@ -42,7 +42,7 @@ func (m *mockFilerOps) CountDirectoryEntries(_ context.Context, dirPath util.Ful return m.countFn(dirPath) } -func (m *mockFilerOps) DeleteEntryMetaAndData(_ context.Context, p util.FullPath, _, _, _, _ bool, _ []int32, _ int64) error { +func (m *mockFilerOps) DeleteEntryMetaAndData(_ context.Context, p util.FullPath, _, _, _, _ bool, _ []int32, _ time.Time) error { if m.deleteFn == nil { return nil } diff --git a/weed/filer/filer_delete_collection_test.go b/weed/filer/filer_delete_collection_test.go index 9701f38710..3300d8324c 100644 --- a/weed/filer/filer_delete_collection_test.go +++ b/weed/filer/filer_delete_collection_test.go @@ -155,7 +155,7 @@ func TestDeleteEntryMetaAndDataDeletesCollectionWhenTheRequestIsCancelledMidDele // The client hangs up just as the bucket entry comes out of the store. store.onDeleteEntry = func(util.FullPath) { cancel() } - if err := f.DeleteEntryMetaAndData(ctx, bucketPath, true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(ctx, bucketPath, true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } if ctx.Err() == nil { @@ -205,7 +205,7 @@ func TestDeleteBucketKeepsSharedCollection(t *testing.T) { seedBucket(t, store, util.FullPath("/buckets/a")) seedBucket(t, store, util.FullPath("/buckets/b")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } @@ -230,7 +230,7 @@ func TestDeleteBucketNamedAfterSharedCollection(t *testing.T) { seedBucket(t, store, util.FullPath("/buckets/shared")) seedBucket(t, store, util.FullPath("/buckets/other")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/shared", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/shared", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } @@ -255,7 +255,7 @@ func TestDeleteBucketKeepsCollectionUsedByNonBucketPath(t *testing.T) { }) seedBucket(t, store, util.FullPath("/buckets/a")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } @@ -276,7 +276,7 @@ func TestDeleteBucketKeepsCollectionFromBroadRule(t *testing.T) { }) seedBucket(t, store, util.FullPath("/buckets/a")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } @@ -298,7 +298,7 @@ func TestDeleteBucketKeepsDefaultCollection(t *testing.T) { }) seedBucket(t, store, util.FullPath("/buckets/a")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } @@ -325,7 +325,7 @@ func TestDeleteBucketKeepsCollectionFromNestedSiblingRule(t *testing.T) { seedBucket(t, store, util.FullPath("/buckets/a")) seedBucket(t, store, util.FullPath("/buckets/b")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } @@ -348,7 +348,7 @@ func TestDeleteBucketUnderFilerGroup(t *testing.T) { }) seedBucket(t, store, util.FullPath("/buckets/photos")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/photos", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/photos", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } @@ -372,7 +372,7 @@ func TestDeleteBucketDeletesResolvedCollection(t *testing.T) { }) seedBucket(t, store, util.FullPath("/buckets/only")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/only", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/only", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } diff --git a/weed/filer/filer_delete_entry.go b/weed/filer/filer_delete_entry.go index 899d337c05..2d82d532f0 100644 --- a/weed/filer/filer_delete_entry.go +++ b/weed/filer/filer_delete_entry.go @@ -117,7 +117,7 @@ func deleteEventEntry(ctx context.Context, entry *Entry) *Entry { type OnChunksFunc func([]*filer_pb.FileChunk) error type OnHardLinkIdsFunc func([]HardLinkId) error -func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isFromOtherCluster bool, signatures []int32, ifNotModifiedAfter int64) (err error) { +func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isFromOtherCluster bool, signatures []int32, notModifiedAfter time.Time) (err error) { if p == "/" { return nil } @@ -128,7 +128,7 @@ func (f *Filer) DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isR if findErr != nil { return findErr } - if ifNotModifiedAfter > 0 && entry.Attr.Mtime.Unix() > ifNotModifiedAfter { + if !notModifiedAfter.IsZero() && entry.Attr.Mtime.After(notModifiedAfter) { return nil } if !isRecursive && entry.IsDirectory() { diff --git a/weed/filer/filer_delete_entry_test.go b/weed/filer/filer_delete_entry_test.go index 7a049fade4..d483b7a779 100644 --- a/weed/filer/filer_delete_entry_test.go +++ b/weed/filer/filer_delete_entry_test.go @@ -55,12 +55,12 @@ func TestKeepRemoteObjectMarkerOnlyLivesOnDeleteEvents(t *testing.T) { require.Contains(t, stored.Extended, "Seaweed-Other") ordinaryCtx, ordinary := WithMetadataEventSink(ctx) - require.NoError(t, f.DeleteEntryMetaAndData(ordinaryCtx, file.FullPath, false, false, false, false, nil, 0)) + require.NoError(t, f.DeleteEntryMetaAndData(ordinaryCtx, file.FullPath, false, false, false, false, nil, time.Time{})) require.False(t, IsMetadataOnlyDelete(ordinary.Last().EventNotification.OldEntry)) require.NoError(t, f.CreateEntry(ctx, &Entry{FullPath: file.FullPath, Attr: Attr{Mode: 0644}}, nil, false, false, nil, true, 255)) keepCtx, keep := WithMetadataEventSink(WithKeepRemoteObject(ctx)) - require.NoError(t, f.DeleteEntryMetaAndData(keepCtx, dir.FullPath, true, false, false, false, nil, 0)) + require.NoError(t, f.DeleteEntryMetaAndData(keepCtx, dir.FullPath, true, false, false, false, nil, time.Time{})) require.Equal(t, "dir", keep.Last().EventNotification.OldEntry.Name) require.True(t, IsMetadataOnlyDelete(keep.Last().EventNotification.OldEntry)) } @@ -83,7 +83,7 @@ func TestDeleteFolderFreesLastHardLinkChunks(t *testing.T) { } require.NoError(t, f.CreateEntry(ctx, linkEntry, nil, false, false, nil, true, 255)) - require.NoError(t, f.DeleteEntryMetaAndData(ctx, "/d", true, false, true, false, nil, 0)) + require.NoError(t, f.DeleteEntryMetaAndData(ctx, "/d", true, false, true, false, nil, time.Time{})) _, err := store.KvGet(ctx, hardLinkId) require.ErrorIs(t, err, ErrKvNotFound) @@ -111,9 +111,61 @@ func TestDeleteFolderKeepsSharedHardLinkChunks(t *testing.T) { }, nil, false, false, nil, true, 255)) } - require.NoError(t, f.DeleteEntryMetaAndData(ctx, "/d", true, false, true, false, nil, 0)) + require.NoError(t, f.DeleteEntryMetaAndData(ctx, "/d", true, false, true, false, nil, time.Time{})) _, err := store.KvGet(ctx, hardLinkId) require.NoError(t, err, "surviving link must keep the inode record") require.Equal(t, 0, f.pendingDeletionCount()) } + +func TestDeleteEntryMetaAndData_IfNotModifiedAfterSubsecond(t *testing.T) { + store := newStubFilerStore() + f := newTestFiler(t, store, NewFilerRemoteStorage()) + ctx := context.Background() + + baseTime := time.Date(2026, 10, 26, 12, 0, 0, 500_000_000, time.UTC) // 12:00:00.500 + filePath := util.FullPath("/subsecond.txt") + + entry := &Entry{ + FullPath: filePath, + Attr: Attr{Mode: 0644, Mtime: baseTime}, + } + require.NoError(t, f.CreateEntry(ctx, entry, nil, false, false, nil, true, 255)) + + // Guard is 100ms before entry Mtime (within the same second) + guardNs := baseTime.Add(-100 * time.Millisecond).UnixNano() + require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, time.Unix(0, guardNs))) + + // Entry must NOT be deleted because entry's Mtime is newer than guardNs + stored, err := f.FindEntry(ctx, filePath) + require.NoError(t, err) + require.NotNil(t, stored, "entry modified after guardNs must be preserved") + + // Guard is at or after entry Mtime + olderGuardNs := baseTime.Add(100 * time.Millisecond).UnixNano() + require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, time.Unix(0, olderGuardNs))) + + // Entry must be deleted now + _, err = f.FindEntry(ctx, filePath) + require.ErrorIs(t, err, filer_pb.ErrNotFound) + + // A guard equal to the entry's Mtime still deletes + require.NoError(t, f.CreateEntry(ctx, entry, nil, false, false, nil, true, 255)) + require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, baseTime)) + _, err = f.FindEntry(ctx, filePath) + require.ErrorIs(t, err, filer_pb.ErrNotFound) + + // An entry mtime beyond the UnixNano range compares as a time, not an int + farFuture := &Entry{FullPath: filePath, Attr: Attr{Mode: 0644, Mtime: time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC)}} + require.NoError(t, f.CreateEntry(ctx, farFuture, nil, false, false, nil, true, 255)) + require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, baseTime)) + stored, err = f.FindEntry(ctx, filePath) + require.NoError(t, err) + require.NotNil(t, stored, "entry past the UnixNano range must be preserved") + + // A whole-second cutoff beyond the UnixNano range still matches: the + // unchanged year-3000 entry is at its guard's second, so it is deleted + require.NoError(t, f.DeleteEntryMetaAndData(ctx, filePath, false, false, false, false, nil, time.Unix(farFuture.Attr.Mtime.Unix(), 999_999_999))) + _, err = f.FindEntry(ctx, filePath) + require.ErrorIs(t, err, filer_pb.ErrNotFound) +} diff --git a/weed/filer/filer_lazy_remote_test.go b/weed/filer/filer_lazy_remote_test.go index f463bb618c..05d3105077 100644 --- a/weed/filer/filer_lazy_remote_test.go +++ b/weed/filer/filer_lazy_remote_test.go @@ -551,7 +551,7 @@ func TestDeleteEntryMetaAndData_EmptyFolderUnderMountKeepsRemotePrefix(t *testin dirPath := util.FullPath("/buckets/mybucket/dir") putDirEntry(store, string(dirPath)) - require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), dirPath, false, false, false, false, nil, 0)) + require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), dirPath, false, false, false, false, nil, time.Time{})) assert.Empty(t, stub.removeCalls, "an empty local folder must not remove its remote prefix") assert.Nil(t, store.getEntry(string(dirPath))) @@ -818,7 +818,7 @@ func TestDeleteEntryMetaAndData_GuardedClientRejectsRemoteBackedFile(t *testing. return nil, fmt.Errorf("reject remote endpoint") } - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.Error(t, err, "guarded rejection should block the remote-backed delete") assert.Len(t, stub.deleteCalls, 0, "guarded rejection should not reach the remote") } @@ -854,7 +854,7 @@ func TestDeleteEntryMetaAndData_GuardedClientAllowsLocalOnlyFile(t *testing.T) { return nil, fmt.Errorf("reject remote endpoint") } - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.NoError(t, err, "local-only file should delete despite a rejected mount endpoint") _, findErr := store.FindEntry(context.Background(), filePath) require.ErrorIs(t, findErr, filer_pb.ErrNotFound, "local metadata should be deleted") @@ -921,7 +921,7 @@ func TestDeleteEntryMetaAndData_RemoteOnlyFileDeletesRemoteAndMetadata(t *testin } f := newTestFiler(t, store, rs) - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.NoError(t, err) _, findErr := store.FindEntry(context.Background(), filePath) @@ -961,7 +961,7 @@ func TestDeleteEntryMetaAndData_IsFromOtherClusterSkipsRemoteDelete(t *testing.T f := newTestFiler(t, store, rs) // isFromOtherCluster=true simulates a replicated delete from another filer - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, true, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, true, nil, time.Time{}) require.NoError(t, err) // Local metadata should be deleted @@ -1018,7 +1018,7 @@ func TestDeleteEntryMetaAndData_UnmountedDirectorySkipsRemoteDelete(t *testing.T _, remoteLoc = f.RemoteStorage.FindMountDirectory(filePath) require.Nil(t, remoteLoc) - require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, true, false, nil, 0)) + require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, true, false, nil, time.Time{})) require.Len(t, stub.deleteCalls, 0) require.Len(t, stub.removeCalls, 0) } @@ -1051,7 +1051,7 @@ func TestDeleteEntryMetaAndData_RemoteOnlyFileNotUnderMountSkipsRemoteDelete(t * } f := newTestFiler(t, store, rs) - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.NoError(t, err) require.Len(t, stub.deleteCalls, 0) } @@ -1079,7 +1079,7 @@ func TestDeleteEntryMetaAndData_RemoteMountWithoutClientResolutionKeepsMetadata( } f := newTestFiler(t, store, rs) - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.Error(t, err) require.ErrorContains(t, err, "resolve remote storage client") require.ErrorContains(t, err, string(filePath)) @@ -1118,7 +1118,7 @@ func TestDeleteEntryMetaAndData_LocalDeleteFailurePreservesMetadata(t *testing.T store.deleteErrByPath[string(filePath)] = errors.New("simulated local delete failure") f := newTestFiler(t, store, rs) - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.Error(t, err) require.ErrorContains(t, err, "filer store delete") require.Len(t, stub.deleteCalls, 1) @@ -1157,7 +1157,7 @@ func TestDeleteEntryMetaAndData_RemoteDeleteNotFoundStillDeletesMetadata(t *test } f := newTestFiler(t, store, rs) - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.NoError(t, err) _, findErr := store.FindEntry(context.Background(), filePath) require.ErrorIs(t, findErr, filer_pb.ErrNotFound) @@ -1191,7 +1191,7 @@ func TestDeleteEntryMetaAndData_RemoteDeleteErrorKeepsMetadata(t *testing.T) { } f := newTestFiler(t, store, rs) - err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{}) require.Error(t, err) stored, findErr := store.FindEntry(context.Background(), filePath) require.NoError(t, findErr) @@ -1272,7 +1272,7 @@ func TestDeleteEntryMetaAndData_RecursiveFolderDeleteRemotesChildren(t *testing. } f := newTestFiler(t, store, rs) - err := f.DeleteEntryMetaAndData(context.Background(), dirPath, true, false, false, false, nil, 0) + err := f.DeleteEntryMetaAndData(context.Background(), dirPath, true, false, false, false, nil, time.Time{}) require.NoError(t, err) // Child file should have been deleted from remote diff --git a/weed/filer/filer_remote_tombstone_test.go b/weed/filer/filer_remote_tombstone_test.go index 6048ae9575..564e50e3c0 100644 --- a/weed/filer/filer_remote_tombstone_test.go +++ b/weed/filer/filer_remote_tombstone_test.go @@ -236,7 +236,7 @@ func TestDeleteEntryMetaAndData_TombstonesPath(t *testing.T) { }, } - require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, 0)) + require.NoError(t, f.DeleteEntryMetaAndData(context.Background(), filePath, false, false, false, false, nil, time.Time{})) // the deleted path must not resurrect through the lazy fetch even while // the remote object is still present diff --git a/weed/filer/leveldb2/empty_folder_race_test.go b/weed/filer/leveldb2/empty_folder_race_test.go index a872676039..782bb4cb0b 100644 --- a/weed/filer/leveldb2/empty_folder_race_test.go +++ b/weed/filer/leveldb2/empty_folder_race_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "testing" + "time" "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/filer/empty_folder_cleanup" @@ -71,7 +72,7 @@ func TestNonRecursiveFolderDeleteKeepsRacingChild(t *testing.T) { } } - if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, 0); err != nil { + if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, time.Time{}); err != nil { t.Fatalf("delete empty folder: %v", err) } @@ -123,7 +124,7 @@ func TestEnsureDirectoryEntryRestoresRacingParent(t *testing.T) { } } - if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, 0); err != nil { + if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, time.Time{}); err != nil { t.Fatalf("delete empty folder: %v", err) } @@ -170,7 +171,7 @@ func TestEnsureDirectoryEntryRestoresExactAttributes(t *testing.T) { if err != nil { t.Fatalf("read folder attributes: %v", err) } - if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, 0); err != nil { + if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, time.Time{}); err != nil { t.Fatalf("delete folder: %v", err) } @@ -316,7 +317,7 @@ func TestEnsureDirectoryEntryNarrowsAWiderRestore(t *testing.T) { if err != nil { t.Fatalf("read folder attributes: %v", err) } - if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, 0); err != nil { + if err := testFiler.DeleteEntryMetaAndData(ctx, dir, false, false, false, false, nil, time.Time{}); err != nil { t.Fatalf("delete folder: %v", err) } diff --git a/weed/filer/meta_log_collection_test.go b/weed/filer/meta_log_collection_test.go index aa18d4064e..f5cef78783 100644 --- a/weed/filer/meta_log_collection_test.go +++ b/weed/filer/meta_log_collection_test.go @@ -3,6 +3,7 @@ package filer import ( "context" "testing" + "time" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" @@ -125,7 +126,7 @@ func TestBucketCollectionKeepsMetaLogTargetCollection(t *testing.T) { }) seedBucket(t, store, util.FullPath("/buckets/a")) - if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, 0); err != nil { + if err := f.DeleteEntryMetaAndData(context.Background(), "/buckets/a", true, false, true, false, nil, time.Time{}); err != nil { t.Fatalf("DeleteEntryMetaAndData: %v", err) } diff --git a/weed/pb/filer.proto b/weed/pb/filer.proto index 42f772a57a..f0bf1d6371 100644 --- a/weed/pb/filer.proto +++ b/weed/pb/filer.proto @@ -524,8 +524,9 @@ message DeleteEntryRequest { bool ignore_recursive_error = 6; bool is_from_other_cluster = 7; repeated int32 signatures = 8; - int64 if_not_modified_after = 9; + int64 if_not_modified_after = 9; // unix time in seconds bool keep_remote_object = 10; + int64 if_not_modified_after_ns = 11; // unix time in nanoseconds; overrides if_not_modified_after when set } message DeleteEntryResponse { diff --git a/weed/pb/filer_pb/filer.pb.go b/weed/pb/filer_pb/filer.pb.go index 8257c18b8b..dc3517c0fc 100644 --- a/weed/pb/filer_pb/filer.pb.go +++ b/weed/pb/filer_pb/filer.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v7.35.0 +// protoc v3.21.12 // source: filer.proto package filer_pb @@ -2780,8 +2780,9 @@ type DeleteEntryRequest struct { IgnoreRecursiveError bool `protobuf:"varint,6,opt,name=ignore_recursive_error,json=ignoreRecursiveError,proto3" json:"ignore_recursive_error,omitempty"` IsFromOtherCluster bool `protobuf:"varint,7,opt,name=is_from_other_cluster,json=isFromOtherCluster,proto3" json:"is_from_other_cluster,omitempty"` Signatures []int32 `protobuf:"varint,8,rep,packed,name=signatures,proto3" json:"signatures,omitempty"` - IfNotModifiedAfter int64 `protobuf:"varint,9,opt,name=if_not_modified_after,json=ifNotModifiedAfter,proto3" json:"if_not_modified_after,omitempty"` + IfNotModifiedAfter int64 `protobuf:"varint,9,opt,name=if_not_modified_after,json=ifNotModifiedAfter,proto3" json:"if_not_modified_after,omitempty"` // unix time in seconds KeepRemoteObject bool `protobuf:"varint,10,opt,name=keep_remote_object,json=keepRemoteObject,proto3" json:"keep_remote_object,omitempty"` + IfNotModifiedAfterNs int64 `protobuf:"varint,11,opt,name=if_not_modified_after_ns,json=ifNotModifiedAfterNs,proto3" json:"if_not_modified_after_ns,omitempty"` // unix time in nanoseconds; overrides if_not_modified_after when set unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2879,6 +2880,13 @@ func (x *DeleteEntryRequest) GetKeepRemoteObject() bool { return false } +func (x *DeleteEntryRequest) GetIfNotModifiedAfterNs() int64 { + if x != nil { + return x.IfNotModifiedAfterNs + } + return 0 +} + type DeleteEntryResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` @@ -7553,7 +7561,7 @@ const file_filer_proto_rawDesc = "" + "\n" + "entry_name\x18\x02 \x01(\tR\tentryName\x12+\n" + "\x06chunks\x18\x03 \x03(\v2\x13.filer_pb.FileChunkR\x06chunks\"\x17\n" + - "\x15AppendToEntryResponse\"\xf9\x02\n" + + "\x15AppendToEntryResponse\"\xb1\x03\n" + "\x12DeleteEntryRequest\x12\x1c\n" + "\tdirectory\x18\x01 \x01(\tR\tdirectory\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12$\n" + @@ -7566,7 +7574,8 @@ const file_filer_proto_rawDesc = "" + "signatures\x121\n" + "\x15if_not_modified_after\x18\t \x01(\x03R\x12ifNotModifiedAfter\x12,\n" + "\x12keep_remote_object\x18\n" + - " \x01(\bR\x10keepRemoteObject\"w\n" + + " \x01(\bR\x10keepRemoteObject\x126\n" + + "\x18if_not_modified_after_ns\x18\v \x01(\x03R\x14ifNotModifiedAfterNs\"w\n" + "\x13DeleteEntryResponse\x12\x14\n" + "\x05error\x18\x01 \x01(\tR\x05error\x12J\n" + "\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\xed\x01\n" + diff --git a/weed/pb/filer_pb/filer_grpc.pb.go b/weed/pb/filer_pb/filer_grpc.pb.go index 6a2b494a39..8a330ce286 100644 --- a/weed/pb/filer_pb/filer_grpc.pb.go +++ b/weed/pb/filer_pb/filer_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v7.35.0 +// - protoc v3.21.12 // source: filer.proto package filer_pb diff --git a/weed/pb/filer_pb/filer_vtproto.pb.go b/weed/pb/filer_pb/filer_vtproto.pb.go index ea2d3747aa..437cdab402 100644 --- a/weed/pb/filer_pb/filer_vtproto.pb.go +++ b/weed/pb/filer_pb/filer_vtproto.pb.go @@ -2491,6 +2491,11 @@ func (m *DeleteEntryRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if m.IfNotModifiedAfterNs != 0 { + i = protohelpers.EncodeVarint(dAtA, i, uint64(m.IfNotModifiedAfterNs)) + i-- + dAtA[i] = 0x58 + } if m.KeepRemoteObject { i-- if m.KeepRemoteObject { @@ -7566,6 +7571,9 @@ func (m *DeleteEntryRequest) SizeVT() (n int) { if m.KeepRemoteObject { n += 2 } + if m.IfNotModifiedAfterNs != 0 { + n += 1 + protohelpers.SizeOfVarint(uint64(m.IfNotModifiedAfterNs)) + } n += len(m.unknownFields) return n } @@ -16197,6 +16205,25 @@ func (m *DeleteEntryRequest) UnmarshalVT(dAtA []byte) error { } } m.KeepRemoteObject = bool(v != 0) + case 11: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field IfNotModifiedAfterNs", wireType) + } + m.IfNotModifiedAfterNs = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.IfNotModifiedAfterNs |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/weed/server/filer_grpc_server.go b/weed/server/filer_grpc_server.go index 8989e10730..4ae6ece87d 100644 --- a/weed/server/filer_grpc_server.go +++ b/weed/server/filer_grpc_server.go @@ -618,7 +618,7 @@ func (fs *FilerServer) applyObjectMutation(ctx context.Context, m *filer_pb.Obje if m.KeepRemoteObject { ctx = filer.WithKeepRemoteObject(ctx) } - err := fs.filer.DeleteEntryMetaAndData(ctx, fullpath, m.IsRecursive, false, m.IsDeleteData, fromOtherCluster, signatures, 0) + err := fs.filer.DeleteEntryMetaAndData(ctx, fullpath, m.IsRecursive, false, m.IsDeleteData, fromOtherCluster, signatures, time.Time{}) if err != nil && err != filer_pb.ErrNotFound { return err } @@ -628,7 +628,7 @@ func (fs *FilerServer) applyObjectMutation(ctx context.Context, m *filer_pb.Obje // transaction. Best-effort: a non-empty or already-removed parent is // the expected no-op, and a failed teardown must not fail the // already-applied delete. - parentErr := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(m.Directory), false, false, false, fromOtherCluster, signatures, 0) + parentErr := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(m.Directory), false, false, false, fromOtherCluster, signatures, time.Time{}) if parentErr != nil && parentErr != filer_pb.ErrNotFound && !errors.Is(parentErr, filer.ErrNonEmptyFolder) { glog.V(1).InfofCtx(ctx, "remove empty parent %s: %v", m.Directory, parentErr) } @@ -1045,6 +1045,21 @@ func (fs *FilerServer) AppendToEntry(ctx context.Context, req *filer_pb.AppendTo return &filer_pb.AppendToEntryResponse{}, err } +// deleteCutoff merges the conditional-delete guards into one cutoff: +// if_not_modified_after_ns wins when set, and the whole-second +// if_not_modified_after maps to the last nanosecond of its second so that +// entry.Mtime.Unix() > sec keeps its exact meaning as a time comparison, +// even for dates int64 nanoseconds cannot hold. +func deleteCutoff(ifNotModifiedAfter, ifNotModifiedAfterNs int64) time.Time { + if ifNotModifiedAfterNs > 0 { + return time.Unix(0, ifNotModifiedAfterNs) + } + if ifNotModifiedAfter <= 0 { + return time.Time{} + } + return time.Unix(ifNotModifiedAfter, 999_999_999) +} + func (fs *FilerServer) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest) (resp *filer_pb.DeleteEntryResponse, err error) { glog.V(4).InfofCtx(ctx, "DeleteEntry %v", req) @@ -1061,7 +1076,7 @@ func (fs *FilerServer) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntr ctx = filer.WithKeepRemoteObject(ctx) } ctx, eventSink := filer.WithMetadataEventSink(ctx) - err = fs.filer.DeleteEntryMetaAndData(ctx, fullpath, req.IsRecursive, req.IgnoreRecursiveError, req.IsDeleteData, req.IsFromOtherCluster, req.Signatures, req.IfNotModifiedAfter) + err = fs.filer.DeleteEntryMetaAndData(ctx, fullpath, req.IsRecursive, req.IgnoreRecursiveError, req.IsDeleteData, req.IsFromOtherCluster, req.Signatures, deleteCutoff(req.IfNotModifiedAfter, req.IfNotModifiedAfterNs)) resp = &filer_pb.DeleteEntryResponse{} if err != nil && err != filer_pb.ErrNotFound { resp.Error = err.Error() diff --git a/weed/server/filer_grpc_server_condition_test.go b/weed/server/filer_grpc_server_condition_test.go index 89086a5645..33f0fc6bc3 100644 --- a/weed/server/filer_grpc_server_condition_test.go +++ b/weed/server/filer_grpc_server_condition_test.go @@ -382,3 +382,38 @@ func TestUpdateEntryConditionEnforced(t *testing.T) { t.Fatalf("update not applied, mtime %d", got) } } + +func TestDeleteCutoff(t *testing.T) { + base := time.Unix(1700000000, 500_000_000) + + // nanoseconds take precedence when set + if got := deleteCutoff(1, base.UnixNano()); !got.Equal(base) { + t.Fatalf("ns guard should win, got %v", got) + } + // no guard stays disabled + if got := deleteCutoff(0, 0); !got.IsZero() { + t.Fatalf("no guard must stay zero, got %v", got) + } + // a whole-second guard covers its full second: entry.Mtime.Unix() > sec + // is entry.Mtime after the last nanosecond of that second + cutoff := deleteCutoff(base.Unix(), 0) + if want := time.Unix(0, (base.Unix()+1)*1_000_000_000-1); !cutoff.Equal(want) { + t.Fatalf("seconds guard = %v, want %v", cutoff, want) + } + if base.After(cutoff) { + t.Fatal("entry inside the guard second must not be treated as newer") + } + // a seconds guard beyond the nanosecond range keeps its date: an entry + // in the guarded second is deleted and only a newer one is skipped + farSec := time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC).Unix() + farCutoff := deleteCutoff(farSec, 0) + if want := time.Unix(farSec, 999_999_999); !farCutoff.Equal(want) { + t.Fatalf("huge seconds guard = %v, want %v", farCutoff, want) + } + if time.Unix(farSec, 0).After(farCutoff) { + t.Fatal("entry in the guarded far-future second must not be treated as newer") + } + if !time.Unix(farSec+1, 0).After(farCutoff) { + t.Fatal("entry past the guarded far-future second must be treated as newer") + } +} diff --git a/weed/server/filer_grpc_server_remote_test.go b/weed/server/filer_grpc_server_remote_test.go index 0d7659c119..7c5f165b3a 100644 --- a/weed/server/filer_grpc_server_remote_test.go +++ b/weed/server/filer_grpc_server_remote_test.go @@ -182,7 +182,7 @@ func TestPruneLeavesTheRemoteAlone(t *testing.T) { assert.False(t, events[0].notification.IsFromOtherCluster, "filer.sync must replicate the prune") assert.Contains(t, events[0].notification.OldEntry.Extended, filer.ExtKeepRemoteObjectKey, "filer.remote.sync must not delete the remote object") - require.NoError(t, fs.filer.DeleteEntryMetaAndData(context.Background(), deleted.FullPath, false, false, false, false, nil, 0)) + require.NoError(t, fs.filer.DeleteEntryMetaAndData(context.Background(), deleted.FullPath, false, false, false, false, nil, time.Time{})) assert.Equal(t, []string{"/deleted.bin"}, client.deletedPaths()) } diff --git a/weed/server/filer_grpc_server_rename.go b/weed/server/filer_grpc_server_rename.go index 7c40b9cb64..146a700ec2 100644 --- a/weed/server/filer_grpc_server_rename.go +++ b/weed/server/filer_grpc_server_rename.go @@ -412,8 +412,7 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee false, false, signatures, - 0, - ); deleteErr != nil { + time.Time{}); deleteErr != nil { return deleteErr } } @@ -532,7 +531,7 @@ func (fs *FilerServer) moveSelfEntry(ctx context.Context, stream filer_pb.Seawee if keepOldRemote { deleteCtx = filer.WithKeepRemoteObject(deleteCtx) } - deleteErr := fs.filer.DeleteEntryMetaAndData(deleteCtx, oldPath, false, false, false, false, signatures, 0) + deleteErr := fs.filer.DeleteEntryMetaAndData(deleteCtx, oldPath, false, false, false, false, signatures, time.Time{}) if deleteErr != nil { return deleteErr } diff --git a/weed/server/filer_server_handlers_write.go b/weed/server/filer_server_handlers_write.go index 9009a0f045..f4208168cf 100644 --- a/weed/server/filer_server_handlers_write.go +++ b/weed/server/filer_server_handlers_write.go @@ -270,7 +270,7 @@ func (fs *FilerServer) DeleteHandler(w http.ResponseWriter, r *http.Request) { defer fs.entryLockTable.ReleaseLock(fullPath, pathLock) } - err = fs.filer.DeleteEntryMetaAndData(context.Background(), fullPath, isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, 0) + err = fs.filer.DeleteEntryMetaAndData(context.Background(), fullPath, isRecursive, ignoreRecursiveError, !skipChunkDeletion, false, nil, time.Time{}) if err != nil && err != filer_pb.ErrNotFound { glog.V(1).Infoln("deleting", objectPath, ":", err.Error()) writeJsonError(w, r, http.StatusInternalServerError, err) diff --git a/weed/server/filer_server_tus_handlers.go b/weed/server/filer_server_tus_handlers.go index a48ad142c1..a203f6ff3e 100644 --- a/weed/server/filer_server_tus_handlers.go +++ b/weed/server/filer_server_tus_handlers.go @@ -12,6 +12,7 @@ import ( "path" "strconv" "strings" + "time" "github.com/google/uuid" "github.com/seaweedfs/seaweedfs/weed/glog" @@ -482,7 +483,7 @@ func (fs *FilerServer) tusConcatFinalHandler(w http.ResponseWriter, r *http.Requ // The chunks now belong to the final entry, so remove only the partials' // session metadata. for _, partial := range partials { - if err := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(fs.tusSessionPath(partial.ID)), true, false, false, false, nil, 0); err != nil { + if err := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(fs.tusSessionPath(partial.ID)), true, false, false, false, nil, time.Time{}); err != nil { glog.V(1).Infof("Failed to cleanup TUS partial session %s: %v", partial.ID, err) } } diff --git a/weed/server/filer_server_tus_session.go b/weed/server/filer_server_tus_session.go index e9d32c2526..88be46fb31 100644 --- a/weed/server/filer_server_tus_session.go +++ b/weed/server/filer_server_tus_session.go @@ -129,7 +129,7 @@ func (fs *FilerServer) claimTusPartial(ctx context.Context, partial *TusSession) // completion. A failed rollback wedges the session as consumed: DELETE and // expiry then preserve its chunks, which leak until removed by fsck. func (fs *FilerServer) rollbackTusSessionConsumed(ctx context.Context, uploadID string) { - if err := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(fs.tusSessionConsumedPath(uploadID)), false, false, false, false, nil, 0); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + if err := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(fs.tusSessionConsumedPath(uploadID)), false, false, false, false, nil, time.Time{}); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { glog.Errorf("TUS session %s wedged as consumed, marker rollback failed: %v", uploadID, err) } } @@ -224,7 +224,7 @@ func (fs *FilerServer) createTusSession(ctx context.Context, uploadID, targetPat // Save session info if err := fs.saveTusSession(ctx, session); err != nil { // Cleanup the directory on failure - fs.filer.DeleteEntryMetaAndData(ctx, sessionDirPath, true, true, false, false, nil, 0) + fs.filer.DeleteEntryMetaAndData(ctx, sessionDirPath, true, true, false, false, nil, time.Time{}) return nil, fmt.Errorf("save session info: %w", err) } @@ -422,7 +422,7 @@ func (fs *FilerServer) saveTusChunk(ctx context.Context, uploadID string, chunk // that is about to be deleted. Data outliving a lost record only leaks. func (fs *FilerServer) deleteTusChunk(ctx context.Context, session *TusSession, chunk *TusChunkInfo) { chunkPath := util.FullPath(fs.tusChunkPath(session.ID, chunk.Offset, chunk.Size, chunk.FileId)) - if err := fs.filer.DeleteEntryMetaAndData(ctx, chunkPath, false, false, false, false, nil, 0); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + if err := fs.filer.DeleteEntryMetaAndData(ctx, chunkPath, false, false, false, false, nil, time.Time{}); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { glog.Errorf("TUS chunk %s record kept, its data leaks: %v", chunkPath, err) return } @@ -446,7 +446,7 @@ func (fs *FilerServer) deleteTusSession(ctx context.Context, uploadID string) er // its consumed marker before re-verifying the .info, so once the .info is // gone no new claim can pass verification, and a claim that did pass was // created earlier and is visible at the check below. - if err := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(fs.tusSessionInfoPath(uploadID)), false, false, false, false, nil, 0); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + if err := fs.filer.DeleteEntryMetaAndData(ctx, util.FullPath(fs.tusSessionInfoPath(uploadID)), false, false, false, false, nil, time.Time{}); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { return fmt.Errorf("delete session info: %w", err) } @@ -470,7 +470,7 @@ func (fs *FilerServer) deleteTusSession(ctx context.Context, uploadID string) er // Delete the session directory sessionDirPath := util.FullPath(fs.tusSessionPath(uploadID)) - if err := fs.filer.DeleteEntryMetaAndData(ctx, sessionDirPath, true, true, false, false, nil, 0); err != nil { + if err := fs.filer.DeleteEntryMetaAndData(ctx, sessionDirPath, true, true, false, false, nil, time.Time{}); err != nil { return fmt.Errorf("delete session directory: %w", err) } @@ -614,7 +614,7 @@ func (fs *FilerServer) completeTusUpload(ctx context.Context, session *TusSessio // Delete the session (but keep the chunks since they're now part of the final file) sessionDirPath := util.FullPath(fs.tusSessionPath(session.ID)) - if err := fs.filer.DeleteEntryMetaAndData(ctx, sessionDirPath, true, false, false, false, nil, 0); err != nil { + if err := fs.filer.DeleteEntryMetaAndData(ctx, sessionDirPath, true, false, false, false, nil, time.Time{}); err != nil { glog.V(1).Infof("Failed to cleanup TUS session directory %s: %v", session.ID, err) } diff --git a/weed/shell/command_fs_verify.go b/weed/shell/command_fs_verify.go index 24007793f0..3f32db9716 100644 --- a/weed/shell/command_fs_verify.go +++ b/weed/shell/command_fs_verify.go @@ -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 { diff --git a/weed/shell/command_fs_verify_test.go b/weed/shell/command_fs_verify_test.go index 5c997ff229..bc895f3846 100644 --- a/weed/shell/command_fs_verify_test.go +++ b/weed/shell/command_fs_verify_test.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "fmt" + "math" "net" "os" "path/filepath" "sync" "testing" + "time" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" @@ -48,10 +50,18 @@ func (s *fsVerifyTestFilerServer) LookupDirectoryEntry(_ context.Context, req *f func (s *fsVerifyTestFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) { s.deleted = append(s.deleted, req) - // mirror the real filer: an entry newer than IfNotModifiedAfter is + // mirror the real filer: an entry newer than the cutoff is // silently skipped, no error and no Error field - if s.entry != nil && req.IfNotModifiedAfter > 0 && s.entry.Attributes.GetMtime() > req.IfNotModifiedAfter { - return &filer_pb.DeleteEntryResponse{}, nil + if s.entry != nil { + entryNewer := false + if req.IfNotModifiedAfterNs > 0 { + entryNewer = s.entry.Attributes.GetMtime()*1e9+int64(s.entry.Attributes.GetMtimeNs()) > req.IfNotModifiedAfterNs + } else if req.IfNotModifiedAfter > 0 { + entryNewer = s.entry.Attributes.GetMtime() > req.IfNotModifiedAfter + } + if entryNewer { + return &filer_pb.DeleteEntryResponse{}, nil + } } s.entry = nil return &filer_pb.DeleteEntryResponse{}, nil @@ -234,13 +244,16 @@ func TestFsVerifyVerifyEntryPrunability(t *testing.T) { // Pruning must only delete the entry when it did not change since the // verification pass — a re-upload fixes the file and must survive. func TestFsVerifyPruneEntryGuards(t *testing.T) { + mtimeNs := time.Now().Add(-time.Hour).UnixNano() + attr := &filer_pb.FuseAttributes{ + Mtime: mtimeNs / 1e9, + MtimeNs: int32(mtimeNs % 1e9), + Md5: []byte("md5"), + } filer := &fsVerifyTestFilerServer{entry: &filer_pb.Entry{ - Name: "file", - Attributes: &filer_pb.FuseAttributes{ - Mtime: 100, - Md5: []byte("md5"), - }, - Chunks: []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 7, FileKey: 42}}}, + Name: "file", + Attributes: attr, + Chunks: []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 7, FileKey: 42}}}, }} commandEnv, _, cleanup := newFsVerifyTestCommandEnv(t, filer, &fsVerifyTestVolumeServer{}) defer cleanup() @@ -248,27 +261,43 @@ func TestFsVerifyPruneEntryGuards(t *testing.T) { chunks := []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 7, FileKey: 42}}} - // unchanged entry: deleted - pruned, err := c.pruneEntry(util.NewFullPath("/buckets/b", "file"), 100, []byte("md5"), chunks) + // unchanged entry: deleted; the request carries the cutoff in seconds + // for older filers and nanoseconds for same-second precision + pruned, err := c.pruneEntry(util.NewFullPath("/buckets/b", "file"), attr, chunks) if err != nil || !pruned { t.Fatalf("expected prune of unchanged entry, got pruned=%v err=%v", pruned, err) } + if len(filer.deleted) != 1 || filer.deleted[0].IfNotModifiedAfter != mtimeNs/1e9 || filer.deleted[0].IfNotModifiedAfterNs != mtimeNs { + t.Fatalf("delete sent wrong cutoffs: %+v", filer.deleted) + } // re-uploaded entry (new mtime/md5): must survive - filer.entry = &filer_pb.Entry{Name: "file", Attributes: &filer_pb.FuseAttributes{Mtime: 200, Md5: []byte("new")}} - pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), 100, []byte("md5"), chunks) + filer.entry = &filer_pb.Entry{Name: "file", Attributes: &filer_pb.FuseAttributes{Mtime: mtimeNs/1e9 + 1, Md5: []byte("new")}} + pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), attr, chunks) if err != nil || pruned { t.Fatalf("changed entry must not be pruned, got pruned=%v err=%v", pruned, err) } + // same-second rewrite that only advanced the nanosecond component: + // must survive + filer.entry = &filer_pb.Entry{ + Name: "file", + Attributes: &filer_pb.FuseAttributes{Mtime: mtimeNs / 1e9, MtimeNs: int32(mtimeNs%1e9) + 1, Md5: []byte("md5")}, + Chunks: []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 7, FileKey: 42}}}, + } + pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), attr, chunks) + if err != nil || pruned { + t.Fatalf("sub-second rewrite must not be pruned, got pruned=%v err=%v", pruned, err) + } + // same-second rewrite: timestamps identical but the chunk set differs — // the new upload must survive filer.entry = &filer_pb.Entry{ Name: "file", - Attributes: &filer_pb.FuseAttributes{Mtime: 100, Md5: []byte("md5")}, + Attributes: &filer_pb.FuseAttributes{Mtime: mtimeNs / 1e9, MtimeNs: int32(mtimeNs % 1e9), Md5: []byte("md5")}, Chunks: []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 7, FileKey: 999}}}, } - pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), 100, []byte("md5"), chunks) + pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), attr, chunks) if err != nil || pruned { t.Fatalf("same-second rewrite must not be pruned, got pruned=%v err=%v", pruned, err) } @@ -276,22 +305,48 @@ func TestFsVerifyPruneEntryGuards(t *testing.T) { // identical chunk set (order-insensitive): prunes filer.entry = &filer_pb.Entry{ Name: "file", - Attributes: &filer_pb.FuseAttributes{Mtime: 100, Md5: []byte("md5")}, + Attributes: &filer_pb.FuseAttributes{Mtime: mtimeNs / 1e9, MtimeNs: int32(mtimeNs % 1e9), Md5: []byte("md5")}, Chunks: []*filer_pb.FileChunk{{Fid: &filer_pb.FileId{VolumeId: 7, FileKey: 42}}}, } - pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), 100, []byte("md5"), chunks) + pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), attr, chunks) if err != nil || !pruned { t.Fatalf("expected prune when chunk set matches, got pruned=%v err=%v", pruned, err) } // entry already gone: no error, no delete filer.entry = nil - pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), 100, nil, chunks) + pruned, err = c.pruneEntry(util.NewFullPath("/buckets/b", "file"), &filer_pb.FuseAttributes{Mtime: mtimeNs / 1e9, MtimeNs: int32(mtimeNs % 1e9)}, chunks) if err != nil || pruned { t.Fatalf("missing entry must be a no-op, got pruned=%v err=%v", pruned, err) } } +func TestDeleteEntryCutoff(t *testing.T) { + // a regular mtime fills both fields + sec, ns, ok := deleteEntryCutoff(1_761_504_000, 500_000_000) + if !ok || sec != 1_761_504_000 || ns != 1_761_504_000_500_000_000 { + t.Fatalf("regular mtime: got %d %d %v", sec, ns, ok) + } + // an mtime in the first seconds after the epoch is unambiguous in both + // fields once units are explicit + sec, ns, ok = deleteEntryCutoff(100, 0) + if !ok || sec != 100 || ns != 100_000_000_000 { + t.Fatalf("epoch mtime: got %d %d %v", sec, ns, ok) + } + // a pre-epoch mtime cannot arm either field — the delete must be skipped + if _, _, ok = deleteEntryCutoff(-100, 0); ok { + t.Fatal("pre-epoch mtime must report ok=false") + } + if _, _, ok = deleteEntryCutoff(0, 0); ok { + t.Fatal("zero mtime must report ok=false") + } + // an mtime past the UnixNano range degrades to the seconds field + sec, ns, ok = deleteEntryCutoff(math.MaxInt64/1_000_000_000+1, 0) + if !ok || ns != 0 || sec != math.MaxInt64/1_000_000_000+1 { + t.Fatalf("overflow mtime: got %d %d %v", sec, ns, ok) + } +} + // An unresolvable chunk manifest is an entry-level verification failure even // when the raw manifest needle is healthy; raw chunks are still checked so a // missing manifest needle can be pruned. diff --git a/weed/shell/command_volume_fsck.go b/weed/shell/command_volume_fsck.go index ef6b61bfab..ff3c3fe0e8 100644 --- a/weed/shell/command_volume_fsck.go +++ b/weed/shell/command_volume_fsck.go @@ -863,7 +863,7 @@ func (c *commandVolumeFsck) purgeEmptyDirectories() { if mtime <= 0 || mtime >= time.Now().Add(-directoryQuietPeriod).Unix() { continue } - if err := c.deleteEmptyDirectory(dir, mtime); err != nil { + if err := c.deleteEmptyDirectory(dir, entry.Attributes); err != nil { if !errors.Is(err, filer.ErrNonEmptyFolder) { fmt.Fprintf(c.writer, "delete empty directory %s: %v\n", dir, err) } @@ -873,16 +873,21 @@ func (c *commandVolumeFsck) purgeEmptyDirectories() { } } -// deleteEmptyDirectory deletes dir unless it changed since it was looked up at mtime, -// so a directory promoted to an S3 object meanwhile survives. The delete is not -// recursive, leaving the filer to reject a directory that is not empty. -func (c *commandVolumeFsck) deleteEmptyDirectory(dir util.FullPath, mtime int64) error { +// deleteEmptyDirectory deletes dir unless it changed since it was looked up, +// so a directory promoted to an S3 object meanwhile survives. The delete is +// not recursive, leaving the filer to reject a directory that is not empty. +func (c *commandVolumeFsck) deleteEmptyDirectory(dir util.FullPath, attr *filer_pb.FuseAttributes) error { parent, name := dir.DirAndName() + sec, ns, ok := deleteEntryCutoff(attr.GetMtime(), attr.GetMtimeNs()) + if !ok { + return nil + } return c.env.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { resp, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{ - Directory: parent, - Name: name, - IfNotModifiedAfter: mtime, + Directory: parent, + Name: name, + IfNotModifiedAfter: sec, + IfNotModifiedAfterNs: ns, }) if err != nil { return err diff --git a/weed/shell/command_volume_fsck_test.go b/weed/shell/command_volume_fsck_test.go index 7804b85224..43bb07efea 100644 --- a/weed/shell/command_volume_fsck_test.go +++ b/weed/shell/command_volume_fsck_test.go @@ -107,7 +107,13 @@ func (s *stubFilerServer) DeleteEntry(ctx context.Context, req *filer_pb.DeleteE if !found { return &filer_pb.DeleteEntryResponse{Error: filer_pb.ErrNotFound.Error()}, nil } - if req.IfNotModifiedAfter > 0 && entry.Attributes.GetMtime() > req.IfNotModifiedAfter { + entryNewer := false + if req.IfNotModifiedAfterNs > 0 { + entryNewer = entry.Attributes.GetMtime()*1e9+int64(entry.Attributes.GetMtimeNs()) > req.IfNotModifiedAfterNs + } else if req.IfNotModifiedAfter > 0 { + entryNewer = entry.Attributes.GetMtime() > req.IfNotModifiedAfter + } + if entryNewer { return &filer_pb.DeleteEntryResponse{}, nil } for path := range s.entries { @@ -209,8 +215,8 @@ func TestVolumeFsckPurgeEmptyDirectoriesKeepsFreshDirectory(t *testing.T) { func TestVolumeFsckPurgeEmptyDirectoriesKeepsChangedDirectory(t *testing.T) { stub := &stubFilerServer{ entries: map[string]*filer_pb.Entry{ - "/race": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: 100}}, - "/race/dir": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: 100}}, + "/race": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Add(-time.Hour).Unix()}}, + "/race/dir": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Add(-time.Hour).Unix()}}, }, writtenAfterLookup: map[string]bool{"/race/dir": true}, }