Files
zapdb/follower_test.go
zeekayandHanzo Dev 253467b84e follower: read a store another process is writing
A host running many nodes writes the same history once per node. It is the same
history because finality made it so, and the arrangement that stops paying for
the copies needs one thing this store could not do: open a directory a live
writer holds.

Read-only cannot. It takes a shared lock, the writer holds an exclusive one, and
they exclude each other — so a reader is refused for exactly as long as a reader
is wanted. BypassLockGuard lifts the refusal but lifts it for a second WRITER
too, and nothing afterwards tells the safe arrangement from the unsafe one.

A follower says which arrangement it is. It takes no lock, deferring to the
writer's, so a second writer still meets that lock and still fails. In exchange
it promises what a follower has to promise: no key, no file, no compaction, and
not the directory it would have created had the path been empty — a follower
that creates a file has already made the writer's store something the writer did
not write. The flag derives ReadOnly rather than asking a caller to set both,
which is what keeps the ten existing `if !ReadOnly` guards correct without
touching any of them.

Two places needed the distinction. A log ending before its file does means a
damaged tail to a lone reader and an append in progress to a follower; the same
bytes, and only the presence of a writer tells them apart. And the truncation
that follows a replay is how a writer discards damage — a follower stops at the
coherent end and leaves the file alone.

What a follower sees while the writer compacts is coherent only under a pinned
manifest generation. That is LP-3701 and is not in this commit; this is the half
that lets the store be opened at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 13:54:29 -07:00

183 lines
5.7 KiB
Go

/*
* SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/
package zapdb
import (
"fmt"
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/require"
)
// A follower reads a store another process is writing. These are the properties
// LP-3701 asks of that arrangement, each written so it fails without the
// Follower option rather than merely passing with it.
/** Every entry in a directory tree, so a follower's footprint can be measured. */
func tree(t *testing.T, dir string) []string {
t.Helper()
var out []string
require.NoError(t, filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dir, p)
if err != nil {
return err
}
if rel != "." {
out = append(out, fmt.Sprintf("%s|%d", rel, fi.Size()))
}
return nil
}))
sort.Strings(out)
return out
}
func writeSome(t *testing.T, db *DB, n int) {
t.Helper()
require.NoError(t, db.Update(func(txn *Txn) error {
for i := 0; i < n; i++ {
if err := txn.Set([]byte(fmt.Sprintf("k%04d", i)), []byte(fmt.Sprintf("v%04d", i))); err != nil {
return err
}
}
return nil
}))
}
// The arrangement itself: a live writer holds the directory, and a follower
// opens it anyway. Ordinary read-only cannot — the writer's exclusive lock and
// the reader's shared one are mutually exclusive — so this is the test that
// fails without the option, and the reason the option exists.
func TestFollowerOpensBeneathALiveWriter(t *testing.T) {
dir, err := os.MkdirTemp("", "zapdb-follower")
require.NoError(t, err)
defer removeDir(dir)
writer, err := Open(getTestOptions(dir))
require.NoError(t, err)
defer writer.Close()
writeSome(t, writer, 64)
require.NoError(t, writer.Sync())
// The mode that cannot: refused for as long as a writer is live, which is
// the whole period a follower is wanted.
_, err = Open(getTestOptions(dir).WithReadOnly(true))
require.Error(t, err, "read-only should still be refused under a live writer")
f, err := Open(getTestOptions(dir).WithFollower(true))
require.NoError(t, err, "a follower should open beneath a live writer")
defer f.Close()
require.NoError(t, f.View(func(txn *Txn) error {
item, err := txn.Get([]byte("k0000"))
if err != nil {
return err
}
return item.Value(func(v []byte) error {
require.Equal(t, "v0000", string(v))
return nil
})
}))
}
// A second WRITER must still be refused. This is the property BypassLockGuard
// would have given away: it removes the refusal for readers and for writers
// alike, and nothing then distinguishes the safe arrangement from the unsafe
// one. A follower defers to the lock rather than removing it.
func TestFollowerDoesNotAdmitASecondWriter(t *testing.T) {
dir, err := os.MkdirTemp("", "zapdb-follower")
require.NoError(t, err)
defer removeDir(dir)
writer, err := Open(getTestOptions(dir))
require.NoError(t, err)
defer writer.Close()
writeSome(t, writer, 8)
f, err := Open(getTestOptions(dir).WithFollower(true))
require.NoError(t, err)
defer f.Close()
_, err = Open(getTestOptions(dir))
require.Error(t, err, "a second writer must still meet the exclusive lock")
}
// A follower writes nothing. Not a key, not a file, and not the directory it
// would have created had the path been empty — the store belongs to the writer,
// and a follower that creates a file has already made it something the writer
// did not write.
func TestFollowerLeavesTheDirectoryAsItFoundIt(t *testing.T) {
dir, err := os.MkdirTemp("", "zapdb-follower")
require.NoError(t, err)
defer removeDir(dir)
writer, err := Open(getTestOptions(dir))
require.NoError(t, err)
writeSome(t, writer, 128)
require.NoError(t, writer.Sync())
before := tree(t, dir)
f, err := Open(getTestOptions(dir).WithFollower(true))
require.NoError(t, err)
require.NoError(t, f.View(func(txn *Txn) error {
_, err := txn.Get([]byte("k0000"))
return err
}))
require.NoError(t, f.Close())
require.Equal(t, before, tree(t, dir), "a follower changed the writer's directory")
require.NoError(t, writer.Close())
}
// An absent directory is a misconfiguration, and a follower says so. Creating
// it would answer a question about a store that is not there with an empty
// store, which reads to the caller as a chain with no history in it.
func TestFollowerRefusesWhatIsNotThere(t *testing.T) {
dir, err := os.MkdirTemp("", "zapdb-follower")
require.NoError(t, err)
defer removeDir(dir)
missing := filepath.Join(dir, "no-such-store")
_, err = Open(getTestOptions(missing).WithFollower(true))
require.Error(t, err, "a follower must not invent a store that is not there")
_, statErr := os.Stat(missing)
require.True(t, os.IsNotExist(statErr), "a follower created the directory it was asked to follow")
}
// A follower is read-only in the sense the rest of the store already
// understands, so the ten places that ask `if !ReadOnly` stay correct without
// being touched. Deriving the flag rather than asking a caller to set both is
// what makes that true; a follower that missed one of those would be a follower
// that writes to somebody else's directory.
func TestFollowerIsReadOnly(t *testing.T) {
dir, err := os.MkdirTemp("", "zapdb-follower")
require.NoError(t, err)
defer removeDir(dir)
writer, err := Open(getTestOptions(dir))
require.NoError(t, err)
writeSome(t, writer, 8)
require.NoError(t, writer.Sync())
defer writer.Close()
f, err := Open(getTestOptions(dir).WithFollower(true))
require.NoError(t, err)
defer f.Close()
require.True(t, f.opt.ReadOnly, "Follower must imply ReadOnly")
require.False(t, f.opt.CompactL0OnClose, "a follower must not compact")
err = f.Update(func(txn *Txn) error { return txn.Set([]byte("x"), []byte("y")) })
require.Error(t, err, "a follower must refuse a write")
}