Skip to content
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

Merged
merged 1 commit into from
May 24, 2024
Merged

Add filewatcher/v2 #654

merged 1 commit into from
May 24, 2024

Conversation

fishy
Copy link
Member

@fishy fishy commented May 22, 2024

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.

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.

@fishy fishy requested a review from a team as a code owner May 22, 2024 23:07
@fishy fishy requested review from kylelemons, konradreiche and mathyourlife-reddit and removed request for a team May 22, 2024 23:07

ctx context.Context
cancel context.CancelFunc
result *v2.Result[any]
Copy link
Contributor

Choose a reason for hiding this comment

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

🔕 value rather than pointer?

Copy link
Member Author

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.

Copy link
Contributor

@kylelemons kylelemons May 24, 2024

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?

Copy link
Member Author

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.

d, mtime, files, err := openAndParse(path, parser, softLimit, hardLimit)
if err != nil {
slog.ErrorContext(r.ctx, "filewatcher: openAndParse returned error", "err", err)
} else {
Copy link
Contributor

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)
Copy link
Contributor

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)

Copy link
Member Author

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.

Copy link
Contributor

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.

Copy link
Contributor

@pacejackson pacejackson May 24, 2024

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).

Copy link
Member Author

@fishy fishy May 24, 2024

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).

Copy link
Member Author

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.

}

// The actual data held in Result.data.
type atomicData[T any] struct {
Copy link
Contributor

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

Copy link
Member Author

Choose a reason for hiding this comment

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

dataAndMtime it is :)

Copy link
Contributor

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.

Copy link
Member Author

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 :)

// Config defines the config to be used in New function.
//
// Can be deserialized from YAML.
type Config struct {
Copy link
Contributor

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

Copy link
Member Author

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

debug log?

Copy link
Member Author

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.

data: data,
mtime: mtime,
})
res.ctx, res.cancel = context.WithCancel(context.Background())
Copy link
Contributor

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

Copy link
Member Author

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).

Copy link
Member Author

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.


// 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) {
Copy link
Contributor

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

//
// This method is not threadsafe.
func (fw *MockFileWatcher[T]) Update(r io.Reader) error {
data, err := fw.parser(r)
Copy link
Contributor

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

Copy link
Member

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.

// 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) {
Copy link
Contributor

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

})
// remove all previously watched files
for _, path := range watcher.WatchList() {
watcher.Remove(path)
Copy link
Member

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.

Copy link
Member Author

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.

//
// This method is not threadsafe.
func (fw *MockFileWatcher[T]) Update(r io.Reader) error {
data, err := fw.parser(r)
Copy link
Member

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.
Copy link
Member

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?

Copy link
Member Author

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.

m := make(map[string]string)
if err := fs.WalkDir(dir, ".", func(path string, de fs.DirEntry, err error) error {
if err != nil {
return nil
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return nil
return err

Unless it's intentionally

Copy link
Member Author

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 Show resolved Hide resolved
// Default to DefaultPollingInterval.
// To disable polling completely, set it to a negative value.
//
// Without polling filewatcher relies solely on fs events from the parent

Choose a reason for hiding this comment

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

Suggested change
// Without polling filewatcher relies solely on fs events from the parent
// Without polling, filewatcher relies solely on fs events from the parent

// 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{

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...),
)

Copy link
Member

@konradreiche konradreiche left a 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)
Copy link
Contributor

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.

defer ticker.Stop()
tickerChan = ticker.C
}
var timer *time.Timer
Copy link
Contributor

Choose a reason for hiding this comment

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

🔕

Suggested change
var timer *time.Timer
var timer *time.Timer // only populated with time.AfterFunc

}

// The actual data held in Result.data.
type atomicData[T any] struct {
Copy link
Contributor

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
Copy link
Contributor

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)

Copy link
Member Author

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 {
Copy link
Contributor

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

Copy link
Member Author

Choose a reason for hiding this comment

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

same here

@fishy fishy force-pushed the filewatcher-v2 branch 3 times, most recently from cdae5ad to 68797d1 Compare May 24, 2024 16:41
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.
@fishy fishy force-pushed the filewatcher-v2 branch from 68797d1 to 4eee114 Compare May 24, 2024 16:43
@fishy fishy merged commit 622dd70 into master May 24, 2024
2 checks passed
@fishy fishy deleted the filewatcher-v2 branch May 24, 2024 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants