A cautionary tale about wasting money on Google Cloud

I was saved by rate limits. The bill still came out to around $13k, but it could easily have been $4.2MM.

tl;dr: Understanding cloud pricing - and cloud architecture - is critical to avoiding nasty surprises. I made some unsubstantiated  assumptions and got bitten. Also: BigQuery is not like a normal database.

The scenario was surprisingly simple. I wanted to do a big "group by" on about 9 billion rows of data. I had loaded the data into an unpartitioned BigQuery table on Google Cloud, which ended up being about 1.2 TB in total size. After the group-by step, I would be doing some other reformatting and analysis, so I had decided to use Google Cloud Dataflow for the grouping. There were about 700,000 evenly sized groups in the data. The group by on dataflow was freezing up, so I decided to mix the dataflow pipeline with another that would find the unique groups first, then make one query per group manually into big query (first red flag: this feels like a hack). Big query scales linearly by dataset size right? So no big deal?

Here’s the problem. Big query charges per query, which in turn scales linearly by the size of the data. So I was going to be making 700,000 queries against a 1.2 TB Big Query dataset. Had big query not rate limited me on this I would have spent $5/Tb/query * 1.2 Tb * 700,000 queries = $4.2MM.

Yikes! Google forgave the $13k, which I appreciated.


Distraction is a big problem

One of the biggest problems in the world today might be "distraction". The alternative to distraction is concentration, and concentration is how we get things done. This is almost tautological, because if we suffer from distraction, we are not focusing on the right things. If we were focusing on the right things, we would not be distracted right? So what are the right things? How do we make space for ourselves to concentrate on the right things?

Reading code for fun and profit

Reading code for fun and profit

Reading a lot of code has made me a better engineer. I'm going to try to record here some of the techniques for reading code effectively that I've picked up over the years.

I'm guessing that most who attempt it quickly realize that reading code like reading a novel is a good way to get discouraged. A novel is broken up into chapters which are meant to be read through linearly. Code is generally structured non-linearly, so it requires a different set of approaches.

Sometime in the future, I'll write a post about how to write readable code, but today I want to focus on reading code. So in the likely scenario that it's a long time before I get around to writing that post, rest assured that knowing how to read code will almost certainly make you better at writing code.

These suggestions are colored by my experience with python and javascript, but for the most part I think that these techniques are fairly language agnostic.

General tips

  1. Don't start with the code. A first bit of advice on reading code: for the most part, don't! Start by reading the docs. While fun and interesting, reading code is often much less efficient than reading documentation. Documentation will often give you the high level foundation you need to understand the code, should you choose to go deeper. For instance, before you dive into the git git mirror, read the docs on git internals!
  2. Read code like a computer executes code. Start at entrypoints (function calls, CLI commands, main routines) and follow references. Make abundant use of grep, ctrl-F, and GitHub search. This will help you be a bit more agnostic to the folder and file structures, which are often rather arbitrary and can be misleading. I've cumulatively wasted hours clicking or cd and ls -ing through folder structures looking for some entrypoint or another only to remember eventually that I could have just searched.
  3. Follow contributing guides. Many projects have contributing guides which will show you how to get a local version up and how to run the tests, which is a great way to discover entrypoints.
  4. Know the language. Confusion about syntax will get in the way of clearer understanding. Sometimes the structure of the language can have an effect on how code is organized (e.g., __init__.py files or *.h files). Also remember that you can also learn a lot even if you don't understand every bit of syntax you encounter.
  5. Start high level. Skimming quickly to understand interfaces will help you see the big picture before you get lost in the details. Be willing to say, "I'll come back to that function - for now I'll just trust that it does what it says it does".
  6. Think about why it's structured the way it is. Code is written in the way it is for a reason. If you keep this in mind even when you are reading poorly structured code, it will be easier to understand the authors' intent.

Reviewing your own code

Taking a step back and reviewing your own code is a great way improve the way you write software.

I took a writing class in college and the main concept they were trying to drill into us was the value of revisions. We would have to write and submit the same essay multiple times - first draft, second draft, final draft - with weeks of revision and discussion in between. Each time, the essay would improve. All too often, the thesis statement an author starts with in the introduction is not the thesis statement the author ends with in the conclusion. When that happens, authors who know what they are doing rewrite the essay with the new thesis.

This happens in code as well. As a reviewer of your own code, you must be willing and eager to delete code. Do not be a victim of the sunk-cost fallacy and get too attached to code you've written. If this is a struggle, deleting code becomes much easier when a thorough test suite is in place, which can give you confidence you need to refactor regularly. All else equal, less code is better than more code.

The process of reviewing your own code with a critical eye can help you find opportunities for abstractions that you missed on the most recent draft. I often start by looking for repeated patterns or by talking directly to the users of the abstractions and interfaces I wrote, about which ones were most useful or confusing.

I find it productive to try to answer some of the following questions

  1. What are the main data structures and abstractions I'm using in this code?
  2. How could data structures or abstractions be changed to enable this project to be simpler, more precise and easier to contribute to?
  3. Have I written enough tests to know that I can refactor without fear?
  4. Would it be easy for others to contribute to or maintain this code? In other words, could they make changes without fear of breaking things?

Reviewing contributions from others

Reviewing contributions made by others is an important part of building a project. This is a big topic, so I'll just mention one key point here that has made my code review astronomically more effective.

Use a checklist!

Whenever possible, this checklist should be in version control so that it is known by all contributors. Sometimes it makes sense to put this into a CONTRIBUTING file, and if using GitHub, it makes sense to put this in a .github/PULL_REQUEST_TEMPLATE.md. The latter is what I use in the eemeter library. I find it very convenient that GitHub auto-populates the descriptions of all new pull requests using that template because it helps contributors be proactive.

Using a checklist should not add a burden to contributors or reviewers. If it is created with care, it should make it as easy as possible for contributors to comply with contributing guidelines. Consider including at least the following in such a checklist for code review:

  1. Does the code conform to the style guide? There are many options for automated code style checking available for many different languages. Some are configurable, some aren't, some just point out issues, some can proactively fix them. Providing instructions here about how to run the automated style checker makes it easy for contributors to write code that conforms. Consider how this could save you from wasting valuable review time and effort discussing the minutia of style-guide conformance.
  2. Did the contributor run the existing test suites and add their own? Similarly, this should proactively give instructions to the contributor about how to run the existing automated test suite.
  3. Did the contributor follow the correct branching, committing, and merging procedures? The checklist should point out where to find instructions for properly executing these procedures.
  4. Did the contributor add appropriate documentation and changelog entries describing their work? Instructions for how to build or contribute to documentation would be appropriate here.

Using this checklist will free you to focus on the highest-value review criteria as you look through the code diff or the branches you're comparing.

  1. Is the code written in a way that takes advantage of appropriate existing abstractions?
  2. Does the code introduce any new complexity?
  3. Are the existing interfaces respected to ensure backwards compatibility, if necessary?

Reading code because the documentation doesn't cut it

Reading code to learn how to use it is often a last resort after you've read the documentation and come up dry. Maybe you are trying to extend the library and you want to look at some examples of how to use the base class that the library implements.

Let me illustrate with an example. I make heavy use of the Django REST Framework python library for, as you may have guessed, writing REST APIs for Django. It has excellent documentation, and it also has a very readable code base.

The library documentation for "Concrete View Classes" describe very clearly the methods provided by those classes and the classes from which they inherit. This documentation is helpful and describes exactly how to use these classes.

The following classes are the concrete generic views. If you're using generic views this is normally the level you'll be working at unless you need heavily customized behavior.

The view classes can be imported from rest_framework.generics.

CreateAPIView

  • Used for create-only endpoints.
  • Provides a post method handler.
  • Extends: GenericAPIView, CreateModelMixin

ListAPIView

  • Used for read-only endpoints to represent a collection of model instances.
  • Provides a get method handler.
  • Extends: GenericAPIView, ListModelMixin

These concrete view classes are available in specific, commonly used configurations. Looking at the code for these view classes shows exactly the same picture, but makes it also clear that these classes are tiny and very simple mappings from HTTP methods to model-related actions, which makes it more obvious, in my opinion, how to use and extend these classes, and how to learn more about what they do.

# Concrete view classes that provide method handlers
# by composing the mixin classes with the base view.

class CreateAPIView(mixins.CreateModelMixin,
                    GenericAPIView):
    """
    Concrete view for creating a model instance.
    """
    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)


class ListAPIView(mixins.ListModelMixin,
                  GenericAPIView):
    """
    Concrete view for listing a queryset.
    """
    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

In this case, the code is almost exactly the same length as the documentation itself, which makes for a pretty good insight-to-effort ratio.

Reading code to learn how to imitate it

This final category of reading code is, in my opinion, the most difficult, but also the most interesting. I have learned a ton reading through the code of libraries or tools that I frequently use, or which are written by developers whose work I admire.

For example:

  • Reading through the requests library taught me that the main API is organized around a single function (request) with a consistent interface, for which the get/post/patch/put/etc... methods are a really simple wrapper. In retrospect this makes a lot of sense, as they must internally share a lot of logic. It's also really intersting that the get/post/patch/put/etc... methods exist at all - the immediacy, intuitiveness, and discoverability of the interace matters a lot for usability.
  • Poking around in the pandas library test suite showed me a bunch of examples of tests, and how useful it can be to write specialized functions specifically for testing that help to capture a ton of repeated testing logic.
  • Trying to figure out something with timezones a while back I was led to the pytz package source code. I mention it here because it is the most unusual python package I have ever had the good fortune of looking through. But it is extraordinarily useful and tons of popular packages rely on it. It's so ubiquitous that I was initially surprised to learn that it wasn't a built in package. Here it is in all its glory, mirrored on GitHub.
  • Curiosity about how the cPython sort function worked internally lead me to find the source code and also this fascinating text file describing it as a "timsort". That's the same Tim (Peters) who wrote the Zen of Python, which I find quite inspiring.

Poking through libraries (I wish I could remember which ones) when I was just starting out writing Python showed me:

  1. How to use setup.py for packaging python projects
  2. That pytest with tox is a pretty popular test runner configuration
  3. That the convention for internal functions is to put an underscore before the function name
  4. That the package six was used a lot for python 2/3 compatibility
  5. That sphinx is a really popular way of building documentation
  6. That it's pretty common to do a bunch of from module import * in __init__.py files specifically
  7. That decorators exist and metaclasses exist

Reading code to learn from it is like doing a puzzle. Or like doing an orienteering course. Or like reading a history book. Or like going down a rabbit hole. Or like deep sea fishing. You may also be trawling for unfamiliar concepts, or for useful tools, or for more effective processes. All of these fish may be found in the vast ocean of open source repositories waiting to be explored.








I reached a cooking milestone!

Yesterday I reached a cooking milestone: I cooked a full, fairly original, tasty meal without recipe! I'm recording that here so that momentous event is not lost to the annals of history.

We had mackerel filets (skin on of course) which had been cured in miso for a day or so then roasted. This was my first time cooking mackerel (h/t Hungry Monkey book) and it was really easy - mackerel is very forgiving and hard to overcook. Since Savannah is growing a baby, we did a bit of research and determined that the mackerel we were eating was an Atlantic mackerel, which is fortunately quite safe and has a low risk of mercury. There are a few other kinds of mackerel out there that are less safe and presumably more carnivorous. This mackerel was very bony, so It might be good next time to learn to de-bone them, assuming that is possible.

We also had a half kabocha squash with olive oil, salt and pepper, roasted in the oven until it was a bit too dry. I think next time I would put a little water on the roasting pan or not cook it quite as long.

Finally, and this was what I was most proud of, we had a little veggie stir fry based on fresh shiitake mushrooms (about 10), which I chopped into fingernail-sized pieces and cooked down in a little oil, salt and water, then stirred in some green onions (white bottoms half cooked, half not cooked, then green tops also thrown in at the end, with a half a head of chopped cabbage and cooked lightly and finished with a little lemon juice (1/2 a lemon), rice vinegar (just a drip) and miso (about a tablespoon). Most of the measurements were eyeballed, though I would probably measure things out if I were to make it again.

All these ingredients came from the local Japanese market, which is our closest(!) grocery store.

Oh and we ate all this with rice. That I have admittedly been able to cook for quite a while.



The hive mind

The other day a few of us went for a hike in the San Gabriel Mountains. Hiking anywhere close to LA is not as solitary of an activity as it might be elsewhere, and this trail certainly was not empty. The hike led to a beautiful waterfall which had a bit more water than usual, presumably due to recent rain, and a moment or two after reaching it we found some unclaimed rocks and sat down.

We had been sitting there for a moment or two resting and taking in the beauty when we looked at each other because we had realized at the same moment that the folks next to us were having roughly the same conversation we had had a few days back. This was a remarkably normal event, and we almost didn't say anything about it.

"Weren't we just...?"

"Yeah."

Whereas a few years ago I might have broken into the strangers conversation and mentioned the funny the coincidence, this time I didn't bother. It felt a little strange to be reminded that we had so much in common with the nameless millions surrounding us in LA, and probably across the US.

How many of us read the same headlines, see the same advertisements, watch the same shows, listen to the same music, use the same apps? How does this affect us as a society?

I'm sure there is quite a lot of variation across and within demographics, but the menu of choices in front of us might not be as extensive as we'd like to believe.

We might actually be better off in this regard than we were a decade or two ago, with more YouTube and less PBS, more podcasts and less public radio.

But I wonder if we aren't all just making the same fairly predictable choices?

A strategy for actually useful test coverage

Test coverage measures the lines or statements of source code your tests execute. Although it may not tell you much about the individual quality of those tests, it can give you a sense for how thorough they are.

If you have never worked on a project with 100% test coverage, my guess is that you are in the majority. Not many projects have 100% test coverage. It is really difficult, and not always practical, to test every single line of code.

When it can be achieved though, it has some significant benefits. Aside from the obvious - well-tested code is generally less buggy and more maintainable than poorly tested code - there are some more subtle benefits as well. For instance, the visual difference between 99% and 100% test coverage in coverage reports is pretty striking. That alone can be a strong motivator for maintaining a high level of coverage, especially since coverage reports can point out exactly which lines aren't covered.

Let me illustrate what that looks like in practice. Here's what a coverage regression looks like (you may have to scroll to the right to see it):

----------- coverage: platform linux, python 3.6.6-final-0 -----------
Name                                     Stmts   Miss  Cover   Missing
----------------------------------------------------------------------
eemeter/__init__.py                         19      0   100%
eemeter/__version__.py                       9      0   100%
eemeter/caltrack/__init__.py                 4      0   100%
eemeter/caltrack/design_matrices.py         23      0   100%
eemeter/caltrack/hourly.py                  42      0   100%
eemeter/caltrack/usage_per_day.py          483      1    99%   744
eemeter/cli.py                              44      0   100%
eemeter/derivatives.py                     134      0   100%
eemeter/exceptions.py                       12      0   100%
eemeter/features.py                        253      0   100%
eemeter/io.py                               52      0   100%
eemeter/metrics.py                          78      0   100%
eemeter/samples/__init__.py                  2      0   100%
eemeter/samples/load.py                     31      0   100%
eemeter/segmentation.py                    106      0   100%
eemeter/transform.py                        83      0   100%
eemeter/visualization.py                    36      0   100%
eemeter/warnings.py                         11      0   100%
tests/conftest.py                           21      0   100%
tests/test_caltrack_design_matrices.py      56      0   100%
tests/test_caltrack_hourly.py               53      0   100%
tests/test_caltrack_usage_per_day.py       832      0   100%
tests/test_cli.py                           46      0   100%
tests/test_derivatives.py                  202      0   100%
tests/test_exceptions.py                    22      0   100%
tests/test_features.py                     436      0   100%
tests/test_io.py                           121      0   100%
tests/test_metrics.py                      147      0   100%
tests/test_samples.py                       31      0   100%
tests/test_segmentation.py                 112      0   100%
tests/test_transform.py                    144      0   100%
tests/test_version.py                        4      0   100%
tests/test_visualization.py                 61      0   100%
tests/test_warnings.py                       9      0   100%
----------------------------------------------------------------------
TOTAL                                     3719      1    99%

Here's what it looks like fixed:

----------- coverage: platform linux, python 3.6.6-final-0 -----------
Name                                     Stmts   Miss  Cover   Missing
----------------------------------------------------------------------
eemeter/__init__.py                         19      0   100%
eemeter/__version__.py                       9      0   100%
eemeter/caltrack/__init__.py                 4      0   100%
eemeter/caltrack/design_matrices.py         23      0   100%
eemeter/caltrack/hourly.py                  42      0   100%
eemeter/caltrack/usage_per_day.py          483      0   100%
eemeter/cli.py                              44      0   100%
eemeter/derivatives.py                     134      0   100%
eemeter/exceptions.py                       12      0   100%
eemeter/features.py                        253      0   100%
eemeter/io.py                               52      0   100%
eemeter/metrics.py                          78      0   100%
eemeter/samples/__init__.py                  2      0   100%
eemeter/samples/load.py                     31      0   100%
eemeter/segmentation.py                    106      0   100%
eemeter/transform.py                        83      0   100%
eemeter/visualization.py                    36      0   100%
eemeter/warnings.py                         11      0   100%
tests/conftest.py                           21      0   100%
tests/test_caltrack_design_matrices.py      56      0   100%
tests/test_caltrack_hourly.py               53      0   100%
tests/test_caltrack_usage_per_day.py       844      0   100%
tests/test_cli.py                           46      0   100%
tests/test_derivatives.py                  202      0   100%
tests/test_exceptions.py                    22      0   100%
tests/test_features.py                     436      0   100%
tests/test_io.py                           121      0   100%
tests/test_metrics.py                      147      0   100%
tests/test_samples.py                       31      0   100%
tests/test_segmentation.py                 112      0   100%
tests/test_transform.py                    144      0   100%
tests/test_version.py                        4      0   100%
tests/test_visualization.py                 61      0   100%
tests/test_warnings.py                       9      0   100%
----------------------------------------------------------------------
TOTAL                                     3731      0   100%

Doesn't that look nice? As you can see, the exact nature and severity of coverage regressions is immediately apparent when you're starting from 100% coverage. I can go look at eemeter/caltrack/usage_per_day.py line 744 and see there is a single uncovered statement, which happens to be a block of code that issues a warning.

So what gives? Why don't more projects have 100% coverage?

The problem is that true 100% test coverage is really hard to achieve in practice, especially if it hasn't been the standard since the start of the project. And even though it's usually possible with a lot of hard work, after some point you get diminishing returns. Consider: is writing more tests for code that is already pretty maintainable really better than working on anything else? Not always. So most projects encourage writing tests, but don't bother mandating 100% coverage. I don't blame them.

But there is a strategy for getting to 100% coverage which I think often gets overlooked. And it is a lot easier that writing real tests. The strategy is to add markers in your code that indicate to your coverage counter to ignore all untested blocks of code.

This may sound pretty extreme. In projects with pretty low test coverage, this can feel like cheating - especially if the project maintainers consider test coverage to be primarily a metric. And for the purpose of the metric, it probably is cheating!

But I think that attitude is generally misses a key insight: that test coverage is primarily a tool, and secondarily a metric. This subtle difference illuminates why an aggressive “ignoring” strategy helps test coverage really shine as a tool.

Here's the system I've found works best:

  1. Write tests as normal. There's no need to write more tests than you usually would for this strategy to work (although this strategy may encourage and enable that).
  2. Configure coverage to report line-by-line. Sometimes this is enabled by default, but if it's not, turn it on. This gives a detailed picture of the state of the test coverage and lets you figure out where you're missing tests. At first this extra information can be entirely overwhelming, but this is a temporary flaw.
  3. Systematically add markers to source files to ignore test coverage for all uncovered code. This should get you up to “100%” coverage. Try to add these markers as unobtrusively as possible. For instance, often you can add these markers at the just the start of a block and that will cause the whole block to be ignored. In the coverage counter I use, this is marker is # pragma: no cover. If you're overwhelmed, you may find it helpful to work file by file. That's what I did and it was surprisingly painless. Ideally you should also add a comment or two about why the code is not tested, even if its just "I haven't gotten around to it yet." This will help newcomers understand what's going on.
  4. Always restore 100% coverage when adding new code, preferably by adding new tests, but otherwise by adding an ignore marker. This habit shouldn't be too tricky if starting from 100% coverage, because the exact lines which aren't covered should be reported and easily visible in the line by line coverage report.
  5. Whenever you want to see or increase your "true" test coverage, temporarily configure the coverage counter to disable the ignores when counting coverage. Often this can be done with a CLI flag. This mode should correspond with the coverage on the project before systematically adding ignore markers. Alternatively, if you're only interested in developing tests and increasing coverage for one relevant part of the code, you can just edit the coverage ignore statements for that section of the code base.
  6. Document in your contributing guide that this strategy is being used and inform contributors of the reasons behind it and how to work with it. This will help contributors understand that you're using test coverage as a tool.
  7. (Optional) If you want to keep the original metric, permanently configure high level metrics (e.g, badges) to disable the ignores. This may help eliminate any lingering feeling that this is cheating. If you do this, local tests should still be configured to respect the ignore markers.

I call this the “fake it ‘til you make it” approach to 100% test coverage. It’s not true 100% test coverage, it's more like “100% coverage of the parts I intended to test”, but I think that's a better standard to be shooting for anyway.

I recently implemented this strategy on three of my most active repositories, and I have found it to be an unequivocal improvement. I and the other developers I work with find that:

  1. 100% test coverage is way easier to maintain than 99% test coverage.
  2. If the code base starts at 100% test coverage, line by line coverage reports are actually readable.
  3. It's helpful that coverage ignore markers are visible in the code as a tangible and immediate reminders for every block of code that is not covered.
  4. It's not burdensome or difficult to remove or disable the ignore markers when necessary.
  5. Perhaps most importantly, we feel generally encouraged to write tests that are both thorough and simple.
  6. We find ourselves catching errors in our tests that we wouldn't otherwise have caught - the most common of these is that we have two tests with the same name, one of which overwrites the other. This shows up in the coverage. This is probably only a problem in languages that allow that kind of syntax like Python does.

If you try this strategy, let me know how it goes!


Want to try cooking something really new? Try doing it for a whole month.

tl;dr - make one type of cuisine for a whole month and you can actually use the obscure ingredients you drove across town to get.

A quest for achievable food nirvana

I wonder: how many secret recipes are there, really?

Probably not many, especially compared to the total number of recipes out there on the internet. Still, there are plenty of folks that think they have a secret recipe but actually don't. For all the good it did me up until a coupe of years ago, all the recipes in the world might have been secret.

But then I realized something. Recipes can't all be secret, or else how could seemingly every Indian restaurant in the states have figured out how to make basically the same Saag Paneer. Sure, there's some variation, but there's enough consistency that the recipe can't be secret. To be fair, it's delicious enough that it would be worth trying to keep it secret.

This realization that the recipes for the food I love are just sitting out there on the internet waiting to be discovered led me to spend more and more time thinking that I would actually try and make the foods I love to cook. So eventually I set out to do it. As it turns out, YouTube is the place to go for Indian recipes, and you can find about a zillion different recipes for any and every indian dish I had ever heard of and a ton more I hadn't. So I figured it must be out there, the perfect recipe for Saag Paneer.

All I needed was a little time to practice and the right ingredients. But as anyone who has gotten a bit ambitious in the ethnic cooking department knows, there's inevitably some ingredient that you have to substitute, or else something you have to travel across town to find. That was certainly the case for me. My Indian spice cupboard was looking a little slim.

Getting around the ingredients problem

Savannah and I have found a workable solution to this is to find enough recipes of the same type (indian, chinese, italian, etc) that share some common specialty ingredients to justify the trip across town for the ingredients to stock our pantry. Luckily, we've found that a lot of the trickiest ingredients are just that - pantry ingredients. Spices, vinegars, various ferments, etc. The time frame that is working for this is about a month. If we gather enough recipes for a month then we can usually find enough overlap to justify the weird ingredients. And one month is about our limit in terms of cooking project stamina.

That pattern is the single best thing about sticking with one cuisine over the course of the month - you get to reuse ingredients that would otherwise probably not be worth purchasing for just one recipe. Instead of trying to find replacements, we would just go out and buy the exact thing the recipe called for, since there was a good chance we would be using it again. Another benefit of that is that I now know my way around a Chinese grocery store. Pro tip: if you're looking for fermented black beans, go to the spices aisle, not the canned vegetables aisle.

Chinese month

The first month we did this, we chose to find a bunch of Chinese wok recipes. Motivating this choice, perhaps unsurprisingly was the need to break in a new wok. Chinese is a huge category of course, but it gave us enough flexibility to find a bunch of recipes that shared ingredients.

With our category in mind, a bit of research confirmed our suspicion that cooking exclusively Chinese would require us to acquire a whole new pantry of ingredients. One of the best things I discovered in that initial process is that it was possible to choose recipes with a fair amount of overlap. A bunch of recipes called for chin kiang vinegar, shao xing wine, sichuan peppercorn, fermented black beans, woks. Relatively unfamiliar tools of Chinese cooking were appearing together in a bunch different recipes alongside more familiar ingredients - soy sauce, garlic, scallions, chicken. I seasoned our wok, went to the nearest chinese market, picked up the ingredients, and we were off to the races. Once we had made the initial investment into stocking our pantry with Chinese cooking essentials, it got a lot easier.

Over the course of the month we made king pao chicken, braised eggplant, wontons in numb and spicy Sichuan-style chili oil, stir fried cucumber with ground pork, fried rice with basil and green beans, stir fried broccoli, and a few other dishes. Most of the recipes we used came from Serious Eats, which I like not just because of the great recipes, but because it gives explanations and teaches the principles behind the recipes. Full list of recipes at the bottom of this article!

By the way, we didn't want to get tired of Chinese food or tired of cooking, so we decided that we wouldn't make ourselves cook every night and when we ate out we didn't eat Chinese. Together, these constraints helped make this experiment sustainable enough for us to keep doing it.

Indian month, Italian month, and onward

High on our first month's success, we decided to go for gold and try indian. We bought a spice grinder, a ton of interesting new spices, and learned that coriander is the same as cilantro (I guess I knew that?). We made palak paneer three times (still haven't gotten it quite right - we'll get there eventually), baingan bharta, a couple of tasty daals, butter chicken, chicken tikka masala, cauliflower curry, egg curry, and channa masala. We might do another Indian month.

When we did an Italian month, almost all the ingredients were familiar, but we started to learn about how the quality of the dish is limited by the quality of the ingredients that comprise it. We made some delicious pastas and learned that the only canned tomatoes you ever need to buy are whole peeled tomatoes and tomato paste. Did you know you can get tomato paste in a tube instead of a can? That is a life changer.

If you do a cooking month, let me know how it goes!


Recipes from our chinese month (h/t Serious Eats):

If you do this, you're definitely going to want a wok.


A design doc checklist

My engineering team and I have been refining a simple structure for software design docs that I find to be remarkably succinct and effective as a planning and communication tool. It takes the form of a four-item checklist.

1. Purpose - review the business case.

Encouraging ourselves to put proposed implementations in terms of business requirements forces us to think practically and creatively about the trade-offs of what is often several proposed implementations. Because features take time to implement, review, and maintain, we save a lot of time and effort when we kill bad ideas quickly by considering them in a business context.

For instance, we find ourselves writing unnecessary proposals to make not-yet-validated parts of our business faster or easier. This section helps us remember to have the discipline not to work on things that don’t matter.

2. Proposal - outline the proposed solution or solutions.

If providing multiple possible solutions, the author should try to pick one that they think will work and explain why they are recommending it. More often than not, reviewers will disagree about what constitutes the most promising proposal. Sometimes an alternative or hybrid proposal will arise during discussion that works better. Sometimes none of the proposals gain consensus. In those cases, it’s always worth going back to the drawing board. The opportunity for early feedback and discussion generally makes for higher quality implementations.

3. Examples - work out some of the key details.

Giving an example or two helps people understand what the proposal or proposals describe. I think the adage about pictures applies here as well: an example is worth a thousand words. That said, sometimes less is more - an example is a sketch, and shouldn’t be a full blown oil painting. Details will almost always change after the document has been written and reviewed, examples are more important for communicating the main ideas than they are for being a precursor to the actual implementation. Sometimes it's better to wait for git diff than it is to try describing everything in prose.

4. Next steps - plan out action items.

This is an important bit often overlooked step that helps facilitate a quick transition from planning mode into execution mode. This is where we suggest details like who should be involved and how much time or effort will be required.

A few other pointers

Sometimes these components become the distinct sections in the document, but it's never a problem if they're a bit more integrated as long as they're all there in some form.

  • Writing the design doc is a little bit useful for the author who is writing it, just like how writing a first draft of an essay will help you figure out your thesis, but the value multiplies when people start commenting on and suggesting revisions.
  • Commenting should be asynchronous. If a lot of discussion is happening, consider a breaking that discussion out of the document and into a synchronous meeting. That meeting is likely to be a lot more productive than one that occurred without a design doc first having been written.
  • If in doubt about whether or not a feature is "big enough" to require a design doc, err toward writing one. Lincoln (apparently) once said, "If I had an hour to chop down a tree, I'd spend 45 minutes sharpening the axe and 15 minutes chopping."
  • I imagine the ideal structure will vary a bit from team to team. I think it was important for our team that everyone had a chance to buy into the process.
  • Design docs should be quick and dirty.

Should design docs live on beyond the discussion?

As a team, we write so many of these that questions of the following sort have arisen:

  • Should design docs be collected as some sort of history?
  • Should design docs be updated to reflect comments or outcomes of discussions?
  • Should design docs be used as feature documentation?

I think the answer to all of these questions is "no". Design docs should be ephemeral because their purpose is to foster productive discussion and guide initial planning. If discussion moves far enough away from the center of gravity of the document that a large update is needed, it's usually easier to start with a blank slate or just to jump straight to the implementation. I find that after the first round of discussion, code review is often more productive and relevant. Design docs are messy, littered with comments and usually outdated within a few hours of starting a real life implementation. But they're useful in the moment!