Skip to content

Commit 5b5eb05

Browse files
committed
backpressure tests
1 parent 04e8948 commit 5b5eb05

1 file changed

Lines changed: 143 additions & 0 deletions

File tree

backup/backup_unit_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package backup
22

33
import (
4+
"bytes"
45
"crypto/sha256"
56
"fmt"
7+
"io"
68
"os"
79
"path/filepath"
810
"testing"
@@ -913,6 +915,147 @@ func TestBackupUnmodifiedFilesSkipped(t *testing.T) {
913915
env.assertBlobEntries(1)
914916
}
915917

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+
9161059
// Test that deleted files are marked as ended by pruneDeletedFiles.
9171060
func TestBackupPruneDeletedFiles(t *testing.T) {
9181061
env := setupUnitTestEnv(t)

0 commit comments

Comments
 (0)