Conversation
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2934 +/- ##
===========================================
+ Coverage 57.35% 68.42% +11.07%
===========================================
Files 2095 5 -2090
Lines 172870 456 -172414
===========================================
- Hits 99142 312 -98830
+ Misses 64842 114 -64728
+ Partials 8886 30 -8856
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
sei-db/wal/changelog.go
Outdated
|
|
||
| // Legacy compatibility: originally, BufferSize > 0 implied async writes. Preserve that for | ||
| // callers who set BufferSize without explicitly setting AsyncWrites. | ||
| if config.BufferSize > 0 { |
There was a problem hiding this comment.
If already we have BufferSize to indicate async or not, why do we still need to add another config for AsyncWrites (true/false) here? Isn't that a redundant config?
There was a problem hiding this comment.
Previously, we would only use the buffer when running in async mode. After my refactor, we always use the buffer regardless of whether we are running in async mode. This means we need to configure buffer size independently of async mode configuration.
My reasoning behind always using the buffer is twofold. It reduces complexity, since there is one fewer code pathway. Additionally, it allows for batching (internal) even when running in sync mode (although you need multiple writer goroutines to take advantage of batching in sync mode).
There was a problem hiding this comment.
Let me know if you feel strongly about this. I can revert back to the old pattern of configuration without a huge rewrite.
There was a problem hiding this comment.
Let me take a look at the new code path first for how buffer is being used now, thanks for explanation!
There was a problem hiding this comment.
I already pushed a change to revert to the previous config pattern. We can still use the old pattern, you'd just lose the ability to configure internal buffer sizes when you are running in sync mode. Perhaps not too much of a problem? Since internal buffer size is unlikely to be important super important when running in sync mode.
sei-db/wal/wal.go
Outdated
|
|
||
| bufferSize := config.BufferSize | ||
| if config.BufferSize == 0 { | ||
| bufferSize = 1024 |
There was a problem hiding this comment.
Maybe have a const for these default value? Same as writeBatchSize
sei-db/wal/wal.go
Outdated
| PruneInterval time.Duration | ||
|
|
||
| // If true, the writes are asynchronous, and will return immediately without waiting for the write to be durable | ||
| AsyncWrites bool |
There was a problem hiding this comment.
I feel it sometimes could be confusing to have two configs to control one feature, user don't know which one dominates without looking into the code. Would suggest still keep only one here which is the bufferSize > 0 to indicate whether we are doing async writes or not
There was a problem hiding this comment.
Config reverted to a single setting, as suggested.
sei-db/wal/wal.go
Outdated
| func (walLog *WAL[T]) mainLoop() { | ||
|
|
||
| pruneInterval := walLog.config.PruneInterval | ||
| if pruneInterval < time.Second { |
There was a problem hiding this comment.
What about the case where we want to disable auto pruning? I think previously we have assumption if pruneInterval <= 0 then we will disable pruning, and we still need to support that case right?
There was a problem hiding this comment.
This is still supported, but I can see how it is confusing in its current form. Currently, prune() becomes a noop if it is disabled:
func (walLog *WAL[T]) prune() {
keepRecent := walLog.config.KeepRecent
if keepRecent <= 0 || walLog.config.PruneInterval <= 0 {
// pruning is disabled
return
}
I've refactored the code to make it a more obvious that pruning is disabled.
var pruneChan <-chan time.Time
if walLog.config.PruneInterval > 0 && walLog.config.KeepRecent > 0 {
pruneTicker := time.NewTicker(walLog.config.PruneInterval)
defer pruneTicker.Stop()
pruneChan = pruneTicker.C
}
// ...
case <-pruneChan:
walLog.prune()
The pruneChan is nil if pruning is not configured. <- nil is legal in go, it will just never be selected/executed.
|
I like the single-writer event loop design for writes/truncates. one question, all reads (ReadAt, Replay, FirstOffset, LastOffset) are now also serialized through the mainLoop. In the old code these were direct calls to tidwall/wal. Have you considered keeping reads as direct calls? It would eliminate ~5 request/response types and simplify the code. I can see the argument for keeping them serialized (avoids read/truncate interleave), but given that reads are mostly only called at startup, curious about your thinking here. |
Converted the read methods back into direct calls. |
| } | ||
|
|
||
| // drain pending work, then tear down | ||
| walLog.drain() |
There was a problem hiding this comment.
Shall we do this instead to be safer?
walLog.cancel() // 1. stop accepting new requests
walLog.drain() // 2. finish in-flight work already in the buffer
There was a problem hiding this comment.
Between drain() returning and cancel() being called , the context is still active. A concurrent caller (e.g. Write()) can successfully send a request into the buffered channel because the context check in Write() passes. But mainLoop has already stopped reading — that request is silently dropped and its errChan will never receive a response.
There was a problem hiding this comment.
Note that even if a caller manages to submit a message to the buffer after Close() is called but before the context is cancelled, they will not block forever waiting for a response. When waiting for a response, we abort early if the context is cancelled:
select {
case _, ok := <-walLog.ctx.Done():
if !ok {
return fmt.Errorf("WAL was closed after write was submitted but before write was finalized, " +
"write may or may not be durable")
}
case err := <-req.errChan:
There was a problem hiding this comment.
That being said, I don't think there is any harm in cancelling the context before draining additional work. Change made.
sei-db/wal/wal.go
Outdated
| } | ||
|
|
||
| select { | ||
| case _, ok := <-walLog.ctx.Done(): |
There was a problem hiding this comment.
I think we usually just do this:
case <-walLog.ctx.Done():
return err
There was a problem hiding this comment.
I'm not sure I follow. In this specific case, there is no pre-existing err to return.
In order to make this code easier to read, I created some helper methods for inserting and removing things from channels. Eventually I'll probably extract these methods to a utility package, but for the time being I put them inside wal.go since there are no other callers.
// Push to a channel, returning an error if the context is cancelled before the value is pushed.
func interuptablePush[T any](ctx context.Context, ch chan T, value T) error {
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled: %w", ctx.Err())
case ch <- value:
return nil
}
}
// Pull from a channel, returning an error if the context is cancelled before the value is pulled.
func interuptablePull[T any](ctx context.Context, ch <-chan T) (T, error) {
var zero T
select {
case <-ctx.Done():
return zero, fmt.Errorf("context cancelled: %w", ctx.Err())
case value, ok := <-ch:
if !ok {
return zero, fmt.Errorf("channel closed")
}
return value, nil
}
}
sei-db/wal/wal.go
Outdated
| asyncWriteErrCh: make(chan error, 1), | ||
|
|
||
| bufferSize := config.WriteBufferSize | ||
| if config.WriteBufferSize == 0 { |
There was a problem hiding this comment.
nit: should this be config.WriteBufferSize <= 0? I think memiavl sometimes passes -1 to mean sync mode. With == 0, negative values can still flow into make(chan, size) and panic.
There was a problem hiding this comment.
Yeah seems like it's breaking some unit test
bdchatham
left a comment
There was a problem hiding this comment.
I'm aligned with the channel based approach
|
|
||
| // Shut down the WAL. Sends a close request to the main loop so in-flight writes (and other work) | ||
| // can complete before teardown. Idempotent. | ||
| func (walLog *WAL[T]) Close() error { |
There was a problem hiding this comment.
nit: this isn't really thread safe although the current call pattern is safe. Could be more defensive/explicit to use sync.once. Two racing threads could try to put multiple entries into the channel. One will hang.
There was a problem hiding this comment.
I think this is thread safe even if you have concurrent Close() calls. The first caller will send a message to the background thread. Other callers may become temporarily blocked. But once the background thread cancels the context, all other callers to Close() will stop waiting.
I added a unit test to verify this behavior.
// Calling close lots of times shouldn't cause any problems.
for i := 0; i < 10; i++ {
require.NoError(t, changelog.Close())
}
There was a problem hiding this comment.
Calling it serially is definitely no problem.
The callout is that thread safety of Close() is implicitly dependent on properly handling closeErrChan. If you protect it differently you don't have to have this implicit dependency.
Not a critical problem but wanted to mention the alternative.
| lastOffset++ | ||
| } | ||
|
|
||
| if err := walLog.log.WriteBatch(batch); err != nil { |
There was a problem hiding this comment.
Seems problematic to return the error to all error channels since some of them may have been successfully written. In the case the wal implementation spans multiple segments then you could write sub-batches but return the error only for the last segment.
There was a problem hiding this comment.
I'm not sure it is important to differentiate which writes became durable on disk and which ones didn't within a batch. In general, I'm not sure that this is a recoverable error if the WAL stops being able to accept new writes, since we're probably bumping into something like a full disk.
There was a problem hiding this comment.
Hmm yeah. I don't have as much context as I'd like. Idk entirely how the WAL is used, but my main concern was the possibility of writing something to it but reporting to the caller that there was actually an error and what that caller will do with that signal
Describe your changes and provide context
Ticket: https://linear.app/seilabs/issue/STO-361/improve-wal-library-correctness
This PR fixes several issues identified in this thread: https://sei-network-io.slack.com/archives/C08KPKN3SLT/p1771432603483039?thread_ts=1771432091.719279&cid=C08KPKN3SLT
wal.go's API and semantics. I didn't think that appropriate in this PR.Testing performed to validate your change
I utilized @yzang2019's benchmark. My changes had no impact on performance in some cases, and had a strong positive impact in others.