Start no AS, peer or peering write after the stop deadline (closes #189)
check / check (push) Canceled after 0s

A write can run past the stop deadline while a full batch's wake-up or a
tick waits beside Stop's context. The select picked one at random, so about
half the time the AS, peer or peering handler started another write after
the deadline. Each loop now takes a waiting stop first, as the prefix
handler's does. The prefix, AS and peer handlers' flush loops were
near-copies, so they are now one function, flushLoop. A new test for each
handler fails about half its runs on the old code.

The AS and peer handler tests give the flush timer an interval longer than
the test. A timer flush during the fill can no longer hang
TestPeerHandlerWaitsAtTheMaximumBatchSize.

Model: opus-5-5
This commit was merged in pull request #191.
This commit is contained in:
2026-10-27 14:56:11 +01:00
parent ce19b14275
commit 05b39eee7a
9 changed files with 232 additions and 136 deletions
+14 -34
View File
@@ -56,7 +56,6 @@ type PeerHandler struct {
// is &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when peerBatch is full
lastFlush time.Time // used only by the flush goroutine
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
@@ -75,19 +74,27 @@ type peerUpdate struct {
// periodically flushes accumulated peer updates to the database. The handler
// should be stopped by calling Stop when it is no longer needed.
func NewPeerHandler(db database.Store, logger *logger.Logger) *PeerHandler {
return newPeerHandler(db, logger, peerBatchTimeout)
}
// newPeerHandler is NewPeerHandler with the flush timer's interval,
// batchTimeout, in place of peerBatchTimeout. A test that the timer must not
// disturb gives an interval longer than the test runs.
func newPeerHandler(
db database.Store, logger *logger.Logger, batchTimeout time.Duration,
) *PeerHandler {
h := &PeerHandler{
db: db,
logger: logger,
peerBatch: make([]peerUpdate, 0, peerBatchSize),
batchFullCh: make(chan struct{}, 1),
lastFlush: time.Now(),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go h.flushLoop()
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
return h
}
@@ -173,33 +180,6 @@ func (h *PeerHandler) Stop(ctx context.Context) {
}
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PeerHandler) flushLoop() {
defer close(h.done)
ticker := time.NewTicker(peerBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Since(h.lastFlush) >= peerBatchTimeout {
h.flushBatch()
}
case <-h.batchFullCh:
h.flushBatch()
case ctx := <-h.stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
h.flushBatch()
}
return
}
}
}
// takeBatch returns the pending updates and leaves an empty batch in their
// place for HandleMessage to fill. They count in writing until flushBatch has
// written them.
@@ -220,11 +200,11 @@ func (h *PeerHandler) takeBatch() []peerUpdate {
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
func (h *PeerHandler) flushBatch() {
// holding the mutex during the write. It reports whether there was a batch.
func (h *PeerHandler) flushBatch() bool {
batch := h.takeBatch()
if len(batch) == 0 {
return
return false
}
// The earliest and the latest time the batch saw each peer, with the peer
@@ -255,5 +235,5 @@ func (h *PeerHandler) flushBatch() {
h.writing = 0
h.mu.Unlock()
h.lastFlush = time.Now()
return true
}