|
1 | 1 | package backup |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "crypto/sha256" |
5 | 6 | "fmt" |
| 7 | + "io" |
6 | 8 | "os" |
7 | 9 | "path/filepath" |
8 | 10 | "testing" |
@@ -913,6 +915,147 @@ func TestBackupUnmodifiedFilesSkipped(t *testing.T) { |
913 | 915 | env.assertBlobEntries(1) |
914 | 916 | } |
915 | 917 |
|
| 918 | +// Test that the hasher cannot race arbitrarily far ahead of the bucketer/uploader. |
| 919 | +// This prevents OOM when backing up directories with millions of files. |
| 920 | +// With sync callback(), blocked bucketer → blocked hasher → blocked sendFile. |
| 921 | +// With async go callback(), hasher spawns goroutines and returns immediately. |
| 922 | +func TestBackupBackpressure(t *testing.T) { |
| 923 | + env := setupUnitTestEnv(t) |
| 924 | + defer env.cleanup() |
| 925 | + |
| 926 | + // All files >= MinBlobSize to bypass bucketer batching. |
| 927 | + seedContent := make([]byte, 1500) |
| 928 | + file1Content := make([]byte, 1500) |
| 929 | + file2Content := make([]byte, 1500) |
| 930 | + blocker1Content := make([]byte, 1502) |
| 931 | + blocker2Content := make([]byte, 1503) |
| 932 | + for i := range seedContent { |
| 933 | + seedContent[i] = byte(i % 256) |
| 934 | + file1Content[i] = byte((i + 50) % 256) |
| 935 | + file2Content[i] = byte((i + 100) % 256) |
| 936 | + } |
| 937 | + for i := range blocker1Content { |
| 938 | + blocker1Content[i] = byte(i % 256) |
| 939 | + } |
| 940 | + for i := range blocker2Content { |
| 941 | + blocker2Content[i] = byte((i + 1) % 256) |
| 942 | + } |
| 943 | + |
| 944 | + // First backup: establish size 1500 in the database |
| 945 | + env.beginBackupOnDir("/mock1/") |
| 946 | + env.sendFile("/mock1/seed.bin", seedContent) |
| 947 | + env.endWalk() |
| 948 | + env.shouldOpen("/mock1/seed.bin", seedContent) |
| 949 | + env.completeBackup() |
| 950 | + env.reset() |
| 951 | + |
| 952 | + // Second backup: create scenario where bucketer is blocked on uploaderCh |
| 953 | + env.beginBackupOnDir("/mock2/") |
| 954 | + |
| 955 | + // blocker1 occupies the uploader (blocked in Open) |
| 956 | + env.sendFile("/mock2/blocker1.bin", blocker1Content) |
| 957 | + blocker1OpenCall := <-env.mockFS.openCalls |
| 958 | + if blocker1OpenCall.path != "/mock2/blocker1.bin" { |
| 959 | + t.Fatalf("expected open for blocker1, got %s", blocker1OpenCall.path) |
| 960 | + } |
| 961 | + |
| 962 | + // blocker2 causes bucketer to block on uploaderCh (uploader busy) |
| 963 | + env.sendFile("/mock2/blocker2.bin", blocker2Content) |
| 964 | + |
| 965 | + // file1 goes to hasher (size 1500 exists in DB), callback will try bucketerCh |
| 966 | + env.sendFile("/mock2/file1.bin", file1Content) |
| 967 | + env.shouldOpen("/mock2/file1.bin", file1Content) |
| 968 | + |
| 969 | + // file2 in goroutine to detect blocking |
| 970 | + file2Done := make(chan struct{}) |
| 971 | + go func() { |
| 972 | + env.sendFile("/mock2/file2.bin", file2Content) |
| 973 | + close(file2Done) |
| 974 | + }() |
| 975 | + |
| 976 | + select { |
| 977 | + case <-file2Done: |
| 978 | + t.Error("backpressure not working: file2 sendFile completed immediately") |
| 979 | + case <-time.After(200 * time.Millisecond): |
| 980 | + // blocked as expected |
| 981 | + } |
| 982 | + |
| 983 | + // Cleanup: unblock everything |
| 984 | + blocker1OpenCall.response <- openResponse{reader: io.NopCloser(bytes.NewReader(blocker1Content))} |
| 985 | + env.shouldOpen("/mock2/blocker2.bin", blocker2Content) |
| 986 | + <-file2Done |
| 987 | + env.shouldOpen("/mock2/file2.bin", file2Content) |
| 988 | + env.endWalk() |
| 989 | + |
| 990 | + // Drain file1 and file2 upload opens |
| 991 | + for i := 0; i < 2; i++ { |
| 992 | + call := <-env.mockFS.openCalls |
| 993 | + var content []byte |
| 994 | + if call.path == "/mock2/file1.bin" { |
| 995 | + content = file1Content |
| 996 | + } else { |
| 997 | + content = file2Content |
| 998 | + } |
| 999 | + call.response <- openResponse{reader: io.NopCloser(bytes.NewReader(content))} |
| 1000 | + } |
| 1001 | + env.completeBackup() |
| 1002 | +} |
| 1003 | + |
| 1004 | +// Test that size claims don't block the hasher - callbacks are appended, not waited on. |
| 1005 | +// This prevents a deadlock where hasher waits on a claim that can't release because |
| 1006 | +// it's stuck in the bucketer waiting for more files to reach min blob size. |
| 1007 | +func TestBackupSizeClaimsDontBlockHasher(t *testing.T) { |
| 1008 | + env := setupUnitTestEnv(t) |
| 1009 | + defer env.cleanup() |
| 1010 | + |
| 1011 | + const numFiles = 20 |
| 1012 | + contents := make([][]byte, numFiles) |
| 1013 | + for i := range contents { |
| 1014 | + contents[i] = make([]byte, 1500) // >= MinBlobSize, same size for all |
| 1015 | + for j := range contents[i] { |
| 1016 | + contents[i][j] = byte((i + j) % 256) // different content per file |
| 1017 | + } |
| 1018 | + } |
| 1019 | + |
| 1020 | + env.beginBackupOnDir("/mock/") |
| 1021 | + |
| 1022 | + // file0 stakes claim, goes to bucketer, immediately to uploader (large file) |
| 1023 | + env.sendFile("/mock/file00.bin", contents[0]) |
| 1024 | + |
| 1025 | + // Hold uploader's Open - don't respond, keeping claim unreleased |
| 1026 | + file0OpenCall := <-env.mockFS.openCalls |
| 1027 | + if file0OpenCall.path != "/mock/file00.bin" { |
| 1028 | + t.Fatalf("expected open for file00, got %s", file0OpenCall.path) |
| 1029 | + } |
| 1030 | + |
| 1031 | + // Send remaining 19 files - all same size, so they go to hasher |
| 1032 | + // Each registers a callback on file0's claim and returns (doesn't block) |
| 1033 | + for i := 1; i < numFiles; i++ { |
| 1034 | + path := fmt.Sprintf("/mock/file%02d.bin", i) |
| 1035 | + env.sendFile(path, contents[i]) |
| 1036 | + env.shouldOpen(path, contents[i]) // hasher opens for hashing |
| 1037 | + } |
| 1038 | + // If callbacks blocked, we'd timeout waiting for shouldOpen above |
| 1039 | + |
| 1040 | + env.endWalk() |
| 1041 | + |
| 1042 | + // Now unblock uploader |
| 1043 | + file0OpenCall.response <- openResponse{reader: io.NopCloser(bytes.NewReader(contents[0]))} |
| 1044 | + |
| 1045 | + // Drain remaining opens (file0 completes, then callbacks fire, triggering uploads) |
| 1046 | + for { |
| 1047 | + select { |
| 1048 | + case call := <-env.mockFS.openCalls: |
| 1049 | + // Find matching content by parsing file number from path |
| 1050 | + var fileNum int |
| 1051 | + fmt.Sscanf(call.path, "/mock/file%02d.bin", &fileNum) |
| 1052 | + call.response <- openResponse{reader: io.NopCloser(bytes.NewReader(contents[fileNum]))} |
| 1053 | + case <-env.done: |
| 1054 | + return |
| 1055 | + } |
| 1056 | + } |
| 1057 | +} |
| 1058 | + |
916 | 1059 | // Test that deleted files are marked as ended by pruneDeletedFiles. |
917 | 1060 | func TestBackupPruneDeletedFiles(t *testing.T) { |
918 | 1061 | env := setupUnitTestEnv(t) |
|
0 commit comments