mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-21 21:44:22 +02:00
- Go/Rust: run the unindexed .dat tail repair on noWriteCanDelete volumes too — deletes still append tombstone records, so the same crash window applies and they previously degraded to fully read-only. - Go readTailNeedle: reject a positive size of 1-3 for v2+ as corrupted before ReadBytes slices the body's 4-byte data-size field and panics; a torn last record is now cut instead of crashing the load. - Tests: TestVolumeLoadRepairsTailOnCanDeleteVolume and TestVolumeLoadCutsDamagedSmallSizeTailOnCanDeleteVolume (Go), and the Rust mirrors volume_load_repairs_tail_on_can_delete_volume / volume_load_cuts_damaged_small_size_tail. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
793 lines
28 KiB
Go
793 lines
28 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/backend"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
// A torn tail is what a crash between the two appends of a write leaves: the
|
|
// needle is in the .dat, its entry never reached the .idx. After a process
|
|
// crash the client was never answered; after a power loss it may have been.
|
|
// These tests write needles (and deletes) normally, drop the last .idx
|
|
// entries, cut or damage the .dat as a crash would, and reload the volume
|
|
// through the real load path.
|
|
|
|
type tornOp struct {
|
|
id uint64
|
|
del bool
|
|
empty bool // a write with no data, which appends a size 0 record like a delete
|
|
}
|
|
|
|
type tornFixture struct {
|
|
dir string
|
|
datName string
|
|
idxName string
|
|
starts []int64 // .dat offset of each op's record
|
|
end int64 // .dat size after every op
|
|
written map[uint64]*needle.Needle
|
|
}
|
|
|
|
func newTornTailTestVolume(t *testing.T, dir string) *Volume {
|
|
t.Helper()
|
|
v, err := NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("load volume: %v", err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// buildTornFixture runs ops against a fresh volume (each op appends one .dat
|
|
// record and one .idx entry), closes it and drops the last unindexed entries.
|
|
func buildTornFixture(t *testing.T, ops []tornOp, unindexed int) *tornFixture {
|
|
t.Helper()
|
|
f := &tornFixture{dir: t.TempDir(), written: map[uint64]*needle.Needle{}}
|
|
v := newTornTailTestVolume(t, f.dir)
|
|
for _, op := range ops {
|
|
at, _, err := v.DataBackend.GetStat()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.starts = append(f.starts, at)
|
|
if op.del {
|
|
if _, err := v.doDeleteRequest(newEmptyNeedle(op.id)); err != nil {
|
|
t.Fatalf("delete needle %d: %v", op.id, err)
|
|
}
|
|
continue
|
|
}
|
|
n := newRandomNeedle(op.id)
|
|
if op.empty {
|
|
n = newEmptyNeedle(op.id)
|
|
}
|
|
if _, _, _, err := v.writeNeedle2(n, true, false, false); err != nil {
|
|
t.Fatalf("write needle %d: %v", op.id, err)
|
|
}
|
|
f.written[op.id] = n
|
|
}
|
|
f.end, _, _ = v.DataBackend.GetStat()
|
|
f.datName, f.idxName = v.FileName(".dat"), v.FileName(".idx")
|
|
v.Close()
|
|
if unindexed > 0 {
|
|
if err := os.Truncate(f.idxName, fileSize(t, f.idxName)-int64(unindexed)*types.NeedleMapEntrySize); err != nil {
|
|
t.Fatalf("drop %d .idx entries: %v", unindexed, err)
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// midRecord is an offset inside the body of op i's record: past its header,
|
|
// short of its end. A fixed offset is not: a needle with a few bytes of data
|
|
// is a 40-byte record, which a cut at +40 leaves whole.
|
|
func (f *tornFixture) midRecord(i int) int64 {
|
|
end := f.end
|
|
if i+1 < len(f.starts) {
|
|
end = f.starts[i+1]
|
|
}
|
|
return midRecordBody(f.starts[i], end)
|
|
}
|
|
|
|
func midRecordBody(start, end int64) int64 {
|
|
return start + types.NeedleHeaderSize + (end-start-types.NeedleHeaderSize)/2
|
|
}
|
|
|
|
func (f *tornFixture) truncateDat(t *testing.T, size int64) {
|
|
t.Helper()
|
|
if err := os.Truncate(f.datName, size); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// flipDataByte damages the data of the record at the given offset, so its
|
|
// CRC no longer matches.
|
|
func (f *tornFixture) flipDataByte(t *testing.T, at int64) {
|
|
t.Helper()
|
|
file, err := os.OpenFile(f.datName, os.O_RDWR, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
b := make([]byte, 1)
|
|
pos := at + types.NeedleHeaderSize + 4 // past the 4-byte DataSize field
|
|
if _, err := file.ReadAt(b, pos); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b[0] ^= 0xff
|
|
if _, err := file.WriteAt(b, pos); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// zeroDataSize zeroes the DataSize field and the first data byte (read as
|
|
// the flags once DataSize is 0) of the record at the given offset: the header
|
|
// still claims a body, the body parses as an empty needle.
|
|
func (f *tornFixture) zeroDataSize(t *testing.T, at int64) {
|
|
t.Helper()
|
|
file, err := os.OpenFile(f.datName, os.O_RDWR, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
if _, err := file.WriteAt(make([]byte, 5), at+types.NeedleHeaderSize); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func assertReadable(t *testing.T, v *Volume, want *needle.Needle) {
|
|
t.Helper()
|
|
got := &needle.Needle{Id: want.Id}
|
|
if _, err := v.readNeedle(got, nil, nil); err != nil {
|
|
t.Fatalf("read needle %d: %v", want.Id, err)
|
|
}
|
|
if !bytes.Equal(got.Data, want.Data) {
|
|
t.Fatalf("needle %d reads back different data", want.Id)
|
|
}
|
|
}
|
|
|
|
func assertNotReadable(t *testing.T, v *Volume, id uint64) {
|
|
t.Helper()
|
|
if _, err := v.readNeedle(&needle.Needle{Id: types.Uint64ToNeedleId(id)}, nil, nil); err == nil {
|
|
t.Fatalf("needle %d reads back; it should be deleted or absent", id)
|
|
}
|
|
}
|
|
|
|
// A recovered size 0 record indexes as a live empty needle: a delete and a
|
|
// write with no data append the identical record, so the repair cannot tell
|
|
// them apart and indexes the entry the write path would have written.
|
|
func assertEmpty(t *testing.T, v *Volume, id uint64) {
|
|
t.Helper()
|
|
n := &needle.Needle{Id: types.Uint64ToNeedleId(id)}
|
|
if _, err := v.readNeedle(n, nil, nil); err != nil {
|
|
t.Fatalf("read needle %d: %v", id, err)
|
|
}
|
|
if len(n.Data) != 0 {
|
|
t.Fatalf("needle %d reads back %d bytes, want an empty needle", id, len(n.Data))
|
|
}
|
|
}
|
|
|
|
func writes(ids ...uint64) []tornOp {
|
|
ops := make([]tornOp, len(ids))
|
|
for i, id := range ids {
|
|
ops[i] = tornOp{id: id}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
func TestVolumeLoadRepairsUnindexedDatTail(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ops []tornOp
|
|
unindexed int
|
|
damage func(t *testing.T, f *tornFixture)
|
|
wantSize func(f *tornFixture) int64 // .dat size after the repair
|
|
reindexed int
|
|
readable []uint64
|
|
empty []uint64 // readable as an empty live needle
|
|
absent []uint64
|
|
}{
|
|
{name: "one complete unindexed needle is re-indexed",
|
|
ops: writes(1, 2, 3, 4), unindexed: 1,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 1,
|
|
readable: []uint64{1, 2, 3, 4}},
|
|
{name: "two complete unindexed needles are re-indexed",
|
|
ops: writes(1, 2, 3, 4, 5), unindexed: 2,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 2,
|
|
readable: []uint64{1, 2, 3, 4, 5}},
|
|
{name: "an unindexed delete is re-indexed as an empty live needle",
|
|
ops: append(writes(1, 2, 3), tornOp{id: 2, del: true}), unindexed: 1,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 1,
|
|
readable: []uint64{1, 3}, empty: []uint64{2}},
|
|
{name: "an unindexed empty write of a live id stays an empty needle",
|
|
ops: append(writes(1, 2, 3), tornOp{id: 2, empty: true}), unindexed: 1,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 1,
|
|
readable: []uint64{1, 3}, empty: []uint64{2}},
|
|
{name: "a partial header is cut",
|
|
ops: writes(1, 2, 3, 4), unindexed: 1,
|
|
damage: func(t *testing.T, f *tornFixture) { f.truncateDat(t, f.starts[3]+7) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[3] },
|
|
readable: []uint64{1, 2, 3}, absent: []uint64{4}},
|
|
{name: "a partial body is cut",
|
|
ops: writes(1, 2, 3, 4), unindexed: 1,
|
|
damage: func(t *testing.T, f *tornFixture) { f.truncateDat(t, f.midRecord(3)) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[3] },
|
|
readable: []uint64{1, 2, 3}, absent: []uint64{4}},
|
|
{name: "a complete needle is re-indexed and the partial one after it cut",
|
|
ops: writes(1, 2, 3, 4, 5), unindexed: 2,
|
|
damage: func(t *testing.T, f *tornFixture) { f.truncateDat(t, f.midRecord(4)) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[4] }, reindexed: 1,
|
|
readable: []uint64{1, 2, 3, 4}, absent: []uint64{5}},
|
|
{name: "a last needle with a bad CRC is cut",
|
|
ops: writes(1, 2, 3, 4), unindexed: 1,
|
|
damage: func(t *testing.T, f *tornFixture) { f.flipDataByte(t, f.starts[3]) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[3] },
|
|
readable: []uint64{1, 2, 3}, absent: []uint64{4}},
|
|
{name: "a last needle whose data size field reads 0 is cut",
|
|
ops: writes(1, 2, 3, 4), unindexed: 1,
|
|
damage: func(t *testing.T, f *tornFixture) { f.zeroDataSize(t, f.starts[3]) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[3] },
|
|
readable: []uint64{1, 2, 3}, absent: []uint64{4}},
|
|
{name: "an unindexed empty write of a new id stays an empty needle",
|
|
ops: append(writes(1, 2), tornOp{id: 3, empty: true}), unindexed: 1,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 1,
|
|
readable: []uint64{1, 2, 3}},
|
|
{name: "an unindexed empty write after a delete of the same id stays an empty needle",
|
|
ops: append(append(writes(1, 2), tornOp{id: 2, del: true}), tornOp{id: 2, empty: true}), unindexed: 1,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 1,
|
|
readable: []uint64{1, 2}},
|
|
{name: "an unindexed write and delete of the same id are re-indexed in order",
|
|
ops: append(writes(1, 2, 3), tornOp{id: 3, del: true}), unindexed: 2,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 2,
|
|
readable: []uint64{1, 2}, empty: []uint64{3}},
|
|
{name: "an unindexed empty write and its delete are re-indexed in order",
|
|
ops: append(writes(1, 2), tornOp{id: 3, empty: true}, tornOp{id: 3, del: true}), unindexed: 2,
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 2,
|
|
readable: []uint64{1, 2}, empty: []uint64{3}},
|
|
{name: "a partial needle after an indexed tombstone is cut",
|
|
ops: append(append(writes(1, 2, 3), tornOp{id: 2, del: true}), tornOp{id: 4}), unindexed: 1,
|
|
damage: func(t *testing.T, f *tornFixture) { f.truncateDat(t, f.midRecord(4)) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[4] },
|
|
readable: []uint64{1, 3}, absent: []uint64{2, 4}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := buildTornFixture(t, tc.ops, tc.unindexed)
|
|
if tc.damage != nil {
|
|
tc.damage(t, f)
|
|
}
|
|
idxBefore := fileSize(t, f.idxName)
|
|
|
|
v := newTornTailTestVolume(t, f.dir)
|
|
defer v.Close()
|
|
if v.noWriteOrDelete || v.noWriteCanDelete {
|
|
t.Fatal("volume with an unindexed .dat tail loaded read-only")
|
|
}
|
|
if got, want := fileSize(t, f.datName), tc.wantSize(f); got != want {
|
|
t.Fatalf(".dat is %d bytes after load, want %d", got, want)
|
|
}
|
|
if got, want := fileSize(t, f.idxName), idxBefore+int64(tc.reindexed)*types.NeedleMapEntrySize; got != want {
|
|
t.Fatalf(".idx is %d bytes after load, want %d (%d entries re-indexed)", got, want, tc.reindexed)
|
|
}
|
|
for _, id := range tc.readable {
|
|
assertReadable(t, v, f.written[id])
|
|
}
|
|
for _, id := range tc.empty {
|
|
assertEmpty(t, v, id)
|
|
}
|
|
for _, id := range tc.absent {
|
|
assertNotReadable(t, v, id)
|
|
}
|
|
extra := newRandomNeedle(100)
|
|
if _, _, _, err := v.writeNeedle2(extra, true, false, false); err != nil {
|
|
t.Fatalf("write after repair: %v", err)
|
|
}
|
|
assertReadable(t, v, extra)
|
|
v.Close()
|
|
|
|
// the repaired volume reloads clean, and keeps what it indexed
|
|
v2 := newTornTailTestVolume(t, f.dir)
|
|
defer v2.Close()
|
|
if v2.noWriteOrDelete {
|
|
t.Fatal("repaired volume loaded read-only on the next load")
|
|
}
|
|
for _, id := range tc.readable {
|
|
assertReadable(t, v2, f.written[id])
|
|
}
|
|
assertReadable(t, v2, extra)
|
|
})
|
|
}
|
|
}
|
|
|
|
// An empty index can hide writes a crash stranded in the .dat: the writable
|
|
// index loader creates the .idx before the first needle append, so a crash
|
|
// after that append leaves a volume whose data nothing indexes. The same
|
|
// repair runs from the first record past the superblock.
|
|
func TestVolumeLoadRepairsTailPastEmptyIndex(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ops []tornOp
|
|
damage func(t *testing.T, f *tornFixture)
|
|
wantSize func(f *tornFixture) int64 // .dat size after the repair
|
|
reindexed int
|
|
readable []uint64
|
|
empty []uint64
|
|
absent []uint64
|
|
}{
|
|
{name: "the only write is re-indexed",
|
|
ops: writes(7),
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 1,
|
|
readable: []uint64{7}},
|
|
{name: "every write is re-indexed",
|
|
ops: writes(1, 2, 3),
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 3,
|
|
readable: []uint64{1, 2, 3}},
|
|
{name: "an unindexed delete is re-indexed as an empty live needle",
|
|
ops: append(writes(1, 2, 3), tornOp{id: 2, del: true}),
|
|
wantSize: func(f *tornFixture) int64 { return f.end }, reindexed: 4,
|
|
readable: []uint64{1, 3}, empty: []uint64{2}},
|
|
{name: "a complete needle is re-indexed and the partial one after it cut",
|
|
ops: writes(1, 2),
|
|
damage: func(t *testing.T, f *tornFixture) { f.truncateDat(t, f.midRecord(1)) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[1] }, reindexed: 1,
|
|
readable: []uint64{1}, absent: []uint64{2}},
|
|
{name: "a last needle with a bad CRC is cut",
|
|
ops: writes(1, 2),
|
|
damage: func(t *testing.T, f *tornFixture) { f.flipDataByte(t, f.starts[1]) },
|
|
wantSize: func(f *tornFixture) int64 { return f.starts[1] }, reindexed: 1,
|
|
readable: []uint64{1}, absent: []uint64{2}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := buildTornFixture(t, tc.ops, len(tc.ops))
|
|
if tc.damage != nil {
|
|
tc.damage(t, f)
|
|
}
|
|
|
|
v := newTornTailTestVolume(t, f.dir)
|
|
defer v.Close()
|
|
if v.noWriteOrDelete || v.noWriteCanDelete {
|
|
t.Fatal("volume with an unindexed .dat tail loaded read-only")
|
|
}
|
|
if got, want := fileSize(t, f.datName), tc.wantSize(f); got != want {
|
|
t.Fatalf(".dat is %d bytes after load, want %d", got, want)
|
|
}
|
|
if got, want := fileSize(t, f.idxName), int64(tc.reindexed)*types.NeedleMapEntrySize; got != want {
|
|
t.Fatalf(".idx is %d bytes after load, want %d (%d entries re-indexed)", got, want, tc.reindexed)
|
|
}
|
|
for _, id := range tc.readable {
|
|
assertReadable(t, v, f.written[id])
|
|
}
|
|
for _, id := range tc.empty {
|
|
assertEmpty(t, v, id)
|
|
}
|
|
for _, id := range tc.absent {
|
|
assertNotReadable(t, v, id)
|
|
}
|
|
v.Close()
|
|
|
|
// the repaired volume reloads clean, and keeps what it indexed
|
|
v2 := newTornTailTestVolume(t, f.dir)
|
|
defer v2.Close()
|
|
if v2.noWriteOrDelete {
|
|
t.Fatal("repaired volume loaded read-only on the next load")
|
|
}
|
|
for _, id := range tc.readable {
|
|
assertReadable(t, v2, f.written[id])
|
|
}
|
|
for _, id := range tc.empty {
|
|
assertEmpty(t, v2, id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An .dat an interrupted append does not explain, over an index with no rows
|
|
// at all, loads the volume read-only: the checks that would quarantine it
|
|
// otherwise all skip an empty index.
|
|
func TestVolumeLoadKeepsUnexplainedTailPastEmptyIndexReadOnly(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
damage func(t *testing.T, f *tornFixture)
|
|
}{
|
|
{name: "zero bytes where the first record would be (id 0)",
|
|
damage: func(t *testing.T, f *tornFixture) {
|
|
file, err := os.OpenFile(f.datName, os.O_RDWR|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
if _, err := file.Write(make([]byte, 64)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}},
|
|
{name: "a damaged record more data follows",
|
|
damage: func(t *testing.T, f *tornFixture) { f.flipDataByte(t, f.starts[0]) }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := buildTornFixture(t, writes(1, 2), 2)
|
|
tc.damage(t, f)
|
|
datBefore, idxBefore := fileSize(t, f.datName), fileSize(t, f.idxName)
|
|
|
|
v := newTornTailTestVolume(t, f.dir)
|
|
defer v.Close()
|
|
if !v.noWriteOrDelete {
|
|
t.Fatal("volume with an unexplained tail past an empty index loaded writable")
|
|
}
|
|
if got := fileSize(t, f.datName); got != datBefore {
|
|
t.Fatalf(".dat changed from %d to %d bytes", datBefore, got)
|
|
}
|
|
if got := fileSize(t, f.idxName); got != idxBefore {
|
|
t.Fatalf(".idx changed from %d to %d bytes", idxBefore, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Every tail an interrupted append does not explain is left exactly as it is,
|
|
// and the volume loads read-only as before.
|
|
func TestVolumeLoadKeepsUnexplainedTailReadOnly(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ops []tornOp
|
|
unindexed int
|
|
damage func(t *testing.T, f *tornFixture)
|
|
}{
|
|
{name: "the last indexed needle's id differs from its entry",
|
|
ops: writes(1, 2, 3, 4), unindexed: 1,
|
|
damage: func(t *testing.T, f *tornFixture) {
|
|
file, err := os.OpenFile(f.datName, os.O_RDWR, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
if _, err := file.WriteAt([]byte{0xff}, f.starts[2]+types.CookieSize+types.NeedleIdSize-1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}},
|
|
{name: "the last indexed needle fails its CRC",
|
|
ops: writes(1, 2, 3, 4), unindexed: 1,
|
|
damage: func(t *testing.T, f *tornFixture) { f.flipDataByte(t, f.starts[2]) }},
|
|
{name: "a zeroed data size field followed by more data",
|
|
ops: writes(1, 2, 3, 4, 5), unindexed: 2,
|
|
damage: func(t *testing.T, f *tornFixture) { f.zeroDataSize(t, f.starts[3]) }},
|
|
{name: "a bad CRC followed by more data",
|
|
ops: writes(1, 2, 3, 4, 5), unindexed: 2,
|
|
damage: func(t *testing.T, f *tornFixture) { f.flipDataByte(t, f.starts[3]) }},
|
|
{name: "zero bytes past the last needle (id 0)",
|
|
ops: writes(1, 2, 3),
|
|
damage: func(t *testing.T, f *tornFixture) {
|
|
file, err := os.OpenFile(f.datName, os.O_RDWR|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
if _, err := file.Write(make([]byte, 64)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}},
|
|
{name: "the index ends past the data",
|
|
ops: writes(1, 2, 3),
|
|
damage: func(t *testing.T, f *tornFixture) { f.truncateDat(t, f.end-10) }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := buildTornFixture(t, tc.ops, tc.unindexed)
|
|
tc.damage(t, f)
|
|
datBefore, idxBefore := fileSize(t, f.datName), fileSize(t, f.idxName)
|
|
|
|
v := newTornTailTestVolume(t, f.dir)
|
|
defer v.Close()
|
|
if !v.noWriteOrDelete {
|
|
t.Fatal("volume with an unexplained tail loaded writable")
|
|
}
|
|
if got := fileSize(t, f.datName); got != datBefore {
|
|
t.Fatalf(".dat changed from %d to %d bytes", datBefore, got)
|
|
}
|
|
if got := fileSize(t, f.idxName); got != idxBefore {
|
|
t.Fatalf(".idx changed from %d to %d bytes", idxBefore, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVolumeLoadLeavesCleanVolumeUntouched(t *testing.T) {
|
|
f := buildTornFixture(t, append(writes(1, 2, 3), tornOp{id: 2, del: true}), 0)
|
|
datBefore, idxBefore := fileSize(t, f.datName), fileSize(t, f.idxName)
|
|
|
|
v := newTornTailTestVolume(t, f.dir)
|
|
defer v.Close()
|
|
if v.noWriteOrDelete || v.noWriteCanDelete {
|
|
t.Fatal("clean volume loaded read-only")
|
|
}
|
|
if got := fileSize(t, f.datName); got != datBefore {
|
|
t.Fatalf("clean .dat changed from %d to %d bytes", datBefore, got)
|
|
}
|
|
if got := fileSize(t, f.idxName); got != idxBefore {
|
|
t.Fatalf("clean .idx changed from %d to %d bytes", idxBefore, got)
|
|
}
|
|
assertReadable(t, v, f.written[1])
|
|
assertReadable(t, v, f.written[3])
|
|
assertNotReadable(t, v, 2)
|
|
}
|
|
|
|
// A volume persisted read-only is not repaired: the tail stays for whoever
|
|
// marked it.
|
|
func TestVolumeLoadLeavesReadOnlyVolumeTailAlone(t *testing.T) {
|
|
dir := t.TempDir()
|
|
v := newTornTailTestVolume(t, dir)
|
|
for _, id := range []uint64{1, 2, 99} {
|
|
if _, _, _, err := v.writeNeedle2(newRandomNeedle(id), true, false, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := v.PersistReadOnly(true, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
datName, idxName := v.FileName(".dat"), v.FileName(".idx")
|
|
v.Close()
|
|
if err := os.Truncate(idxName, fileSize(t, idxName)-types.NeedleMapEntrySize); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
datBefore, idxBefore := fileSize(t, datName), fileSize(t, idxName)
|
|
|
|
v = newTornTailTestVolume(t, dir)
|
|
defer v.Close()
|
|
if !v.noWriteOrDelete {
|
|
t.Fatal("volume persisted read-only loaded writable")
|
|
}
|
|
if got := fileSize(t, datName); got != datBefore {
|
|
t.Fatalf(".dat of a read-only volume changed from %d to %d bytes", datBefore, got)
|
|
}
|
|
if got := fileSize(t, idxName); got != idxBefore {
|
|
t.Fatalf(".idx of a read-only volume changed from %d to %d bytes", idxBefore, got)
|
|
}
|
|
}
|
|
|
|
// A canDelete volume still appends a tombstone record on every delete, so it
|
|
// can tear a tail the same way: it is repaired and keeps taking deletes.
|
|
func TestVolumeLoadRepairsTailOnCanDeleteVolume(t *testing.T) {
|
|
dir := t.TempDir()
|
|
v := newTornTailTestVolume(t, dir)
|
|
var last *needle.Needle
|
|
for _, id := range []uint64{1, 2, 99} {
|
|
last = newRandomNeedle(id)
|
|
if _, _, _, err := v.writeNeedle2(last, true, false, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := v.PersistReadOnly(true, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
idxName := v.FileName(".idx")
|
|
v.Close()
|
|
if err := os.Truncate(idxName, fileSize(t, idxName)-types.NeedleMapEntrySize); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
v = newTornTailTestVolume(t, dir)
|
|
defer v.Close()
|
|
if v.noWriteOrDelete || !v.noWriteCanDelete {
|
|
t.Fatal("canDelete volume with a torn tail did not load canDelete after the repair")
|
|
}
|
|
assertReadable(t, v, last)
|
|
}
|
|
|
|
// A torn record whose header advertises a positive size smaller than the
|
|
// body's own 4-byte data-size field is damaged, not decodable — reading it
|
|
// used to panic on the slice. As the last record it is cut, and a canDelete
|
|
// volume keeps taking deletes after the repair.
|
|
func TestVolumeLoadCutsDamagedSmallSizeTailOnCanDeleteVolume(t *testing.T) {
|
|
dir := t.TempDir()
|
|
v := newTornTailTestVolume(t, dir)
|
|
var last *needle.Needle
|
|
for _, id := range []uint64{1, 2, 99} {
|
|
last = newRandomNeedle(id)
|
|
if _, _, _, err := v.writeNeedle2(last, true, false, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := v.PersistReadOnly(true, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
datName, idxName := v.FileName(".dat"), v.FileName(".idx")
|
|
version := v.Version()
|
|
v.Close()
|
|
if err := os.Truncate(idxName, fileSize(t, idxName)-types.NeedleMapEntrySize); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
datBefore := fileSize(t, datName)
|
|
|
|
// a complete-looking record whose header claims a 2-byte body
|
|
damagedSize := types.Size(2)
|
|
record := make([]byte, needle.GetActualSize(damagedSize, version))
|
|
util.Uint32toBytes(record[0:types.CookieSize], 0xabcd)
|
|
util.Uint64toBytes(record[types.CookieSize:types.CookieSize+types.NeedleIdSize], uint64(types.Uint64ToNeedleId(7)))
|
|
util.Uint32toBytes(record[types.CookieSize+types.NeedleIdSize:types.NeedleHeaderSize], uint32(damagedSize))
|
|
dat, err := os.OpenFile(datName, os.O_WRONLY|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := dat.Write(record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := dat.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
v = newTornTailTestVolume(t, dir)
|
|
defer v.Close()
|
|
if v.noWriteOrDelete || !v.noWriteCanDelete {
|
|
t.Fatal("canDelete volume with a damaged-size tail did not load canDelete after the repair")
|
|
}
|
|
assertReadable(t, v, last)
|
|
if got := fileSize(t, datName); got != datBefore {
|
|
t.Fatalf("damaged tail was not cut: .dat is %d bytes, want %d", got, datBefore)
|
|
}
|
|
}
|
|
|
|
// An .idx that ends in a partial entry keeps the existing behaviour:
|
|
// the volume does not load writable and neither file is changed.
|
|
func TestVolumeLoadLeavesPartialIdxEntryAlone(t *testing.T) {
|
|
f := buildTornFixture(t, writes(1, 2, 3), 0)
|
|
idxPartial := fileSize(t, f.idxName) - types.NeedleMapEntrySize/2
|
|
if err := os.Truncate(f.idxName, idxPartial); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
datBefore := fileSize(t, f.datName)
|
|
|
|
v2, err := NewVolume(f.dir, f.dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
|
|
if err == nil {
|
|
t.Logf("partial .idx: volume loaded, noWriteOrDelete=%v", v2.noWriteOrDelete)
|
|
if !v2.noWriteOrDelete {
|
|
v2.Close()
|
|
t.Fatal("volume with a partial .idx entry loaded writable")
|
|
}
|
|
v2.Close()
|
|
} else {
|
|
t.Logf("partial .idx: volume not loaded: %v", err)
|
|
}
|
|
if got := fileSize(t, f.datName); got != datBefore {
|
|
t.Fatalf(".dat changed from %d to %d bytes", datBefore, got)
|
|
}
|
|
if got := fileSize(t, f.idxName); got != idxPartial {
|
|
t.Fatalf(".idx changed from %d to %d bytes", idxPartial, got)
|
|
}
|
|
}
|
|
|
|
// faultyDatFile fails Sync, or the read of a whole record at one offset, the
|
|
// way a failing disk does.
|
|
type faultyDatFile struct {
|
|
backend.BackendStorageFile
|
|
failSync bool
|
|
failRecordAt int64
|
|
failRecordSet bool
|
|
}
|
|
|
|
var errFaultyDisk = errors.New("input/output error")
|
|
|
|
func (f *faultyDatFile) Sync() error {
|
|
if f.failSync {
|
|
return errFaultyDisk
|
|
}
|
|
return f.BackendStorageFile.Sync()
|
|
}
|
|
|
|
func (f *faultyDatFile) ReadAt(p []byte, off int64) (int, error) {
|
|
if f.failRecordSet && off == f.failRecordAt && len(p) > types.NeedleHeaderSize {
|
|
return 0, errFaultyDisk
|
|
}
|
|
return f.BackendStorageFile.ReadAt(p, off)
|
|
}
|
|
|
|
// A read error is not evidence of a torn write: the last record is kept.
|
|
func TestWalkUnindexedDatTailKeepsRecordOnReadError(t *testing.T) {
|
|
f := buildTornFixture(t, writes(1, 2, 3, 4), 1)
|
|
file, err := os.Open(f.datName)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dat := &faultyDatFile{BackendStorageFile: backend.NewDiskFile(file), failRecordAt: f.starts[3], failRecordSet: true}
|
|
defer dat.Close()
|
|
|
|
records, cutAt, err := walkUnindexedDatTail(dat, needle.Version3, f.starts[3], f.end, 0)
|
|
if err == nil {
|
|
t.Fatalf("read error on the last record: walk returned %d record(s) and a cut at %d, want an error", len(records), cutAt)
|
|
}
|
|
if errors.Is(err, errTailNotTorn) {
|
|
t.Fatalf("read error reported as an unexplained tail: %v", err)
|
|
}
|
|
if !errors.Is(err, errFaultyDisk) {
|
|
t.Fatalf("walk error %v does not carry the read error", err)
|
|
}
|
|
}
|
|
|
|
// A repair that changed a file but could not sync it fails the integrity
|
|
// check, so the volume loads read-only rather than writable on an unsynced
|
|
// repair.
|
|
func TestCheckVolumeDataIntegrityFailsOnUnsyncedRepair(t *testing.T) {
|
|
dir := t.TempDir()
|
|
v := newTornTailTestVolume(t, dir)
|
|
var starts []int64
|
|
for _, id := range []uint64{1, 2, 3, 4} {
|
|
at, _, _ := v.DataBackend.GetStat()
|
|
starts = append(starts, at)
|
|
if _, _, _, err := v.writeNeedle2(newRandomNeedle(id), true, false, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
end, _, _ := v.DataBackend.GetStat()
|
|
// persisted read-only so the load below does not repair the tail itself
|
|
if err := v.PersistReadOnly(true, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
datName, idxName := v.FileName(".dat"), v.FileName(".idx")
|
|
v.Close()
|
|
if err := os.Truncate(idxName, fileSize(t, idxName)-types.NeedleMapEntrySize); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Truncate(datName, midRecordBody(starts[3], end)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
v = newTornTailTestVolume(t, dir)
|
|
defer v.Close()
|
|
v.noWriteOrDelete = false
|
|
v.DataBackend = &faultyDatFile{BackendStorageFile: v.DataBackend, failSync: true}
|
|
indexFile, err := os.OpenFile(idxName, os.O_RDWR, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer indexFile.Close()
|
|
|
|
if _, err := CheckVolumeDataIntegrity(v, indexFile); err == nil {
|
|
t.Fatal("integrity check passed although the repaired .dat was not synced")
|
|
}
|
|
}
|
|
|
|
// partialWriteIndex accepts only the first n bytes of a write, as a full disk
|
|
// does.
|
|
type partialWriteIndex struct {
|
|
data []byte
|
|
accept int
|
|
truncated bool
|
|
}
|
|
|
|
func (p *partialWriteIndex) WriteAt(b []byte, off int64) (int, error) {
|
|
n := min(len(b), p.accept)
|
|
if end := int(off) + n; end > len(p.data) {
|
|
p.data = append(p.data, make([]byte, end-len(p.data))...)
|
|
}
|
|
copy(p.data[off:], b[:n])
|
|
if n < len(b) {
|
|
return n, errors.New("no space left on device")
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (p *partialWriteIndex) Truncate(size int64) error {
|
|
p.data = p.data[:size]
|
|
p.truncated = true
|
|
return nil
|
|
}
|
|
|
|
func (p *partialWriteIndex) Sync() error { return nil }
|
|
func (p *partialWriteIndex) Name() string { return "test.idx" }
|
|
|
|
// A failed append leaves no partial entry behind.
|
|
func TestAppendIndexEntriesRemovesPartialWrite(t *testing.T) {
|
|
idxFile := &partialWriteIndex{data: make([]byte, 2*types.NeedleMapEntrySize), accept: types.NeedleMapEntrySize + 5}
|
|
entries := make([]byte, 2*types.NeedleMapEntrySize)
|
|
if err := appendIndexEntries(idxFile, entries, 2*types.NeedleMapEntrySize); err == nil {
|
|
t.Fatal("a partial append reported success")
|
|
}
|
|
if got, want := len(idxFile.data), 2*types.NeedleMapEntrySize; got != want {
|
|
t.Fatalf(".idx is %d bytes after a failed append, want %d", got, want)
|
|
}
|
|
}
|