Skip to content

Commit 27977e1

Browse files
authored
Merge pull request #313 from mohamedawnallah/failGracefullyOnHeaderWrite
headerfs: fail gracefully on header write
2 parents ef73743 + b7bf07a commit 27977e1

File tree

4 files changed

+341
-4
lines changed

4 files changed

+341
-4
lines changed

headerfs/file.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package headerfs
33
import (
44
"bytes"
55
"fmt"
6-
"os"
6+
"io"
77

88
"github.com/btcsuite/btcd/chaincfg/chainhash"
99
"github.com/btcsuite/btcd/wire"
@@ -17,10 +17,32 @@ type ErrHeaderNotFound struct {
1717

1818
// appendRaw appends a new raw header to the end of the flat file.
1919
func (h *headerStore) appendRaw(header []byte) error {
20-
if _, err := h.file.Write(header); err != nil {
20+
// Get current file position before writing. We'll use this position to
21+
// revert to if the write fails partially.
22+
currentPos, err := h.file.Seek(0, io.SeekCurrent)
23+
if err != nil {
2124
return err
2225
}
2326

27+
n, err := h.file.Write(header)
28+
if err != nil {
29+
// If we wrote some bytes but not all (partial write),
30+
// truncate the file back to its original size to maintain
31+
// consistency. This removes the partial/corrupt header.
32+
if n > 0 {
33+
truncErr := h.file.Truncate(currentPos)
34+
if truncErr != nil {
35+
return fmt.Errorf("failed to write header "+
36+
"type %s: partial write (%d bytes), "+
37+
"write error: %w, truncate error: %v",
38+
h.indexType, n, err, truncErr)
39+
}
40+
}
41+
42+
return fmt.Errorf("failed to write header type %s: write "+
43+
"error: %w", h.indexType, err)
44+
}
45+
2446
return nil
2547
}
2648

@@ -166,7 +188,7 @@ func (f *FilterHeaderStore) readHeaderRange(startHeight uint32,
166188

167189
// readHeadersFromFile reads a chunk of headers, each of size headerSize, from
168190
// the given file, from startHeight to endHeight.
169-
func readHeadersFromFile(f *os.File, headerSize, startHeight,
191+
func readHeadersFromFile(f File, headerSize, startHeight,
170192
endHeight uint32) (*bytes.Reader, error) {
171193

172194
// Each header is headerSize bytes, so using this information, we'll

headerfs/file_test.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package headerfs
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strings"
10+
"testing"
11+
)
12+
13+
// TestAppendRow verifies that headerStore.appendRaw correctly appends data to
14+
// the file, handles full and partial write errors, and properly recovers from
15+
// failures.
16+
func TestAppendRow(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
initialData []byte
20+
headerToWrite []byte
21+
writeFn func([]byte, File) (int, error)
22+
truncFn func(int64, File) error
23+
expected []byte
24+
wantErr bool
25+
errMsg string
26+
}{
27+
{
28+
name: "ValidWrite AppendsData",
29+
initialData: []byte{0x01, 0x02, 0x03},
30+
headerToWrite: []byte{0x04, 0x05, 0x06},
31+
expected: []byte{
32+
0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
33+
},
34+
},
35+
{
36+
name: "WriteError NoData Preserved",
37+
initialData: []byte{0x01, 0x02, 0x03},
38+
headerToWrite: []byte{0x04, 0x05, 0x06},
39+
writeFn: func(p []byte, _ File) (int, error) {
40+
return 0, errors.New("simulated write failure")
41+
},
42+
expected: []byte{0x01, 0x02, 0x03},
43+
wantErr: true,
44+
errMsg: "simulated write failure",
45+
},
46+
{
47+
name: "PartialWrite MidwayError Rollback",
48+
initialData: []byte{0x01, 0x02, 0x03},
49+
headerToWrite: []byte{0x04, 0x05, 0x06},
50+
writeFn: func(p []byte, file File) (int, error) {
51+
// Mock a partial write - write the first two
52+
// bytes.
53+
n, err := file.Write(p[:2])
54+
if err != nil {
55+
return n, err
56+
}
57+
58+
return n, errors.New("simulated partial " +
59+
"write failure")
60+
},
61+
expected: []byte{0x01, 0x02, 0x03},
62+
wantErr: true,
63+
errMsg: "simulated partial write failure",
64+
},
65+
{
66+
name: "TruncateError CompoundFail",
67+
initialData: []byte{0x01, 0x02, 0x03},
68+
headerToWrite: []byte{0x04, 0x05, 0x06},
69+
writeFn: func(p []byte, file File) (int, error) {
70+
// Mock a partial write - write just the first
71+
// byte.
72+
n, err := file.Write(p[:1])
73+
if err != nil {
74+
return n, err
75+
}
76+
77+
return n, errors.New("simulated partial " +
78+
"write failure")
79+
},
80+
truncFn: func(size int64, _ File) error {
81+
return errors.New("simulated truncate failure")
82+
},
83+
expected: []byte{0x01, 0x02, 0x03, 0x04},
84+
wantErr: true,
85+
errMsg: fmt.Sprintf("failed to write header type %s: "+
86+
"partial write (1 bytes), write error: "+
87+
"simulated partial write failure, truncate "+
88+
"error: simulated truncate failure", Block),
89+
},
90+
{
91+
name: "PartialWrite TruncateFail Unrecovered",
92+
initialData: []byte{0x01, 0x02, 0x03},
93+
headerToWrite: []byte{0x04, 0x05, 0x06},
94+
writeFn: func(p []byte, file File) (int, error) {
95+
// Mock a partial write - write the first two
96+
// bytes.
97+
n, err := file.Write(p[:2])
98+
if err != nil {
99+
return n, err
100+
}
101+
102+
return n, errors.New("simulated partial " +
103+
"write failure")
104+
},
105+
truncFn: func(size int64, file File) error {
106+
// Simulate an incomplete truncation: shrink the
107+
// file by just one byte, leaving part of the
108+
// partial write data in place in other words
109+
// not fully removing the partially written
110+
// header from the end of the file.
111+
err := file.Truncate(4)
112+
if err != nil {
113+
return err
114+
}
115+
116+
return errors.New("simulated truncate failure")
117+
},
118+
expected: []byte{0x01, 0x02, 0x03, 0x04},
119+
wantErr: true,
120+
errMsg: fmt.Sprintf("failed to write header type "+
121+
"%s: partial write (2 bytes), write error: "+
122+
"simulated partial write failure, truncate "+
123+
"error: simulated truncate failure", Block),
124+
},
125+
{
126+
name: "NormalWrite ValidHeader DataAppended",
127+
initialData: []byte{},
128+
headerToWrite: []byte{0x01, 0x02, 0x03},
129+
expected: []byte{0x01, 0x02, 0x03},
130+
},
131+
}
132+
133+
for _, test := range tests {
134+
t.Run(test.name, func(t *testing.T) {
135+
// Create a temporary file for testing.
136+
tmpFile, cleanup := createFile(t, "header_store_test")
137+
defer cleanup()
138+
139+
// Write initial data.
140+
_, err := tmpFile.Write(test.initialData)
141+
if err != nil {
142+
t.Fatalf("Failed to write initial "+
143+
"data: %v", err)
144+
}
145+
146+
// Reset the file position to the end of initial data.
147+
_, err = tmpFile.Seek(
148+
int64(len(test.initialData)), io.SeekStart,
149+
)
150+
if err != nil {
151+
t.Fatalf("Failed to seek: %v", err)
152+
}
153+
154+
// Create a mock file that wraps the real file.
155+
mockFile := &mockFile{
156+
File: tmpFile,
157+
writeFn: test.writeFn,
158+
truncFn: test.truncFn,
159+
}
160+
161+
// Create a header store with our mock file.
162+
h := &headerStore{
163+
file: mockFile,
164+
headerIndex: &headerIndex{indexType: Block},
165+
}
166+
167+
// Call the function being tested.
168+
err = h.appendRaw(test.headerToWrite)
169+
if err == nil && test.wantErr {
170+
t.Fatal("expected an error, but got none")
171+
}
172+
if err != nil && !test.wantErr {
173+
t.Fatalf("unexpected error: %v", err)
174+
}
175+
if err != nil && test.wantErr &&
176+
!strings.Contains(err.Error(), test.errMsg) {
177+
178+
t.Errorf("expected error message %q to be "+
179+
"in %q", test.errMsg, err.Error())
180+
}
181+
182+
// Reset file position to start for reading.
183+
if _, err := tmpFile.Seek(0, io.SeekStart); err != nil {
184+
t.Fatalf("Failed to seek to start: %v", err)
185+
}
186+
187+
// Read the file contents.
188+
actualData, err := io.ReadAll(tmpFile)
189+
if err != nil {
190+
t.Fatalf("Failed to read file: %v", err)
191+
}
192+
193+
// Compare expected vs. actual file contents.
194+
if !bytes.Equal(actualData, test.expected) {
195+
t.Fatalf("Expected file data: %v, "+
196+
"got: %v", test.expected, actualData)
197+
}
198+
})
199+
}
200+
}
201+
202+
// BenchmarkHeaderStoreAppendRaw measures performance of headerStore.appendRaw
203+
// by writing 80-byte headers to a file and resetting position between writes
204+
// to isolate raw append performance from file size effects.
205+
func BenchmarkHeaderStoreAppendRaw(b *testing.B) {
206+
// Setup temporary file and headerStore.
207+
tmpFile, cleanup := createFile(b, "header_benchmark")
208+
defer cleanup()
209+
210+
store := &headerStore{
211+
file: tmpFile,
212+
headerIndex: &headerIndex{indexType: Block},
213+
}
214+
215+
// Sample header data.
216+
header := make([]byte, 80)
217+
218+
// Reset timer to exclude setup time.
219+
b.ResetTimer()
220+
221+
// Run benchmark.
222+
for i := 0; i < b.N; i++ {
223+
if err := store.appendRaw(header); err != nil {
224+
b.Fatal(err)
225+
}
226+
227+
// Reset file position to beginning to maintain constant file
228+
// size. This isolates the appendRaw performance overhead
229+
// without measuring effects of increasing file size.
230+
if _, err := tmpFile.Seek(0, io.SeekStart); err != nil {
231+
b.Fatal(err)
232+
}
233+
}
234+
}
235+
236+
// mockFile wraps a real file but allows us to override the Write, Sync, and
237+
// Truncate methods.
238+
type mockFile struct {
239+
*os.File
240+
writeFn func([]byte, File) (int, error)
241+
syncFn func() error
242+
truncFn func(int64, File) error
243+
}
244+
245+
// Write implements the Write method for FileInterface.
246+
func (m *mockFile) Write(p []byte) (int, error) {
247+
if m.writeFn != nil {
248+
return m.writeFn(p, m.File)
249+
}
250+
return m.File.Write(p)
251+
}
252+
253+
// Sync implements the Sync method for FileInterface.
254+
func (m *mockFile) Sync() error {
255+
if m.syncFn != nil {
256+
return m.syncFn()
257+
}
258+
return m.File.Sync()
259+
}
260+
261+
// Truncate implements the Truncate method for FileInterface.
262+
func (m *mockFile) Truncate(size int64) error {
263+
if m.truncFn != nil {
264+
return m.truncFn(size, m.File)
265+
}
266+
return m.File.Truncate(size)
267+
}
268+
269+
// Ensure mockFile implements necessary interfaces.
270+
var _ io.Writer = &mockFile{}
271+
272+
// createFile creates a temporary file for testing.
273+
func createFile(t testing.TB, filename string) (*os.File, func()) {
274+
tmpFile, err := os.CreateTemp(t.TempDir(), filename)
275+
if err != nil {
276+
t.Fatalf("Failed to create temp file: %v", err)
277+
}
278+
279+
cleanup := func() {
280+
tmpFile.Close()
281+
os.Remove(tmpFile.Name())
282+
}
283+
284+
return tmpFile, cleanup
285+
}

headerfs/index.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ const (
7474
numSubBucketBytes = 2
7575
)
7676

77+
// String returns the string representation of the HeaderType.
78+
func (h HeaderType) String() string {
79+
switch h {
80+
case Block:
81+
return "Block"
82+
case RegularFilter:
83+
return "RegularFilter"
84+
default:
85+
return fmt.Sprintf("UnknownHeaderType(%d)", h)
86+
}
87+
}
88+
7789
// headerIndex is an index stored within the database that allows for random
7890
// access into the on-disk header file. This, in conjunction with a flat file
7991
// of headers consists of header database. The keys have been specifically

headerfs/store.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package headerfs
33
import (
44
"bytes"
55
"fmt"
6+
"io"
67
"os"
78
"path/filepath"
89
"sync"
@@ -84,6 +85,23 @@ var headerBufPool = sync.Pool{
8485
New: func() interface{} { return new(bytes.Buffer) },
8586
}
8687

88+
// File defines the minimum file operations needed by headerStore.
89+
type File interface {
90+
// Basic I/O operations.
91+
io.Reader
92+
io.Writer
93+
io.Closer
94+
95+
// Extended I/O positioning.
96+
io.Seeker
97+
io.ReaderAt
98+
99+
// File-specific operations.
100+
Stat() (os.FileInfo, error)
101+
Sync() error
102+
Truncate(size int64) error
103+
}
104+
87105
// headerStore combines a on-disk set of headers within a flat file in addition
88106
// to a database which indexes that flat file. Together, these two abstractions
89107
// can be used in order to build an indexed header store for any type of
@@ -96,7 +114,7 @@ type headerStore struct {
96114

97115
fileName string
98116

99-
file *os.File
117+
file File
100118

101119
*headerIndex
102120
}

0 commit comments

Comments
 (0)