<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Aniruddha]]></title><description><![CDATA[Aniruddha]]></description><link>https://i0exception.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg</url><title>Aniruddha</title><link>https://i0exception.substack.com</link></image><generator>Substack</generator><lastBuildDate>Mon, 06 Apr 2026 07:02:18 GMT</lastBuildDate><atom:link href="https://i0exception.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Aniruddha]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[i0exception@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[i0exception@substack.com]]></itunes:email><itunes:name><![CDATA[Aniruddha]]></itunes:name></itunes:owner><itunes:author><![CDATA[Aniruddha]]></itunes:author><googleplay:owner><![CDATA[i0exception@substack.com]]></googleplay:owner><googleplay:email><![CDATA[i0exception@substack.com]]></googleplay:email><googleplay:author><![CDATA[Aniruddha]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Rethinking Code Reviews]]></title><description><![CDATA[Engineering teams are drowning in PRs and nobody wants to say it out loud.]]></description><link>https://i0exception.substack.com/p/rethinking-code-reviews</link><guid isPermaLink="false">https://i0exception.substack.com/p/rethinking-code-reviews</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Sun, 05 Apr 2026 20:57:10 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ab6d0716-78a0-472a-8d8f-bb2b7eeff342_610x317.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Engineering teams are drowning in PRs and nobody wants to say it out loud.</p><p>At Pocus, as we adopted more AI coding tools, code reviews became the bottleneck. I had some time over the weekend to dig into why we even have code reviews, which led me to <a href="https://graphite.com/blog/the-ancient-origins-of-code-review">this post</a> by the <a href="http://graphite.dev">Graphite</a> team. Long story short, they started as a way to catch bugs because they were expensive to fix. As tests took over that job, reviews quietly reinvented themselves around readability and coherence. That shift made sense: source code was the highest level of representation available, and keeping it readable was the best way to keep a codebase maintainable over time.</p><p>Coding agents have fundamentally changed how code gets written, and the review process hasn't kept up. Something that took days now takes minutes, and PR volume has gone up by an order of magnitude. Engineers used to carve out focused time to review carefully, which was feasible when frequency was manageable. Today the same engineer has a dozen PRs waiting before lunch. This is not a discipline problem. It is a structural one.</p><h3>Abstractions</h3><p>The reason for this is simple. We have never reviewed the code that actually runs on the machine. When you push code to production, a compiler transforms it. A JIT runtime might recompile it again at execution time. The binary executing on the CPU looks nothing like what you wrote. Nobody asks to review the optimized assembly. We implicitly agreed that the right level to reason about code is the highest level of representation available.</p><p>For most of software history, that abstraction was source code. It no longer is. When you open an unfamiliar codebase today, most engineers ask Claude to explain what the relevant module does, how data flows, what the abstractions are. English is now where intent is expressed and decisions are made. Code is increasingly the compiled artifact of that English, the same way assembly is the output of a compiler given source code. The same logic that pointed reviews toward source code now points them toward English. Reviewing below the highest level of representation is a broken process. We are doing it because we haven&#8217;t fully accepted what has changed.</p><h3>Pipeline</h3><p>Think about how the code generation pipeline has changed today</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!_qTs!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!_qTs!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 424w, https://substackcdn.com/image/fetch/$s_!_qTs!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 848w, https://substackcdn.com/image/fetch/$s_!_qTs!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 1272w, https://substackcdn.com/image/fetch/$s_!_qTs!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!_qTs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png" width="602" height="372.06944444444446" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:890,&quot;width&quot;:1440,&quot;resizeWidth&quot;:602,&quot;bytes&quot;:70740,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://i0exception.substack.com/i/193232313?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!_qTs!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 424w, https://substackcdn.com/image/fetch/$s_!_qTs!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 848w, https://substackcdn.com/image/fetch/$s_!_qTs!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 1272w, https://substackcdn.com/image/fetch/$s_!_qTs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F67aeff24-8118-4bc1-a654-6eccb31412aa_1440x890.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>When humans wrote the plan and the code. It made sense for humans to review both. Reviewing the code in the new world is like reviewing the assembly in the old one. It is the wrong level.</p><p>The engineer&#8217;s job is to own the plan. That means spending real time in plan mode, working with the agent to understand the problem space, identify the right abstractions, and surface the business context no tool can fully internalize. Most engineers doing this well already run in plan mode much longer before letting the agent write any code. Once the plan exists, let the agent iterate without interruption. At Pocus, the difference in quality between code reviewed after one agent pass versus several was consistently significant.</p><p>This also means accepting that agent-generated code won&#8217;t always be as clean as what a skilled engineer would write by hand, similar to how compilers sacrifice readability for performance. That&#8217;s manageable. Refactoring is easy with high test coverage and a written plan. Rewriting in a new language becomes tractable when the plan exists independently of the implementation.</p><h3>Future</h3><p>My strong belief is that the code review process needs to be rethought from the ground up. Not just to deal with the influx of code, but to open up contribution to people who aren&#8217;t traditionally software engineers. Today, making a change to a codebase requires knowing the language, the conventions, the patterns. In a plan-first world, an engineer who understands the logic and knows language X can meaningfully contribute to a codebase written in language Y. The plan is language-agnostic. The agent handles the translation. This is a bigger shift than it sounds. It fundamentally changes who can contribute to a codebase and how teams are structured.</p><p>Plans need to become first-class artifacts. Not a one-liner PR description, but a structured document: what new classes are we adding, what existing tools are we reusing, what invariants might we be breaking etc. The back and forth happens on the plan, not the code. Code review gets scoped down to one question: did the agent implement what the plan said? The plan gets committed alongside the code. The plan shows the diff. The code is the current snapshot of the world.</p><h3>Today</h3><p>We're in a transitionary period. We haven't fully arrived at the future but we've definitely left the past behind. Things are moving fast enough that some of this might be outdated in a month. Here are some things I've found to be helpful.</p><p>Let agents loop and review their own code before you jump in. Looking earlier means reviewing something that&#8217;s about to change anyway. Iterate on your CLAUDE.md every day. Every time the agent produces something wrong, check in how you want it to think about the codebase. You get to good output faster than you&#8217;d expect. <a href="https://graphite.com/docs/learn-to-stack">Stack your PRs</a>. Agents produce large sprawling changes and stacking forces the right level of decomposition.</p><p>Invest heavily in tests - fuzzy as well as deterministic. Non-determinism in code generation should be caught by tests, not human reviewers. Agents make high coverage easy to achieve and tests are what make refactoring safe when agent output needs cleaning up.</p><p>Start breaking up the monorepo. Most large monorepos contain things that have no business being together. A data ingestion pipeline and internal warehouse models share almost no code yet often live in the same repo. Agents struggle with giant context. Clearer boundaries with explicit contracts make agent output dramatically better.</p><h3>Coda</h3><p>Try a thought experiment: imagine deleting your IDE. What would actually break? The answer tells you a lot about where you're still working the way you used to.</p><p>The engineers who figure this out earliest will have a real advantage. The instinct to read every diff and personally verify every change made sense in the old world. The new instinct is to stay close to the plan, trust the harness, and spend your judgment on what only you can provide. Things are moving fast and chaotic right now. Embrace the chaos. </p>]]></content:encoded></item><item><title><![CDATA[Reflections on Technical Tradeoffs]]></title><description><![CDATA[The tools, systems, and architectural bets behind Pocus]]></description><link>https://i0exception.substack.com/p/reflections-on-technical-tradeoffs</link><guid isPermaLink="false">https://i0exception.substack.com/p/reflections-on-technical-tradeoffs</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Mon, 30 Mar 2026 06:59:13 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A little under three years ago, I joined a tiny startup called Pocus. I joined when the product did a much narrower set of things than it does today. I had a front-row seat to that transformation, both organizationally and technically. Last week, we joined Apollo, and that journey now continues at a much larger scale.</p><p>I&#8217;ve had some time to reflect on the path here and on the decisions we made, both right and wrong. One of the most important parts of growth is not repeating the same mistakes and compounding on the good decisions. This felt like the right time to write some of them down before they get blurry. Many of these technical decisions shaped not just the product, but the team as well, because at some level, you do ship your org chart.</p><p>Pocus is not a typical CRUD SaaS application. We worked with large enterprise and mid-market tech companies like Canva, Asana, and many others that wanted to bring together huge volumes of fragmented go-to-market data and actually do something useful with it.</p><p>That meant ingesting data from CRMs (hello Salesforce), data warehouses, emails, call transcripts, product signals, event streams, and third-party datasets; matching and enriching that data; mapping it onto messy, customer-specific hierarchies; and then making it queryable and actionable inside the product. Customers wanted to build lists, define audiences, run workflows, monitor signals, and later use AI systems on top of both structured and unstructured data. In many cases, they were dealing with tens of millions of records and deeply imperfect data, and they still expected the product to feel interactive.</p><p>Then, at some point in the middle of all this, AI and LLMs became real product primitives. All of that messy data now had to work in a world where models could reason over it. So the product we have today looks completely different from the one that existed when I joined.</p><p>That&#8217;s the context for this post. A lot of the important technical decisions were not about building isolated systems in the abstract. They were about building a product that could support large-scale, messy, high-consequence data workloads with a relatively small engineering team.</p><p>A lot more went into building Pocus than the decisions in this post. Hiring, team design, execution, and the processes around building and operating the system mattered just as much, if not more. Each of those probably deserves its own separate post.</p><p>This post is only about the technical side: the architecture, data platform, infrastructure, orchestration, reliability, and AI system decisions that shaped how we built it.</p><p>Another thing that is worth calling out: a lot of these decisions might be relevant to other startups building agentic AI products over messy data while still working toward product-market fit. At the same time, many of them will not make sense if you are too early or too late. They may be wrong if you are still pre-PMF, and they may also be wrong if you already have strong PMF and are mostly dealing with scaling challenges. That matters because decisions do not work in isolation. Context matters a lot, and the right learning applied at the wrong stage can be actively harmful.</p><p>Also, somewhat obvious, but everything here reflects my own experience and opinions. Nothing in this post should be read as an official position or endorsement from my current employer, any former employer, or anyone else I&#8217;ve worked with.</p><p>I&#8217;ve graded the decisions using three viewpoints:</p><ul><li><p>&#128994; <strong>Repeat</strong> &#8212; I would do this again</p></li><li><p>&#128993; <strong>Revisit</strong> &#8212; This worked fine, but I&#8217;m not fully convinced it would be the right choice if I were doing something new</p></li><li><p>&#128308; <strong>Regret</strong> &#8212; I would not do this again</p></li></ul><p>I&#8217;ve grouped the technical decisions into three buckets:</p><ul><li><p><strong>Tools that helped us build and operate the platform</strong> &#8212; the things the engineering team used to move faster and run the system</p></li><li><p><strong>Core technologies behind the platform</strong> &#8212; the systems and infrastructure that actually stored, processed, and served the data</p></li><li><p><strong>System design and architecture decisions</strong> &#8212; the deeper bets around complexity, consistency, abstractions, and overall system shape</p></li></ul><p>I&#8217;ve tried to be as exhaustive as I can about the decisions I remember, though I&#8217;m sure I&#8217;ve missed a few! </p><h2>Tools</h2><h4>Building</h4><p>These were tools we used to build the system. Some of these already existed when I joined, while others were introduced by the team.</p><ul><li><p>&#128994; <strong>Statsig feature flags for rollouts</strong></p><ul><li><p>Probably the most generous free plan around if you&#8217;re using just feature flags.</p></li></ul></li><li><p>&#128994; <strong>DuckDB for misc debugging</strong></p></li><li><p>&#128994; <strong>Momentic over Playwright for webapp testing</strong></p><ul><li><p>Playwright tests were flaky, and no one really trusted them. We moved to Momentic about half a year ago, wrote most of our tests there, and finally got to a place where we could deploy with confidence.</p></li></ul></li><li><p>&#128994; <strong>GitHub Actions for CI/CD</strong></p><ul><li><p>It&#8217;s the most basic tool ever, but it was more than good enough to get started. I don&#8217;t think we were ever seriously worried about outgrowing it.</p></li></ul></li><li><p>&#128994; <strong>Namespace / Blacksmith instead of GitHub runners</strong></p><ul><li><p>GitHub runners are horrible. They&#8217;re not just slow, they&#8217;re also incredibly expensive. We moved to Blacksmith and eventually to Namespace. Both platforms were great.</p></li></ul></li><li><p>&#128994; <strong>Graphite instead of GitHub for reviews</strong></p><ul><li><p>GitHub hasn&#8217;t meaningfully improved the PR and code review process in years. Moving to Graphite, especially with stacked PRs, made a noticeable difference to our velocity. Stacking made AI generated code easier to reason about. Later, we also adopted their merge queue, which was another clear improvement over the status quo.</p></li></ul></li><li><p>&#128994; <strong>BiomeJS instead of ESLint</strong></p><ul><li><p>This actually made it feasible for us to run lint as a pre-commit hook.</p></li></ul></li><li><p>&#128994; <strong>Giving engineers an AI budget and freedom to use tools</strong></p><ul><li><p>The agentic coding space is evolving so quickly that we avoided locking ourselves into any long-term contract. Instead, we gave engineers a reasonable AI budget and the freedom to use whatever worked best for them. Most people used Cursor or Claude. Some used Codex.</p></li></ul></li><li><p>&#128993; <strong>Tilt for local development</strong></p><ul><li><p>This worked great until coding agents came around. Supporting local dev environments across multiple parallel agentic coding tools is still an unsolved problem. Tilt was hard for agents to integrate with and close the loop.</p></li></ul></li><li><p>&#128993; <strong>Automated code review</strong></p><ul><li><p>We tried quite a few tools here. We probably tested most of them before they were ready for primetime. Codex was especially good at catching hard-to-find logic bugs. Claude Code was great at reviewing its own code.</p></li></ul></li><li><p>&#128993; <strong>GitHub</strong></p><ul><li><p>Reliability is probably the biggest reason I&#8217;d consider moving off it.</p></li></ul></li><li><p>&#128993; <strong>Zapier for coordinating state between tools</strong></p><ul><li><p>In a world where writing code is fast and cheap, no-code solutions mostly add a layer of complexity you just don&#8217;t need. That said, Zapier had a huge number of integrations that simply worked. We looked at n8n, Gumloop, and others, but never got them working reliably.</p></li></ul></li><li><p>&#128308; <strong>Retool for one-off tools</strong></p><ul><li><p>In practice, it was often easier to just check in custom scripts or build one-off UIs directly into the product.</p></li></ul></li><li><p>&#128308; <strong>Kotlin + TypeScript as two backend languages</strong></p><ul><li><p>Going from one language to two was a mistake. The operational overhead of supporting multiple backend languages is real. I think agentic coding makes this somewhat less painful now, but we made this decision before Claude, and it was a massive pain.</p></li></ul></li></ul><h4>Monitoring</h4><ul><li><p>&#128994; <strong>Honeycomb for tracing</strong></p><ul><li><p>This was probably one of the most heavily used tools on the engineering team. Investing early in a strong tracing solution paid off. Many of our alerts and dashboards lived in Honeycomb. Moving from Tempo to Honeycomb was a very good decision.</p></li></ul></li><li><p>&#128994; <strong>LogRocket / Jam.dev for bug reporting</strong></p><ul><li><p>Highly recommend this. It&#8217;s a huge improvement over getting bug reports as written descriptions or videos.</p></li></ul></li><li><p>&#128994; <strong>incident.io for alerting and oncall</strong></p></li><li><p>&#128993; <strong>Sentry</strong></p><ul><li><p>Sentry was very helpful for frontend errors. We never really got enough out of it for backend error and exception monitoring. Maybe we were just using it wrong.</p></li></ul></li><li><p>&#128308; <strong>Grafana + Prometheus + Loki self-hosted</strong></p><ul><li><p>I would happily pay for the cloud version if I were doing this again.</p></li></ul></li><li><p>&#128308; <strong>Standalone product analytics tool</strong></p><ul><li><p>As someone who helped build one of these tools, this one hurts a bit. But not being able to combine all of our business data in one place made it hard to get enough value out of it. As the marginal cost of writing SQL kept dropping because of LLMs, most of our operational and exploratory analysis ended up happening in Snowflake instead of a standalone analytics tool.</p></li></ul></li><li><p>&#128308; <strong>Snowflake for internal reporting</strong></p><ul><li><p>This gets expensive very quickly. Then you end up spending a lot of time doing unnatural things just to save money. Snowflake is a great tool, but if you&#8217;re trying to use your budget as efficiently as possible, I&#8217;d probably start somewhere else.</p></li></ul></li></ul><h2>Core technologies behind the platform</h2><h4>Fullstack</h4><ul><li><p>&#128994; <strong>Prisma for database schemas</strong></p></li><li><p>&#128994; <strong>Vite over Next.js</strong></p><ul><li><p>We started on Next.js, but quickly realized we were barely using the features that justified the added complexity. Moving to Vite was a huge improvement.</p></li></ul></li><li><p>&#128994; <strong>Tailwind</strong></p><ul><li><p>I still remember someone on our team telling me at our first offsite how amazing Tailwind was. It took us a while to fully migrate, but it was absolutely the right decision.</p></li></ul></li><li><p>&#128993; <strong>Vendor for auth and RBAC</strong></p><ul><li><p>This is one of those type 1 decisions that&#8217;s very hard to undo later. Be very careful about which vendor you pick.</p></li></ul></li><li><p>&#128993; <strong>NestJS</strong></p><ul><li><p>Nest adds a layer of complexity and abstraction that I think we probably could have done without.</p></li></ul></li></ul><h4>Platform &amp; Infra</h4><ul><li><p>&#128994; <strong>DBT wherever possible,</strong></p><ul><li><p>At first, we only used DBT to build models for internal use. Over time, we started using it in many more places, including flows that weren&#8217;t just about internal reporting. It&#8217;s a very simple idea, but it works extremely well.</p></li></ul></li><li><p>&#128994; <strong>Airbyte for connecting to SaaS tools</strong></p><ul><li><p>Over time, we leaned much more heavily on self-hosted Airbyte to move data around. Cloud pricing is pretty prohibitive, and this is one of those tools that is actually fairly straightforward to self-host.</p></li></ul></li><li><p>&#128994; <strong>Temporal for workflows and async work</strong></p><ul><li><p>Moving from Prefect to Temporal was one of those very high-leverage decisions that opens up an entirely new way of building systems. The cloud version was too expensive for our use cases, but it was easy enough to self-host.</p></li></ul></li><li><p>&#128994; <strong>ClickHouse, then StarRocks, instead of ES or Postgres</strong></p><ul><li><p>A lot of people I&#8217;ve talked to lean toward Elasticsearch or Postgres with some form of columnar extension. Having built a columnar database at a previous job, I was very motivated not to build one again. ClickHouse, and later StarRocks, gave us a very solid foundation for the kinds of interactive queries we needed.</p></li></ul></li><li><p>&#128994; <strong>Postgres as source of truth for frequently updated data</strong></p><ul><li><p>Postgres is great when the workload is dominated by frequent updates. Over time, we moved more and more of that kind of data into Postgres.</p></li></ul></li><li><p>&#128994; <strong>S3 as source of truth for unstructured and semi-structured data</strong></p></li><li><p>&#128994; <strong>Athena for async data processing</strong></p><ul><li><p>Some of our athena queries ran for many minutes. The 60 minute timeout still makes little sense to me. </p></li></ul></li><li><p>&#128994; <strong>Parquet as the storage format</strong></p></li><li><p>&#128994; <strong>WarpStream instead of Kafka</strong></p><ul><li><p>Having worked with Kafka at all of my previous jobs, I was honestly surprised by how easy WarpStream was to run and use.</p></li></ul></li><li><p>&#128993; <strong>Iceberg and S3Tables</strong></p><ul><li><p>Iceberg is great. I think it was still a bit early for some of our use cases, but I&#8217;d watch this space closely, especially with S3Tables on AWS.</p></li></ul></li><li><p>&#128993; <strong>RisingWave instead of Flink / Spark</strong></p><ul><li><p>First impressions were great. It&#8217;s just too early for me to say whether it&#8217;s stable enough long term.</p></li></ul></li></ul><h4>Infra</h4><ul><li><p>&#128994; <strong>nOps instead of committed spend</strong></p><ul><li><p>We used to commit to a certain amount of EC2 spend, but we were still too early to really benefit from 3-year commitments. Our AWS rep pointed us to nOps, though there are a bunch of companies that do this kind of machine and spend arbitrage to get you better discounts than standard 1-year commitments.</p></li></ul></li><li><p>&#128994; <strong>TypeScript as the sole language</strong></p><ul><li><p>We leaned heavily on TypeScript as the main language across the stack. I was pretty skeptical going in, especially after spending a decade mostly writing Scala, C++, and Go, but I ended up being pleasantly surprised by how productive it was.</p></li></ul></li><li><p>&#128994; <strong>Managed EKS / Kubernetes</strong></p><ul><li><p>Kubernetes gets a bad reputation for being overly complex. In my experience, a lot of that pain comes from people running their own clusters. Managed EKS was an absolute breeze, and Kubernetes was consistently a net positive for us.</p></li></ul></li><li><p>&#128994; <strong>Tailscale for VPN</strong></p></li><li><p>&#128993; <strong>Vercel for frontend hosting</strong></p><ul><li><p>It&#8217;s pretty straightforward to host a React app on AWS. The marginal benefits of some of Vercel&#8217;s features didn&#8217;t really justify the extra complexity for us.</p></li></ul></li><li><p>&#128993; <strong>AWS instead of GCP</strong></p><ul><li><p>Having worked with GCP for many years, I really missed its simplicity and structure. That said, the AWS account team was great. AWS feels like a platform that works as much because of the people behind it as the technology itself.</p></li></ul></li><li><p>&#128993; <strong>Cloudflare</strong></p><ul><li><p>The marginal benefit of using Cloudflare over the cloud provider&#8217;s native tooling, like Route53, probably wasn&#8217;t worth the extra operational burden.</p></li></ul></li><li><p>&#128308; <strong>Postgres via RDS</strong></p><ul><li><p>RDS is great, but it gets expensive fast, and the pricing levers are pretty opaque unless you&#8217;re running at a scale where a dedicated cluster clearly makes sense. There are a lot more options now that I&#8217;d want to explore.</p></li></ul></li><li><p>&#128308; <strong>Terraforming everything</strong></p><ul><li><p>A declarative language only really makes sense if you truly need the same infra stack to be deployable across multiple customers or regions. We didn&#8217;t. Terraform mostly got in the way, and every time you needed procedural logic, the workaround was painful.</p></li></ul></li></ul><h2>Systems Design and Architecture</h2><ul><li><p>&#128994; <strong>Scaling vertically instead of horizontally</strong></p><ul><li><p>An engineer&#8217;s first instinct is often to scale horizontally as soon as the system needs to support more customers. But modern CPUs keep getting better, and I had already seen a lot of the pain that comes with horizontal scaling in previous jobs. We made a deliberate decision to scale vertically for as long as possible, and it was incredibly helpful while we were moving fast.</p></li></ul></li><li><p>&#128994; <strong>SQL as the data processing layer</strong></p><ul><li><p>I didn&#8217;t fully appreciate how versatile SQL could be until I saw it used for all kinds of workloads at Pocus. Offloading complexity to SQL-speaking systems like Athena, ClickHouse, Snowflake, and Postgres was a game changer for what the product could support. LLMs also happen to be ridiculously good at writing complex SQL.</p></li></ul></li><li><p>&#128994; <strong>No microservices</strong></p><ul><li><p>Microservice hell is very real. We avoided microservices entirely. Over time, our APIs grew to support hundreds of GraphQL operations, all deployed as a monolith.</p></li></ul></li><li><p>&#128994; <strong>Separating deployments by sync, async, and stateful workload types</strong></p><ul><li><p>There&#8217;s a real difference between how synchronous request paths behave, how long-running async workloads behave, and how stateful agents behave. Treating those as separate deployment types made the system much more stable.</p></li></ul></li><li><p>&#128994; <strong>Monorepo over multiple repos</strong></p></li><li><p>&#128994; <strong>Eventual consistency over strong consistency</strong></p><ul><li><p>Any strong consistency guarantee becomes very hard once your system spans multiple machines. You need to design with that in mind from the beginning. It&#8217;s very hard to retrofit later.</p></li></ul></li><li><p>&#128994; <strong>Overprovisioning instead of early multitenancy work</strong></p><ul><li><p>Overprovisioning worked surprisingly well for us. We never really ran out of capacity. Compared to the complexity of solving multitenancy early, the extra infrastructure spend was cheap. I expected we&#8217;d outgrow this faster than we did.</p></li></ul></li><li><p>&#128994; <strong>Not investing in RAG early</strong></p><ul><li><p>Sometimes the right move is to ignore the hype and trust your own experiments. We never got results from RAG that matched how it was being marketed at the time. Vector embeddings are a pretty weak proxy for the right contextual knowledge. It was one of those ideas that sounded much better in theory than it worked in practice.</p></li></ul></li><li><p>&#128994; <strong>Not picking an agent framework early</strong></p><ul><li><p>Models are evolving too quickly to lock yourself into a framework too early.</p></li></ul></li><li><p>&#128993; <strong>Using AI abstractions like Vercel AI SDK</strong></p><ul><li><p>AI is moving so fast that targeting the lowest common denominator across providers can end up holding you back.</p></li></ul></li><li><p>&#128993; <strong>Investing in canaries</strong></p><ul><li><p>We invested relatively late in production canaries. At our scale, most of the issues that mattered only really showed up under real production load. Over time, we got much better about building canaries, even for stateful systems.</p></li></ul></li><li><p>&#128993; <strong>Not using self-hosted models for LLMs</strong></p><ul><li><p>As costs keep rising, self-hosted models are definitely something I&#8217;d want to explore more seriously.</p></li></ul></li><li><p>&#128993; <strong>Using a staging cluster for pre-prod testing</strong></p><ul><li><p>At one point we had dev, staging, and prod clusters. In practice, staging never gave us much confidence that we didn&#8217;t already have from dev. Moving away from staging and toward canaries was the better path for building real deployment confidence.</p></li></ul></li><li><p>&#128308; <strong>GraphQL as the query layer</strong></p><ul><li><p>GraphQL is complex and full of footguns. I&#8217;m still not sure the added complexity was worth it.</p></li></ul></li><li><p>&#128308; <strong>Not implementing soft deletions early</strong></p><ul><li><p>This is one of those type 1 decisions that is very hard to retrofit. I would absolutely do this from day 1 if I were doing it again.</p></li></ul></li><li><p>&#128308; <strong>Not investing in evals early</strong></p><ul><li><p>We invested too little, too late in AI evals. Evals are essential if you want to understand whether your AI product is actually working. Unlike more deterministic systems, AI outputs are much harder to judge. If you&#8217;re building any kind of AI platform, evals should be a day 1 concern.</p></li></ul></li><li><p>&#128308; <strong>Prompt versioning outside the repository</strong></p><ul><li><p>We tried versioning and managing prompts in a separate tool. Prompts were much easier to iterate in our codebase. Iterating on them in isolation sounds nice, but it&#8217;s harder to make work in practice than it seems.</p></li></ul></li><li><p>&#128308; <strong>Docs in code</strong></p><ul><li><p>I know almost no one who actually likes writing Markdown. I&#8217;d strongly consider tools that still connect back to the repository, but offer a much better WYSIWYG experience, more like Notion.</p></li></ul></li></ul><p>I&#8217;m sure I&#8217;ll disagree with some of this a few years from now. That&#8217;s probably a good thing. The tools will change, the constraints will change, and hopefully my thinking will improve too. But this is the clearest snapshot I can give of what seemed to matter at the time.</p><p>If you&#8217;ve found tools that worked especially well for your team and aren&#8217;t on this list, I&#8217;d genuinely love to hear about them.</p>]]></content:encoded></item><item><title><![CDATA[Rendezvous Hashing: An alternative to Consistent Hashing]]></title><description><![CDATA[In any kind of stateful distributed system, the problem of mapping a key to a set of machines is pretty common.]]></description><link>https://i0exception.substack.com/p/rendezvous-hashing-8c00e2fb58b0</link><guid isPermaLink="false">https://i0exception.substack.com/p/rendezvous-hashing-8c00e2fb58b0</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Tue, 07 Jan 2020 07:45:11 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In any kind of stateful distributed system, the problem of mapping a key to a set of machines is pretty common. Even if a distributed system is stateless, you might still want to map a key to the same set of machines for better locality of processing. In its essence, this is very similar to how hash tables work&#8202;&#8212;&#8202;map a set of <em>k </em>keys to <em>n&nbsp;</em>buckets.</p><p>The simplest way to do this is to use modular operations. <a href="https://en.wikipedia.org/wiki/Hash_function">Hash</a> your key to get a fixed length value, then compute the modulo with <em>n </em>and pick the machine in that slot. For a uniform hash function, this works well if the number of endpoints doesn&#8217;t change very frequently and if the cost of re-mapping keys between endpoints is low. If either of those two is not true, this performs very poorly because all of your keys could get remapped if the size of the list&nbsp;changes.</p><p>These days, the standard way to limit the number of keys being re-mapped is to use <a href="https://en.wikipedia.org/wiki/Consistent_hashing">consistent hashing</a>. Most major distributed databases use it in some form or another. Consistent hashing is a special kind of hashing where on an average, <em>K/n </em>keys are remapped whenever the list of endpoints changes (<em>K </em>is the total number of keys). The term <em>consistent hashing</em> first appeared in literature in 1997 in <a href="https://dl.acm.org/doi/10.1145/258533.258660">this paper</a>. In consistent hashing, both the keys and the buckets are hashed onto a circle. A key maps to the first bucket that is encountered in the clockwise direction (or counter-clockwise&#8202;&#8212;&#8202;it doesn&#8217;t really matter). Searching for the bucket responsible for a key is pretty simple&#8202;&#8212;&#8202;pre compute the hash values for all buckets and sort them, hash the key and then run a binary search (in<em> O(log(n))</em>) to find the lowest value that&#8217;s higher than the hash of the key. When the buckets are resized, some keys move over to the closest new bucket. On average, the number of keys that need to move is <em>K/n&#8202;&#8212;&#8202;</em>which is&nbsp;ideal.</p><p>One of the biggest drawbacks of consistent hashing is that keys can be imbalanced across buckets. This is mainly because of how resizing is handled. For example, if a bucket is removed, all keys mapped to that bucket move over to the next one (similar for the case where a bucket is added). Ideally, these keys would be distributed equally across all the remaining buckets. To overcome this problem, most implementations divide each physical machine into multiple virtual nodes. Even then, the keys now spread out over as many virtual nodes you assign to a physical machine instead of the ideal state of the load spreading out over all of them. If the number of virtual nodes is not higher than the number of machines, the load can be distributed unevenly.</p><p>Rendezvous hashing predates consistent hashing <a href="http://www.eecs.umich.edu/techreports/cse/96/CSE-TR-316-96.pdf">by a year</a> and takes a very different approach to solving these problems, while maintaining the <em>K/n</em> re-mapping invariant. Unfortunately, it&#8217;s not as well known as consistent hashing. It&#8217;s also known as <em>Highest Random Weight</em> hashing, because of how it&#8217;s implemented. Conceptually and practically, it&#8217;s much simpler to understand and implement. You hash the <em>key </em>and the <em>machine </em>together and then pick the one with the highest hash&nbsp;value.</p><pre><code>type router struct {
  endpoints []*Endpoint
}</code></pre><pre><code>func (r *router) Get(key string) *Endpoint {
  var ep *Endpoint
  hashVal := -INF</code></pre><pre><code>  for _, e := range r.endpoints {
    h = hash(key, e)
    if h &gt; hashVal {
      ep = e
      hashVal = h
    }
  }
  return ep
}</code></pre><p>In case of a uniform hash function, if the buckets change, the keys (on an average, <em>K/n</em> keys) get spread out over all other buckets instead of just one or the number of virtual nodes that were assigned to a machine. The biggest drawback of rendezvous hashing is that it runs in <em>O(n) </em>instead of <em>O(log(n))</em>. However, because you don&#8217;t typically have to break each node into multiple virtual nodes, <em>n </em>is typically not large enough for the run-time to be a significant factor.</p><p>We actually used this at Twitter in our internal pub/sub platform, EventBus. EventBus was modeled similar to Kafka&#8202;&#8212;&#8202;there were topics, and topics had subscriptions. A group of clients together consumed a subscription. We called this smallest unit, a stream. Unlike Kafka, EventBus had separate storage and serving layers&#8202;&#8212;&#8202;so you could scale out the serving layer horizontally. More importantly, any machine could serve a stream. Also, unlike Kafka, we supported a mode where all clients within a subscription could choose to receive a full copy of the stream and implement their own filtering.</p><p>Initially, we randomly assigned these streams to different serving machines. This worked fine when the number of streams was in the low hundreds. However, over time, some of our most popular topics (like the one with tweets) gathered streams numbering in the tens of thousands, many with client-side streaming enabled. Because the serving layer kept a local cache of events for each stream and different streams could be reading data at different offsets, every machine started keeping a large amount of data in memory&#8202;&#8212;&#8202;leading to horrendous GC pressure. We needed an easy way for a group of clients to independently converge on the same serving machine for a particular stream so that an item, once cached, could be sent to multiple clients. We used rendezvous hashing to do this with pretty good results. The clients would select a machine while starting consumption and then periodically rebalance every 5&#8211;10 minutes till the throughput across different machines stabilized.</p><p>Sometimes, elegant and obscure algorithms tend to outperform conventional wisdom.</p>]]></content:encoded></item><item><title><![CDATA[Sampling — the good, the bad, and the ugly]]></title><description><![CDATA[Benjamin Franklin once said &#8212; &#8220;Those who give up essential accuracy for temporary speed deserve neither speed nor accuracy&#8221;.]]></description><link>https://i0exception.substack.com/p/sampling-the-good-the-bad-and-the-ugly-4b5f85e8ce2</link><guid isPermaLink="false">https://i0exception.substack.com/p/sampling-the-good-the-bad-and-the-ugly-4b5f85e8ce2</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Wed, 16 Oct 2019 08:25:17 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Benjamin Franklin once said&#8202;&#8212;&#8202;&#8220;Those who give up essential accuracy for temporary speed deserve neither speed nor accuracy&#8221;. In the real world, however, you frequently have to trade off one for the other. This tradeoff becomes increasingly appealing as your data volume increases. In this post we&#8217;ll discuss some common sampling techniques and ways to get the most out of your data, especially as it relates to <a href="https://mixpanel.com/behavioral-analytics/">product or behavioral user analytics</a>.</p><p>Before we look at how to sample, it&#8217;s important to understand what the data being sampled looks like. In most cases you&#8217;re going to collect <a href="https://support.google.com/analytics/answer/1033068?hl=en">events</a>. These are an immutable record of user interactions. Each event typically has a timestamp and a user identifier associated with it. Optionally, you might want to collect some <a href="https://help.mixpanel.com/hc/en-us/articles/115004708186-Event-Properties-Super-Properties-People-Properties">metadata</a> with each event. Typically, once you add instrumentation to your apps or websites (or use a tool that automatically collects everything), these events are generated in response to every user interaction. So, if you&#8217;re already generating these events&#8202;&#8212;&#8202;why&nbsp;sample?</p><p>There are two main reasons why you might want to consider looking at a smaller subset of your data for insights&#8202;&#8212;&#8202;<em><strong>speed </strong></em>and <em><strong>cost</strong></em>. In some cases, you can get faster results if you decide to spend more on computation. However, not all computations are infinitely parallelizable.</p><h3>How to&nbsp;sample</h3><p>Whether you decide to sample by dropping data during collection or at query time, how you choose to ignore data matters. It&#8217;s important to have the sampling be random&#8202;&#8212;&#8202;otherwise you&#8217;ll run into <a href="https://en.wikipedia.org/wiki/Sampling_bias">sampling bias</a>, which makes analysis hard. There are a few ways to do&nbsp;this.</p><h4>Sample every&nbsp;event</h4><p>This is the most naive way to sample data, but it works well if you only care about aggregates. Here, the decision to sample is independent of the event or user being tracked. When you run aggregates, you can multiply by the inverse of the sampling factor to get an approximate value. The biggest drawback of this approach is that any kind of analysis that depends on a sequence of events (like a funnel report) is usually incorrect.</p><h4>Sample high volume&nbsp;events</h4><p>Not all events are made equal. You are likely to have a few outliers that contribute the most to event volume. Random sampling for these outliers and collecting all other events un-sampled works well in practice. This has the same drawbacks as the previous approach, but the impact is restricted to analysis that spans the outliers.</p><h4>Sample all&nbsp;users</h4><p>For user analytics, every event is likely to have an associated user identifier. This represents the individual you are tracking information about. The goal of this approach is to keep all activity for a small sample of users and discard all activity for the others. If the user identifiers you keep data for are selected at random, you can extrapolate the results to get accurate numbers. The good part about this approach is that it works for aggregates as well as for any analysis that depends on a sequence of events, so long as it is per-user.</p><h4>Sample high volume events by&nbsp;user</h4><p>Here, we take the good parts of approaches 2 and 3 and combine them. We sample events by user but only restrict the sampling to high volume events. Any analysis that you do on events that don&#8217;t involve outliers gets full fidelity whereas anything that&#8217;s done across outliers still has accurate numbers as long as the analysis is done per-user. Most <a href="http://mixpanel.com">major analytics providers</a> let you do&nbsp;this.</p><h4>Sample by users but always track certain populations</h4><p>This is basically the same as the previous approach, except you have some way to always track a specific set of users based on some pre-defined criteria. Say, you want to track everything about users who pay you more than $1000 per month&#8202;&#8212;&#8202;you can do that by always including every event for any of these users. This works well if this sample is relatively small compared to the rest of your user base and you are careful about addressing the edge cases of tracking when someone enters or exits this special population.</p><h3>User Identity Management</h3><p>Before we look at where to sample, it&#8217;s important to look at one of the biggest challenges with user based sampling. In this day and age, people have multiple devices that they might interact with your application or website on. In addition, users might do the bulk of their activity while logged out and only identify themselves when they have to. This flexibility makes it hard to decide which user identifiers should be in the sample and which shouldn&#8217;t&#8202;&#8212;&#8202;mainly because it&#8217;s a chicken and egg&nbsp;problem.</p><h4>Anonymous and Logged In&nbsp;activity</h4><p>For most applications and websites, some kind of user identification is required to interact with the product. There are exceptions (as we&#8217;ll see later), but for a majority of products, Login acts as the great filter. Once a user logs in, the decision of whether to include it in the sample can mostly be made on the basis of whatever unique identifier your database has for that user. It&#8217;s also possible to tie back any of their anonymous activity to the identified user based either on heuristics or actual knowledge (if someone logs in on a device where you previously tracked anonymous activity, there is a good chance that the anonymous activity can be tied to the logged-in user).</p><p>However, there are entire verticals where a bulk of your users are going to be anonymous. Travel, e-commerce, search, video etc. see a lot of anonymous activity and users don&#8217;t necessarily identify themselves during an interaction with your&nbsp;product.</p><h4>Users with multiple&nbsp;accounts</h4><p>The other challenge that some products run into is where users have different identifiers on different platforms. You might use a phone number to identify on the app and an email address to identify on a website. Additionally, you might also allow your users to identify using social media accounts. Tying all these activities back to the same user typically requires some kind of heuristics or best-effort matching and it usually happens long after you&#8217;ve been tracking information with these different identifiers.</p><h3>Where to&nbsp;sample</h3><p>User identity management plays a big role in determining the utility of your approach to sampling, because once you decide <em>how</em> you want to sample your data, the other important decision you&#8217;ll have to make is <em>where</em> to do&nbsp;this.</p><p>Broadly speaking, you have 2 choices&nbsp;&#8212;</p><ol><li><p>collect everything and sample when you run&nbsp;queries.</p></li><li><p>drop data during collection and run queries on the sampled&nbsp;data.</p></li></ol><p>If you&#8217;re considering sampling as a way to reduce costs, it&#8217;s helpful to understand the 3 types of costs associated with data&nbsp;&#8212;</p><p><em><strong>Collection</strong></em> costs are those associated with tracking and processing the data all the way up to the point where you can decide whether to include the event in the sample or&nbsp;not.</p><p><em><strong>Storage</strong></em> costs are what you pay for keeping the data around at rest. These costs compound over time as the data footprint increases.</p><p><em><strong>Query</strong></em> costs are what you pay for processing the sampled data to get meaningful insights.</p><p>Here&#8217;s what the costs look like based on the approach you&nbsp;take</p><pre><code>+----------------------+-------------+----------+----------+
|        Option        |  Collection |  Storage |   Query  |
+----------------------+-------------+----------+----------+
| Sample at query      |  Full       |  Full    |  Sampled |
| Sample at collection |  Full       |  Sampled |  Sampled |
+----------------------+-------------+----------+----------+</code></pre><h4>Sample at&nbsp;query</h4><p>This is a little more expensive than the second one because you pay full storage costs and your collection costs <em>might</em> be a little higher depending on how early in your collection process you can determine whether a user falls in the sample or not. However, it has very few drawbacks because you don&#8217;t drop any of the data so you can merge user activity as and when you discover connections between anonymous, logged-in and users with multiple accounts. If you can afford to, collect everything.</p><h4>Sample at collection</h4><p>If you absolutely must drop data, there are a few ways to try to minimize the impact&nbsp;&#8212;</p><h4>Sampling Technique</h4><p>Always sample just the high volume events by the user identifier and keep everything around at full fidelity. This reduces the impact of sampling to any analysis that involves the outliers in terms of&nbsp;volume.</p><h4>Anonymous vs. Logged In&nbsp;users</h4><p>If your product has low anonymous activity and most users identify themselves before any interaction, you might be able to get by with keeping a copy of all the anonymous data and only sample data for users who have identified themselves. This gives you full visibility into any anonymous activity and at the same time, any analysis that spans anonymous and logged-in usage is&nbsp;correct.</p><p>If your product has high anonymous activity and low logged in activity, flip the two&#8202;&#8212;&#8202;sample all the anonymous data and keep all the logged in activity around for analysis. The drawback of doing this is that analysis spanning anonymous and logged-in usage will be incorrect.</p><p>Unfortunately, for most other cases, sampling at collection results in either incomplete or inaccurate data and there&#8217;s no real way to counter&nbsp;that.</p><h3>Conclusion</h3><p>Sampling plays an important role in improving the speed of analysis and, in some cases, reducing costs. If you can afford to pay the additional processing and storage costs, always sample while running queries. The costs for running queries are typically much higher than the other two, especially as you scan the data multiple times for different kinds of analysis. Keeping a full copy of all the data also lets you use that data for any kind of analysis that involves machine learning or statistical modeling. It also lets you run exploratory analysis on a sample while still retaining the ability to run more important queries on the full&nbsp;dataset.</p><p>If you absolutely can&#8217;t afford to keep a full copy, try to minimize the impact of sampling by reducing the scope of user identity management challenges on your choice of sampling technique and make sure that you factor in all the corner cases when interpreting the results of your&nbsp;queries.</p>]]></content:encoded></item><item><title><![CDATA[Iterating over maps in Go]]></title><description><![CDATA[While the Go programming language specification only states that the iteration order over maps is not guaranteed to be the same across invocations, Go maps in action goes a step further to say that the order is randomized.]]></description><link>https://i0exception.substack.com/p/map-iteration-in-go-275abb76f721</link><guid isPermaLink="false">https://i0exception.substack.com/p/map-iteration-in-go-275abb76f721</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Sat, 27 Jul 2019 04:29:45 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>While the <a href="https://golang.org/ref/spec">Go programming language specification</a> only states that the iteration order over maps is not guaranteed to be the same across invocations, <a href="https://blog.golang.org/go-maps-in-action">Go maps in action</a> goes a step further to say that the order is randomized. So, when someone at work asked how I would design a set of integers that returned a random entry on every get&nbsp;, I suggested this neat&nbsp;trick</p><pre><code>type intSet map[int]struct{}</code></pre><pre><code>func (s intSet) put(v int) {
        s[v] = struct{}{}
}</code></pre><pre><code>func (s intSet) get() (int, bool) {
        for k := range s {
                return k, true
        }
        return 0, false
}</code></pre><p>Turns out that this approach is incorrect because, while it returns a &#8220;random&#8221; number on every get, the probability for every element is not the&nbsp;same.</p><p>To test this implementation, let&#8217;s actually fill up a map with some values and see the distribution over a million&nbsp;runs.</p><pre><code>func main() {
        s := make(intSet)
        for i := 0; i &lt; 8; i++ {
                s.put(i)
        }</code></pre><pre><code>        counts := make(map[int]int)
        for i := 0; i &lt; 1024*1024; i++ {
                v, ok := s.get()
                if !ok {
                        return
                }
                counts[v]++
        }</code></pre><pre><code>        for k, v := range counts {
                fmt.Printf("Value: %v, Count: %v\n", k, v)
        }
}</code></pre><p>This is the output you get on running&nbsp;this</p><pre><code>code|&#8658; ./code
Value: 1, Count: 131026
Value: 7, Count: 130957
Value: 3, Count: 131064
Value: 5, Count: 131288
Value: 2, Count: 131080
Value: 0, Count: 130813
Value: 4, Count: 131137
Value: 6, Count: 131211</code></pre><p>That&#8217;s good, right? The distribution of each number is roughly equal. Let&#8217;s change the numbers a bit and see what happens. For the next run, I added the numbers 0 to 4&nbsp;instead.</p><pre><code>code|&#8658; ./code
Value: 1, Count: 131175
Value: 2, Count: 131593
Value: 3, Count: 130904
Value: 0, Count: 654904</code></pre><p>While the counts for 1&nbsp;, 2 and 3 are roughly the same, 0 occurs almost 5 times as often. A truly random distribution would have been around 250000 occurrences of each&nbsp;number.</p><p>To explain this anomaly, it&#8217;s important to understand how maps are implemented in go. Unsurprisingly, maps are implemented using go. The <a href="https://github.com/golang/go/blob/master/src/runtime/map.go">map.go</a> file in <a href="https://github.com/golang/go/tree/master/src/runtime">src/runtime</a> contains the common parts of the implementation (there are some optimized map implementations for common types like integers and strings). The comments in map.go help lay out the structure of a&nbsp;map</p><pre><code>// A map is just a hash table. The data is arranged
// into an array of buckets. Each bucket contains up to
// 8 key/value pairs. The low-order bits of the hash are
// used to select a bucket. Each bucket contains a few
// high-order bits of each hash to distinguish the entries
// within a single bucket.
//
// If more than 8 keys hash to a bucket, we chain on
// extra buckets.</code></pre><p>Let&#8217;s take a look at what happens when you&#8217;re iterating over a map. If you disassemble the for loop, you&#8217;ll see something like&nbsp;this.</p><pre><code>TEXT main.intSet.get(SB) /home/aniruddha/code/main.go
  ...
  main.go:10  0x488e56  4889442408   MOVQ AX, 0x8(SP)
  main.go:10  0x488e5b  488d442418   LEAQ 0x18(SP), AX
  main.go:10  0x488e60  4889442410   MOVQ AX, 0x10(SP)
  main.go:10  0x488e65  e8763df8ff   CALL runtime.mapiterinit(SB)
  main.go:10  0x488e6a  488b442418   MOVQ 0x18(SP), AX
  ...
  main.go:11  0x488e93  c3    RET</code></pre><p>The call to mapiterinit is what sets up the iterator and then calls the mapiternext function to get the first element in the map. Here&#8217;s the part of the code in mapiterinit that actually computes where to start iterating &#8212;</p><pre><code>r := uintptr(fastrand())
if h.B &gt; 31-bucketCntBits {
  r += uintptr(fastrand()) &lt;&lt; 31
}
it.startBucket = r &amp; bucketMask(h.B)
it.offset = uint8(r &gt;&gt; h.B &amp; (bucketCnt - 1))
it.bucket = it.startBucket</code></pre><p>We generate a random number using fastrand() and then use it to get the starting bucket and a random offset within that bucket (remember, maps in go are implemented as an array of buckets with 8 elements in each bucket). mapiternext then iterates over the elements to return the first valid entity&#8202;&#8212;&#8202;while doing so, it skips over any empty&nbsp;ones</p><pre><code>for ; i &lt; bucketCnt; i++ {
  offi := (i + it.offset) &amp; (bucketCnt - 1)
  if isEmpty(b.tophash[offi]) || b.tophash[offi] == evacuatedEmpty {
    // TODO: emptyRest is hard to use here, as we start iterating
    // in the middle of a bucket. It's feasible, just tricky.
        continue
  }
  ...
}</code></pre><p>Because the element we start with could be empty, the probability of getting a valid element is actually dependent on the number of empty buckets and elements immediately preceding it. For example, if there is 1 bucket with 2 valid entities like in the example below&nbsp;&#8212;</p><pre><code>[NULL, NULL, 10, NULL, NULL, NULL, NULL, 20]</code></pre><p>We&#8217;ll get 10 if we start with elements 0, 1 or 2 and 20 if we start with 3, 4, 5, 6 or 7. So the perceived probability of getting a 10 is 3/8 and for 20 is 5/8&nbsp;.</p><p>While this was a toy problem that I was trying to solve, the broader learning for me was to not base solutions on ones interpretation of library documentation. It&#8217;s almost always a good idea to test how things behave in practice even if the documentation feels clear and&nbsp;correct.</p>]]></content:encoded></item><item><title><![CDATA[Common traps while using defer in go]]></title><description><![CDATA[The defer statement in go is really handy in improving code readability.]]></description><link>https://i0exception.substack.com/p/some-common-traps-while-using-defer-205ebbdc0a3b</link><guid isPermaLink="false">https://i0exception.substack.com/p/some-common-traps-while-using-defer-205ebbdc0a3b</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Tue, 20 Mar 2018 22:02:24 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The <em>defer</em> statement in <em>go</em> is really handy in improving code readability. However, in some cases its behavior is confusing and not immediately obvious. Even after writing <em>go</em> for over 2 years, there are times when a <em>defer</em> in the wild leaves me scratching my head. My goal is to compile a list of behaviors which have stumped me in the past, mainly as a note to&nbsp;myself.</p><h4>Defer scopes to a function, not a&nbsp;block</h4><p>A variable exists only within the scope of a code block. However, a <em>defer</em> statement within a block is only executed when the enclosing function returns. I&#8217;m not sure what the rationale for this is, but it can catch you off guard if you&#8217;re, say, allocating resources in a loop but <em>defer</em> the deallocation.</p><pre><code>func do(files []string) error {
  for _, file := range files {
    f, err := os.Open(file)
    if err != nil {
      return err
    }
    defer f.Close() // This is wrong!!
    // use f
  }
}</code></pre><h4>Chaining methods</h4><p>If you chain methods in a <em>defer</em> statement, everything except the last function will be evaluated at call time. <em>defer</em> expects a function as the &#8220;<em>argument&#8221;.</em></p><pre><code>type logger struct {}
func (l *logger) Print(s string) {
  fmt.Printf("Log: %v\n", s)
}</code></pre><pre><code>type foo struct {
  l *logger
}</code></pre><pre><code>func (f *foo) Logger() *logger {
  fmt.Println("Logger()")
  return f.l
}</code></pre><pre><code>func do(f *foo) {
  defer f.Logger().Print("done")
  fmt.Println("do")
}
 
func main() {
  f := &amp;foo{
    l: &amp;logger{},
  }
  do(f)
}</code></pre><p>Prints &#8212;</p><pre><code>Logger()
do
Log: done</code></pre><p>The Logger() function is called before any of the work in do() is executed.</p><h4>Function arguments</h4><p>Okay, but what if the last method in the chain takes an argument? Surely, if it is executed after the enclosing function returns, any changes made to the variables will be captured.</p><pre><code>type logger struct {}
func (l *logger) Print(err error) {
  fmt.Printf("Log: %v\n", err)
}</code></pre><pre><code>type foo struct {
  l *logger
}</code></pre><pre><code>func (f *foo) Logger() *logger {
  fmt.Println("Logger()")
  return f.l
}</code></pre><pre><code>func do(f *foo) (err error) {
  defer f.Logger().Print(err)
  fmt.Println("do")
  return fmt.Errorf("ERROR")
}
 
func main() {
  f := &amp;foo{
    l: &amp;logger{},
  }
  do(f)
}</code></pre><p>Guess what this&nbsp;prints?</p><pre><code>Logger()
do
Log: &lt;nil&gt;</code></pre><p>The value of err is captured at call time. Any changes made to this variable are not captured by the <em>defer</em> statement because they don&#8217;t point to the same&nbsp;value.</p><h4>Calling methods on non-pointer types</h4><p>We saw how chained methods behave in a <em>defer</em> statement. Exploring this further, if the called method is not defined on a pointer receiver type, calling it in a <em>defer</em> will actually make a copy of the instance.</p><pre><code>type metrics struct {
  success bool
  latency time.Duration
}</code></pre><pre><code>func (m metrics) Log() {
  fmt.Printf("Success: %v, Latency: %v\n", m.success, m.latency)
}</code></pre><pre><code>func foo() {
  var m metrics
  defer m.Log()</code></pre><pre><code>  start := time.Now()
  // Do something
  time.Sleep(2*time.Second)
  
  m.success = true
  m.latency = time.Now().Sub(start)
}</code></pre><p>This prints&nbsp;&#8212;</p><pre><code>Success: false, Latency: 0s</code></pre><p>m is copied when <em>defer</em> is called. m.Foo() is basically shorthand for&nbsp;Foo(m)</p><h4>Conclusion</h4><p>If you&#8217;ve spent enough time writing <em>go</em>, these might not feel like &#8220;<em>traps</em>&#8221;. But for someone new to the language, there are definitely a lot of places where the <em>defer </em>statement does not satisfy the <a href="https://en.wikipedia.org/wiki/Principle_of_least_astonishment">principle of least astonishment</a>. There are a <a href="http://devs.cloudimmunity.com/gotchas-and-common-mistakes-in-go-golang/">bunch</a> <a href="https://blog.learngoprogramming.com/gotchas-of-defer-in-go-1-8d070894cb01">of</a> <a href="https://blog.learngoprogramming.com/5-gotchas-of-defer-in-go-golang-part-ii-cc550f6ad9aa">other</a> <a href="https://blog.learngoprogramming.com/5-gotchas-of-defer-in-go-golang-part-iii-36a1ab3d6ef1">places</a> that go into more detail about some other common mistakes while writing <em>go</em>. Do check them&nbsp;out.</p>]]></content:encoded></item><item><title><![CDATA[Runtime overhead of using defer in go]]></title><description><![CDATA[Golang has a pretty nifty keyword named defer.]]></description><link>https://i0exception.substack.com/p/runtime-overhead-of-using-defer-in-go-7140d5c40e32</link><guid isPermaLink="false">https://i0exception.substack.com/p/runtime-overhead-of-using-defer-in-go-7140d5c40e32</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Wed, 07 Mar 2018 08:37:40 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Golang has a pretty nifty keyword named defer. As explained <a href="https://blog.golang.org/defer-panic-and-recover">here</a>, a defer statement pushes a function call onto a list. The list of saved calls is executed after the surrounding function returns. Defer is commonly used to simplify functions that perform various clean-up&nbsp;actions.</p><p>Using defer, however, is not free. Using go&#8217;s benchmarking support, we can try to quantify this overheard.</p><p>The following two functions do the same work, but one calls a function in a defer statement while the other&nbsp;doesn&#8217;t</p><pre><code>package main</code></pre><pre><code>func doNoDefer(t *int) {
  func() {
    *t++
  }()
}</code></pre><pre><code>func doDefer(t *int) {
  defer func() {
    *t++
  }()
}</code></pre><p>Let&#8217;s benchmark these&nbsp;&#8212;</p><pre><code>package main</code></pre><pre><code>import (
  "testing"
)</code></pre><pre><code>func BenchmarkDeferYes(b *testing.B) {
  t := 0
  for i := 0; i &lt; b.N; i++ {
    doDefer(&amp;t)
  }
}</code></pre><pre><code>func BenchmarkDeferNo(b *testing.B) {
  t := 0
  for i := 0; i &lt; b.N; i++ {
    doNoDefer(&amp;t)
  }
}</code></pre><p>Running this with go -bench on an 8 core google cloud VM gives&nbsp;us</p><pre><code>&#8658; go test -v -bench BenchmarkDefer -benchmem
goos: linux
goarch: amd64
pkg: cmd
BenchmarkDeferYes-8  20000000   62.4 ns/op  0 B/op  0 allocs/op
BenchmarkDeferNo-8   500000000  3.70 ns/op  0 B/op  0 allocs/op</code></pre><p>As expected, both these functions don&#8217;t allocate any memory. But doDefer is roughly <em><strong>16 times</strong></em> more expensive than doNoDefer. To understand why defer is this expensive, let&#8217;s look at the disassembled code.</p><p>The disassembly for the actual functions called inside doDefer and doNoDefer is the&nbsp;same</p><pre><code>main.go:10   MOVQ 0x8(SP), AX
main.go:11   MOVQ 0(AX), CX
main.go:11   INCQ CX
main.go:11   MOVQ CX, 0(AX)
main.go:12   RET</code></pre><p>The doNoDefer sets up the necessary registers and then calls main.doNoDefer.func1</p><pre><code>TEXT main.doNoDefer(SB) main.go
main.go:3  MOVQ FS:0xfffffff8, CX
main.go:3  CMPQ 0x10(CX), SP
main.go:3  JBE 0x450b65
main.go:3  SUBQ $0x10, SP
main.go:3  MOVQ BP, 0x8(SP)
main.go:3  LEAQ 0x8(SP), BP
main.go:3  MOVQ 0x18(SP), AX
main.go:6  MOVQ AX, 0(SP)
main.go:6  CALL main.doNoDefer.func1(SB)
main.go:7  MOVQ 0x8(SP), BP
main.go:7  ADDQ $0x10, SP
main.go:7  RET
main.go:3  CALL runtime.morestack_noctxt(SB)
main.go:3  JMP main.doNoDefer(SB)</code></pre><p>The doDefer function also sets up registers, but there are additional function calls&#8202;&#8212;&#8202;the first one to runtime.deferproc which sets up the deferred function to be called. The second one is to runtime.deferreturn&#8202;&#8212;&#8202;which in turn calls itself for every defer statement encountered in the function.</p><pre><code>TEXT main.doDefer(SB) main.go
main.go:9    MOVQ FS:0xfffffff8, CX
main.go:9    CMPQ 0x10(CX), SP
main.go:9    JBE 0x450bd3
main.go:9    SUBQ $0x20, SP
main.go:9    MOVQ BP, 0x18(SP)
main.go:9    LEAQ 0x18(SP), BP
main.go:9    MOVQ 0x28(SP), AX
main.go:12   MOVQ AX, 0x10(SP)
main.go:10   MOVL $0x8, 0(SP)
main.go:10   LEAQ 0x218e3(IP), AX
main.go:10   MOVQ AX, 0x8(SP)
main.go:10   CALL runtime.deferproc(SB)
main.go:10   TESTL AX, AX
main.go:10   JNE 0x450bc3
main.go:13   NOPL
main.go:13   CALL runtime.deferreturn(SB)
main.go:13   MOVQ 0x18(SP), BP
main.go:13   ADDQ $0x20, SP
main.go:13   RET
main.go:10   NOPL
main.go:10   CALL runtime.deferreturn(SB)
main.go:10   MOVQ 0x18(SP), BP
main.go:10   ADDQ $0x20, SP
main.go:10   RET
main.go:9    CALL runtime.morestack_noctxt(SB)
main.go:9    JMP main.doDefer(SB)</code></pre><p><a href="https://golang.org/src/runtime/panic.go?s=1703:1741#L63">deferproc</a> and <a href="https://golang.org/src/runtime/panic.go?s=8427:8457#L306">deferreturn</a> are both non-trivial functions and they do a bunch of accounting and setup at entry and exit. In short, don&#8217;t use defer in hot code paths. The overhead is non-trivial and not&nbsp;obvious.</p>]]></content:encoded></item><item><title><![CDATA[Memory Mapped Files]]></title><description><![CDATA[Memory mapping of files is a very powerful abstraction that many operating systems support out of the box.]]></description><link>https://i0exception.substack.com/p/memory-mapped-files-5e083e653b1</link><guid isPermaLink="false">https://i0exception.substack.com/p/memory-mapped-files-5e083e653b1</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Sat, 03 Feb 2018 09:27:15 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Memory mapping of files is a very powerful abstraction that many operating systems support out of the box. Linux does this via the mmap system call. In most cases where an application reads (or writes) to a file at arbitrary positions, using mmap is a solid alternative to the more traditional read/write system calls. We&#8217;ve used it in the analytics database at Mixpanel to improve performance or make code more readable and I wanted to spend some time figuring out what actually happens under the&nbsp;hood.</p><p>At a high level, the mmap system call lets you read and write to a file as if you were accessing an array in memory. There are two main modes in which files can be mapped&#8202;&#8212;&#8202;MAP_PRIVATE and MAP_SHARED. In MAP_PRIVATE, any changes that you make to the file are in memory and not written back to it. In MAP_SHARED, changes made to the file are visible to other memory mappings of that file and are eventually committed to&nbsp;disk.</p><p>To understand what happens on calling mmap, it&#8217;s important to understand two things&#8202;&#8212;&#8202;how linux handles files and how memory addressing works.</p><p>You can open a file for reading or writing using the open system call. This returns a file descriptor. Linux maintains a global file descriptor table and adds an entry to it representing the opened file. This entry is represented by the <a href="https://elixir.free-electrons.com/linux/v4.15/source/include/linux/fs.h#L852">file</a> structure which is local to the process. Internally, linux uses the <a href="https://elixir.free-electrons.com/linux/v4.15/source/include/linux/fs.h#L570">inode</a> struct to represent the file. The file struct has a pointer to this and linux ensures that multiple file descriptors that touch the same file point to the same inode so that their changes are visible to each other. The i_mapping field on the inode struct is what&#8217;s used to get the right set of pages from the page cache for an offset in the&nbsp;file.</p><p>In linux, processes have a virtual memory address space that&#8217;s, well, virtual. This memory is not usually backed by physical memory unless you&#8217;re actually reading or writing to some part of it. Linux further divides the memory space into equal sized pages and a page is the unit of access as far as the kernel is concerned. So, when a process calls mmap, the short answer is that nothing really happens. The kernel simply reserves some part of this virtual memory address space and returns the address. The <a href="https://elixir.free-electrons.com/linux/v4.15/source/mm/mmap.c#L1321">do_mmap</a> function is what eventually gets called after some bookkeeping and does most of the work for allocating this virtual memory in the process&#8217; address space. This function stores a pointer to the file struct in the <a href="https://elixir.free-electrons.com/linux/v4.15/source/include/linux/mm_types.h#L280">vm_area_struct</a> struct that represents the returned&nbsp;address.</p><p>When the process accesses the address, a page fault occurs. The page fault handler locates the vm_area_struct struct in the process&#8217;s address space and eventually finds the pages in the page cache that map to the file offsets being accessed. These pages are marked as dirty if there&#8217;s a write and mapped directly to user space&#8202;&#8212;&#8202;this way there is no need to copy data from kernel to user&nbsp;space.</p><p>Once you&#8217;re done using the memory mapped area, the munmap system call can be used to free up the memory. Any data written to the page cache is periodically committed to disk, although you can force it with msync. While mmap is useful, it definitely has drawbacks. Misses in the page cache always result in the page being read into the cache even if a write is going to overwrite the contents. Offsets need to be aligned to page boundaries. Error handling happens via signals because there is no way to indicate otherwise. And finally, you can&#8217;t mmap all types of file descriptors(pipes for example). As usual, conditions apply&#8202;&#8212;&#8202;so make sure you don&#8217;t use mmap indiscriminately..</p>]]></content:encoded></item><item><title><![CDATA[Writing tests in Go]]></title><description><![CDATA[Recently, I bumped into this article by Segment&#8217;s engineering team.]]></description><link>https://i0exception.substack.com/p/some-thoughts-on-testing-in-go-d5fdf58fa471</link><guid isPermaLink="false">https://i0exception.substack.com/p/some-thoughts-on-testing-in-go-d5fdf58fa471</guid><dc:creator><![CDATA[Aniruddha]]></dc:creator><pubDate>Tue, 23 Jan 2018 10:32:16 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!lluD!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F26394448-1a6b-4c4d-b5ba-31f799cbc55e_400x400.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Recently, I bumped into <a href="https://segment.com/blog/5-advanced-testing-techniques-in-go/">this</a> article by Segment&#8217;s engineering team. It has a lot of good advice and some helpful links about writing good tests in Go. I wanted to discuss a few more things that I&#8217;ve found useful in the two-ish years that I&#8217;ve been using the language.</p><p>For those of you who haven&#8217;t used Go, consider using it for your next side project. It&#8217;s a very opinionated programming language with a spec that you can mostly hold in your head and a fairly comprehensive standard library. Also, unlike most other languages, you don&#8217;t have to deal with a ton of testing frameworks. Although the standard library provides good support for writing tests, I&#8217;ve found the following techniques useful in writing better testable&nbsp;code.</p><h4>It&#8217;s okay to test unexposed functions</h4><p>The default package layout in Go encourages housing code and tests in the same package. Tests can access &#8220;private&#8221; functions and members&#8202;&#8212;&#8202;this is okay. I&#8217;ve mostly encountered this in the context of helper functions that are only used within a package. The alternative is to expose them publicly, which has its drawbacks.</p><h4>Control time</h4><p>Avoid using the time package to block or schedule execution. Consider using something like <a href="https://github.com/jonboulle/clockwork">clockwork</a> to pass in a fake, controllable clock in unit tests. Controlling time lets you write more deterministic unit tests. This is useful when you&#8217;re testing behavior that depends on time&#8202;&#8212;&#8202;timeouts, retries, scheduled runs&nbsp;etc.</p><h4>Use Go&#8217;s race&nbsp;detector</h4><p>Data access races are really hard to debug. Fortunately, Go has support for detecting them&#8202;&#8212;&#8202;so use it. <a href="https://golang.org/doc/articles/race_detector.html">This</a> is a good starting point to understand how to use the race detector. Remember that it will only test the code paths that your tests execute. So you still need to write a test that exercises the&nbsp;race.</p><h4>Write benchmarks</h4><p>Go makes writing benchmarks easy. <a href="https://golang.org/pkg/testing/#hdr-Benchmarks">This</a> is a good starting point to understand how to write them. Make sure you have benchmarks for the performance sensitive parts of your&nbsp;code.</p><h4>Use setup functions</h4><p>This is useful if you want to setup some external state that is used by the function or implementation being tested. An example would be something that operates on a directory. Instead of having every test function create a temporary directory and clean up after itself, write a generator function that does&nbsp;this.</p><pre><code>func withTempDir(t *testing.T, f func(d string)) {
 dir, err := ioutil.TempDir(...)
 if assert.NoError(err) {
  defer os.RemoveAll(dir)
  f(dir)
 }
}</code></pre><pre><code>func Test(t *testing.T) {
 withTempDir(t, func(dir string) {
  // use dir in test
 })
}</code></pre><h4>Accept interfaces, return&nbsp;structs</h4><p>Interfaces can be mocked; structs cannot. Having interfaces as member variables makes it easy to mock their behavior. Returning structs (concrete implementations) means that the caller gets to decide how to use the returned&nbsp;value.</p><p>That said, use mocks carefully. With mocks, you&#8217;re testing your understanding of the interface, at the time the test was written. While this is ideal, it&#8217;s not always practical&#8202;&#8212;&#8202;especially in high velocity codebases. If you think the underlying implementation is unstable, test it in a separate package to avoid diverging.</p><p>Lastly, use a mock generator like <a href="https://github.com/vektra/mockery">mockery</a> instead of writing them yourself.</p><h4>Use self referential interfaces</h4><p>This is a neat trick that I&#8217;ve found useful for testing behavior that is either non-deterministic or doesn&#8217;t fit well in a unit test because it makes network calls or depends on an external service. Let&#8217;s say you want to test the behavior of a function A() on a struct of type Foo that makes a non-deterministic function call that uses a member variable (like a network connection) in Foo&nbsp;. An easy way to do this is to move the non-determinism into a function B() on Foo and introduce a new member variable on Foo that satisfies an interface exposed by B() and call B() on this member. The actual code can use an instance of Foo as the member variable and the tests can provide a mock. The code below should make things&nbsp;clearer.</p><pre><code>package main</code></pre><pre><code>import (
 "fmt"
)</code></pre><pre><code>type doer interface {
 B()
}</code></pre><pre><code>type Foo struct {
 msg string
 d doer
}</code></pre><pre><code>func (f *Foo) B() {
 fmt.Printf("i am non deterministic: %v\n", f.msg)
}</code></pre><pre><code>func (f *Foo) A() {
 f.d.B()
 fmt.Println("test me")
}</code></pre><pre><code>func main() {
 x := &amp;Foo{
  msg:"go",
 }
 x.d = x // x.d = MockDoer() in tests
 x.A()
}</code></pre><p>Although many of these techniques are useful, deciding where to use them is always a judgement call. Choose&nbsp;wisely!</p>]]></content:encoded></item></channel></rss>