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

Reorganize data-fetching code / add tests #4

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

reagent
Copy link
Contributor

@reagent reagent commented Nov 7, 2017

This is a rather big change -- my main goal was to introduce a Client interface to allow for dependency injection when testing. In addition, I reorganized the data structures a bit and was able to remove some duplicate code.

* Added a new `Client` interface to facilitate testing
* Removed duplication when providing meetup names
* Pushed event fetching and sorting to new Schedule struct
* Simplified templates by rendering events inline
* Added tests for `data` package
Copy link
Member

@jasonkeene jasonkeene left a comment

Choose a reason for hiding this comment

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

There was a number of issues with this PR. The main one being the deadlock in Poll. I was thinking perhaps we can make these changes together in a recorded pairing session that gets posted on youtube/twitch. Let me know if that is something that you might be interested in doing.

go test ./...

fmt:
find . -type f -name '*.go' | xargs gofmt -w
Copy link
Member

Choose a reason for hiding this comment

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

You can use goimports -w . to do the same thing here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🆒 TIL

s.mu.Lock()
defer s.mu.Unlock()
return s.meetupSchedule
return data, err
Copy link
Member

Choose a reason for hiding this comment

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

Line 29 and 31 can be merged.

return ioutil.ReadAll(resp.Body)

vs

data, err = ioutil.ReadAll(resp.Body)

return data, err

Copy link
Contributor Author

Choose a reason for hiding this comment

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

derp -- good call.

events := s.poll()
s.updateCache(events)
time.Sleep(s.pollingInterval)
func (c MeetupClient) Get(url string) (data []byte, err error) {
Copy link
Member

Choose a reason for hiding this comment

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

This type only has one method and the method does not use the receiver for anything. This causes me to think if this could just be a function. Passing a function vs an interface into FetchEvents would make mocking easier and is actually more performant.

It might be worth thinking about this: https://www.youtube.com/watch?v=o9pEzgHorH0&t=2m10s

Also, the implementation here has nothing to do with the meetup API. It just makes a GET request and returns the body as a []byte. At the very least a better name should be chosen.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'm bringing too much Ruby over to Go, so creating an interface was my first hammer. I think this is what you're suggesting:

// data.go

type DataFetcher func(string) ([]byte, error)

func FetchData(url string) (data []byte, err error) {
	resp, err := http.Get(url)

	if resp != nil {
		defer resp.Body.Close()
	}

	if err != nil {
		return data, err
	}

	data, err = ioutil.ReadAll(resp.Body)

	return data, err
}

func (s *Schedule) FetchEvents(fetcher DataFetcher) (err error) {
	url := fmt.Sprintf("https://api.meetup.com/%s/events?status=upcoming", s.key)

	data, err := fetcher(url)

	if err != nil {
		return err
	}

	err = json.Unmarshal(data, &s.Events)

	if err != nil {
		return err
	}

	sort.SliceStable(s.Events, func(i, j int) bool {
		return s.Events[i].Time < s.Events[j].Time
	})

	return err
}

Which simplifies the testing side as you mentioned:

// data_test.go
func TestFetchEventsStoresEvents(t *testing.T) {
	s := Schedule{key: "Boulder-Gophers"}

	s.FetchEvents(func(url string) (data []byte, error) {
		return []byte(`[{"id":"id","name":"Event","time":400}]`), nil
	})

	expected := []Event{Event{ID: "id", Name: "Event", Time: 400}}

	if !reflect.DeepEqual(s.Events, expected) {
		t.Fail()
	}
}

I like it.

func (s *Store) Poll() {
for {
s.mu.Lock()
defer s.mu.Unlock()
Copy link
Member

Choose a reason for hiding this comment

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

This has a few problems:

  1. This deadlocks. The mutex is acquired, then it's unlock is deferred. The defer will only run when the function returns or panics. Since this is in an infinite loop this will never occur so only one iteration of this loop will occur.
  2. Using defer inside a loop like this is a common mistake that often leads to a defer leak. This won't leak since only one iteration can occur but that is a bug blocking a bug. See: http://devs.cloudimmunity.com/gotchas-and-common-mistakes-in-go-golang/#anamedeferred_call_exeadeferredfunctioncallexecution
  3. If this did do what you intended, sleeping with a lock acquired should be avoided.
  4. Holding a lock open while doing I/O can cause problems. The code here uses the default http client which can block forever causing a deadlock. Even if that wasn't the case doing I/O with a lock open should be avoided.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The link you provided describes exactly why I implemented it this way -- I think this is another instance of me making changes w/o understanding the underlying design decisions.

Copy link
Member

Choose a reason for hiding this comment

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

It's a very common mistake.

return err
}

sort.SliceStable(s.Events, func(i, j int) bool {
Copy link
Member

Choose a reason for hiding this comment

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

I'm curious as to why you chose to change to a stable sort. It is not needed here and has a performance cost.

I ran some benchmarks to demonstrate this: https://gist.github.com/jasonkeene/3c4d20275ca5c131e63c5f34f0a03815#file-results-txt

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I looked up sort.Slice to learn about it and saw the comment "The sort is not guaranteed to be stable. For a stable sort, use SliceStable.". I reached for SliceStable thinking that in the case where 2 events had the same time, it would preserve the order that they were returned by the API. I didn't know about the performance cost and the possibility of 2 events with the same timestamp seems pretty slim. I'm fine with reverting this.

Copy link
Member

Choose a reason for hiding this comment

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

Stable sort sounds like a good idea then. From what I recall the meetup API does not return very precise timestamps. They are rounded significantly. This increases the chance of timestamps being the same. If that is the case preserving order makes sense.

Good catch!


c := NewClient(`[{"id":"id","name":"Event","time":400}]`, nil)

s.FetchEvents(c)
Copy link
Member

Choose a reason for hiding this comment

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

You should assert that the error returned her is nil.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

]
`, nil)

s.FetchEvents(c)
Copy link
Member

Choose a reason for hiding this comment

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

You should assert that the error returned her is nil.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

type Store interface {
AllEvents() *data.MeetupSchedule
}
// type Store interface {
Copy link
Member

Choose a reason for hiding this comment

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

I wouldn't commit code that is commented out. If it isn't needed just delete it. It is in version control so if it is needed in the future it can be brought back.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I just missed this when I committed -- I can rebase this out.


func Start(addr string, s Store) error {
func Start(addr string, s *data.Store) error {
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering why *data.Store was chosen over Store. Do you need the concrete type?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't see the advantage of an interface in this instance -- what's the best practice here?

Copy link
Member

Choose a reason for hiding this comment

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

Typically, I receive small interfaces. This decouples your code, allows for callers to provide alternative implementations which is good for testing.

That said, passing concrete types is more performant. I only cross that bridge when it is necessary tho.

return &IndexHandler{
store: s,
store: *s,
Copy link
Member

Choose a reason for hiding this comment

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

Dereferencing here makes a copy of the Store. Likely this is not what is intended.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not at all, will need to fix this.

@reagent
Copy link
Contributor Author

reagent commented Nov 26, 2017

Thanks for spending the time doing a thorough review of this PR -- this is exactly what I was hoping for by submitting a larger (but hopefully not overwhelming) change. I'll go back through your comments tomorrow when I have time and respond to each.

@reagent
Copy link
Contributor Author

reagent commented Nov 28, 2017

I'd like to pair on these changes if you have time. I'm not really into the idea of recording it, but I'm open to it if you feel strongly about it. My email is on my profile if you want to coordinate something this week or next. Let me know.

@jasonkeene
Copy link
Member

Cool, we can pair w/o recording. These shouldn't take long. I'll email you in to coordinate.

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