Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
745 lines
27 KiB
Go
745 lines
27 KiB
Go
package command
|
|
|
|
import (
|
|
"container/heap"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
func makeResp(dir, name string, isDir bool, tsNs int64, isNew bool) *filer_pb.SubscribeMetadataResponse {
|
|
resp := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: dir,
|
|
TsNs: tsNs,
|
|
EventNotification: &filer_pb.EventNotification{},
|
|
}
|
|
entry := &filer_pb.Entry{
|
|
Name: name,
|
|
IsDirectory: isDir,
|
|
}
|
|
if isNew {
|
|
resp.EventNotification.NewEntry = entry
|
|
} else {
|
|
resp.EventNotification.OldEntry = entry
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// makeDirUpdateResp builds an in-place attribute update event for a directory
|
|
// (same parent and same name on both sides — matches filer_pb.IsUpdate).
|
|
func makeDirUpdateResp(parent, name string, tsNs int64) *filer_pb.SubscribeMetadataResponse {
|
|
return &filer_pb.SubscribeMetadataResponse{
|
|
Directory: parent,
|
|
TsNs: tsNs,
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{Name: name, IsDirectory: true},
|
|
NewEntry: &filer_pb.Entry{Name: name, IsDirectory: true},
|
|
NewParentPath: parent,
|
|
},
|
|
}
|
|
}
|
|
|
|
func makeRenameResp(oldDir, oldName, newDir, newName string, isDir bool, tsNs int64) *filer_pb.SubscribeMetadataResponse {
|
|
return &filer_pb.SubscribeMetadataResponse{
|
|
Directory: oldDir,
|
|
TsNs: tsNs,
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{
|
|
Name: oldName,
|
|
IsDirectory: isDir,
|
|
},
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: newName,
|
|
IsDirectory: isDir,
|
|
},
|
|
NewParentPath: newDir,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestPathAncestors(t *testing.T) {
|
|
tests := []struct {
|
|
path util.FullPath
|
|
expected []util.FullPath
|
|
}{
|
|
{"/a/b/c/file.txt", []util.FullPath{"/a/b/c", "/a/b", "/a", "/"}},
|
|
{"/a/b", []util.FullPath{"/a", "/"}},
|
|
{"/a", []util.FullPath{"/"}},
|
|
{"/", nil},
|
|
}
|
|
for _, tt := range tests {
|
|
got := pathAncestors(tt.path)
|
|
if len(got) != len(tt.expected) {
|
|
t.Errorf("pathAncestors(%q) = %v, want %v", tt.path, got, tt.expected)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.expected[i] {
|
|
t.Errorf("pathAncestors(%q)[%d] = %q, want %q", tt.path, i, got[i], tt.expected[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFileVsFileConflict verifies that two file operations on the same path conflict,
|
|
// and on different paths do not.
|
|
func TestFileVsFileConflict(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add a file job
|
|
active := makeResp("/dir1", "file.txt", false, 1, true)
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// Same file should conflict
|
|
same := makeResp("/dir1", "file.txt", false, 2, true)
|
|
if !p.conflictsWith(same) {
|
|
t.Error("expected conflict for same file path")
|
|
}
|
|
|
|
// Different file should not conflict
|
|
diff := makeResp("/dir1", "other.txt", false, 3, true)
|
|
if p.conflictsWith(diff) {
|
|
t.Error("unexpected conflict for different file path")
|
|
}
|
|
|
|
// File in different directory should not conflict
|
|
diffDir := makeResp("/dir2", "file.txt", false, 4, true)
|
|
if p.conflictsWith(diffDir) {
|
|
t.Error("unexpected conflict for file in different directory")
|
|
}
|
|
}
|
|
|
|
// TestFileUnderActiveDirConflict verifies that a file under an active directory operation
|
|
// conflicts, but a file outside does not.
|
|
func TestFileUnderActiveDirConflict(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add a directory job at /dir1
|
|
active := makeResp("/", "dir1", true, 1, true)
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// File under /dir1 should conflict
|
|
under := makeResp("/dir1", "file.txt", false, 2, true)
|
|
if !p.conflictsWith(under) {
|
|
t.Error("expected conflict for file under active directory")
|
|
}
|
|
|
|
// File deeply nested under /dir1 should conflict
|
|
deep := makeResp("/dir1/sub/deep", "file.txt", false, 3, true)
|
|
if !p.conflictsWith(deep) {
|
|
t.Error("expected conflict for deeply nested file under active directory")
|
|
}
|
|
|
|
// File in /dir2 should not conflict
|
|
outside := makeResp("/dir2", "file.txt", false, 4, true)
|
|
if p.conflictsWith(outside) {
|
|
t.Error("unexpected conflict for file outside active directory")
|
|
}
|
|
|
|
// File at /dir1 itself (not under, at) SHOULD conflict with an active
|
|
// barrier dir at /dir1 — same-path promotions must serialize.
|
|
atSame := makeResp("/", "dir1", false, 5, true)
|
|
if !p.conflictsWith(atSame) {
|
|
t.Error("expected conflict for file at same path as active barrier dir")
|
|
}
|
|
}
|
|
|
|
// TestDirWithActiveFileUnder verifies that a directory operation conflicts when
|
|
// there are active file jobs under it.
|
|
func TestDirWithActiveFileUnder(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add file jobs under /dir1
|
|
f1 := makeResp("/dir1/sub", "file.txt", false, 1, true)
|
|
path, newPath, kind := extractJobInfo(f1)
|
|
p.activeJobs[f1.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// Directory /dir1 should conflict (has active file under it)
|
|
dirOp := makeResp("/", "dir1", true, 2, true)
|
|
if !p.conflictsWith(dirOp) {
|
|
t.Error("expected conflict for directory with active file under it")
|
|
}
|
|
|
|
// Directory /dir2 should not conflict
|
|
dirOp2 := makeResp("/", "dir2", true, 3, true)
|
|
if p.conflictsWith(dirOp2) {
|
|
t.Error("unexpected conflict for directory with no active jobs under it")
|
|
}
|
|
}
|
|
|
|
// TestDirVsDirConflict verifies ancestor/descendant directory conflict detection.
|
|
func TestDirVsDirConflict(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add directory job at /a/b
|
|
active := makeResp("/a", "b", true, 1, true)
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// /a/b/c (descendant) should conflict
|
|
desc := makeResp("/a/b", "c", true, 2, true)
|
|
if !p.conflictsWith(desc) {
|
|
t.Error("expected conflict for descendant directory")
|
|
}
|
|
|
|
// /a (ancestor) should conflict
|
|
anc := makeResp("/", "a", true, 3, true)
|
|
if !p.conflictsWith(anc) {
|
|
t.Error("expected conflict for ancestor directory")
|
|
}
|
|
|
|
// Same-path barrier dir SHOULD conflict: concurrent create/delete/rename
|
|
// on the same directory must serialize.
|
|
same := makeResp("/a", "b", true, 4, true)
|
|
if !p.conflictsWith(same) {
|
|
t.Error("expected conflict for same-path barrier directory")
|
|
}
|
|
|
|
// Sibling directory should not conflict
|
|
sibling := makeResp("/a", "c", true, 5, true)
|
|
if p.conflictsWith(sibling) {
|
|
t.Error("unexpected conflict for sibling directory")
|
|
}
|
|
}
|
|
|
|
// TestRenameConflict verifies that rename events with two paths check both paths.
|
|
func TestRenameConflict(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add file job at /dir1/file.txt
|
|
f1 := makeResp("/dir1", "file.txt", false, 1, true)
|
|
path, newPath, kind := extractJobInfo(f1)
|
|
p.activeJobs[f1.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// Rename from /dir2/a.txt to /dir1/file.txt should conflict (newPath matches)
|
|
rename := makeRenameResp("/dir2", "a.txt", "/dir1", "file.txt", false, 2)
|
|
if !p.conflictsWith(rename) {
|
|
t.Error("expected conflict for rename whose destination matches active file")
|
|
}
|
|
|
|
// Rename from /dir1/file.txt to /dir2/b.txt should conflict (oldPath matches)
|
|
rename2 := makeRenameResp("/dir1", "file.txt", "/dir2", "b.txt", false, 3)
|
|
if !p.conflictsWith(rename2) {
|
|
t.Error("expected conflict for rename whose source matches active file")
|
|
}
|
|
|
|
// Rename between unrelated paths should not conflict
|
|
rename3 := makeRenameResp("/dir3", "x.txt", "/dir4", "y.txt", false, 4)
|
|
if p.conflictsWith(rename3) {
|
|
t.Error("unexpected conflict for rename between unrelated paths")
|
|
}
|
|
}
|
|
|
|
// TestActiveRenameConflict verifies that an active rename job registers both paths.
|
|
func TestActiveRenameConflict(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add active rename job: /dir1/old.txt -> /dir2/new.txt
|
|
rename := makeRenameResp("/dir1", "old.txt", "/dir2", "new.txt", false, 1)
|
|
path, newPath, kind := extractJobInfo(rename)
|
|
p.activeJobs[rename.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
if newPath != "" {
|
|
p.addPathToIndex(newPath, kind)
|
|
}
|
|
|
|
// File at /dir1/old.txt should conflict
|
|
f1 := makeResp("/dir1", "old.txt", false, 2, true)
|
|
if !p.conflictsWith(f1) {
|
|
t.Error("expected conflict at rename source path")
|
|
}
|
|
|
|
// File at /dir2/new.txt should conflict
|
|
f2 := makeResp("/dir2", "new.txt", false, 3, true)
|
|
if !p.conflictsWith(f2) {
|
|
t.Error("expected conflict at rename destination path")
|
|
}
|
|
|
|
// File at unrelated path should not conflict
|
|
f3 := makeResp("/dir3", "other.txt", false, 4, true)
|
|
if p.conflictsWith(f3) {
|
|
t.Error("unexpected conflict at unrelated path")
|
|
}
|
|
}
|
|
|
|
// TestRootDirConflict verifies that an active job at / conflicts with everything.
|
|
func TestRootDirConflict(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add directory job at /
|
|
// Note: a dir entry at "/" would be created as FullPath("/").Child("somedir")
|
|
// But let's test what happens with an active dir at /some/path and check root
|
|
active := makeResp("/some", "dir", true, 1, true)
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// Root dir should conflict because active dir /some/dir is under /
|
|
// A new directory at "/" should see descendantCount["/"] > 0
|
|
if p.descendantCount["/"] <= 0 {
|
|
t.Error("expected descendantCount['/'] > 0 for active job under root")
|
|
}
|
|
}
|
|
|
|
// TestIndexCleanup verifies that removing a job properly cleans up all indexes.
|
|
func TestIndexCleanup(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Add then remove a file job
|
|
path := util.FullPath("/a/b/c/file.txt")
|
|
p.addPathToIndex(path, kindFile)
|
|
|
|
if p.activeFilePaths[path] != 1 {
|
|
t.Errorf("expected activeFilePaths count 1, got %d", p.activeFilePaths[path])
|
|
}
|
|
if p.descendantCount["/a/b/c"] != 1 {
|
|
t.Errorf("expected descendantCount['/a/b/c'] = 1, got %d", p.descendantCount["/a/b/c"])
|
|
}
|
|
|
|
p.removePathFromIndex(path, kindFile)
|
|
|
|
if len(p.activeFilePaths) != 0 {
|
|
t.Errorf("expected empty activeFilePaths after removal, got %v", p.activeFilePaths)
|
|
}
|
|
if len(p.descendantCount) != 0 {
|
|
t.Errorf("expected empty descendantCount after removal, got %v", p.descendantCount)
|
|
}
|
|
}
|
|
|
|
// TestWatermarkWithHeap verifies watermark advancement using the min-heap.
|
|
func TestWatermarkWithHeap(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Simulate adding jobs in order
|
|
for _, ts := range []int64{10, 20, 30} {
|
|
jobPath := util.FullPath("/file" + string(rune('0'+ts/10)))
|
|
p.activeJobs[ts] = &syncJobPaths{path: jobPath, kind: kindFile}
|
|
p.addPathToIndex(jobPath, kindFile)
|
|
heap.Push(&p.tsHeap, ts)
|
|
}
|
|
|
|
if p.tsHeap[0] != 10 {
|
|
t.Errorf("expected heap min=10, got %d", p.tsHeap[0])
|
|
}
|
|
|
|
// Remove non-oldest (ts=20) — heap top should stay 10
|
|
delete(p.activeJobs, 20)
|
|
p.removePathFromIndex("/file2", kindFile)
|
|
// Lazy clean: top is 10 which is still active, so no pop
|
|
for p.tsHeap.Len() > 0 {
|
|
if _, active := p.activeJobs[p.tsHeap[0]]; active {
|
|
break
|
|
}
|
|
heap.Pop(&p.tsHeap)
|
|
}
|
|
if p.tsHeap[0] != 10 {
|
|
t.Errorf("expected heap min=10 after removing 20, got %d", p.tsHeap[0])
|
|
}
|
|
|
|
// Remove oldest (ts=10) — lazy clean should find 30
|
|
delete(p.activeJobs, 10)
|
|
p.removePathFromIndex("/file1", kindFile)
|
|
for p.tsHeap.Len() > 0 {
|
|
if _, active := p.activeJobs[p.tsHeap[0]]; active {
|
|
break
|
|
}
|
|
heap.Pop(&p.tsHeap)
|
|
}
|
|
if p.tsHeap.Len() != 1 || p.tsHeap[0] != 30 {
|
|
t.Errorf("expected heap min=30 after removing 10 and 20, got len=%d", p.tsHeap.Len())
|
|
}
|
|
}
|
|
|
|
// TestNonBarrierDirUpdateDoesNotBlockDescendants verifies the loosened
|
|
// dir-conflict rule: an attribute-only directory update (same parent + same
|
|
// name) must NOT block file events under that directory. A barrier dir event
|
|
// (create/delete/rename) on the same path still must.
|
|
func TestNonBarrierDirUpdateDoesNotBlockDescendants(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
|
|
t.Run("attribute update on /dir1 does not block file under it", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Active non-barrier: attribute update on /dir1.
|
|
active := makeDirUpdateResp("/", "dir1", 1)
|
|
path, newPath, kind := extractJobInfo(active)
|
|
if kind != kindNonBarrierDir {
|
|
t.Fatalf("expected kindNonBarrierDir for dir attribute update, got %v", kind)
|
|
}
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// File under /dir1 should NOT conflict with the attribute update.
|
|
under := makeResp("/dir1", "file.txt", false, 2, true)
|
|
if p.conflictsWith(under) {
|
|
t.Error("file under a non-barrier dir update should not conflict")
|
|
}
|
|
|
|
// Nested file should also not conflict.
|
|
deep := makeResp("/dir1/sub/deep", "file.txt", false, 3, true)
|
|
if p.conflictsWith(deep) {
|
|
t.Error("deeply nested file under a non-barrier dir update should not conflict")
|
|
}
|
|
})
|
|
|
|
t.Run("barrier dir create at the same path still blocks descendants", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
active := makeResp("/", "dir1", true, 1, true) // create
|
|
path, newPath, kind := extractJobInfo(active)
|
|
if kind != kindBarrierDir {
|
|
t.Fatalf("expected kindBarrierDir for dir create, got %v", kind)
|
|
}
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
under := makeResp("/dir1", "file.txt", false, 2, true)
|
|
if !p.conflictsWith(under) {
|
|
t.Error("file under an active barrier dir create should still conflict")
|
|
}
|
|
})
|
|
|
|
t.Run("barrier dir delete still waits for in-flight descendants", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Active file under /dir1.
|
|
f := makeResp("/dir1", "file.txt", false, 1, true)
|
|
path, newPath, kind := extractJobInfo(f)
|
|
p.activeJobs[f.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// Incoming barrier delete on /dir1 should still wait for the
|
|
// in-flight file descendant.
|
|
del := makeResp("/", "dir1", true, 2, false)
|
|
if !p.conflictsWith(del) {
|
|
t.Error("barrier dir delete should wait for descendant file job")
|
|
}
|
|
})
|
|
|
|
t.Run("non-barrier dir update still keeps ancestor barrier waiting", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
|
|
// Active non-barrier dir update at /a/b.
|
|
upd := makeDirUpdateResp("/a", "b", 1)
|
|
path, newPath, kind := extractJobInfo(upd)
|
|
p.activeJobs[upd.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// A barrier delete on /a (the ancestor) should wait for it.
|
|
del := makeResp("/", "a", true, 2, false)
|
|
if !p.conflictsWith(del) {
|
|
t.Error("barrier ancestor dir delete should wait for non-barrier descendant update")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSamePathBarrierSerialization verifies the tightened same-path rules:
|
|
// a barrier dir in flight at p serializes every other job at p (file, barrier
|
|
// dir, or non-barrier update), and a file in flight at p serializes incoming
|
|
// files and barrier dirs at p.
|
|
func TestSamePathBarrierSerialization(t *testing.T) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
|
|
t.Run("barrier dir at p blocks same-path file", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
active := makeResp("/", "dir1", true, 1, true) // dir create
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
file := makeResp("/", "dir1", false, 2, true)
|
|
if !p.conflictsWith(file) {
|
|
t.Error("file at path of active barrier dir should conflict")
|
|
}
|
|
})
|
|
|
|
t.Run("barrier dir at p blocks another same-path barrier dir", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
active := makeResp("/", "dir1", true, 1, true) // dir create
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
del := makeResp("/", "dir1", true, 2, false) // dir delete, same path
|
|
if !p.conflictsWith(del) {
|
|
t.Error("concurrent create/delete on same dir path should conflict")
|
|
}
|
|
})
|
|
|
|
t.Run("barrier dir at p blocks non-barrier update at same path", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
active := makeResp("/", "dir1", true, 1, true) // dir create
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
upd := makeDirUpdateResp("/", "dir1", 2)
|
|
if !p.conflictsWith(upd) {
|
|
t.Error("attribute update on dir being created should wait for the create")
|
|
}
|
|
})
|
|
|
|
t.Run("file at p blocks same-path barrier dir", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
active := makeResp("/", "thing", false, 1, true) // file create at /thing
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// Barrier dir at /thing (e.g. a file→dir promotion) must wait.
|
|
promoteDir := makeResp("/", "thing", true, 2, true)
|
|
if !p.conflictsWith(promoteDir) {
|
|
t.Error("barrier dir at path of active file should conflict")
|
|
}
|
|
})
|
|
|
|
t.Run("non-barrier update at p blocks incoming barrier dir at same path", func(t *testing.T) {
|
|
// Regression test for a bug spotted in review: an in-flight
|
|
// attribute update on /dir1 must serialize against a later
|
|
// delete/rename/create on /dir1.
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
active := makeDirUpdateResp("/", "dir1", 1)
|
|
path, newPath, kind := extractJobInfo(active)
|
|
if kind != kindNonBarrierDir {
|
|
t.Fatalf("expected kindNonBarrierDir, got %v", kind)
|
|
}
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
del := makeResp("/", "dir1", true, 2, false) // dir delete
|
|
if !p.conflictsWith(del) {
|
|
t.Error("barrier dir at path of active non-barrier update should conflict")
|
|
}
|
|
// Ensure the removal path also cleans up the non-barrier index.
|
|
p.removePathFromIndex(path, kind)
|
|
if len(p.activeNonBarrierDirPaths) != 0 {
|
|
t.Errorf("activeNonBarrierDirPaths not cleaned up, got %v", p.activeNonBarrierDirPaths)
|
|
}
|
|
})
|
|
|
|
t.Run("non-barrier update at p does NOT block same-path non-barrier update", func(t *testing.T) {
|
|
p := NewMetadataProcessor(noop, 100, 0)
|
|
active := makeDirUpdateResp("/", "dir1", 1)
|
|
path, newPath, kind := extractJobInfo(active)
|
|
p.activeJobs[active.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
|
|
// Concurrent attribute bumps are allowed: last writer wins.
|
|
upd2 := makeDirUpdateResp("/", "dir1", 2)
|
|
if p.conflictsWith(upd2) {
|
|
t.Error("concurrent non-barrier dir updates should not conflict")
|
|
}
|
|
})
|
|
}
|
|
|
|
// benchResult prevents the compiler from optimizing away the conflict check.
|
|
var benchResult bool
|
|
|
|
// BenchmarkConflictCheck measures conflict check cost with varying active job counts.
|
|
// With the index-based approach, cost should be O(depth) regardless of job count.
|
|
func BenchmarkConflictCheck(b *testing.B) {
|
|
for _, numJobs := range []int{32, 256, 1024} {
|
|
b.Run(fmt.Sprintf("jobs=%d", numJobs), func(b *testing.B) {
|
|
noop := func(resp *filer_pb.SubscribeMetadataResponse) error { return nil }
|
|
p := NewMetadataProcessor(noop, numJobs+1, 0)
|
|
|
|
// Fill with active jobs in different directories
|
|
for i := range numJobs {
|
|
dir := fmt.Sprintf("/dir%d/sub%d", i/100, i%100)
|
|
name := fmt.Sprintf("file%d.txt", i)
|
|
resp := makeResp(dir, name, false, int64(i+1), true)
|
|
path, newPath, kind := extractJobInfo(resp)
|
|
p.activeJobs[resp.TsNs] = &syncJobPaths{path: path, newPath: newPath, kind: kind}
|
|
p.addPathToIndex(path, kind)
|
|
}
|
|
|
|
// Benchmark conflict check for a non-conflicting event
|
|
probe := makeResp("/other/path", "test.txt", false, int64(numJobs+1), true)
|
|
var r bool
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
r = p.conflictsWith(probe)
|
|
}
|
|
benchResult = r
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMetadataProcessorEmptyMarkerKeepsWatermarkStale: the MaxUnsyncedEvents
|
|
// marker (empty EventNotification, fresh timestamp) is dropped by AddSyncJob and
|
|
// does NOT advance processedTsWatermark, so offsetFunc keeps publishing the stale
|
|
// offset. This is why the client must not drive sync_offset off the watermark
|
|
// for these markers.
|
|
func TestMetadataProcessorEmptyMarkerKeepsWatermarkStale(t *testing.T) {
|
|
const staleOffset = int64(1_000_000_000)
|
|
freshTs := staleOffset + int64(time.Hour) // a "now"-ish source timestamp
|
|
|
|
p := NewMetadataProcessor(func(*filer_pb.SubscribeMetadataResponse) error { return nil }, 4, staleOffset)
|
|
|
|
marker := &filer_pb.SubscribeMetadataResponse{
|
|
TsNs: freshTs,
|
|
EventNotification: &filer_pb.EventNotification{},
|
|
}
|
|
if !filer_pb.IsEmpty(marker) {
|
|
t.Fatal("marker should be IsEmpty")
|
|
}
|
|
|
|
p.AddSyncJob(marker)
|
|
|
|
if got := p.processedTsWatermark.Load(); got != staleOffset {
|
|
t.Fatalf("empty marker advanced watermark to %d; want it to stay stale at %d", got, staleOffset)
|
|
}
|
|
t.Logf("marker carried fresh ts %d but watermark stayed stale at %d", freshTs, staleOffset)
|
|
}
|
|
|
|
// waitForJobsToDrain blocks until every job goroutine has finished bookkeeping.
|
|
func waitForJobsToDrain(t *testing.T, p *MetadataProcessor) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
p.activeJobsLock.Lock()
|
|
remaining := len(p.activeJobs)
|
|
p.activeJobsLock.Unlock()
|
|
if remaining == 0 {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatal("timed out waiting for sync jobs to drain")
|
|
}
|
|
|
|
// TestFailedJobHoldsWatermark verifies that a job that returns an error keeps
|
|
// the watermark — and therefore the persisted sync offset — behind the failed
|
|
// event, so a restart replays it. Advancing past it drops the event for good:
|
|
// the file stays local-only and nothing ever retries the upload.
|
|
func TestFailedJobHoldsWatermark(t *testing.T) {
|
|
const failedTsNs = int64(200)
|
|
// a permanent error, so util.Retry gives up on the first attempt
|
|
fn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
if resp.TsNs == failedTsNs {
|
|
return errors.New("AccessDenied: Access Denied")
|
|
}
|
|
return nil
|
|
}
|
|
// concurrency 1 runs the jobs serially in timestamp order
|
|
p := NewMetadataProcessor(fn, 1, 0)
|
|
|
|
p.AddSyncJob(makeResp("/dir", "a.txt", false, 100, true))
|
|
waitForJobsToDrain(t, p)
|
|
if got := p.processedTsWatermark.Load(); got != 100 {
|
|
t.Fatalf("watermark = %d after a successful job, want 100", got)
|
|
}
|
|
|
|
p.AddSyncJob(makeResp("/dir", "b.txt", false, failedTsNs, true))
|
|
waitForJobsToDrain(t, p)
|
|
if got := p.processedTsWatermark.Load(); got != 100 {
|
|
t.Fatalf("watermark = %d after a failed job, want it held at 100", got)
|
|
}
|
|
|
|
// later events keep flowing, but the offset stays behind the failure
|
|
p.AddSyncJob(makeResp("/dir", "c.txt", false, 300, true))
|
|
waitForJobsToDrain(t, p)
|
|
if got := p.processedTsWatermark.Load(); got != 100 {
|
|
t.Fatalf("watermark = %d after a later success, want it held at 100", got)
|
|
}
|
|
}
|
|
|
|
// TestFailedJobHoldsWatermarkAtOldestFailure verifies that the watermark is
|
|
// pinned by the oldest failure, not the most recent one.
|
|
func TestFailedJobHoldsWatermarkAtOldestFailure(t *testing.T) {
|
|
fn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
if resp.TsNs == 200 || resp.TsNs == 400 {
|
|
return errors.New("AccessDenied: Access Denied")
|
|
}
|
|
return nil
|
|
}
|
|
p := NewMetadataProcessor(fn, 1, 0)
|
|
|
|
for _, ts := range []int64{100, 200, 300, 400, 500} {
|
|
p.AddSyncJob(makeResp("/dir", fmt.Sprintf("f%d.txt", ts), false, ts, true))
|
|
waitForJobsToDrain(t, p)
|
|
}
|
|
|
|
if got := p.processedTsWatermark.Load(); got != 100 {
|
|
t.Fatalf("watermark = %d, want it held at 100 by the failure at 200", got)
|
|
}
|
|
}
|
|
|
|
// TestSyncStreamMetrics verifies the per-event and byte counters and the
|
|
// in-flight gauges across success and failure outcomes. Bytes count only the
|
|
// chunk delta: the failing create's 40, the successful create's 100, the
|
|
// update's new 60-byte chunk but not its shared one, and nothing for the
|
|
// delete despite its chunk.
|
|
func TestSyncStreamMetrics(t *testing.T) {
|
|
fn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
|
if resp.TsNs == 2 {
|
|
return errors.New("AccessDenied: Access Denied")
|
|
}
|
|
return nil
|
|
}
|
|
p := NewMetadataProcessor(fn, 100, 0)
|
|
p.SetMetrics("srcFiler", "dstFiler", "TestSyncStreamMetrics", "/")
|
|
|
|
create := makeResp("/dir1", "a.txt", false, 1, true)
|
|
create.EventNotification.NewEntry.Chunks = []*filer_pb.FileChunk{{FileId: "1,a0", Size: 100}}
|
|
failing := makeResp("/dir1", "b.txt", false, 2, true)
|
|
failing.EventNotification.NewEntry.Chunks = []*filer_pb.FileChunk{{FileId: "2,b0", Size: 40}}
|
|
shared := &filer_pb.FileChunk{FileId: "3,c0", Size: 30}
|
|
update := &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/dir1",
|
|
TsNs: 3,
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{Name: "c.txt", Chunks: []*filer_pb.FileChunk{shared}},
|
|
NewEntry: &filer_pb.Entry{Name: "c.txt", Chunks: []*filer_pb.FileChunk{shared, {FileId: "3,c1", Size: 60}}},
|
|
NewParentPath: "/dir1",
|
|
},
|
|
}
|
|
del := makeResp("/dir1", "d.txt", false, 4, false)
|
|
del.EventNotification.OldEntry.Chunks = []*filer_pb.FileChunk{{FileId: "4,d0", Size: 999}}
|
|
|
|
for _, resp := range []*filer_pb.SubscribeMetadataResponse{create, failing, update, del} {
|
|
p.AddSyncJob(resp)
|
|
}
|
|
waitForJobsToDrain(t, p)
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
got float64
|
|
want float64
|
|
}{
|
|
{"received", testutil.ToFloat64(p.metrics.received), 4},
|
|
{"processed", testutil.ToFloat64(p.metrics.processed), 3},
|
|
{"failed", testutil.ToFloat64(p.metrics.failed), 1},
|
|
{"in_flight", testutil.ToFloat64(p.metrics.inFlight), 0},
|
|
{"received_bytes", testutil.ToFloat64(p.metrics.receivedBytes), 200},
|
|
{"processed_bytes", testutil.ToFloat64(p.metrics.processedBytes), 160},
|
|
{"failed_bytes", testutil.ToFloat64(p.metrics.failedBytes), 40},
|
|
{"in_flight_bytes", testutil.ToFloat64(p.metrics.inFlightBytes), 0},
|
|
} {
|
|
if tc.got != tc.want {
|
|
t.Errorf("%s = %v, want %v", tc.name, tc.got, tc.want)
|
|
}
|
|
}
|
|
}
|