Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Repair can truncate valid large records, and pre-existing copy files briefly expose WAL data before permissions are tightened.
2 open findings
What changed in this PR
Bounds WAL record sizes to prevent oversized allocations and secures repair-copy permissions.
Changes:
- Adds
ErrRecordTooBigand enforces record-size limits during reads and appends. - Makes repair copies owner-only and adds regression tests.
| File | Description |
|---|---|
pkg/wal/writeaheadlog.go |
Enforces configured record limits. |
pkg/wal/reader.go |
Adds bounded record reading. |
pkg/wal/reader_test.go |
Tests oversized records. |
pkg/wal/util.go |
Tightens repair-copy permissions. |
pkg/wal/util_test.go |
Tests private repair copies. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
fd9c4eb to
c180e4a
Compare
| vc.HandleMessage(2, msg2) | ||
|
|
||
| reasonAboutInFlightLowerThanLatestDecision.Wait() | ||
|
|
There was a problem hiding this comment.
no reason for this change here in this PR
c180e4a to
7b80b7d
Compare
| var ( | ||
| ErrCRC = errors.New("wal: crc verification failed") | ||
| ErrWALUnmarshalPayload = errors.New("wal: failed to unmarshal payload") | ||
| ErrRecordTooBig = errors.New("wal: record too big") |
There was a problem hiding this comment.
ErrRecordTooBig on the last file should go through repair like the other torn-write errors — ReadAll (lines 576-578) doesn't list it, so what used to surface as io.ErrUnexpectedEOF (short ReadFull) and auto-repair now fails startup. scanRepairFile already truncates on any non-EOF error, it just needs to be reachable.
if index == lastIndex &&
(errors.Is(readErr, io.ErrUnexpectedEOF) ||
errors.Is(readErr, ErrCRC) ||
errors.Is(readErr, ErrWALUnmarshalPayload) ||
errors.Is(readErr, ErrRecordTooBig)) {| assert.ErrorIs(t, err, ErrRecordTooBig) | ||
| assert.Nil(t, record) | ||
| assert.NoError(t, r.Close()) | ||
| }) |
There was a problem hiding this comment.
Worth a case in TestWriteAheadLogFile_Repair as well — assertTestRepair only does assert.Error on the first ReadAll, so it can't tell a repairable error from a fatal one. This fails on the current branch and passes with the ReadAll change:
t.Run("oversized record header", func(t *testing.T) {
dirPath := filepath.Join(testDir, "oversized")
assert.NoError(t, os.MkdirAll(dirPath, walDirPermPrivateRWX))
wal, err := Create(logger, dirPath, &Options{FileSizeBytes: 10 * 1024, BufferSizeBytes: 2048})
assert.NoError(t, err)
const NumBytes = 1024
const NumRec = 102
data1 := make([]byte, NumBytes)
for m := range NumRec {
for n := range NumBytes {
data1[n] = byte(m)
}
assert.NoError(t, wal.Append(data1, false))
}
assert.NoError(t, wal.Close())
// append a header claiming a ~4GB record to the last file
names, err := dirReadWalNames(dirPath)
assert.NoError(t, err)
lastFile := filepath.Join(dirPath, names[len(names)-1])
f, err := os.OpenFile(lastFile, os.O_RDWR, walFilePermPrivateRW)
assert.NoError(t, err)
_, err = f.Seek(0, io.SeekEnd)
assert.NoError(t, err)
header := make([]byte, recordHeaderSize)
binary.LittleEndian.PutUint64(header, 0xFFFFFFFF)
_, err = f.Write(header)
assert.NoError(t, err)
assert.NoError(t, f.Close())
wal, err = Open(logger, dirPath, &Options{FileSizeBytes: 10 * 1024, BufferSizeBytes: 2048})
assert.NoError(t, err)
_, err = wal.ReadAll()
assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.NoError(t, wal.Close())
assertTestRepair(t, logger, dirPath, NumRec)
})7b80b7d to
44353a7
Compare
| errors.Is(readErr, ErrCRC) || | ||
| errors.Is(readErr, ErrWALUnmarshalPayload)) { | ||
| errors.Is(readErr, ErrWALUnmarshalPayload) || | ||
| errors.Is(readErr, ErrRecordTooBig)) { |
There was a problem hiding this comment.
Note that trying to repair on any of these errors may lead the application (fabric) to start smartbft in a state from the past even before a committed record (block). For example, if the file is corrupt somewhere before a TruncateTo(true) control record, you may succeed to "repair" the file, but the end result will be you start smartbft with a wal record that was already committed.
The repair mechanism is against a torn write on the last record, repairing any other corruption is not so easy. We assume that the error is on the last record only. Any other corruption is not necessarily repairable.
Take a look at the test cases in TestWriteAheadLogFile_Repair - only on the tail.
The safe option is to throw away the node and start a new one.
There was a problem hiding this comment.
I suggest checking and fixing it only if the error is in the tail. Throw out the rest.
44353a7 to
fea3f94
Compare
b5b0337 to
8b41335
Compare
|
@HagarMeir @C0rWin @tock-ibm I've made all the corrections. If there are any more, please write. If there is any doubt that this pr is needed at all, also, please write. |
I will review again, thanks @pfi79 |
tock-ibm
left a comment
There was a problem hiding this comment.
Thanks for the updates, @pfi79. The size bound, the ErrRecordTooBig → repair path and the private .copy all look good, and limiting repair to the tail is the right direction. I re-reviewed 8b41335 with the analysis below in mind. Most of it is about whether isTornTail really separates a torn last record from corruption in the middle of the file.
Blocking: a lossy repair is still possible
readableRecordsFollow only reports "records follow" if the chain is clean all the way to EOF. After a crash, the last record is usually torn. So a corrupt record in the middle plus a torn tail (the likely combination), or a second corruption further on, makes every probe return false. isTornTail then returns true, and the file is truncated at the first bad record. This is the case I raised in the earlier thread: starting smartbft from a state before a committed record.
Reproduced on this branch: 20 records of 1KB, flip one CRC bit of R1, truncate the last 100 bytes (tearing R19). Repair() returns nil and the file shrinks from 20716 to 1056 bytes. R2..R18, which were synced and possibly committed, are silently dropped.
Repro test
func TestRepairMidCorruptPlusTornTail(t *testing.T) {
basicLog, _ := zap.NewDevelopment()
logger := basicLog.Sugar()
dir := t.TempDir()
w, err := Create(logger, dir, &Options{FileSizeBytes: 1024 * 1024})
assert.NoError(t, err)
for range 20 {
assert.NoError(t, w.Append(make([]byte, 1024), false))
}
assert.NoError(t, w.Close())
names, _ := dirReadWalNames(dir)
last := filepath.Join(dir, names[0])
r, err := NewLogRecordReader(logger, last)
assert.NoError(t, err)
_, err = r.Read() // R0
assert.NoError(t, err)
off, _ := r.logFile.Seek(0, io.SeekCurrent)
assert.NoError(t, r.Close())
f, err := os.OpenFile(last, os.O_RDWR, walFilePermPrivateRW)
assert.NoError(t, err)
b := make([]byte, 1)
_, _ = f.ReadAt(b, off+5)
b[0] ^= 1
_, _ = f.WriteAt(b, off+5) // corrupt R1's CRC
fi, _ := f.Stat()
assert.NoError(t, f.Truncate(fi.Size()-100)) // tear R19
assert.NoError(t, f.Close())
assert.ErrorIs(t, Repair(logger, dir), ErrNotRepairable) // fails: nil, file truncated to 1056 bytes
}Suggested fix (details inline):
- Accept as soon as one CRC link is verified, i.e. a record whose CRC chains from the previous record's header. Don't require a clean run to EOF.
- Set
haveCRConly afterverifyCRCsucceeds. - When the bad record's header is intact, look first at
offset + 8 + len + pad, the exact position of the next record, before falling back to probing every offset. - Add the test above.
Other torn-tail cases that are still not handled
These were reproduced on this branch. They are not regressions, but they belong to the same story. Either fix them here or say in the description that they're out of scope:
- A full header with 0 payload bytes.
io.ReadFullreturnsio.EOF, notErrUnexpectedEOF, soReadAlltreats it as clean and the leftover header is never repaired.readPayloadcould turn thatio.EOFintoio.ErrUnexpectedEOF. - A garbage tail that decodes to an unknown type (e.g. Type=7).
wal: unexpected LogRecord_Typeisn't onReadAll's repairable list, so startup fails. - A torn CRC anchor in a new last file (crash during
saveCRC).Open()→checkWalFilesreturnsio.ErrUnexpectedEOF, butInitializeAndReadAllonly callsRepairfor errors fromReadAll()(writeaheadlog.go:785), so startup fails.
(A zero-filled tail is recovered correctly.)
Smaller items
copyFiledoesn'tSync()the copy before the original is truncated or deleted (inline).- Probing every 8-byte offset and re-walking the chain each time can be O(n²) reads and allocations on a 64MB file (inline).
- The bound in
readHeaderuses the whole file size, not the bytes left after the current position (inline). - An older bug in a touched function: in the loop of
scanRepairFile, a failingSeek(util.go:334) returns withoutr.Close(). On Windows the leaked handle then blocks the truncate. - The
Repair()doc comment (writeaheadlog.go:286) still says "we use pre-allocated / recycled files". Files are created fresh withO_CREATEand truncated on close, so this is no longer true, and it hides the fact that a torn write is the only expected source of a bad tail. - The PR description mentions only the size bound and the permissions. Please also describe the mid-file /
ErrNotRepairablebehavior change, since it changes when a node can restart on its own.
|
|
||
| if payloadLength == 0 || pos+int64(recordHeaderSize)+payloadLength > fileSize { | ||
| // Torn or out of bounds, the chain ends here. | ||
| return false, nil |
There was a problem hiding this comment.
This ends the chain as "not following" when the probe reaches a torn or out-of-bounds record, even if valid chained records were already verified before it. Combined with a torn last record (the usual state after a crash), every probe fails and isTornTail returns true, so a mid-file corruption gets truncated (see the repro in the review body). Once at least one CRC link has been verified, this should return true, not false.
|
|
||
| if haveCRC && !verifyCRC(prevCRC, crc, payload) { | ||
| // The chain is broken: from here on there is nothing we can trust. | ||
| return false, nil |
There was a problem hiding this comment.
Same issue: a CRC break later in the chain throws away the links already verified before it. A second corruption anywhere after the first makes the repair lossy. Suggest returning true once one link has been verified, and only returning false here when no link has been verified yet.
| } | ||
|
|
||
| prevCRC = crc | ||
| haveCRC = true |
There was a problem hiding this comment.
haveCRC is set after the first record, whose CRC can't be checked (it depends on the corrupt record). So a single random record that decodes and ends exactly at EOF counts as "records follow", which contradicts the doc comment above. Suggest setting a verified flag only after verifyCRC succeeds, and returning that flag.
| switch record.Type { | ||
| case protos.LogRecord_ENTRY, protos.LogRecord_CONTROL, protos.LogRecord_CRC_ANCHOR: | ||
| default: | ||
| return false, nil |
There was a problem hiding this comment.
Nit: CRC_ANCHOR is accepted in the middle of the chain and CRC-checked as if it were a data record. In the real reader an anchor appears only first in a file, and its header CRC is the previous CRC, not a CRC over its payload. This is one example of readableRecordsFollow drifting from LogRecordReader.Read. Could it reuse a reader positioned at pos with a seeded CRC instead of re-implementing the parsing?
|
|
||
| fileSize := fileInfo.Size() | ||
|
|
||
| for offset := recordStart + int64(recordHeaderSize); offset+int64(recordHeaderSize) <= fileSize; offset += int64(recordHeaderSize) { |
There was a problem hiding this comment.
When the corrupt record's header is intact (only its payload or CRC is bad), the next record starts exactly at recordStart + 8 + len + pad. Checking that position first gives a precise answer, and the probing of every offset is then only needed when the length itself is corrupt.
Also on cost: up to fileSize/8 starting offsets, each re-walking the chain with a fresh ReadAt and allocations, is O(n²) in the worst case. On a 64MB file that's ~8M probes at startup. Reading the remainder once into a buffer and scanning in memory would avoid it.
| return err | ||
| } | ||
|
|
||
| return output.Close() |
There was a problem hiding this comment.
The copy is the only evidence once scanRepairFile truncates or deletes the original, but it's never fsynced. A power loss right after the repair can leave the original truncated and the .copy empty or partial. Suggest calling output.Sync() before Close().
|
|
||
| // A record, together with its header, must fit in the file it is read from. | ||
| // Otherwise a corrupted header makes us allocate up to 4GB. | ||
| if int64(length)+int64(getPadSize(int(length))) > r.fileSizeBytes-int64(recordHeaderSize) { |
There was a problem hiding this comment.
Minor: this compares against the whole file size, not the bytes left after the current position. A corrupt header near the end of a 64MB file can still declare ~64MB and drive that allocation before ReadFull fails. Bounding by fileSizeBytes - pos would reject it immediately. The check in readPayload (line 188) is then redundant, since every caller passes a length already bounded here.
8b41335 to
4a310c4
Compare
fixed |
…ive a ~4GB allocation on Open. Reject such records with ErrRecordTooBig, both on read and on append. Repair copies are now 0600, and an existing .copy gets chmod'ed, since WriteFile does not touch its mode. **Repair no longer performs lossy repairs.** `Repair()` now truncates the last file only when the corruption is at the tail (the last record is torn). When a corrupt record is followed by readable records, the repair is refused with `ErrNotRepairable` and the file is left untouched (a `.copy` is still saved). Previously `Repair()` silently truncated the file at the first bad record, which could drop records that were already synced and possibly committed. This changes whether a node can restart on its own: a mid-file corruption is no longer repaired automatically. **Torn-tail detection.** `isTornTail` now decides "torn" using the same rules as `LogRecordReader.Read`: it accepts a chain as soon as one CRC link is verified (instead of requiring a clean chain to EOF), probes the exact offset of the next record first when the bad header is intact, and reseeds the chain on a `CRC_ANCHOR`. This makes the repair correct for the usual crash state - a corrupt record in the middle plus a torn tail. **Record-size bound.** `readHeader` bounds a record against the bytes left in the file (`fileSizeBytes - pos`), so a corrupt header near the end of a 64MB file is rejected before the payload allocation. A header that cannot fit the file at all is reported as `ErrRecordTooBig`; a record that only outgrows the remaining bytes is still `io.ErrUnexpectedEOF` (a torn tail, the repairable error). The redundant check in `readPayload` is removed. Signed-off-by: Fedor Partanskiy <fredprtnsk@gmail.com>
4a310c4 to
9f17f2a
Compare

A corrupt or forged record header could claim 0xFFFFFFFF bytes and drive a
~4GB allocation on Open. Reject such records with ErrRecordTooBig, both on
read and on append. Repair copies are now 0600, and an existing .copy gets
chmod'ed, since WriteFile does not touch its mode.
Repair no longer performs lossy repairs.
Repair()now truncates thelast file only when the corruption is at the tail (the last record is torn).
When a corrupt record is followed by readable records, the repair is refused
with
ErrNotRepairableand the file is left untouched (a.copyis stillsaved). Previously
Repair()silently truncated the file at the first badrecord, which could drop records that were already synced and possibly
committed. This changes whether a node can restart on its own: a mid-file
corruption is no longer repaired automatically.
Torn-tail detection.
isTornTailnow decides "torn" using the same rulesas
LogRecordReader.Read: it accepts a chain as soon as one CRC link isverified (instead of requiring a clean chain to EOF), probes the exact offset
of the next record first when the bad header is intact, and reseeds the chain
on a
CRC_ANCHOR. This makes the repair correct for the usual crash state -a corrupt record in the middle plus a torn tail.
Record-size bound.
readHeaderbounds a record against the bytes left inthe file (
fileSizeBytes - pos), so a corrupt header near the end of a 64MBfile is rejected before the payload allocation. A header that cannot fit the
file at all is reported as
ErrRecordTooBig; a record that only outgrows theremaining bytes is still
io.ErrUnexpectedEOF(a torn tail, the repairableerror). The redundant check in
readPayloadis removed.