Skip to content

Commit 3f075e0

Browse files
headerfs: fail gracefully on write
This commit adds recovery mechanism from failures that may happen during headers I/O write
1 parent f779ba8 commit 3f075e0

File tree

2 files changed

+286
-1
lines changed

2 files changed

+286
-1
lines changed

headerfs/file.go

Lines changed: 23 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

78
"github.com/btcsuite/btcd/chaincfg/chainhash"
89
"github.com/btcsuite/btcd/wire"
@@ -16,10 +17,31 @@ type ErrHeaderNotFound struct {
1617

1718
// appendRaw appends a new raw header to the end of the flat file.
1819
func (h *headerStore) appendRaw(header []byte) error {
19-
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 {
2024
return err
2125
}
2226

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 consistency.
31+
// 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 type %s: partial "+
36+
"write (%d bytes), write error: %w, truncate "+
37+
"error: %v", h.indexType, n, err, truncErr)
38+
}
39+
}
40+
41+
return fmt.Errorf("failed to write header type %s: write "+
42+
"error: %w", h.indexType, err)
43+
}
44+
2345
return nil
2446
}
2547

headerfs/file_test.go

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

0 commit comments

Comments
 (0)