package moves import ( "go/ast" "go/parser" "go/token" "os" "path/filepath" "reflect" "strings" "testing" "time" ) const ( tjHost = "a.go.example.com" tjOp = "mv_abcdefghijklmnopqrstuvwxyz" ) func tjRead(t *testing.T, dd string) *Journal { t.Helper() j, err := ReadJournal(dd, tjHost, tjOp) if err != nil { t.Fatal(err) } return j } func tjCommit(t *testing.T, dd string, j *Journal) { t.Helper() if err := j.Commit(dd); err != nil { t.Fatal(err) } } // Fix round 1, ruling 3, and fix round 2, ruling 2: a move's journal has // more than one writer on a box (the landing's pass, the probe owners and // attempts), each a read-modify-write through UpdateJournal: a writer that // read before another's write commits only its own changes, so the // landing's progress mark and the other writer's step both survive, in // either order. A step the writer itself removes is removed. func TestJournalWritersKeepEachOthersSteps(t *testing.T) { dd := t.TempDir() landing, other := tjRead(t, dd), tjRead(t, dd) other.Mark("files-plain-g1", "sha256:f") tjCommit(t, dd, other) landing.Phase, landing.Attempt = "probing", 1 landing.Mark("probe-admitting-1", `{"session":7}`) tjCommit(t, dd, landing) got := tjRead(t, dd) if !got.Done("files-plain-g1") || !got.Done("probe-admitting-1") || got.Phase != "probing" || got.Attempt != 1 { t.Fatalf("after the landing's later commit: %+v", got) } if !landing.Done("files-plain-g1") { t.Fatalf("a committed value is not the journal as written: %+v", landing.Steps) } // The other order: the other writer commits last. landing, other = tjRead(t, dd), tjRead(t, dd) landing.Mark("probe-passed-1", "ok") tjCommit(t, dd, landing) other.Mark("files-plain-g2", "sha256:g") tjCommit(t, dd, other) got = tjRead(t, dd) if !got.Done("probe-passed-1") || !got.Done("files-plain-g2") || !got.Done("probe-admitting-1") || !got.Done("files-plain-g1") || got.Phase != "probing" { t.Fatalf("after the other's later commit: %+v", got) } // A step the writer removes is removed; another's is not touched. j := tjRead(t, dd) j.Unmark("probe-passed-1") tjCommit(t, dd, j) if got := tjRead(t, dd); got.Done("probe-passed-1") || !got.Done("files-plain-g2") { t.Fatalf("after an Unmark: %+v", got.Steps) } } // Fix round 2, ruling 2: a stale writer never restores a value another // writer advanced or deleted after the stale writer read it. It writes only // what it changed itself. func TestStaleJournalWriterRestoresNothing(t *testing.T) { dd := t.TempDir() if err := UpdateJournal(dd, tjHost, tjOp, func(j *Journal) error { j.Mark("probe-attempt-1", "started") j.Mark("scratch", "here") j.Phase = "restored" return nil }); err != nil { t.Fatal(err) } stale := tjRead(t, dd) // Another writer advances one step, deletes another and moves the phase. if err := UpdateJournal(dd, tjHost, tjOp, func(j *Journal) error { j.Mark("probe-attempt-1", "passed") j.Unmark("scratch") j.Phase = "probing" return nil }); err != nil { t.Fatal(err) } stale.Mark("probe-admitting-2", "x") tjCommit(t, dd, stale) got := tjRead(t, dd) if got.Steps["probe-attempt-1"] != "passed" { t.Errorf("an advanced step was restored: %q", got.Steps["probe-attempt-1"]) } if got.Done("scratch") { t.Errorf("a deleted step was restored: %+v", got.Steps) } if got.Phase != "probing" { t.Errorf("a phase the stale writer did not change was restored: %q", got.Phase) } if !got.Done("probe-admitting-2") { t.Errorf("the stale writer's own change is lost: %+v", got) } } // Fix round 1, ruling 3: one lock per journal. A writer that commits while // another holds the journal in UpdateJournal (the landing's admitting mark) // waits for it, and both changes are in the journal after. func TestJournalLockSerializesWriters(t *testing.T) { dd := t.TempDir() other := tjRead(t, dd) other.Mark("files-plain-g1", "sha256:f") done := make(chan error, 1) err := UpdateJournal(dd, tjHost, tjOp, func(j *Journal) error { go func() { done <- other.Commit(dd) }() select { case err := <-done: t.Errorf("a commit wrote the journal (%v) while UpdateJournal held it", err) case <-time.After(100 * time.Millisecond): } j.Phase, j.Attempt = "probing", 1 j.Mark("probe-admitting-1", `{"session":7}`) return nil }) if err != nil { t.Fatal(err) } if err := <-done; err != nil { t.Fatal(err) } got := tjRead(t, dd) if !got.Done("probe-admitting-1") || !got.Done("files-plain-g1") || got.Phase != "probing" { t.Fatalf("the journal lost a writer's change: %+v", got) } } // Fix round 2, ruling 2: there is no plain write. Journal has no exported // Write, and the whole-file write (write) is called only by UpdateJournal, // which holds the lock across the read, the change and the write; every // other writer commits its own changes through it (Commit). A production // caller cannot read a journal and then write it whole. func TestJournalHasNoPlainWrite(t *testing.T) { if _, ok := reflect.TypeOf(&Journal{}).MethodByName("Write"); ok { t.Fatal("Journal has a plain Write") } files, err := filepath.Glob("*.go") if err != nil { t.Fatal(err) } fset := token.NewFileSet() var callers []string for _, f := range files { if strings.HasSuffix(f, "_test.go") { continue } af, err := parser.ParseFile(fset, f, nil, 0) if err != nil { t.Fatal(err) } for _, d := range af.Decls { fd, ok := d.(*ast.FuncDecl) if !ok || fd.Body == nil { continue } ast.Inspect(fd.Body, func(n ast.Node) bool { if c, ok := n.(*ast.CallExpr); ok { if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "write" { callers = append(callers, f+":"+fd.Name.Name) } } return true }) } } if !reflect.DeepEqual(callers, []string{"journal.go:UpdateJournal"}) { t.Fatalf("the journal's whole-file write is called by %v, want only UpdateJournal", callers) } // Every journal written in this package's tests reads back: a smoke // check that Commit makes a journal where there was none. dd := t.TempDir() j := tjRead(t, dd) j.Mark("a", "b") tjCommit(t, dd, j) if _, err := os.Stat(filepath.Join(JournalDir(dd, tjHost, tjOp), journalFile)); err != nil { t.Fatalf("Commit made no journal: %v", err) } }