-
Notifications
You must be signed in to change notification settings - Fork 79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add filewatcher/v2 #654
Add filewatcher/v2 #654
Conversation
|
||
ctx context.Context | ||
cancel context.CancelFunc | ||
result *v2.Result[any] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔕 value rather than pointer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
v2.Result
contains a field of type atomic.Value
so it has copy-protection and can only be passed by pointer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, it recognizes value fields of sync.Mutex
as long as you don't copy the containing struct... this doesn't seem like it should be different. Can you create the struct without copying?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
non-pointer type copy-protected fields are appropriate when you just use their zero values in the constructors directly (e.g. locks, zero atomic values, etc.), for this, since it's a wrapper from v2, when constructing it you need to get the data from v2 and feed into this type, so a copy will always happen.
filewatcher/v2/filewatcher.go
Outdated
d, mtime, files, err := openAndParse(path, parser, softLimit, hardLimit) | ||
if err != nil { | ||
slog.ErrorContext(r.ctx, "filewatcher: openAndParse returned error", "err", err) | ||
} else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔕 return in the if block so you can outdent the else?
if timer == nil { | ||
timer = time.AfterFunc(fsEventsDelay, forceReload) | ||
} else { | ||
timer.Reset(fsEventsDelay) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reset is not safe to call if there is a chance that it might be expiring concurrently (which is basically always)
https://pkg.go.dev/time#Timer.Reset
if !t.Stop() {
<-t.C
}
t.Reset(d)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the timer here was created via time.AfterFunc
, if you look at its doccomment, it says:
The returned Timer's C field is not used and will be nil.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoops, yep. Got the timer and ticker confused.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably a good idea to add a comment about this, I could see someone reading this code later and being confused (since it relies not only on fully understanding what the code is doing, but also a specific line in an entirely separate piece of documentation).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the problem is that both time.NewTimer
and time.AfterFunc
creates a *time.Timer
, but time.Timer.Reset
has this caveat if it's created via time.NewTimer
that you should do the additional check to avoid race condition, but that Reset
itself is wrong (you just must always use it with some additional checks in certain conditions).
so overall I think the better approach is to be very careful to time.NewTimer
instead of being careful to time.Timer.Reset
. in most cases, you use time.NewTimer
to do something after the timer fires, which is better done via time.AfterFunc
which is much safer. or some other cases it might be able to be replaced by time.NewTicker
(cases that the channel is important, for example to be used with select
). I'm not sure if there are some cases you should use time.NewTimer
over the other 2 (maybe there are some very niche cases).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added comments linking back to this thread.
filewatcher/v2/filewatcher.go
Outdated
} | ||
|
||
// The actual data held in Result.data. | ||
type atomicData[T any] struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the name atomicData
to me implies a type that handles the atomicity itself, rather than the data stored inside an atomic.Value.
I would probably call it dataSnapshot
or something to imply that it's the pairing of a data and its mtime
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dataAndMtime
it is :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you want to go that route, dataAtTime
or dataWithTime
or dataAt
would read more smoothly I think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok dataAt
it is then :)
filewatcher/v2/filewatcher.go
Outdated
// Config defines the config to be used in New function. | ||
// | ||
// Can be deserialized from YAML. | ||
type Config struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't even have the Config in the v2 API.
You can have a filewatcherv1.OptionsForV2(Config)
that returns a filewatcherv2.Option
corresponding to whichever of its options were set to provide forward compatibility without having to carry forward the tightly coupled YAML
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I copied v1 code into v2 directory and start modifying them and forgot to remove this one :)
data, mtime, files, err = openAndParse(path, parser, opt.fileSizeLimit, hardLimit) | ||
if errors.Is(err, fs.ErrNotExist) { | ||
lastErr = err | ||
time.Sleep(opt.initialReadInterval) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
debug log?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is the business logic. for the initial load, if the file does not exit, we just sleep for initialReadInterval
and keep retrying.
filewatcher/v2/filewatcher.go
Outdated
data: data, | ||
mtime: mtime, | ||
}) | ||
res.ctx, res.cancel = context.WithCancel(context.Background()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not use the input ctx? or at least have a WithBackgroundContext option? you might not even need your close (unless people want to check its error) of you let the context contol its lifecycle
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the ctx passed in controls how long people are willing to wait for the file to become available (see the doccomment of New
), so it will not last for the whole lifecycle of the background checks (the whole lifecycle of the service/pod).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
but on second thought since we have context.WithoutCancel
now (we don't have that back in the days we implemented v0), it's probably appropriate to base from context.WithoutCancel(ctx)
over context.Background()
. made that change.
filewatcher/v2/filewatcher.go
Outdated
|
||
// NewMockFilewatcher returns a pointer to a new MockFileWatcher object | ||
// initialized with the given io.Reader and Parser. | ||
func NewMockFilewatcher[T any](r io.Reader, parser Parser[T]) (*MockFileWatcher[T], error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would put this in filewatchertest rather than in the main file
filewatcher/v2/filewatcher.go
Outdated
// | ||
// This method is not threadsafe. | ||
func (fw *MockFileWatcher[T]) Update(r io.Reader) error { | ||
data, err := fw.parser(r) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a fake, not a mock
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This took me some time but now I'm fully sold on mock vs fakes.
filewatcher/v2/filewatcher.go
Outdated
// given DirParser. | ||
// | ||
// It provides Update function to update the data after it's been created. | ||
func NewMockDirWatcher[T any](dir fs.FS, parser DirParser[T]) (*MockDirWatcher[T], error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same two notes -- should be filewatchertest.NewFakeDirWatcher
filewatcher/v2/filewatcher.go
Outdated
}) | ||
// remove all previously watched files | ||
for _, path := range watcher.WatchList() { | ||
watcher.Remove(path) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we care about logging the error here at all? Though I feel like it'll make it just unnecessarily noisy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yea those will likely be very noisy.
filewatcher/v2/filewatcher.go
Outdated
// | ||
// This method is not threadsafe. | ||
func (fw *MockFileWatcher[T]) Update(r io.Reader) error { | ||
data, err := fw.parser(r) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This took me some time but now I'm fully sold on mock vs fakes.
|
||
switch ev.Op { | ||
default: | ||
// Ignore uninterested events. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we only ignore fsnotify.Chmod
. Should we?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we don't have anything special to do with Chmod
.
filewatcher/v2/filewatcher_test.go
Outdated
m := make(map[string]string) | ||
if err := fs.WalkDir(dir, ".", func(path string, de fs.DirEntry, err error) error { | ||
if err != nil { | ||
return nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return nil | |
return err |
Unless it's intentionally
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is intentional. in the lambda of WalkDir
when you return non-nil error you stops the walk.
filewatcher/v2/filewatcher.go
Outdated
// Default to DefaultPollingInterval. | ||
// To disable polling completely, set it to a negative value. | ||
// | ||
// Without polling filewatcher relies solely on fs events from the parent |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Without polling filewatcher relies solely on fs events from the parent | |
// Without polling, filewatcher relies solely on fs events from the parent |
filewatcher/v2/filewatcher.go
Outdated
// Please note that this does not include errors returned by the first parser | ||
// call, which will be returned directly. | ||
func New[T any](ctx context.Context, path string, parser Parser[T], options ...Option) (*Result[T], error) { | ||
opt := opts{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could rewrite using the default options and then appending user provided options such as:
opt := opts{}
// Default options
optList := []Option{
WithFSEventsDelay(DefaultFSEventsDelay),
WithPollingInterval(DefaultPollingInterval),
WithInitialReadInterval(DefaultInitialReadInterval),
WithFileSizeLimit(DefaultMaxFileSize),
}
// Default options get overridden by user provided options when processed in order.
optList = append(optList, options...)
or using the WithOptions
note above from @kylelemons
WithOptions(
// Default options
WithFSEventsDelay(DefaultFSEventsDelay),
WithPollingInterval(DefaultPollingInterval),
WithInitialReadInterval(DefaultInitialReadInterval),
WithFileSizeLimit(DefaultMaxFileSize),
// User options
WithOptions(options...),
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, also gave it a test as a replacement for v1 in one of our libraries. Everything is working as expected.
if timer == nil { | ||
timer = time.AfterFunc(fsEventsDelay, forceReload) | ||
} else { | ||
timer.Reset(fsEventsDelay) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoops, yep. Got the timer and ticker confused.
filewatcher/v2/filewatcher.go
Outdated
defer ticker.Stop() | ||
tickerChan = ticker.C | ||
} | ||
var timer *time.Timer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔕
var timer *time.Timer | |
var timer *time.Timer // only populated with time.AfterFunc |
filewatcher/v2/filewatcher.go
Outdated
} | ||
|
||
// The actual data held in Result.data. | ||
type atomicData[T any] struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you want to go that route, dataAtTime
or dataWithTime
or dataAt
would read more smoothly I think.
@@ -188,35 +189,35 @@ func New(ctx context.Context, cfg Config) (*Result, error) { | |||
return &Result{result: result}, nil | |||
} | |||
|
|||
// MockFileWatcher is an implementation of FileWatcher that does not actually read |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is also called a Mock when it's a Fake (or Stub, depending on how you think about "read only" types)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is v1, renaming would be a breaking change.
@@ -225,29 +226,29 @@ func (fw *MockFileWatcher) Stop() {} | |||
// MockDirWatcher is an implementation of FileWatcher for testing with watching | |||
// directories. | |||
type MockDirWatcher struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also a fake/stub not mock
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same here
cdae5ad
to
68797d1
Compare
Being the first lib implemented in baseplate.go and long predates generics, the API of filewatcher lacks type safety and can cause other annoyances. Add filewatcher/v2 that provides: * Generics/type safety * Use io.Closer over Stop() * Use With- options over a Config struct * Use slog at error level over log wrapper And change original filewatcher lib to a thin wrapper of v2. Also add filewatcher/v2/fwtest and move the fakes there. This change intentionally does not change any of its users (experiments, secrets, etc.), as a proof that we did not break v0 API. The next change will switch them to use v2.
Being the first lib implemented in baseplate.go and long predates generics, the API of filewatcher lacks type safety and can cause other annoyances.
Add filewatcher/v2 that provides:
And change original filewatcher lib to a thin wrapper of v2.
This change intentionally does not change any of its users (experiments, secrets, etc.), as a proof that we did not break v0 API. The next change will switch them to use v2.