Skip to content

Truncate the WAL whenever no proposal is in flight - #735

Draft
pfi79 wants to merge 1 commit into
hyperledger:mainfrom
pfi79:truncate-wal-on-sync
Draft

pfi79 wants to merge 1 commit into
hyperledger:mainfrom
pfi79:truncate-wal-on-sync

Conversation

@pfi79

@pfi79 pfi79 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Watching the stands, I found that some nodes are accumulating wal files, while they are quite working and efficient. The height of the blocks is normal. I think it's just that there are no signals for such nodes to trim the wal files. Below is my suggestion to change the situation:

The WAL only freed space at a truncation point, which only a pre-prepare carried. A node that never proposed never deleted a file: files rotated by size, truncateIndex stayed zero, and restarts accumulated anchor-only files.

PersistedState.Save now derives the truncation flag from whether a proposal is in flight rather than from the message type, so NewView after sync or view change and ViewChange with nothing in flight also truncate. A commit never does, keeping a pre-prepare and its commit together. Open also drops trailing anchor-only files, so restarts stop adding files.

#736

@pfi79
pfi79 marked this pull request as draft October 23, 2026 12:41
@pfi79
pfi79 force-pushed the truncate-wal-on-sync branch 2 times, most recently from 6329c7f to aa13246 Compare October 23, 2026 13:04
@pfi79
pfi79 marked this pull request as ready for review October 23, 2026 13:12

@HagarMeir HagarMeir left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. I left 10 inline comments. The main concern: nothing stops the controller from restarting a view after the ViewChanger has left it (via the sync → changeView path). That leaves the new truncation logic open to WAL sequences that fail recovery ([NewView, Commit] / [PP, ViewChange, Commit]). It also accounts for the timing dependence in state.go. Separately, the new AbortChan case in Decide can drop committed decisions, AbortView is now blocking, and clearing InFlight under Condition B discards prepared state. The last three comments are nits.

This review was written by Claude (an AI assistant) on behalf of @HagarMeir.

// irrelevant, and either way there is nothing left to recover from it. Clearing it here
// makes the new view record a truncation point, by the rule PersistedState.Save applies.
if v.InFlight != nil {
v.InFlight.clear()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race: the aborted view can be restarted before this NewView is saved.

processNewViewMsg relies on AbortView to keep the old view stopped while it clears InFlight and writes a truncating NewView record. Nothing stops the controller from restarting that view after AbortView returns and before the save.

Scenario: AbortView returns, then the heartbeat monitor calls Sync(). On the controller loop, sync() returns (0,0,0) and run() calls changeView(currentViewNumber, ...), which restarts the stopped view. The restarted view gets a pre-prepare and saves a ProposedRecord, which stores the in-flight. The VC then clears InFlight and saves NewView with truncateTo=true, which drops that pre-prepare, and the restarted view appends a Commit. The WAL now ends [NewView, Commit]. On restart recoverPrepared returns "expected second last message to be a pre-prepare" and NewProposer panics. The in-flight the live view still needs has also been cleared.

I think this needs a guard that keeps the controller from restarting a view the view-changer has already left.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

Comment thread internal/bft/viewchanger.go Outdated
// pre-prepare in the WAL. AbortView waits for the view to stop, and it also stops
// the view before the view data message is prepared below. If the controller has
// already moved to a newer view, this view change is stale.
if !v.Controller.AbortView(v.currView) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering this comment claims isn't enforced.

Moving AbortView before the ViewChange save is meant to stop a commit from landing after that record, but the controller can still restart the aborted view (the sync/changeView path) before Save runs.

Scenario: AbortView(v.currView) returns true. The controller loop takes a pending syncChan token and changeView restarts the stopped view at the same number. That view writes PP(s), the VC writes ViewChange(v), then the view writes Commit(s). The WAL ends [PP, ViewChange, Commit], and recovery fails because entries[-2] is not a pre-prepare.

This is the same root cause as the comment on processNewViewMsg. A left-view guard would cover both.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

Comment thread internal/bft/viewchanger.go Outdated
// the view before the view data message is prepared below. If the controller has
// already moved to a newer view, this view change is stale.
if !v.Controller.AbortView(v.currView) {
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The restore path can silently drop a restored view change.

With restore=true and SyncOnStart=true: the node restarts with a ViewChange as the last WAL record, and the controller's syncOnStart advances to view w > NextView and calls InformNewView, but the VC run loop's select picks Restore first. AbortView(NextView) returns false (view < currView), so this early return exits after startViewChange has already set nextView/checkTimeout, without sending ViewData or clearing votes. Before this change the code continued past this point.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

// In case we are in the middle of shutting down,
// abort deciding.
return
case <-view.AbortChan():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A committed decision can be dropped.

Adding case <-view.AbortChan() to the first select of Decide drops a committed decision whenever the view is aborted, even when decisionChan has buffer room and the decision would previously have been queued and delivered.

Scenario: the view reaches decide() (startNextSeq has already run and the Commit is persisted) just as the controller calls Abort. Both select cases are ready and Go picks one at random, so about half the time the decision is discarded: Deliver is not called, the checkpoint stays at s-1, and ViewSequences already says s+1. The node then needs a view change or sync to deliver s. On the sync path (abortCurrentView) there is no view change to "recover" the proposal, so the comment's claim does not hold there.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

// not be overtaken by a record written by the view, such as a truncating new
// view record. It returns false when the view is older than the current one or
// when the controller is stopping.
func (c *Controller) AbortView(view uint64) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AbortView now blocks the ViewChanger run loop.

AbortView used to be a buffered fire-and-forget send. Now it blocks until the controller loop handles the request, and the controller loop can be inside a long Synchronizer.Sync()/fetchState or an application Deliver.

Scenario: the controller is in sync() doing a long state transfer when a ViewChange quorum arrives. The VC loop blocks in AbortView, so ticks and view-change timeouts are not processed and incMsgs fills. HandleMessage then blocks the comm goroutine that also feeds the Collector, so fetchState's StateTransferResponses cannot arrive and it times out.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

Comment thread internal/bft/viewchanger.go Outdated
}

// The quorum has decided the fate of the proposal in flight: it is either committed or
// irrelevant, and either way there is nothing left to recover from it. Clearing it here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This clears the node's own prepared in-flight proposal.

InFlight is now cleared unconditionally, including under Condition B (noInFlight). That throws away this node's own prepared in-flight proposal, a protocol-relevant change made only to make the NewView record a truncation point.

Scenario: the node is prepared on X at seq s in view v, and the NewView for v+1 is accepted under Condition B (a quorum excluding this node reported nothing prepared). InFlight is cleared, so if v+1 fails before any proposal, this node's ViewData for v+2 no longer carries X/prepared. This seems to conflict with the in-flight carry-over rules in the GHSA-x46p fix (don't drop prepared state), and it ties WAL cleanup to view-change safety. Could the truncation decision be made without mutating InFlight here?

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

Comment thread internal/bft/state.go Outdated
// A commit never is: its pre-prepare has to survive even when another goroutine prunes the
// in-flight proposal (Controller.sync, through maybePruneInFlight) while this save is running,
// so the flag for a commit is not read off that shared state.
truncateTo := msgToSave.GetProposedRecord() != nil ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truncation decision depends on goroutine timing.

The truncation flag for NewView/ViewChange is read from shared InFlightData, which the VC (clear) and the controller (maybePruneInFlight) both change outside the controller loop.

Scenario: after the VC's AbortView returns, the controller loop can run sync(), whose maybePruneInFlight clears in-flight while the VC is saving a ViewChange, or the reverse. Whether that record truncates the WAL then depends on scheduling rather than on a stable checkpoint. A ProposedRecord saved by a restarted view between the read and the Append can be truncated away.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

// abortCurrentView stops the current view and waits for it to finish. It is
// called from the controller loop, which also owns abortViewChan, so it must
// not post an abort request to that channel.
func (c *Controller) abortCurrentView() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: abortCurrentView re-implements abortView (relinquishLeaderToken + currView.Abort) without the view-number check. Could it call c.abortView(c.getCurrentViewNumber()) instead? Otherwise any future change to abortView, such as logging, metrics or a left-view guard, won't apply to the sync path.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

Comment thread pkg/wal/writeaheadlog.go Outdated
//
// The last file of the WAL is kept even when it is an anchor-only one: an empty WAL still has to be
// a WAL of at least one file, both for Open() to have something to open and for ReadAll() to read.
func (w *WriteAheadLogFile) removeAnchorOnlyTailFiles(dirPath string, walNames []string) ([]string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: removeAnchorOnlyTailFiles keeps walNames and activeIndexes in parallel only to recompute w.index from walNames[0] (which it never changes), and it reopens every tail file that checkWalFiles has just opened. The root cause, ReadAll always creating a fresh file, is left in place. Would it be simpler for ReadAll/deleteAndCreateFile to reuse the last file when it's anchor-only instead of creating index+1? That would stop the accumulation at its source and make this cleanup pass on Open unnecessary.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

Comment thread internal/bft/viewchanger.go Outdated
// ViewController controls the view
//
//go:generate mockery -dir . -name ViewController -case underscore -output ./mocks/
//go:generate mockery --dir . --name ViewController --case underscore --output ./mocks/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: only this go:generate line was switched to mockery v2 flag syntax, and only its mock was regenerated (with v2.53.8). Every other directive in the package still uses v1 single-dash flags and v1-generated mocks, so go generate ./internal/bft/... now needs two incompatible mockery versions (v2 rejects -dir/-name, v1 rejects --dir). Either keep v1 here or migrate them all together.

(Written by Claude, an AI assistant, on behalf of @HagarMeir.)

@HagarMeir

Copy link
Copy Markdown
Contributor

Thanks for looking into the WAL file count. Some thoughts on the description and on how to move forward:

Could you open an issue with a reproduction? It would help to see the CountOfFiles metric over time, an ls -l of the WAL directory of an affected node, and how often that node restarts. Nothing in the tracker reports this yet.

I think the cause is different from what the description says. The description says "a node that never proposed never deleted a file", but the truncation point is not leader-only. Every replica saves the pre-prepare with truncateTo=true when it accepts it (View.processProposal → State.Save(ProposedRecord) → WAL.Append(b, true)). So a healthy node at normal block height gets a truncation point every sequence, and its older files are deleted at the next file switch. Also, without pre-prepares a node writes almost nothing, so it can't reach the 64 MB rotation size.

What I believe actually grows the file count is restarts with no pre-prepare between them. On each restart ReadAll ends with deleteAndCreateFile(), which opens a fresh file holding only a CRC anchor. Files are deleted only if they come before truncateIndex, so each such restart leaves one more file. The new integration test, TestRestartingNodeThatDoesNotProposeDoesNotGrowWAL, reproduces exactly this: a partitioned node in a restart loop. The files are tiny, because they're truncated to their used size on close, so the impact is mostly the file count and startup read time, not disk space.

Suggestion: split the PR in two.

  1. A pkg/wal-only fix. Either drop trailing anchor-only files on Open, or (maybe simpler) have ReadAll/deleteAndCreateFile reuse the last file when it holds only the anchor instead of creating index+1. This is self-contained and low-risk, and I think it fixes the reported symptom by itself.
  2. Truncating on NewView/ViewChange (the old TODO in state.go). This one changes consensus behaviour: AbortView becomes blocking, Decide gets an abort case, InFlight is cleared on NewView, and a sync abort path is added. As described in my review comments, it also needs a guard that keeps the controller from restarting a view the ViewChanger has already left. Otherwise the WAL can end in [NewView, Commit] or [PP, ViewChange, Commit], which recovery rejects. It deserves its own PR and a description of these behaviour changes.

Happy to review either part.

This comment was written by Claude (an AI assistant) on behalf of @HagarMeir.

@HagarMeir

Copy link
Copy Markdown
Contributor

To check the restart theory, I ran a quick experiment on main against pkg/wal alone. "Restart" here means InitializeAndReadAll + Close, and "decision" means Append(pp, true) + Append(commit, false):

Scenario WAL files
A. 10 restarts, nothing written in between 2 → 3 → … → 11 (one more per restart)
B. 10 restarts, one decision between each stays at 2
C. no restarts, 2000 decisions, FileSizeBytes: 4096 (many file switches) stays at 2

So the growth is real, but it happens only when a node restarts without a pre-prepare between restarts: a partitioned or crash-looping node, one that only catches up via sync, or one stuck in view changes. A node that keeps accepting pre-prepares, leader or follower, doesn't grow, and the accumulated files are deleted as soon as one pre-prepare arrives.

That makes me think the pkg/wal change alone (don't add a fresh anchor-only file on each restart) fixes the reported symptom. The consensus-side changes (truncating on NewView/ViewChange, blocking AbortView, clearing InFlight on NewView) don't seem needed for it. Given the risks in my review comments, I'd suggest landing the WAL fix on its own first.

This comment was written by Claude (an AI assistant) on behalf of @HagarMeir.

@pfi79
pfi79 force-pushed the truncate-wal-on-sync branch from aa13246 to 2467d9d Compare October 25, 2026 14:50
@pfi79
pfi79 marked this pull request as draft October 25, 2026 14:51
@pfi79

pfi79 commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for this. I left 10 inline comments. The main concern: nothing stops the controller from restarting a view after the ViewChanger has left it (via the sync → changeView path). That leaves the new truncation logic open to WAL sequences that fail recovery ([NewView, Commit] / [PP, ViewChange, Commit]). It also accounts for the timing dependence in state.go. Separately, the new AbortChan case in Decide can drop committed decisions, AbortView is now blocking, and clearing InFlight under Condition B discards prepared state. The last three comments are nits.

This review was written by Claude (an AI assistant) on behalf of @HagarMeir.

Your AI agent is looking at the repository in an interesting way.

  1. Share the promt?
  2. Maybe add AGENTS.md to the repository?

@HagarMeir

Copy link
Copy Markdown
Contributor

Thanks for this. I left 10 inline comments. The main concern: nothing stops the controller from restarting a view after the ViewChanger has left it (via the sync → changeView path). That leaves the new truncation logic open to WAL sequences that fail recovery ([NewView, Commit] / [PP, ViewChange, Commit]). It also accounts for the timing dependence in state.go. Separately, the new AbortChan case in Decide can drop committed decisions, AbortView is now blocking, and clearing InFlight under Condition B discards prepared state. The last three comments are nits.
This review was written by Claude (an AI assistant) on behalf of @HagarMeir.

Your AI agent is looking at the repository in an interesting way.

  1. Share the promt?
  2. Maybe add AGENTS.md to the repository?

no special prompt
maybe specific for this PR since I know the code and know what to ask and what to focus on
I can try to create a md file in a separate PR

@pfi79
pfi79 force-pushed the truncate-wal-on-sync branch from 2467d9d to 52b8c55 Compare October 26, 2026 08:37
@HagarMeir

Copy link
Copy Markdown
Contributor

The WAL part of this PR is now in #739. Once that merges, could you rebase this one so it carries only the internal/bft truncation changes?

Written by Claude (an AI assistant) on behalf of @HagarMeir.

@pfi79

pfi79 commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

The WAL part of this PR is now in #739. Once that merges, could you rebase this one so it carries only the internal/bft truncation changes?

Written by Claude (an AI assistant) on behalf of @HagarMeir.

I do rebase every time. but I removed it in the draft in order to try to do all the work in small words more understandable.

@pfi79
pfi79 force-pushed the truncate-wal-on-sync branch 2 times, most recently from e4f77c5 to a24e0ad Compare October 28, 2026 12:46
The WAL only freed space at a truncation point, which only a pre-prepare
carried. A node that never proposed never deleted a file: files rotated by
size and truncateIndex stayed zero.

PersistedState.Save now derives the truncation flag from whether a proposal
is in flight rather than from the message type, so NewView after sync or view
change and ViewChange with nothing in flight also truncate. A commit never
does, keeping a pre-prepare and its commit together.

Signed-off-by: Fedor Partanskiy <fredprtnsk@gmail.com>
@pfi79
pfi79 force-pushed the truncate-wal-on-sync branch from a24e0ad to b853746 Compare October 28, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants