<?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:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Benchling Engineering - Medium]]></title>
        <description><![CDATA[The official blog of the Benchling engineering team. - Medium]]></description>
        <link>https://benchling.engineering?source=rss----3d4aa8fb07ea---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Benchling Engineering - Medium</title>
            <link>https://benchling.engineering?source=rss----3d4aa8fb07ea---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Fri, 17 Apr 2026 08:42:39 GMT</lastBuildDate>
        <atom:link href="https://benchling.engineering/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Fragmentation to framework: Spec-first development at Benchling]]></title>
            <link>https://benchling.engineering/fragmentation-to-framework-spec-first-development-at-benchling-9b97302bddcf?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/9b97302bddcf</guid>
            <category><![CDATA[platform-engineering]]></category>
            <category><![CDATA[biotechnology]]></category>
            <category><![CDATA[benchling]]></category>
            <dc:creator><![CDATA[Eli Levine]]></dc:creator>
            <pubDate>Thu, 19 Feb 2026 17:29:16 GMT</pubDate>
            <atom:updated>2026-02-19T17:29:14.847Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y8ZsBmhYb7gPTvkFdbCOmw.png" /></figure><h3>Reaching the limit of manual platform development</h3><p>Benchling’s platform handles diverse scientific data, such as DNA sequences, antibodies, notebook entries, inventory containers, workflow runs, and dozens more. Each object type carries unique domain logic: how it is validated, what relationships it holds, and what actions users can perform on it.</p><p>As Benchling matured, capabilities were added that customers expected to work across all these objects including REST APIs for integration, a data warehouse for analytics, search indexing, and configuration migration tools for moving setups between tenants, among many others.</p><p>Each product team is expected to expose their data in all platform surface areas. However, because this process is manual it can also be brittle and costly. With M object types and N platform capabilities, and each object requires custom integration with each capability, you’re maintaining M×N integration points. Add a new object? You’ll need to integrate it with every platform capability. Add a new capability? You’ll need to integrate it with every object.</p><p>In practice, this meant product and tech debt: some objects were available via API but missing from the warehouse, or a feature was exposed in the UI but not in other platform surface areas. It also meant behavioral drift. The same object would have slightly different field names or validation logic depending on which surface you accessed it through.</p><p>As Benchling grew, so did its customers. Enterprise customers expect platforms that are designed for multi-modal integration that covers the full spectrum of Benchling’s data and functionality.</p><p>AI is quickly reshaping how knowledge work is done across all industries. But some fundamentals have not shifted. The same integration capabilities that make enterprise architectures more powerful are what makes agents more powerful too: both require data access and interoperability.</p><p>Thus a different approach was needed. Ideally one where the cost of adding types of domain-specific scientific data and functionality, and platform capabilities did not grow exponentially. And one where platform functionality can be built quickly and uniformly to unlock the power of enterprise and AI for our customers.</p><h3>Decoupling apps from platform</h3><p>Most platform capabilities were asking the same questions about every object. What is the object’s data model: what is it called, what fields does it have and what are the relationships to other objects? How do I find and read one? How do I create or update one?</p><p>These questions were being answered separately for each object &lt;&gt; platform capability pair. API shapes were defined via OpenAPI specs. Warehouse mappers (which internally need to find and read objects) defined warehouse tables shapes. The search team maintained its own index configurations (which also needed to find and read objects). Each team solved the same fundamental problems, such as implementing the shape and behavior of domain objects, in isolation.</p><p>The alternative is the spec-first approach: define each object once, in a way that any platform capability can consume. Instead of objects integrating with platform capabilities, objects declare their shape and behavior through a unified contract. This enables platform capabilities to read from that schema and operate generically across all objects that conform to it. The integration point moves from per-platform capability implementations to a single shared one.</p><p>This reframes the work for both sides. Object owners focus on defining their objects correctly and implementing domain-specific logic. They don’t write API endpoints or warehouse mappers. Platform teams focus on making these capabilities robust and performant.</p><p>We call our implementation of this approach the Object Framework.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*afKvi_JLS6kb-jHLycxi4Q.png" /></figure><h3>Introducing the Object Framework</h3><p>The architecture of Benchling’s core service follows a fairly classic three-tier architecture. Data is stored in a relational database. SQLAlchemy models represent persistent data. Business logic related to various product areas resides in a largely monolithic codebase. The code is well-structured internally but lacks an overall organization for higher levels of the stack to automatically “work” with all types of Benchling data and functionality, and to consistently expose it externally to customers through various platform touch points, such as APIs.</p><p>The Object Framework does not replace the core architecture. Instead it provides a wrapper layer to give it enough structure so that Benchling’s product functionality can be leveraged uniformly. The framework consists of three major components outlined below.</p><p><strong>Domain Graph</strong> is the schema layer. It is a unified type system that serves as the single source of truth for all object definitions. We use <a href="https://www.apollographql.com/tutorials/lift-off-part1/03-schema-definition-language-sdl">GraphQL Schema Definition Language</a> (SDL) as the specification language, chosen because it is declarative, has good support for relationship between types and allows for metadata extensibility via directives. This is where every object in Benchling is defined. The Domain Graph declares field names, types, relationships, and allows types to specify various types of metadata, such stability levels and feature flags. This controls the object’s shape and declarative aspects of their behavior. It also allows object owners to declaratively control the behavior of platform capabilities with SDL directives.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zBMV952AIwPlJNzLu15iRA.png" /><figcaption>Example of an object defined in the domain graph</figcaption></figure><p><strong>Data Connectors</strong> are the public service interface of objects. They encapsulate domain-specific business logic behind a standardized and dependency-free interface, abstracting away an object’s internal implementation details. A connector is implemented in Python and implements standard CRUD operations, such as get, list, create, update, and archive, as well as other domain-specific methods that make up its public interface. When another domain or a platform capability needs to interact with an object, it calls the connector. Connectors are registered and retrieved by type of domain object, which avoids direct coupling.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SUMgkOwWHr6ao7deEOq9zA.png" /><figcaption>Example of a registered data connector for a domain object</figcaption></figure><p><strong>Data Mappers</strong> are the persistence layer. They translate between storage representations and domain objects. This abstraction isolates business logic from database details.</p><h3>Impact across the stack</h3><p>For platform teams, the framework means building a capability once and having it work everywhere. A new type of API called <a href="https://benchling.com/api/v3-alpha/reference#/Benchling.AaSequence/Benchling.AaSequence.BulkCreate">Bulk Import API</a> is a good example. It is a new platform capability that exposes a file-based API for ingesting large amounts of Benchling object data. Each data connector implements standard methods for creating and updating batches of objects. Bulk Import API builds on top of this to create a higher-level API that accepts large amounts of data and orchestrates calls to data connectors.</p><p>There is a significant amount of complexity that goes into building a robust and scalable system. Bulk Import API endpoints automatically chunk large payloads, distribute work across our job infrastructure, and handle per-record results and errors. The team that built it wrote zero object-specific code, allowing it to focus on uniquely challenging features of this horizontal capability, not object coverage. When a domain team onboards a new object to the framework bulk import just works for that object with no additional integration required.</p><p>The same pattern holds for all other platform capabilities built on the framework.</p><p>For domain teams, the framework means focusing on what they know best. The engineers who understand the intricacies of notebook entries or molecular biology workflows spend their time on domain logic and user experience, not on wiring up API endpoints or warehouse mappers. Define the object in the Domain Graph, implement the connector interface, and the platform capabilities follow.</p><p>For customers, the impact is consistency and coverage. Field names are the same whether you’re querying the API or looking at the warehouse. Validation logic behaves identically across surfaces. If an object exists in Benchling, it’s accessible and consistent across the whole platform.</p><p>Several platform capabilities have shipped on top of the framework: V3 REST API (in beta), V3 Bulk Import and Export APIs (in beta), V3 Events (in beta), V3 GraphQL API (internal), with more on the way.</p><h3>Lessons learned</h3><p><strong>Developer experience is key.</strong> Earlier initiatives at Benchling aimed at similar goals but required teams to adopt rigid patterns that didn’t easily adapt to their domains’ needs. The Object Framework was more successful by focusing on interfaces over implementation: define your object in the Domain Graph and implement the connector contract. How the code is organized internally is up to domain teams. This, of course, is not black-and-white. More standardization is better in some cases. But when you are adopting a framework on top of existing systems, especially those that handle varied life science domains, one must be mindful of the flexibility that domain teams need to retain.</p><p><strong>A framework only delivers value when teams actually use it.</strong> In order to facilitate adoption we have invested in an explicit cross-team program: monthly adoption goals, tracked coverage across domain teams, coordination between platform engineers providing support and domain engineers doing the migration work. Dashboards show which objects are in the framework, at what stability level, and what’s blocking the rest.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CB7Qu_kHjKlS0_jUYkJL-w.png" /><figcaption>Example of internal tooling built for tracking adoption</figcaption></figure><p><strong>Tension between stability and flexibility</strong>. Benchling platform defines <a href="https://docs.benchling.com/docs/stability">multiple levels of stability</a>. Teams feel the pull in both directions: stay at Alpha too long, and internal consumers won’t depend on your object, customers can’t confidently build on it. Promote too early, and you’re locked into field names and behaviors you’ll want to change. While this is by design, we learned that stability lock-in is an acute concern for object owners.</p><p><strong>The codebase becomes self-documenting.</strong> Language models excel when they have clear structure, such as well-defined interfaces, consistent patterns, explicit contracts. The Domain Graph is both human-readable and machine-parseable by design. When an engineer or an AI assistant needs to access data from another domain, the answer is always “call the connector.” The more objects that conform to the framework, the more examples exist for a model or a human to learn from.</p><h3>Building toward a unified platform</h3><p>The Object Framework represents a maturation in how Benchling builds applications. The project isn’t finished. We think of it as a flywheel, where each object added to the framework increases the value of building new platform capabilities on top of it and each capability added increases the value of onboarding the next object.</p><p>For customers, we hope this means a platform that gets more consistent, complete over time. One that delivers value and innovation faster.</p><h3>Acknowledgements</h3><p>The Object Framework represents work by engineers across Benchling. Special thanks to the Domain Graph team and many application and platform teams who refined the framework through real-world usage and feedback.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9b97302bddcf" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/fragmentation-to-framework-spec-first-development-at-benchling-9b97302bddcf">Fragmentation to framework: Spec-first development at Benchling</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Leveling Up: Your Roadmap to Senior Engineering Manager]]></title>
            <link>https://benchling.engineering/leveling-up-your-roadmap-to-senior-engineering-management-593545039822?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/593545039822</guid>
            <category><![CDATA[leadership]]></category>
            <category><![CDATA[career-development]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[engineering-management]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Swathi Sundar]]></dc:creator>
            <pubDate>Wed, 28 Jan 2026 00:21:09 GMT</pubDate>
            <atom:updated>2026-01-28T00:21:08.239Z</atom:updated>
            <content:encoded><![CDATA[<h4>Tactical Reflections and Self-Assessment for Your Journey from Team Management to Organizational Leadership</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0W62u-PrdO26h4hx_OgOPg.png" /></figure><p>Are you an individual contributor (IC)<a href="https://medium.com/@swathi-sundar/should-i-become-an-engineering-manager-em-1011f0372481"> who’s just transitioned into an Engineering Manager</a> and trying to find your path to the next level? Or are you a seasoned Engineering Manager (EM) figuring out how to break through to a Senior Engineering Manager role? Read on!</p><p>Most companies have a career matrix that calls out the expectations for moving from Engineering Manager (M1) to Senior Engineering Manager (M2) also known as Manager of Managers (MoM). In this blog, I’ll share reflections and frameworks to help you find the right direction and progress your career.</p><h3>What is a Senior Engineering Manager Role?</h3><p>You might wonder what a Senior Engineering Manager (M2) or Manager of Managers (MoM) actually does.</p><p>As an M2, you draw on years of hands-on leadership experience. You typically oversee large or multiple teams, or lead major software initiatives that span several projects or departments. Your responsibilities often include managing complex programs with broad organizational impact and longer-term goals, going well beyond the scope of a single application or scrum team.</p><p>Here are some pre-requisites to consider that will impact your path to Senior Engineering Manager:</p><p><strong>Time in Role</strong><br>One of the key factors is the actual wall-clock time you spend as an effective engineering manager. This will include managing a single team through multiple quarters and overseeing several product releases before stepping up to Senior EM.</p><p>In most companies, the Engineering Manager ladder includes an M0 or transition band for those moving internally from IC roles. You typically stay at that level for about a year before moving into M1, which is considered the entry-level EM role. Some companies, like Uber or Facebook, only hire externally for M1 (not for M0). In other organizations, there may not be a transition band, and M1 itself is broad enough to include both new managers and those hired from outside to manage a single team. The M1 role can be a terminal level if you prefer to continue managing a single team. Depending on your company, it may take a couple of years to excel as a frontline EM and transition through M0 → M1 → M2.</p><p>There are also some experiences you’ll likely only encounter with time. For example, you might start with a strong team and not have to worry about managing a low performer for a year or two. Or your company or team may not have had open headcount to fill, so you won’t gain experience in hiring or dealing with the challenges of a rapidly growing team right away.</p><p><strong>Breadth vs Depth</strong> <br>You’ll also need to choose between a deep-technical manager path and a broader scope managing multiple teams.</p><p>Historically, opportunities for deep-technical management roles were limited. However, with the rapid adoption of AI across industries, the demand for leaders with strong technical expertise is growing significantly. Today, being a deep-technical manager is more important than ever for driving innovation and guiding teams through complex, technology-driven challenges. Most career ladders don’t do a great job of calling out the split between these two tracks as they do for ICs. The key difference is the scope of your impact and how you achieve it.</p><p>You could be a manager of a single scrum team at the M2/Senior EM level if your work has significant leverage and strategic impact across the organization, or if you play an architect-type role for a team that requires it. For example, areas like search, infrastructure, and machine learning often have deep M2 managers. If you go down the deep path, you’ll likely specialize in an area (such as search or ML) and stick to that area for much of your career, becoming known as a specialist.</p><p>Most people, however, follow the broad path as generalists. This is the more standard trajectory:</p><ul><li>You manage a team.</li><li>You organically grow the team with an expanded charter</li></ul><blockquote>The keyword here is <strong>organic</strong>, growing as the business grows and NOT to engage in empire-building.</blockquote><ul><li>You split the team into two or more. If you prove yourself competent, you may have the opportunity to run both teams.</li><li>You hire a manager for one team and continue to manage the other directly, eventually moving into manager-of-managers roles as your group expands.</li></ul><p>Another version of the broad path sometimes emerges: you manage one team very well, and as the need for another team arises in an adjacent area — or an existing team in an adjacent area needs a manager — you are asked to take on a second team in a rapidly growing organization.</p><p>As you can see, both paths depend on business needs and opportunities that arise over multiple years.</p><p>Having said that, let’s look at some tactical ways you can move from M1 to M2, or from Engineering Manager to Senior Engineering Manager:</p><h3><strong>1. People Management</strong></h3><p>The key here is to improve your team’s efficiency through <strong>active</strong> people management — enabling and helping your people stay engaged and succeed at your company.</p><p>Today, most companies are highly selective in their hiring, so you’ll likely work with engineers who are driven and focused, and who can often self-organize and deliver results. The difference you make as their manager is in how you help them grow and become better engineers.</p><blockquote><em>Aiming for and achieving mediocrity in People Management is a reachable goal for most Engineering Manager/M1, but striving for excellence here is important as you scale into Senior Engineering Manager.</em></blockquote><p>To excel as an Engineering Manager, you need to consider:</p><ul><li>Are you working on a career plan for each engineer that clearly outlines what they need to grow and improve to be successful in your company?</li><li>Are you guiding your team members through improvement and feedback loops multiple times, covering different skill sets?</li><li>Are you helping some of your engineers get promoted from one level to the next (for example, from L to L+1) over the course of a couple of performance feedback cycles? Does this include growing junior and mid-level engineers, as well as shaping senior and staff-level leaders?</li><li>If you have several new team members who are just starting to be productive at your company, are you considering what support you can provide to help them be effective at their level?</li><li>Are you holding a high bar for performance, and, if needed, actively managing the performance of those who do not meet expectations?</li></ul><p><strong>🌟 Your path to Senior EM🌟</strong></p><ul><li>Are you making your engineers stewards of your company, not just your team? Are you identifying key talent who could become future technical or people leaders, and helping them find their path? Are you getting them the visibility they need from your leadership team?</li><li>Are you encouraging and enabling your senior and staff engineers to contribute to broader groups or organizations outside your scrum team? Are you actively finding opportunities for them to pursue that go beyond your team’s boundaries?</li><li>If someone on your team wants to explore management, are you setting them up to mentor interns or junior ICs, giving them the chance to pseudo-manage a project or team, and helping them transition from an IC to an EM role?</li><li>In short, are you advocating ruthlessly for your team?</li></ul><p>So how do you know if you are excelling in this area?<br>Some signals you can look for include your team’s employee satisfaction scores, pulse survey results focusing on the manager section, and upward feedback from your direct reports.</p><h3><strong>2. Execution &amp; Delivering Results</strong></h3><p>This is the most important area because even if you meet every other expectation, if your team isn’t delivering consistent and impactful results, there is no path to the next level. Your primary role as a manager is to <strong>deliver value for the business</strong>. How effectively you do this depends on your skills in people management, technical leadership, and product strategy. The only way for you to deliver measurable value to your company is to execute flawlessly and ensure your team delivers results.</p><blockquote><em>Given infinite time and resources, all features can, and will be delivered, but the nuance here is how do you </em><strong><em>deliver these features faster with high quality and with lesser resources.</em></strong></blockquote><p>To excel as an Engineering Manager, consider:</p><ul><li>How can you continuously increase the velocity and quality of your team’s work?</li><li>Is everyone on your team working at their fullest potential? If not, how can you unlock it?</li><li>Are you pushing your team enough? Are you holding them accountable?</li><li>How can your team do the most impactful work, while delegating or having a process to scale for the more routine tasks?</li><li>Are you building the most important things for the business that align with your team’s charter?</li><li>What projects should you be investing in to provide direct impact for your company’s priorities?</li><li>What metrics do you have to measure your team’s success in execution?</li></ul><p><strong>🌟 Your path to Senior EM🌟</strong></p><ul><li>What cross-team partnerships do you need to forge to ensure smooth execution for your team?</li><li>How can you share the right level of detail with your manager to gain their support for investing in or resourcing your team? [managing up]</li><li>What is the perception of your team and your team’s execution across the organization? Do leaders feel like this is a critical team to invest in? If not, what level of context should you share, or what actions do you need to actively take to change the priority or perception of your team?</li></ul><h3><strong>3. Technical Leadership</strong></h3><p>Your main focus here should be developing a technical strategy and ensuring you deliver on it. This could mean building a brand new 0-to-1 product, or taking on platform migrations to improve scalability or performance.</p><blockquote><em>The incremental improvements in day to day is almost a given, but looking at a 1/2/3 year roadmap and investing in the right technical solution is the key.</em></blockquote><p>To excel as an Engineering Manager, consider the below:</p><ul><li>Are you regularly reviewing the critical technical plans for your team? Do you have a forum for reviewing architectural decisions?</li><li>What technical trade-offs should you consider between long-term and short-term investments?</li><li>How much should you slow down to invest in tech debt so you can move faster on overall deliverables?</li><li>Do you have the right operational rigor? Are you ensuring operational excellence in reliability (including availability and latency), performance, data integrity, and proactive detection and monitoring to maintain the overall technical health of your team?</li></ul><p><strong>🌟 Your path to Senior EM🌟</strong></p><ul><li>Are you doing thorough research to understand where your company is headed and anticipating the need to scale? Are you looking for cues in All Hands meetings and having conversations with your Head of Strategy or your VP/CTO?</li><li>Are you ensuring that your technical investments align with your company’s multi-year technology strategy?</li><li>Are you identifying gaps in technical strategy across the organization and building alignment for your proposals with key decision makers? Are you partnering with and deploying your trusted staff, senior staff, or architects to help make your vision a reality?</li><li>Where should you leverage AI, and which AI tools should you invest in as a team or organization?</li></ul><h3><strong>4. Strategy &amp; Partnerships</strong></h3><p>In most companies, Product Managers (PMs) and Engineering Managers (EMs) are two peas in a pod. While you each have distinct roles, building the right product features — in the right order — to unlock customer value is a shared responsibility. This includes:</p><ul><li>Partnering with your PM to define goals that address critical product gaps.</li><li>Creating a structured feedback loop with customers to inform your roadmap.</li><li>Co-owning the product backlog and collaborating on prioritization.</li><li>Developing innovative technical solutions to execute the roadmap.</li></ul><p>Healthy debate and occasional tension over priorities are normal and even necessary. For platform and tooling teams, investing in developer experience for internal customers remains equally critical.</p><p>To excel as an Engineering Manager, consider:</p><ul><li>What features should we build next year to align with the company’s key focus areas?</li><li>Will this investment drive new revenue, improve customer adoption, or accelerate engineering velocity?</li><li>How do we measure the impact of our developer experience improvements?</li><li>What are the top unresolved pain points for our customers, and how can we address them?</li></ul><p><strong>🌟 Your path to Senior EM🌟</strong></p><ul><li>What are the top three problems for your customers, and should you solve them? If so, can you pick one unsolved pain point and get your team to chase after it?</li><li>What else should your team focus on to help your company succeed? Should you invest in turning your product into a platform and building for leverage?</li><li>What should you NOT do?</li></ul><blockquote><em>What will be the impact if we do NOT invest in your team’s charter at all for the next six months? What else should your team work on to drive revenue for your company?</em></blockquote><p>It can be difficult to ask yourself this kind of existential question, but doing so will help you assess the importance of your team and enable you to shape your team’s charter more effectively.</p><h3><strong>5. Scaling the organization</strong></h3><p>What this entails is building the right engineering culture for your team and your company.</p><blockquote>Being satisfied with status-quo is likely one of the failure modes here.</blockquote><p>To excel as an Engineering Manager, consider:</p><ul><li>Is what we are doing good enough? And what would make it great?”</li><li>Are you providing a psychologically safe environment for your team? Do they feel comfortable suggesting alternative and innovative ideas that challenge the current situation and lead to new ideas, processes, or solutions?</li><li>Are you participating in working groups and helping to push for a better culture?</li></ul><p><strong>🌟 Your path to Senior EM🌟</strong></p><ul><li>Are you looking beyond your own team to improve processes across your company? Every organization faces challenges in areas like hiring, branding, onboarding, fostering innovation (such as through hackathons), setting up effective mentorship for engineers who feel stagnated, improving interview questions, maintaining social connections across teams, enhancing technical operations, investing in documentation, and refining SDLC processes, among many others.</li><li>To truly excel and move toward the M2/Senior EM role, are you stepping up to drive these changes from the front, making a measurable difference and impact across the organization?</li></ul><p>That’s it! As you reflect on your journey from Engineering Manager to Senior Engineering Manager, remember that growth in leadership is both a personal and organizational pursuit. By focusing on people development, technical strategy, operational excellence, and cross-functional collaboration, you can position yourself — and your team — for lasting impact.</p><p>The landscape of engineering leadership is evolving rapidly, especially with the rise of AI and emerging technologies. How do you envision the EM or Senior EM role changing as AI becomes an even more integral part of our organizations? I’d love to hear your thoughts — share your perspective in the comments below!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=593545039822" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/leveling-up-your-roadmap-to-senior-engineering-management-593545039822">Leveling Up: Your Roadmap to Senior Engineering Manager</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Multi-Model Playbook]]></title>
            <link>https://benchling.engineering/the-multi-model-playbook-20d5fba48562?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/20d5fba48562</guid>
            <category><![CDATA[agentic-engineering]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[biotechnology]]></category>
            <category><![CDATA[benchling]]></category>
            <dc:creator><![CDATA[Sumedh Bhattacharya]]></dc:creator>
            <pubDate>Fri, 16 Jan 2026 16:02:06 GMT</pubDate>
            <atom:updated>2026-01-16T16:02:05.725Z</atom:updated>
            <content:encoded><![CDATA[<h3>The Multi-Model Playbook: Patterns in Agentic Engineering</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ci6KUXDLMB-9m3CFJmoGmw.png" /></figure><p>Building production AI systems that work reliably across multiple model providers requires more than just swapping API keys. Over the past year, working on AI agents like the Data Entry Agent and Compose Agent at Benchling, I’ve learned that successful multi-provider strategies come down to understanding what’s universal versus what’s provider-specific, and designing around those constraints. <strong>The clearest revelation here was that the architectural principles underlying reliable software — modularity, separation of concerns, clear interfaces — apply just as fundamentally to AI systems as they do to traditional code.</strong></p><p>The Data Entry Agent (DEA) extracts structured data from PDFs and images, while Compose is an agent that helps scientists write electronic lab notebooks (ELNs) by extracting content from attached files, connecting that with data in Benchling’s Registry, and outputting structured scientific protocols, analysis, and more. These systems currently support five different model families (OpenAI GPT, Anthropic Claude, Google Gemini, Meta Llama, and Amazon Nova), typically using four in any given run. This experience has revealed patterns that hold true across providers — patterns around task decomposition, prompt structure, caching strategies, and data presentation. While each provider has its quirks, these foundational strategies have proven consistently effective.</p><p>In this post, I’ll cover:</p><ul><li>How to break down problems for optimal LLM performance</li><li>Why the system versus user prompt distinction matters for caching</li><li>Best practices for presenting structured data as context</li><li>Practical comparisons between model providers</li><li>How to apply these principles when using AI coding assistants.</li></ul><h3>Breaking Down Problems: Small &amp; Complex versus Large &amp; Simple</h3><p>LLMs lose accuracy when handling multiple separate tasks simultaneously or when operating on large input contexts. The sweet spot is to give them either a small, complex task or a large, simple one.</p><p>Even for complex tasks, it’s often better to identify modular, independent portions and run them in parallel on lighter models. For example, when building a PDF data import tool, we found that asking Sonnet 4.5 to transcribe an entire large file in one completion produced inconsistent results — it would summarize or gloss over certain sections. Instead, we used Haiku 4.5 to transcribe small chunks independently and in parallel, then stitched them together at the end using a MapReduce approach. This proved more accurate, faster, and cheaper, despite using a lighter model.</p><h3>System Prompt versus User Prompt: It’s About Caching</h3><p>The distinction between system and user prompts doesn’t significantly affect accuracy, but it matters enormously for prompt caching. Since the system prompt always precedes the user prompt in the final message to the LLM, any change to the system prompt causes a cache miss even if the user prompt remains unchanged. Therefore, when an operation requires multiple completions over the same base content, keep the system prompt constant and vary only the user prompt for different tasks.</p><p>The system prompt is reintroduced at the beginning of each conversation turn and can be cached across all turns. Subsequent turns are appended after it, with the latest user or assistant message appearing last.</p><p>For effective prompt caching, every string up to the cache point must match exactly. This means the system prompt must remain identical since it’s the first item in the message blocks, and user prompt blocks must also match up to the cache point.</p><h3>Presenting Structured Data as Context</h3><p>There’s no single best approach, but I’ve found the following patterns generally improve accuracy:</p><p><strong>Tabular data: </strong>Raw CSV works fine for smaller datasets. However, when column or row counts exceed 20, include indexes alongside the data itself. Without them, the model loses track of its position and can mix values across rows or columns.</p><p><strong>Non-tabular data:</strong> JSON works excellently. The nested structure and close semantic proximity between keys and values means key/value pairs are rarely mixed up.</p><p><strong>Source files: </strong>When including source files you want the model to reference, place them at the very beginning of the user prompt within specially formatted <em>&lt;documents&gt;&lt;/documents&gt;</em> XML tags, with individual <em>&lt;document&gt;&lt;/document&gt;</em> tags for each file. While this is specifically recommended for Anthropic’s long context usage, the general strategy of separating distinct prompt areas with XML tags works well across providers.</p><h3>Model Provider Comparisons</h3><p>The above Sonnet vs Haiku guidance refers to different-sized language models provided by Anthropic; the same could be said about Google’s Gemini 2.5 Pro vs Gemini 2.5 Flash models. However, each provider has slight quirks. These comparisons are between equivalent model tiers across providers:</p><p><strong>Anthropic</strong> delivers the best combination of accuracy, speed, and cost. Sonnet 4.5, with long context and extended thinking enabled, was the first model to pass our hardest evaluation test cases for Data Entry Agent across both image and text inputs. Even Haiku 4.5 now offers nearly Sonnet 4 levels of accuracy at much faster speeds and lower costs.</p><p><strong>Gemini</strong> is typically accurate with structured data but quite slow, especially with 2.5 Pro. This is likely better now with Gemini 3 but we are waiting for that to come out of Preview prior to using it in production.</p><p><strong>OpenAI</strong> is usually fast and consistently produces well-structured JSON, but sometimes struggles with accuracy or understanding nuanced instructions. This has improved greatly with GPT 5 and 5.1 but still lags behind Sonnet 4.5 for our use-cases without custom prompt tuning.</p><p><strong>Llama</strong> performs adequately in terms of accuracy and speed but has difficulty generating well-structured JSON for large responses.</p><p><strong>Nova</strong> showed the lowest scores across all of our model providers for our use cases. Nova Premier is very slow, and Nova Pro lacks accuracy. Both struggle to produce well-formed JSON responses.</p><h3>Applying These Principles to AI Coding Assistants</h3><p>While the strategies above focus on production systems and API usage, the same principles apply when using AI coding assistants like Cursor or Antigravity for development work. The key insight — breaking down complexity and being strategic about context — translates directly to these tools.</p><p>I’ve found two largely different styles of using AI coding assistants for different types of scenarios:</p><p><strong>For new features where I don’t have much existing context: </strong>First, I ask the assistant to plan and investigate the approach. I iterate on the plan until I’m satisfied, digging into any areas of the code it’s identifying that I don’t know about, then ask it to execute pieces modularly. At each modular step, I either test manually or use Antigravity or Cursor’s browser integration to have it test itself for full-stack changes. For these problems, I let it work largely autonomously on each modular step, checking in only at intervals, since each step requires a fair bit of thinking and writing. This approach is fantastic for quickly prototyping and exploring solution spaces. However, the code generated through this exploratory process isn’t production-ready as-is — it needs to be broken down into modular chunks, with each chunk then refined through the second style below to create well-scoped, tested PRs for production.</p><p><strong>For tasks with existing context:</strong> This is where I take a more hands-on, deliberate approach, whether refining prototyped code or building well-understood features from scratch. I provide very detailed instructions and include all files I know it will need for context. The more specific and accurate my instructions, the better the response and the more it feels like it is doing exactly what I would expect it to do. While it’s working, I watch the output as it’s being generated to ensure it stays on track and to understand exactly what it’s writing. At any moment it goes off track, I stop it and correct it. Then, at each step, I also @-mention the Git diff from the main branch to ask it to write unit tests specifically for the portion of code it has just written to guarantee its validity. This tighter feedback loop produces well-scoped, readable, and tested code that only requires minimal manual intervention prior to being ready for production.</p><p><strong>Remote vs. Local Agents:</strong> I’ve found it quite useful to do the above practices in parallel for different tasks. For example, I will first work with a local agent on a new feature to come up with the plan. Then, I’ll hand the plan off to a remote agent such that it can work on the cloud on a new branch. While it’s working, I’ll switch my local branch so that I can also work on a well-scoped problem that I have existing context on. Here, I’ll iterate with the agent, make my manual edits, and get a PR ready. Once the PR is set up and CI is running, I can now pull in the new feature branch my remote agent was working on to see how it is doing.</p><h3>Key Takeaways</h3><p>When you break problems down sufficiently, lighter, cheaper models like Haiku can often replace Sonnet. The architectural discipline of modular task decomposition improves reliability regardless of which model you choose. While the cost difference per run might seem small, it compounds at scale. More importantly, the improved reliability and maintainability make this approach worthwhile even beyond cost considerations.</p><p>The deeper lesson here is that good engineering practices for AI systems mirror good engineering practices in general. <strong>The same principles that make traditional software maintainable — modularity, separation of concerns, clear interfaces — also make AI systems more reliable and debuggable. </strong>When an LLM fails on a monolithic task, it’s often unclear where things went wrong. When it fails on a well-scoped subtask, the problem is isolated and fixable.</p><p>This has implications for how we should think about building with AI going forward. As models continue to improve, the temptation will be to throw increasingly complex, multi-step problems at them as single prompts. But the systems that will scale and remain maintainable are those that treat LLMs as components in a larger architecture, not as magical black boxes that can handle anything. <strong>The future of production AI isn’t about finding the one perfect model — it’s about building systems that work reliably across models, degrade gracefully when models fail, and remain comprehensible to the humans maintaining them.</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=20d5fba48562" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/the-multi-model-playbook-20d5fba48562">The Multi-Model Playbook</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How We Run Terraform At Scale]]></title>
            <link>https://benchling.engineering/how-we-run-terraform-at-scale-da7bb75dc394?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/da7bb75dc394</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[cloud-infrastructure]]></category>
            <category><![CDATA[infrastructure-as-code]]></category>
            <dc:creator><![CDATA[Christian Monaghan]]></dc:creator>
            <pubDate>Tue, 04 Mar 2025 16:32:39 GMT</pubDate>
            <atom:updated>2025-03-06T20:24:25.507Z</atom:updated>
            <content:encoded><![CDATA[<p>Managing over 165k cloud resources across hundreds of workspaces could seem daunting. But for us, it’s just another day at Benchling. Here’s how we do it.</p><p>We currently have:</p><ul><li>165k cloud resources under management</li><li>625 Terraform workspaces</li><li>38 AWS accounts</li><li>170 engineers (40 of whom are infra specialists)</li></ul><p>We perform:</p><ul><li>225 infrastructure releases daily (terraform apply operations)</li><li>723 plans daily (terraform plan operations)</li></ul><p>We’ve been successfully operating Benchling’s infrastructure release system for the past two years (spoiler, it’s Terraform Cloud), over which time we’ve doubled our infrastructure footprint with minimal additional release overhead.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SAXPar4lfLKCbMmTDJeT8g.png" /></figure><h3>Before Terraform Cloud: The Chaos</h3><p>Our infra release process wasn’t always this smooth. Let me rewind and take you back to how it was before.</p><p>As is common guidance for small Terraform projects, our team would previously apply all infrastructure changes via laptop. Also in line with common guidance, our team used S3 to store state files, with DynamoDB state locks, which prevented any apply-time collisions. This is a great strategy for a small team working on up to a dozen workspaces. However, this slowly starts to break down as the team’s workspace footprint grows. It’s like the proverbial frog in the pot of water, slowly heated to a boil. By the time we made the switch, Benchling was managing 350 workspaces. We were approaching the boiling point.</p><h4>Pain Points: Developer Toil and Inefficiency</h4><p>Managing 350 workspaces with this approach had several downsides:</p><ol><li><strong>Necessitated elevated AWS access </strong>permissions for the infrastructure team.</li><li>It was <strong>time-consuming</strong> as the engineer had to navigate to each directory, run terraform apply, review and approve the run, then verify it succeeded. Very commonly a single change could affect over 120 workspaces, which would mean repeating this process 120 times. (We had developed a custom python script which helped parallelize this somewhat.)</li><li><strong>Accumulated infra drift</strong>. Often an engineer would go to apply their change and find numerous unrelated pending infrastructure changes. This situation could arise for many reasons — a previous engineer had missed this workspace while rolling out a change, did not realize an apply step was required, or missed that their apply had failed. The unlucky engineer who encountered this would then need to track down the author of the change which caused this drift, confirm whether this change was intended and safe to apply, and roll out the change. Then they’d have to repeat this process for each of the impacted workspaces.</li></ol><p>Applying a single change could easily take a full day, particularly if you encountered unexpected drift (pain point #3). Because of the release overhead associated with an additional workspace, the team was incentivized towards several anti-patterns.</p><p>The first anti-pattern was to put as many resources as possible into a single directory/workspace to minimize the number of workspaces that required an apply (thus minimizing pain point #2). This meant some workspaces were managing upwards of 4k resources, which made plan times painstakingly long (30+ min) and increased the blast radius for any change that went poorly.</p><p>These excessive plan times for our monster workspaces (pain point #2) and accumulated drift (pain point #3) pushed our team towards a second anti-pattern — using the Terraform -target feature. This feature allows a developer to limit changes to a subset of the full infrastructure configuration. While this can be useful in limited circumstances, it functions by only applying changes to a subset of terraform’s acyclic graph (which maps all resource dependencies), so it can cause all sorts of unintended chaos if used indiscriminately. Hashicorp themselves, the authors of Terraform, <a href="https://developer.hashicorp.com/terraform/cli/commands/plan#resource-targeting">strongly discourage</a> use of the -target feature for routine operations due to the possible side effects.</p><p>Overall this tooling gap was a source of developer toil and risk. It was clear for an organization at our scale we needed to automate our infrastructure release process.</p><h4>Our Solution: Automate Terraform with Terraform Cloud</h4><p>We evaluated several infrastructure automation tools — specifically Spacelift, Terraform Cloud, and Atlantis. We ended up deciding on Terraform Cloud, mostly for the perceived benefits of working with Hashicorp, who were larger, more established, and authored and owned Terraform.</p><p>Successfully rolling out Terraform Cloud required two big changes to our developer workflow. In particular:</p><ol><li>Move from an “apply then merge” workflow to a “merge then apply” workflow. This was a big source of uncertainty as we rolled out since there is really no way to test for apply-time errors on <em>all</em> workspaces before merging a PR to our main branch.</li><li>Move to untargeted applies.</li></ol><p>We helped ease the pain of this transition with several training sessions, a detailed FAQ, a dedicated Slack channel for questions, and by carefully watching Terraform Cloud for the first few months to ensure no backlogs of releases, erroring runs, etc.</p><p>We used an incremental rollout strategy to limit the blast radius, to give our engineering teams time to build familiarity in lower-risk workspaces first, and to learn and adapt our resource capacity planning for Terraform Cloud agents.</p><h4>The Impact: Efficiency, Reliability, and Developer Happiness</h4><p>The resulting impact of this change:</p><ul><li>Eliminated drift (problem #3 above), a huge source of risk and developer toil.</li><li>Roughly <strong>8000 developer hours saved annually</strong> (40 infra specialists × 4 hrs/wk × 50 weeks/yr = 8000 hrs) — that’s equivalent to getting 4 developers back!</li><li>Audit log of every change for a given workspace linked to commit and author. We can’t emphasize enough just how helpful this is for debugging issues.</li><li>Speculative plans — a prospective change can be automatically tested across dozens of impacted workspaces and the results displayed directly in GitHub CI.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Xq4fbdkJfDgAhlEI" /></figure><p><em>This screenshot shows how we limit speculative plans to a small number of canary workspaces (in this case just one).</em></p><p>In the time since we initially rolled out Terraform Cloud two years ago, we’ve continued to refine and improve this system in many ways both big and small.</p><h3>How We Run Terraform Cloud Today</h3><p>We host our own installation of Terraform Cloud with our TFC agents running in our own AWS account. (Hashicorp calls this product Terraform Cloud for Business.) We prefer to keep all admin access to production infrastructure in-house without conferring any production permissions to Hashicorp. We run these Terraform Cloud agents in our own ECS cluster. Our contract allows us to run up to 200 concurrent agents, though we typically run 120 across two agent pools (40 in our dev pool and 80 in our prod pool). This allows us to release changes to our 625 workspaces with high concurrency. For example, if a single change impacted 80 workspaces, it can be applied to all 80 workspaces simultaneously.</p><h4>The Things We Monitor Obsessively</h4><ol><li><strong>Agent exhaustion / concurrency limits</strong>: If there are no available agents for a sustained period, we page our on-call (we intend to implement autoscaling one day).</li><li><strong>Plan time</strong>: If plan time exceeds 4 min in dev, we notify our team. We care most about ensuring quick plan times in dev workspaces since this reduces developer feedback loops during infra development.</li><li><strong>Infra drift</strong>: After a year of measuring minimal drift, we eventually stopped measuring this because drift doesn’t meaningfully exist in our infrastructure anymore. Since 1) all applies are untargeted, 2) zero engineers have prod write access by default, 3) releases are so frequent that any drift is quickly addressed by the next release.</li></ol><h4>Quality of Life Optimizations</h4><p>Although Terraform Cloud has been a great tool for us, as a large-footprint organization and power-user, we’ve found it lacks some features we need. Here are the custom features we’ve built around it.</p><h4>TFC CLI</h4><p>Some of our Terraform modules are used across many many workspaces. For example we have 261 workspaces affected by changes to our “deploy” module. Any change that impacts this module requires 261 reviews and approvals, even though the actual changes are substantively the same. Clicking through the Terraform Cloud UI is tedious, so we wrote a CLI. Our tool lets us run tfc apply --commit abcd1234 to review plans and apply changes. A more sophisticated invocation might look like tfc review --commit abcd1234 --wildcard update:module.stack.*.access_controls --include-tag type:deploy. This command auto-approves changes that match the specific commit SHA, the wildcard resource address, and the provided label/tag.</p><p>We’ve added several other features over time, but the commands that get the most heavy use are tfc run (trigger new plans), and tfc review (review and approve pending applies).</p><h4>Notifications</h4><p>Because we require manual review and approval for each production change, a developer can easily merge their change and then forget to apply it. We built a Slack notifier service to solve this problem. It runs every 10 minutes and notifies the commit author of any pending Terraform Cloud applies. It only runs during business hours and does an exponential backoff so as to not be too annoying.</p><h4>Workspace Managers</h4><p>We have 625 workspaces, so of course we manage our Terraform workspaces with Terraform! We make heavy use of the <a href="https://registry.terraform.io/providers/hashicorp/tfe/latest/docs">tfe provider</a>. We built a tfc-workspace module which we use to provision each workspace.</p><h4>Ownership Delegation</h4><p>Our team owns Terraform Cloud as a service provided to our infra, security, and developer teams. We try to keep these workspaces in a non-errored state, with providers updated monthly, and any deprecation warnings addressed promptly. However some workspaces manage resources outside our team’s expertise, at which time we need to delegate to the appropriate team to address these issues. To solve this we’ve developed a convention of applying tags to each workspace with tags like owner:{github_team_name}, for example owner:infra-monolith or owner:security-eng. This allows us to notify the appropriate team when an issue with the workspace arises.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/504/0*TBzm4eenjYzQyIIl" /></figure><p><em>Here’s an example of how we tag workspaces today. We apply these via our </em><em>tfc-workspace terraform module to keep naming consistent.</em></p><h4>TFC Usage Reporting</h4><p>Lately, our Terraform Cloud contract has come up for renewal, which means we need to predict future growth and usage. Unfortunately, Terraform Cloud only tells you total Resources Under Management at the current point in time, but nothing else.</p><p>To this end we built a script that uses the TFC API to query each state version for each workspace, going back a year, and tabulates this data into a CSV, after which we build some charts. These charts allow us to track growth by provider resource type (e.g. aws_s3_bucket, aws_ec2_instance), workspace type (e.g. type:region), or AWS account. Ick, but it works.</p><h4>TFC State Backup</h4><p>We need a disaster recovery strategy in case Terraform Cloud is down. During a disaster recovery incident we can revert to local mode, if approved to do so, utilizing break-glass functionality and processes. However, the one gap here is we need access to the state file, which is stored in Terraform Cloud. To protect against a loss of the state file, we’ve implemented a flavor of <a href="https://dev.to/mnsanfilippo/how-to-backup-the-terraform-states-from-terraform-cloud-workspaces-1km4">this post</a> to back them up to an S3 bucket after each apply.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*c5EuQlGpUp7XZKT8" /></figure><p><em>Here’s an example of our state backup webhook. Upon completion of a </em><em>terraform apply, it triggers a lambda which copies the terraform state file from Terraform Cloud to a secondary location in S3 which we can use in a disaster recovery event.</em></p><h4>Workspace Dependency Map</h4><p>One great thing about Terraform Cloud is it allows you to have a given workspace watch certain repository directories for changes. For example, a workspace can watch for changes to the tf-modules/* and trigger plans if anything in that directory changes. However, this doesn’t work very well at our size because we both use a monorepo and also have 180+ modules with 625 workspaces each using some subset of those modules. (For example, if all 625 workspaces tracked tf-modules/* and a single file was changed in that directory, then it would trigger 625 runs, quickly exhausting our agent pool of 120 agents, even if most workspaces resulted in a no-op.) Thus, we built a custom tool that maps out the module dependency tree for each workspace and generates a yaml configuration which is read by our tfc-workspace module to determine which directories to watch.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*fiRua0_w7LS9M_Ir" /></figure><p><em>This shows the directories we track for one example workspace.</em></p><h4>Provider Upgrades With Dependabot</h4><p>With 625 workspaces, each using an average of 3 providers, that’s 1875 unique provider-workspace upgrades to perform. We use Dependabot to help with this, upgrading all providers on a monthly cadence.</p><p>Even managing Dependabot at this scale takes some work, so we’ve built automation that allows us fine-grained manipulation of the dependabot.yml file. This enables us to allow-list certain providers for upgrade, deny-list other providers, isolate upgrades to just dev or prod workspaces, or treat individual workspaces with special conditions. Here’s a <a href="https://gist.github.com/cmonaghan/7e714071a1c4be1026f01ab3efb1b8dd">gist that shows how our </a><a href="https://gist.github.com/cmonaghan/7e714071a1c4be1026f01ab3efb1b8dd">dependabot.yml is structured</a>. Fully-generated for all workspaces, this file runs to 2000+ lines of yaml.</p><h3>Ongoing Improvements: Optimizing for Scale</h3><p>Our infra release system is still a work in progress. It’s not perfect, but we continue to make improvements every day. Here’s where we’d love to take it next:</p><h4>Staged Rollouts</h4><p>We currently use a main branch. Once merged to main, it gets released to most workspaces (our validated customers, or GxP customers, are an exception as they only receive quarterly releases). We’d like to move to staged rollouts with more release tiers (e.g. dev, staging, prod, gxp). We would verify success across the tier before promoting to the next tier.</p><h4>Decompose Large Workspaces Into Many Smaller Workspaces</h4><p>Although we’ve grown from 350 to 625 workspaces, we still have numerous workspaces managing thousands of Terraform resources. This makes plan and apply operations slow to complete. Since rolling out changes to all workspaces is now fully automated now, we should lean into decomposing these workspaces further, breaking these 625 workspaces into 1500+ workspaces to reduce plan and apply time and to minimize blast radius.</p><h4>Enhanced Notifications</h4><p>The ability to reassign Slack notifications to other users has been a popular feature request. Also, it’d be nice if the Slack bot prepared a tfc review command scoped to just the impacted workspaces.</p><h4>Agent Autoscaling</h4><p>Auto scaling this kind of workload is complicated and Hashicorp supports an EKS Operator to do just that. We hope to migrate our agent pool to EKS to employ the supported pattern.</p><h4>Open-Source</h4><p>We’ve built a lot of custom tooling to support our infrastructure automation. Most of it solves use cases we imagine many other teams have, so we’d love to open-source this work.</p><h3>It Takes a Village</h3><p>This system was built by many hands, thanks to the collaboration and insights of many engineers across Benchling. Our deep gratitude to all those Benchlings who have been partners in helping us get to where our infrastructure story is today!</p><p>We hope this post proves helpful to you and your organization in designing your cloud infrastructure for scale.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=da7bb75dc394" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/how-we-run-terraform-at-scale-da7bb75dc394">How We Run Terraform At Scale</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building an LLM-Powered Slackbot]]></title>
            <link>https://benchling.engineering/building-an-llm-powered-slackbot-557a6241e993?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/557a6241e993</guid>
            <category><![CDATA[amazon-bedrock]]></category>
            <category><![CDATA[large-language-models]]></category>
            <category><![CDATA[knowledge-base]]></category>
            <category><![CDATA[slackbot]]></category>
            <category><![CDATA[retrieval-augmented-gen]]></category>
            <dc:creator><![CDATA[Christian Monaghan]]></dc:creator>
            <pubDate>Fri, 13 Dec 2024 17:32:12 GMT</pubDate>
            <atom:updated>2024-12-13T17:32:12.728Z</atom:updated>
            <content:encoded><![CDATA[<h3>Background</h3><p>At Benchling we run cloud infrastructure across several regions and environments. To coordinate and manage this complexity, our team operates a self-hosted implementation of Terraform Cloud, managing around 160,000 terraform resources across five data centers. About 50 engineers from across the engineering org release some form of infrastructure change within a given month — some are infrastructure specialists, and others are application engineers who are completely new to Terraform Cloud.</p><p>Understandably, we get a lot of questions about how to use Terraform Cloud or how to debug a specific issue, and that forum is usually in Slack. We have a glorious 20-page FAQ in Confluence that answers most questions, supplemented by numerous Slack threads documenting previous problems and their eventual solutions.</p><p>So we <em>have</em> good documentation, but <em>finding</em> it is a pain. Who wants to read through a 20-page FAQ? Or go Slack spelunking to find that answer 40 messages deep into a thread?</p><p>We set out to solve this problem by building a Slackbot that could dynamically answer any user question without doing any tedious searching. To accomplish this we implemented a Retrieval-Augmentated Generation (RAG) Large Language Model (LLM). Here’s the story of how we did it and what we learned along the way.</p><h3>What we built</h3><p>We built an internal Slackbot that enables Benchling engineers to interact with a knowledge base to answer common Terraform Cloud questions. It also serves as a reference implementation for future LLM-powered tools at Benchling. It demonstrates how we can combine disparate information sources, both internal and public (web, Slack, Confluence), with the latest Large Language Models to expose this to the user through a familiar Slack interface. This pattern can be reused to develop Slack assistants for other specialized knowledge areas such as answering HR questions, surfacing past solutions to customer issues, or explaining software error codes.</p><p>Here’s what the interface looks like:</p><figure><img alt="An example showing the Slackbot interface" src="https://cdn-images-1.medium.com/max/699/0*mzdPzC4t6l9T20X_" /></figure><h3>How does it work?</h3><p>We built the RAG LLM portion of our tool using Amazon Bedrock. Read more about how this works in<a href="https://aws.amazon.com/what-is/retrieval-augmented-generation/"> this AWS post</a>. The TLDR is:</p><p><em>Retrieval-Augmented Generation (RAG) is the process of optimizing the output of a large language model, so it references an authoritative knowledge base outside of its training data sources before generating a response.</em></p><p>For simplicity we’ll just use the term “knowledge base” throughout the rest of this post. The core concept behind it is:</p><ol><li>Search a database for content relevant to the user’s query</li><li>Feed this content into an LLM prompt, along with instructions for how to use this content and generate a response</li></ol><p>You can visualize it like this:</p><figure><img alt="A diagram describing how the Knowledge Base and User Query are combined to submit information to the LLM prompt." src="https://cdn-images-1.medium.com/max/1024/0*gjc6JKiwYplJCqHi" /></figure><p>To see how this works in practice, take a look at Bedrock’s default knowledge base LLM prompt:</p><figure><img alt="The Amazon Bedrock LLM prompt template." src="https://cdn-images-1.medium.com/max/708/0*510Rldbrlz2fYAmf" /></figure><p>This prompt is comprised of three key components:</p><ol><li>Instructions</li><li>Search results</li><li>User query</li></ol><p>To set up our knowledge base, we used the Amazon Bedrock knowledge base setup wizard, which walks you through the steps in a few minutes. Behind the scenes it creates an OpenSearch Serverless database (a specific type of vector database within the Amazon OpenSearch service, used to source content related to the user query). It also sets up all the necessary IAM roles and policies, creates the Bedrock resources, and establishes data sources (the reference data that will be embedded and stored in the vector database). These data sources are then processed by async jobs and saved in the OpenSearch Serverless database.</p><h3>What data powers our knowledge base?</h3><p>We’ve implemented our knowledge base so that four different data sources are ingested and stored in the vector database. When a user query is received, the system runs a search against the vector database to find the most relevant sections of text across all the ingested data sources. Those query results are then fed into an LLM prompt (we use Claude 3.5 Sonnet v2) to synthesize a helpful response based on the retrieved answers.</p><p>The data sources we configured are:</p><ul><li><strong>Confluence</strong>: Terraform Cloud FAQ (this page was exported to PDF then stored to S3)</li><li><strong>Web</strong>: Selected Terraform Cloud documentation on Hashicorp’s public documentation site</li><li><strong>Web</strong>: Selected Terraform language documentation on Hashicorp’s public documentation site</li><li><strong>Slack</strong>: Selected Slack threads where a Terraform Cloud issue was raised and eventually solved (for the proof of concept these were hand-copied from a few Slack threads, pasted into a .txt file and stored to S3)</li></ul><p>This is a minimal set of data to prove out these concepts, but we can expand and enrich each of these or add new data sources in the future.</p><p>Here are what the currently supported data sources look like in Amazon Bedrock:</p><figure><img alt="Screenshot of the Amazon Bedrock setup wizard." src="https://cdn-images-1.medium.com/max/1024/0*BJDFfKZkWlrDnBnI" /></figure><p>And these are the data sources we have configured:</p><figure><img alt="A screenshot of Amazon Bedrock data sources" src="https://cdn-images-1.medium.com/max/1024/0*C4GWuWEcj-uSg1vC" /></figure><p>After going through the process of building out our knowledge base and integrating it with Slack, here’s what we learned:</p><h3>Limitations</h3><p><strong>No images.</strong> The knowledge base cannot process images submitted as part of a query, nor does it include any images from our documentation in its responses. This is unfortunate as our help documents include numerous images in the form of architecture diagrams, screenshots of a UI component, or an error message.</p><p><strong>No terraform support, yet. </strong>The Terraform AWS provider’s current support for Amazon Bedrock is a bit paltry. None of the resources we used here are supported by the provider yet, though support will likely be added soon. We’ll keep checking back on the <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/bedrock_custom_model">Terraform Bedrock resources page</a> until the latest knowledge base resources are supported.</p><h3>Potential future enhancements</h3><p><strong>Present answer citation links to the user. </strong>Currently this is available in the Bedrock UI when testing a model. However the answer we send to Slack does not include any citations or link to the source documents.</p><p><strong>Make it easy to save relevant Slack threads to the knowledge base. </strong>For example, it would be nice to allow the user to trigger a webhook from Slack with something like “<em>@help-terraform-cloud remember this thread.</em>”</p><p><strong>Automatic updates for each data source.</strong> Currently a manual data sync is required. We plan to set up a Cloudwatch event cron to trigger a data sync at least weekly.</p><p><strong>Use the Confluence API.</strong> Currently we are exporting our FAQ page from Confluence to PDF and saving this to S3. In the future we plan to connect to Confluence via API.</p><p><strong>Multi-turn conversation.</strong> Currently our Lambda is a stateless function and only the Slack message that explicitly tags our @help-terraform-cloud user is made available. One enhancement could be to preserve conversation context so the user can have a multi-turn conversation and build on a previous answer.</p><h3>Learnings</h3><p><strong>Chunking strategies</strong>. In our initial prototype we used the default Bedrock chunking strategy of 300 tokens. This returns about one paragraph of text. This led to substandard results since many of our FAQ answers include several ordered steps and can stretch into several paragraphs. This meant our search results were often cut off midway, providing incomplete documentation to the LLM prompt. There are several alternative chunking strategies to choose from, and after trying a few, we found that Hierarchical chunking worked best, with a parent token size of 1500 tokens (about 5 paragraphs). The goal is to select a token size near the upper limit of your longest answers. However you also don’t want your token size any larger than necessary, as this feeds more (possibly irrelevant) data to the LLM which could confuse its answers. For our FAQ, our longest answers were around 1500 tokens in length, and thus this was a good fit. You’ll want to try out a few different chunking strategies and test how it performs with each to find the best fit.</p><figure><img alt="Screenshot showing the chunking strategy selection in Amazon Bedrock" src="https://cdn-images-1.medium.com/max/1024/0*QbEuwC6m0fpUAbM0" /></figure><p><strong>Parsing PDFs is quite robust</strong>. Although it loses all the images, it’s quite robust at parsing text. Pointing Bedrock at a PDF in S3 worked on the first try.</p><p><strong>Setting up a knowledge base is easy! </strong>Previously, setting up all the necessary plumbing for a knowledge base yourself would have been a multi-day project. However Bedrock’s knowledge base feature automates this process into something that takes minutes instead of days.</p><p><strong>More targeted help bots?</strong> Perhaps the ease of deployment paves the way for numerous targeted help bots in the future. Using a more tightly-scoped dataset also reduces the chances of hallucination or the potential for non-relevant data to be returned from the vector database.</p><h3>Architecture</h3><p>Our architecture is quite simple. It’s comprised of:</p><ul><li>A Slack App</li><li>AWS API Gateway</li><li>AWS Lambda (runs a stateless python function)</li><li>AWS Bedrock</li><li>AWS OpenSearch Serverless (vector database)</li></ul><figure><img alt="Architecture diagram that displays Slack, AWS API Gateway, Lambda, Bedrock, and a Vector Database." src="https://cdn-images-1.medium.com/max/1024/0*WP86JxJLN5HIfR5L" /></figure><p>We’re using two different models:</p><ul><li>Amazon Titan Text Embeddings v2 (for embedding)</li><li>Claude 3.5 Sonnet v2 (for inference)</li></ul><p>Since the Terraform AWS provider doesn’t yet support the Bedrock resources we use, our implementation was created manually via the Bedrock Knowledge Base setup wizard in the UI.</p><p>The infrastructure components we use for the API Gateway and Lambda were built using open-source community modules and we can share our implementation with you here:</p><pre>##<br># variables.tf<br>##<br>variable &quot;environment&quot; {<br>  description = &quot;Name of the lambda function&quot;<br>  type        = string<br>  validation {<br>    condition     = contains([&quot;dev&quot;, &quot;prod&quot;, &quot;sandbox&quot;], var.environment)<br>    error_message = &quot;Environment must be a valid value&quot;<br>  }<br>}<br><br>variable &quot;knowledge_base_id&quot; {<br>  description = &quot;Bedrock knowledge base id&quot;<br>  type        = string<br>}<br><br>variable &quot;account_name&quot; {<br>  description = &quot;Name of the AWS account&quot;<br>  type        = string<br>}<br><br>##<br># main.tf<br>##<br>locals {<br>  service_name      = &quot;tfc-help-slackbot-${var.environment}&quot;<br>  bedrock_model_arn = &quot;arn:aws:bedrock:${data.aws_region.current.name}::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0&quot;<br>  account_id        = data.aws_caller_identity.current.account_id<br>}<br><br>data &quot;aws_region&quot; &quot;current&quot; {}<br>data &quot;aws_caller_identity&quot; &quot;current&quot; {}<br><br>data &quot;aws_secretsmanager_secret&quot; &quot;slack_token&quot; {<br>  name = &quot;${var.account_name}/tfc_help_slackbot/slack_token&quot;<br>}<br><br>data &quot;aws_secretsmanager_secret&quot; &quot;slack_signing_secret&quot; {<br>  name = &quot;${var.account_name}/tfc_help_slackbot/slack_signing_secret&quot;<br>}<br><br>module &quot;api_gateway&quot; {<br>  source  = &quot;terraform-aws-modules/apigateway-v2/aws&quot;<br>  version = &quot;5.2.0&quot;<br><br>  name               = &quot;http-${local.service_name}&quot;<br>  description        = &quot;API Gateway for ${local.service_name}&quot;<br>  protocol_type      = &quot;HTTP&quot;<br>  create_domain_name = false<br><br>  cors_configuration = {<br>    allow_headers  = []<br>    allow_methods  = [&quot;*&quot;]<br>    allow_origins  = [&quot;*&quot;]<br>    expose_headers = []<br>  }<br><br>  routes = {<br>    &quot;$default&quot; = {<br>      integration = {<br>        uri                    = module.lambda.lambda_function_arn<br>        payload_format_version = &quot;2.0&quot;<br>        timeout_milliseconds   = 30000<br>      }<br>    }<br>  }<br>}<br><br>module &quot;lambda&quot; {<br>  source  = &quot;terraform-aws-modules/lambda/aws&quot;<br>  version = &quot;7.4.0&quot;<br><br>  function_name = local.service_name<br>  description   = &quot;@help-terraform-cloud slackbot&quot;<br>  handler       = &quot;index.lambda_handler&quot;<br>  runtime       = &quot;python3.12&quot;<br>  source_path = [<br>    {<br>      path             = &quot;${path.module}/files&quot;,<br>      pip_requirements = &quot;${path.module}/files/requirements.txt&quot;<br>    }<br>  ]<br>  trigger_on_package_timestamp      = false # only rebuild if files have changed<br>  create_role                       = true<br>  role_name                         = local.service_name<br>  policies                          = [aws_iam_policy.lambda.arn]<br>  attach_policies                   = true<br>  number_of_policies                = 1<br>  memory_size                       = 128 # MB<br>  timeout                           = 60  # seconds<br>  architectures                     = [&quot;arm64&quot;]<br>  publish                           = true # required otherwise get error &quot;We currently do not support adding policies for $LATEST.&quot;<br>  cloudwatch_logs_retention_in_days = 90<br>  environment_variables = {<br>    SLACK_TOKEN_ARN          = data.aws_secretsmanager_secret.slack_token.arn<br>    SLACK_SIGNING_SECRET_ARN = data.aws_secretsmanager_secret.slack_signing_secret.arn<br>    REGION_NAME              = data.aws_region.current.name<br>    KNOWLEDGE_BASE_ID        = var.knowledge_base_id<br>    MODEL_ARN                = local.bedrock_model_arn<br>  }<br>  allowed_triggers = {<br>    APIGatewayAny = {<br>      service    = &quot;apigateway&quot;<br>      source_arn = &quot;${module.api_gateway.api_execution_arn}/*&quot;<br>    }<br>  }<br>}<br><br><br>##<br># iam.tf<br>##<br>resource &quot;aws_iam_policy&quot; &quot;lambda&quot; {<br>  name   = &quot;tfc-help-slackbot-${var.environment}&quot;<br>  policy = data.aws_iam_policy_document.lambda.json<br>}<br><br>data &quot;aws_iam_policy_document&quot; &quot;lambda&quot; {<br>  statement {<br>    sid    = &quot;CloudWatchCreateLogGroupAccess&quot;<br>    effect = &quot;Allow&quot;<br>    actions = [<br>      &quot;logs:CreateLogGroup&quot;,<br>    ]<br>    resources = [<br>      &quot;arn:aws:logs:${data.aws_region.current.name}:${local.account_id}:*&quot;,<br>    ]<br>  }<br>  statement {<br>    sid    = &quot;CloudWatchWriteLogsAccess&quot;<br>    effect = &quot;Allow&quot;<br>    actions = [<br>      &quot;logs:CreateLogStream&quot;,<br>      &quot;logs:PutLogEvents&quot;,<br>    ]<br>    resources = [<br>      &quot;arn:aws:logs:${data.aws_region.current.name}:${local.account_id}:log-group:/aws/lambda/${local.service_name}:*&quot;,<br>    ]<br>  }<br>  statement {<br>    sid    = &quot;BedrockAccess&quot;<br>    effect = &quot;Allow&quot;<br>    actions = [<br>      &quot;bedrock:InvokeModel&quot;,<br>      &quot;bedrock:RetrieveAndGenerate&quot;,<br>      &quot;bedrock:Retrieve&quot;,<br>    ]<br>    resources = [<br>      &quot;arn:aws:bedrock:${data.aws_region.current.name}:${local.account_id}:knowledge-base/${var.knowledge_base_id}&quot;,<br>      local.bedrock_model_arn,<br>    ]<br>  }<br>  statement {<br>    sid    = &quot;SecretsManagerAccess&quot;<br>    effect = &quot;Allow&quot;<br>    actions = [<br>      &quot;secretsmanager:GetSecretValue&quot;,<br>    ]<br>    resources = [<br>      data.aws_secretsmanager_secret.slack_token.arn,<br>      data.aws_secretsmanager_secret.slack_signing_secret.arn,<br>    ]<br>  }<br>  statement {<br>    effect  = &quot;Allow&quot;<br>    actions = [&quot;kms:Decrypt&quot;]<br>    resources = [<br>      &quot;arn:aws:kms:*:1234567890:key/mrk-abcd123456789abcd123&quot;,<br>    ]<br>  }<br>}<br><br><br>##<br># outputs.tf<br>##<br>output &quot;api_endpoint&quot; {<br>  value       = module.api_gateway.api_endpoint<br>  description = &quot;This is the API endpoint to save in the slack app configuration&quot;<br>}</pre><p>Note this terraform code presupposes that the following sensitive values were previously set in AWS Secrets Manager:</p><ul><li>{account_name}/tfc_help_slackbot/slack_token</li><li>{account_name}/tfc_help_slackbot/slack_signing_secret</li></ul><h3>Where can you use knowledge bases in your work?</h3><p>Are there situations where you wish you had access to an LLM that also had knowledge specific to your team or company? Think of scenarios such as:</p><ul><li>Information lookup (e.g. error codes)</li><li>Answering common questions</li></ul><p>Do you have a high quality text-based dataset?</p><ul><li>FAQ docs</li><li>Public web documentation</li><li>Conversation histories (fact-checked)</li></ul><p>If you have a use case where answers to the above questions are both true, then you might want to consider using a knowledge base.</p><p>You will also want to assess the security and privacy risks to your company. Some questions we asked before starting development:</p><ul><li>Is this data sensitive / proprietary?</li><li>What is the downside risk of an incorrect result or hallucination?</li><li>Which models are already approved for use at Benchling? Can we use one of these models, or do we need to get a new model approved?</li></ul><p>Overall it was relatively quick to get this prototype up and running. We advocate for experimenting with new tools and technologies as soon as they become available, and this is one technology that seems mature enough for broader use. We hope this was a helpful guide that can support you in building your own LLM-based tools!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=557a6241e993" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/building-an-llm-powered-slackbot-557a6241e993">Building an LLM-Powered Slackbot</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Scaling Scientific Data: Migrating Benchling’s Schema Model for Performance at Scale]]></title>
            <link>https://benchling.engineering/scaling-scientific-data-migrating-benchlings-schema-model-for-performance-at-scale-2a91cf971040?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/2a91cf971040</guid>
            <category><![CDATA[data-modeling]]></category>
            <category><![CDATA[scalable-architecture]]></category>
            <category><![CDATA[postresql]]></category>
            <category><![CDATA[database-optimization]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Melody Ding]]></dc:creator>
            <pubDate>Wed, 04 Dec 2024 15:01:06 GMT</pubDate>
            <atom:updated>2024-12-04T15:10:06.356Z</atom:updated>
            <content:encoded><![CDATA[<p>Benchling is a unified platform for scientific data. It allows scientists to collaborate on complex science, automate work, and power AI. Customers store large volumes of data on our platform, leveraging it across many applications both within Benchling and in their own infrastructure. It’s critical that customer data is accessible in a performant and scalable way.</p><p>In this article, we’ll explore a recent shift in how we store and retrieve customer data. By migrating to a more compact structure, we’ve tackled key performance challenges associated with increased data volumes. This transition has required a careful balance between speed and flexibility, as well as a phased approach that minimized disruption for users.</p><h3>Benchling Schemas</h3><p>At the core of Benchling’s system is <em>Schemas</em>, a product that allows both Benchling internal teams and customers to configure the various shapes of data, defining fields, attributes, and constraints that entities must follow. These data structures represent entities like equipment, storage, biological molecules, workflows, tasks, lab notes, and recorded results from scientific tests. Schemas reside in what we refer to as the <strong>definition layer</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/379/0*6kTsr8z-zKVheVb4" /><figcaption>An example schema for defining the data structure of a molecule</figcaption></figure><p>Each instance of a schema, referred to as a <strong>schematizable item</strong>, represents the actual data input by scientists. We call this the <strong>instance layer</strong>. These items are populated with field values conforming to the schema’s defined fields. As Benchling’s user base grows and the amount of schematized data ingested into the platform increases every year, optimizing the storage of field values has become crucial.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/700/0*e-N2ohX2XPDnNXk8" /><figcaption>Relationship between actual instances of a molecule and its defined schema</figcaption></figure><h3>The Challenge: Scale and Performance</h3><p>Historically, Benchling saw a shift from manual data upload by scientists to integrations with lab equipment, leading to automated data collection. This significantly increased the speed and volume of data ingestion.</p><p>Assay results, capturing experimental data, are the most common schematized items in Benchling. Assays are laboratory procedures used to measure the presence, amount, or activity of a specific target (such as a molecule or biological entity) in a sample. By 2021, declining assay results ingestion performance made it apparent that we needed a more scalable approach to storing field values to (1) <strong>improve data ingestion speed</strong> and (2) <strong>avoid database scaling limitations</strong>, particularly to stay within PostgreSQL size limits without resorting to sharding.</p><h3>The Old World: Entity-Attribute-Value Model (EAV)</h3><p>Benchling initially adopted an <strong>Entity-Attribute-Value (EAV)</strong> model, a flexible data model that is well-suited for storing sparse data. This model was effective in Benchling’s early years when data volumes were manageable and access patterns were less defined.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/700/0*erZlkVXqMn0G6tX3" /><figcaption>Attribute values used to be stored using an EAV model</figcaption></figure><p>However, as the volume of data increased, several shortcomings of the EAV model emerged:</p><p><strong>Sparse Rows:</strong> In the EAV table, each data type was represented as a separate column, but only one column per row was typically populated. This resulted in sparse rows with unused columns.</p><p><strong>Metadata Overhead:</strong> Postgres metadata for each row created inefficiencies at scale. Each attribute required its own row, leading to O(<em>n*k</em>) rows for <em>n</em> entities and <em>k</em> attributes. Postgres’s 23-byte overhead per row exacerbated the space inefficiency.</p><p><strong>Inefficient Access Patterns: </strong>Most reads require fetching all the attributes for an entity at once. Spreading these attributes across many rows required us to query for and return many rows to read a single entity.</p><p><strong>Extraneous Joins: </strong>Since each entity’s attributes are stored in separate rows, querying for matches on multiple attributes required multiple joins against the EAV table. For example, finding entity matches on “name”, “formula”, and “weight” would require that the EAV table join against itself two times.</p><p>These limitations made the EAV model less practical for Benchling’s needs.</p><h3>The New World: Using PostgreSQL’s JSONB</h3><p>To address EAV shortcomings, we adopted PostgreSQL’s JSONB data type, which supports efficient key-value querying and indexing. This allowed us to condense all the field values for an entity into a single JSON blob stored in one row.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/700/0*4I8NhTgmmOaX947l" /><figcaption>We now use a JSONB column to store attribute values</figcaption></figure><p>This new model had several advantages:</p><ul><li><strong>Compact Data Storage:</strong> The JSONB format significantly reduced the number of rows by storing all attributes of an entity in one row.</li><li><strong>Improved Read and Write Performance:</strong> Since most reads involve retrieving all the attributes of an entity, querying a single row proved much faster than querying for attributes across several rows. Similarly, when uploading an entity, writing one large row to the database proved to be faster than writing many sparse rows.</li></ul><p>However, this model also introduced new challenges:</p><ul><li><strong>Querying Field Information:</strong> Some questions are less efficient to answer. For example, checking if a field has non-empty values requires more complex queries that dive into JSON structures. A key-value index could mitigate this issue.</li><li><strong>Lock Contention:</strong> Since all the fields of an entity are stored in one JSONB document, if one process updates the “formula” field and another process updates the “weight” field, they are both modifying the same row. This can create a bottleneck, as only one process can lock the row at a time. In contrast, in the old EAV model, different fields were stored in different rows, so there was less contention. This trade-off was considered acceptable because such simultaneous writes are rare in Benchling’s current usage patterns.</li></ul><p>To mitigate some of these challenges, we also considered alternatives like <a href="https://www.postgresql.org/docs/current/indexes-types.html#INDEXES-TYPES-GIN">GIN indexes</a> for faster key-value lookups within JSONB fields. Specifically, we need to quickly traverse entity linkages, and answer questions like, “What other entities link to a given entity?”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/700/0*_qSp7CqyX6keKXPg" /><figcaption>The new JSONB structure makes answering questions like above less straightforward</figcaption></figure><p>However, while GIN indexes speed up reads, they slow down inserts because PostgreSQL needs to update the index every time a new JSONB document is inserted. Because we are storing millions of records, constantly updating a GIN index would become a bottleneck. Since we were already bottlenecked on data ingestion speed, we opted for a simpler approach: adding a table to track linkages between entities.</p><p>Inserting a row for every linkage does cause high overhead and is less space efficient than a GIN index, but this is no worse than our previous model where we also stored linkages in a separate table outside of the EAV table. The linkage table allows us to make minor updates to keys without triggering a re-index of the entire JSONB structure, reducing unnecessary overhead. Additionally, by recording the attribute name in the linkage table, we can efficiently query not only which items are linked together but also the specific attribute that establishes the link.</p><h3>Performance Improvements</h3><p>After rolling out the new model, as expected, we saw improved performance in bulk reads and writes. This improved performance across areas such as our data warehouse and results ingestion in notebook tables.</p><p>Here is a sample of our resulting metrics from internal tests and aggregate data from production environments:</p><ul><li>Up to <strong>7x faster ingestion</strong> of assay results</li><li><strong>33% faster</strong> to map items from our internal database to our data warehouse models</li><li><strong>60% faster </strong>to find items that need to be updated in the data warehouse when a sequence is updated</li><li>Querying entities in our Analysis product is about <strong>2x faster</strong></li></ul><p>Performance results may vary depending on data volume, workload patterns, and system configuration. The improvements highlighted here are most significant in environments with higher data volumes.</p><h3>Granular Rollouts for Data Integrity</h3><p>Schema field values are pervasive across Benchling, necessitating data parity between the old and new systems. We first applied the rollout phases to our results product, since that’s where Benchling was scaling the most and starting to encounter ingestion slowness. To overcome the limitations of written tests — such as the inability to account for all possible edge cases encountered in production — we rolled out this refactor over the course of three years in several phases.</p><p><strong>Stage 1: Dual writes and integrity checks:</strong> Every value update writes to <em>both</em> the old tables and the new tables. However, we are still reading from the old tables. At this stage, we ran nightly backfills from the old table to the new table, logging any discovered inconsistencies along the way. Using the nightly integrity check, we patched code paths that either missed writes to the new table or caused inconsistent writes.</p><p><strong>Stage 2: Switching to JSONB reads (still with integrity checks):</strong> After feeling confident that the new table contained the correct field values, we switched to reading from the new tables. We are still writing to both tables at this stage, and continuing the nightly integrity check between the two tables.</p><p><strong>Stage 3: Deprecating the old EAV Tables: </strong>This was the point of no return. We deprecated the old field values table and stopped writing to it. Any attempt to access the old field value tables raised an error, since this is preferred over potentially missing a write to the new tables or reading corrupt data from the old tables.</p><h3>Challenges and Solutions</h3><p>Throughout the migration, we encountered various challenges, particularly with maintaining <strong>data integrity</strong> and ensuring <strong>system boundaries</strong> were clear. A few key issues included:</p><ul><li><strong>Transaction Isolation</strong>: Using a READ-COMMITTED isolation level helped prevent most race conditions without causing process blocks. However, some race conditions required additional <a href="https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS">advisory locks</a> on top of Postgres’s row-level locks.</li><li><strong>Deadlock Management</strong>: With the new system, we had to manage potential deadlocks that arose from lock contention when multiple processes updated fields and related data on an overlapping range of entities.</li><li><strong>Type System Flexibility</strong>: The less-strict type system in the JSONB model introduced new complexities in input validation and data coercion. Ensuring that different field types were correctly represented required meticulous attention to detail, more thorough testing, and close collaboration between product and engineering teams to align on expected behaviors.</li><li><strong>Scope Creep: </strong>With such a significant refactor, there was a temptation to enhance existing architectures beyond a one-to-one migration. To avoid endlessly expanding the project scope, we focused on a few select improvements that offered major benefits. One example was restructuring how we check for unique constraint violations between schematizable items. This restructuring reduced the time for<em> </em>duplicate checking from <em>several minutes to just 10–20 milliseconds</em>, significantly improving performance for customers with large datasets.</li></ul><p>To overcome most of these challenges, we emphasized the importance of <strong>thorough testing</strong> and <strong>clear error logging</strong> to identify and fix issues in a timely manner.</p><h3>Looking Ahead: Building for Resilience and Scale</h3><p>To keep pace with the growing demands of our platform, we’re focusing on several enhancements that will make Benchling even more efficient, resilient, and ready for the future. Here’s how we are preparing:</p><p><strong>Clearer Performance Monitoring: </strong>Transparency about how well your product performs is the first step to making a great user experience. One of our immediate priorities is to instrument better metrics that can give us deeper insights into the system’s performance, both at a granular level (such as individual field value access times) and across the broader application. This will help us quickly identify any bottlenecks and address them proactively.</p><p><strong>Refactoring Field Access Patterns:</strong> Despite the separation between our data layer and our application logic, changing the implementation of the data layer <em>does</em> affect the application layer. With the shift to storing schematized field values as JSON blobs, we have the opportunity to optimize how different teams across Benchling access these values to take advantage of the new representation, which could reduce lock contention and improve overall system performance.</p><p><strong>Strengthening System Boundaries:</strong> Modular code is the key to fast development in a growing team and a rapidly expanding product. We plan to build cleaner interfaces and enforce stricter contracts across modules. This will help reduce the complexity of future migrations and ensure more maintainable code.</p><h3>Key Takeaways</h3><p>From this migration project, we’ve learned several valuable lessons:</p><ol><li><strong>Trade-offs in Data Modeling:</strong> While the new JSONB-based model solved many performance and storage issues, it introduced challenges around querying and lock contention. It’s important to carefully evaluate these trade-offs to ensure the benefits outweigh the drawbacks.</li><li><strong>Granular Rollouts Minimize Risk:</strong> Rolling out the new data model in multiple phases allowed us to address edge cases and ensure data integrity. This staged approach is essential for large-scale migrations that touch critical parts of the system.</li><li><strong>Importance of Robust Testing and Monitoring:</strong> A combination of unit testing, integration testing, and live integrity checks was key to identifying and addressing issues in the system.</li><li><strong>Cross-team Collaboration is Crucial:</strong> Since field values are accessed and updated across various teams and workflows, collaborating with product managers and other engineering teams was essential. Ensuring that the new model integrates smoothly into all parts of the system required clear communication and coordination.</li></ol><h3>Building for Scale</h3><p>Migrating from the entity-attribute-value model to a more compact JSONB-based representation ensured that Benchling can scale with its growing data ingestion demands. While the new model has significantly improved read and write performance, it also required careful planning to manage the trade-offs involved, such as lock contention and querying complexity. Our phased approach to the migration enabled us to catch and resolve issues as they arose, ensuring a smooth transition with minimal impact on users.</p><p>Moving forward, we will continue refining the system by improving performance metrics, optimizing data access patterns, and ensuring that our infrastructure is capable of supporting Benchling’s continued growth. The lessons learned during this migration process have not only strengthened our engineering practices but have also set the foundation for more scalable and efficient data models in the future. With a focus on collaboration and continuous improvement, we’re well-positioned to tackle the challenges ahead.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2a91cf971040" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/scaling-scientific-data-migrating-benchlings-schema-model-for-performance-at-scale-2a91cf971040">Scaling Scientific Data: Migrating Benchling’s Schema Model for Performance at Scale</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A behind-the-scenes look at building interactive analysis capabilities in Benchling]]></title>
            <link>https://benchling.engineering/a-behind-the-scenes-look-at-building-interactive-analysis-capabilities-in-benchling-fa6ec1bab1e5?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/fa6ec1bab1e5</guid>
            <category><![CDATA[data-visualization]]></category>
            <category><![CDATA[apache-arrow]]></category>
            <category><![CDATA[duckdb]]></category>
            <category><![CDATA[data-analysis]]></category>
            <category><![CDATA[apache-parquet]]></category>
            <dc:creator><![CDATA[Wonja Fairbrother]]></dc:creator>
            <pubDate>Tue, 11 Jun 2024 13:01:25 GMT</pubDate>
            <atom:updated>2024-06-11T13:01:24.580Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Authors:</strong> <a href="https://medium.com/u/659f0bd47f1e">Wonja Fairbrother</a> and <a href="https://medium.com/u/14ad0a4d36fc">Eli Levine</a></p><p>Science is iterative. To design the next experiment, scientists need to analyze the results of previous ones. Interactive Analysis in Benchling allows scientists to perform real-time data transformation, visualization, and analysis without having to transfer it into other systems. In this post we will describe the architecture behind interactive analysis capabilities in Benchling and give a peek into the decision journey we took along the way¹.</p><p>Interactive Analysis allows scientists to:</p><p>1. Select data from many sources:</p><ul><li>Benchling entity and results data</li><li><a href="https://www.benchling.com/blog/open-source-data-standards-allotrope">Instrument data</a></li><li>Notebook tables</li><li>Data upload via both API and UI</li></ul><p>2. Transform, visualize, and analyze data in real time, without leaving Benchling:</p><ul><li>Data transformations: filtering, aggregations, window functions, etc.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*EFswzasV35LBugAf" /></figure><ul><li>Visualizations: line chart, bar chart, scatter plot, etc.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*-44FTMiBPbvZ8Zeb" /></figure><ul><li>Scientific analysis methods: <a href="https://en.wikipedia.org/wiki/IC50">IC50</a> and various curve fitting functions</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*FqJD1L_KWRjcPpGd" /></figure><h3>Overall architecture</h3><p>The architecture backing Interactive Analysis consists of:</p><ol><li>The Benchling web application</li><li>An auto-scaling stateless internal service running on EKS that performs the transformations</li><li>Temporary S3 storage locations for input and output data, shared between the web app and the service</li></ol><p>The frontend of the application is responsible for taking in input datasets and transformation configurations from users. The backend of the web application collects all the input data from the appropriate sources, serializes and uploads the data to S3, and sends a synchronous transformation request to the service.</p><p>The service’s API consists of one main endpoint that takes in a JSON payload of transformation parameters. The service can accept a single transformation, or a list of many transformations to perform. In this endpoint, the service downloads and deserializes the input data, performs the transformation with an analysis engine, and serializes and uploads the resulting data to S3. Each request spins up its own self-contained in-memory analysis engine, so that no data is stored outside of memory or between requests. As such, the service can serve requests for multiple customers concurrently.</p><p>When the request completes, the web app downloads and deserializes the output data and displays it to the user. The frontend enables charting and visualization on the output data from any transform step. The user can then apply more transformations in an iterative manner, and the output then becomes the input to the next transformation.</p><p>All of these input and output data files can pile up very quickly in S3. However, since analysis is an exploratory and iterative process, they are ephemeral — therefore they are stored in a “temporary” S3 bucket with a lifecycle configuration to keep storage costs down.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*SPqJYII01jTjEprt" /></figure><h3>Service internals</h3><h3>Data storage and representation</h3><p>Because the implementation details of the transformations are not exposed to the user, we have the freedom to choose the data format that works best for us and our architecture. However, there are some considerations to take into account:</p><ul><li>Type information for each column should be preserved as data is transformed.</li><li>Primitive data types determine valid operations and visualizations on specific columns.</li><li>Benchling types (references to a first class object in Benchling like an Entity, DNASequence, or Protocol) are used to allow object linkage in the application.</li><li>Type awareness is critical for building successful machine learning models, and vastly simplifies feature engineering and preparation of AI-ready data.</li><li>The data format should be optimized for read-heavy analytical queries.</li><li>De/serialization latency should be as low as possible to support the interactive experience.</li></ul><p>Given these factors, we chose <a href="https://parquet.apache.org/">Parquet</a> as our preferred data storage format, and <a href="https://arrow.apache.org/docs/index.html">Arrow</a> as our in-memory data representation. Parquet is a compact and efficient columnar file format, and Arrow is a language-agnostic columnar data structure platform. Using Parquet and Arrow together unlocks many advantages. The columnar in-memory layout allows for O(1) random access, efficient column pruning, predicate pushdown, and improved data compression in analytical workloads. Arrow’s Parquet serializer also has a lightweight schema encoding mechanism to attach type information while keeping data interchange fast.</p><p>We support other file formats (CSV, JSON, Avro) for analytical data at Benchling, but using Parquet for Interactive Analysis queries gives us the speed we are looking for. For the flexibility to switch between formats, we have a BenchlingDataFrame wrapping interface that is shared between services. This interface handles serialization and type schema bookkeeping. The service has an I/O module that talks to S3 and de/serializes the data using the BenchlingDataFrame interface.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*c9UTG8ApowSFK4OI" /></figure><h3>Analysis kernel</h3><p>For the analysis internals of the service, our aim is to keep things as lightweight as possible, with our low latency goals in mind. Our users are accustomed to performing these types of analyses locally on their machines, so the experience needs to be as close to that as possible, to minimize any degradation of user experience.</p><p>Transformations fall into two categories: basic and advanced. Think of basic operations as things you might want to do in Excel like filter, pivot, or create new columns. Advanced operations are those that involve more complex calculations, statistics, or machine learning.</p><p>The vast majority of transformations that we anticipate our users will perform are basic transformations. In addition, we are primarily working with tabular data. This makes the choice to use <a href="https://duckdb.org/">DuckDB</a> as a base case for transforms straightforward. DuckDB is a serverless SQL OLAP engine with support for larger-than-memory processing, APIs in multiple languages, direct querying of different file formats (Parquet, CSV, JSON), an active community, and many useful extensions and integrations being added with each new version. It is optimized for fast analytical queries on tabular data, fitting our use case exactly. DuckDB can also directly operate on Arrow and Pandas objects.</p><p>While DuckDB supports a wide range of SQL functions and complex SQL queries that mostly cover basic transformations, we want to be able to use different libraries and tools for transforms that involve advanced statistical functions. To accomplish this, we have an ExecutionEngine wrapper class that can choose the right underlying methods to use for a given operation. Here, our use of Arrow gives us an advantage too — Arrow has zero-copy interoperability between systems (<a href="https://arrow.apache.org/overview/">ref</a> — see image). This allows us to use DuckDB for SQL-like operations, and Pandas, etc for others, selecting the best underlying tool to perform each analytical action on any input Arrow Table. For example, when the service receives a request for a four parameter logistic regression (4PL) transformation, the ExecutionEngine can simply zero-copy convert the Arrow Table to a Pandas DataFrame, and call the right function that uses Pandas or Numpy rather than DuckDB.</p><h3>Summary</h3><p>We use Apache Parquet in tandem with Apache Arrow for fast serialization and optimized read-heavy analytical queries, and use our BenchlingDataFrame interface to preserve data type information. The service’s ExecutionEngine wrapper and Arrow’s zero-copy interop allow us to use DuckDB for common operations while having the option to call other packages into play when needed.</p><h3>Key architectural decisions</h3><p>When designing a new system there are invariably decisions that must be made along the way. Here we highlight some notable decision points that our team encountered, and our thought process for resolving them. We started the project with a proof of concept (POC) that used the simplest architecture possible for Benchling’s environment: a stateless service that performed data transformations on datasets stored in S3 synchronously. We started with a <em>service</em> because Benchling’s infrastructure team has built excellent support for quickly standing up Kubernetes services. This was a paved path with few unknowns. We started with a <em>stateless service</em> because state requires management, which introduces complexity, which stood in the way of getting an end-to-end prototype into customers hands quickly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Nc54-vjDL3fBLPcO" /></figure><h3>Stateless vs. stateful service</h3><p>A user interactively analyzing their data usually performs a number of data operations in sequence, such as loading a dataset, applying a filter, applying another filter and performing an aggregation. One decision we had to make is whether we should implement caching of intermediate data during a user’s interactive analysis session. Basically: save the results of an operation, such that the next user operation is performed faster.</p><p>We considered a number of possible solutions to caching intermediate data. One approach was to employ an architecture in some ways similar to <a href="https://jupyter.org/hub">JupyterHub</a>, employing a backend kernel dedicated to a user session. All user interactions within a user session would be routed to the same dedicated node that would cache data in memory for subsequent calls to access with low latency. Another approach was to keep a cluster of nodes with routing logic either based on user session or dataset hash. The idea would be the same: subsequent calls would likely encounter datasets already in memory. Before embarking on the journey of building one of these approaches we spent time looking at the necessity of going this route.</p><p>The life cycle of a single data operation, such as a filter, without caching is something like this: (1) read data from S3 (mostly network overhead), (2) load data into DuckDB, (3) perform actual data operation, (4) write out results to S3. Steps (3) and (4) are invariables — they would have to be performed even with caching. (3) is the actual data operation and writing out data to S3 in (4) is required in order for UI to render charts and tables. The main question to answer was whether the S3 network transfer + load into Arrow times were significant enough to invest in some sort of caching of intermediate data.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/734/0*pxAeZGcTf7AZs4iq" /></figure><p>A quick test in a representative environment showed that even for some of the largest datasets we expect additional overhead of always loading from S3 is on the order of 350ms, which is within product requirements for latency. In this case the simpler solution was deemed sufficient, at least until Benchling has a need to support significantly larger datasets.</p><h3>Service vs on-demand container</h3><p>On the topic of big data, there is the possibility that our service will not be able to handle very large datasets and/or a large number of transformations without running out of the memory allocated to a single replica. In order to scale for these scenarios, one could argue that we should spin up a container with enough memory to handle the request, rather than sending all requests to the memory-constrained service.</p><p>Then, why not just use on-demand containers for all requests to begin with? The primary reason is latency — we cannot viably add the time cost of spinning up a container (on the order of a few seconds) to the round trip of a transformation, especially for trivial operations like filtering. And, as mentioned above, currently the vast majority of analyses are done on data that fit comfortably into service worker nodes. Therefore, using the service is acceptable and preferable in most cases.</p><p>Nonetheless, having the option of running transforms in a container would enable some of Benchling’s larger customers with larger data to use Interactive Analysis. In addition, we plan to allow customers to run a saved set of analytical transformations on incoming data in an automated manner. This truly asynchronous non-interactive use case is fitting for an on-demand container as there is no user sitting in front of a screen waiting on the result, affording us the extra spin-up time cost. Our infrastructure team’s internal compute framework allows us to package up our service image and use it to run one-off jobs, making this option easy to integrate in the future.</p><h3>Result data in payload vs S3</h3><p>Realistically, a user will only need to inspect a preview or summary of a transformation result, especially if the result is large. That being the case, the service could simply return the displayable result preview in the response payload, cutting out the need for the web application to download and deserialize the full output from S3. It might seem as though this option would be the most straightforward for the POC, but it would have required a large refactor in the backend of the web app. The de facto way that the backend represents these tabular data requires that the full object is downloaded and deserialized into memory. So, we decided to use the existing functionality for the initial version, and show the result in the UI by reading from S3. Now that we are in the stage of improving and optimizing the architecture, we are refactoring the web app’s dataset representation to allow partial loading. From there, the service can pass back only what is needed for display, and the web app can directly use that, further reducing end to end transformation latency.</p><p>Interactive Analysis is a powerful addition to Benchling, empowering scientists and researchers to perform complex data analysis with ease without switching between applications. Hopefully we have been able to show you some internal mechanics that make the product tick, and the architectural decisions that went into building it. We are excited to see the impact it will have on advancing research and look forward to the innovative ways in which our users will use it to power their R&amp;D.</p><p>[1]: This post may describe features in testing that are subject to change.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fa6ec1bab1e5" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/a-behind-the-scenes-look-at-building-interactive-analysis-capabilities-in-benchling-fa6ec1bab1e5">A behind-the-scenes look at building interactive analysis capabilities in Benchling</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Signals, shells, and docker: an onion of footguns]]></title>
            <link>https://benchling.engineering/signals-shells-and-docker-an-onion-of-footguns-ee592e2b587b?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/ee592e2b587b</guid>
            <category><![CDATA[linux]]></category>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[bash]]></category>
            <dc:creator><![CDATA[raylu]]></dc:creator>
            <pubDate>Wed, 22 May 2024 16:01:32 GMT</pubDate>
            <atom:updated>2024-05-22T16:01:31.522Z</atom:updated>
            <content:encoded><![CDATA[<p>On a few occasions, we’ve needed to debug POSIX signals (SIGINT, SIGTERM, etc.). Inevitably, there’s a shell involved too. One day, we were debugging some weird interaction between signals, shells, and containers and found ourselves bamboozled by some behaviors. People who consider themselves knowledgeable about Linux have found some of the details of our investigation surprising, so read on if this sort of thing doesn’t make you want to defenestrate your laptop and become an alpaca-farming hermit.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*kHXBgOZPjmxyt91C.png" /></figure><h3>The scene of the crime</h3><p>At Benchling, we have a pretty standard testing/continuous integration (CI) setup: when you push code to a pull request branch, we run tests for you. A few years back, we added a little optimization: if you push again and tests are still running on the previous commit, we cancel the previous test run. You probably don’t care about that run anyway and we save some money… or do we?</p><p>The code that runs our tests is basically</p><pre>def test_pipeline() -&gt; int:<br>    test_result = subprocess.run([&quot;pytest&quot;, …])<br>    report_test_metrics()<br>    upload_artifacts()<br>    return test_result.returncode</pre><p>So our process tree is</p><pre>test_pipeline<br>└──pytest</pre><p>subprocess.run blocks until the child process exits, so it should take almost all the time. We see in our CI logs that the tests get interrupted halfway through and then we see no more logs, so it sure looks like it’s working. But we’re able to get metrics and artifacts for our canceled runs, which makes no sense. We’ll later discover that while we reported that the run was canceled and stopped forwarding logs, pytest just kept running.</p><h3>Back to basics</h3><p>Thinking that perhaps the problem was not forwarding a signal from test_pipeline to pytest, we thought about basic signal handling first. In a terminal running zsh, we can get the pid of zsh with</p><pre>$ echo $$<br>20147</pre><p>Then, we can run bash inside zsh and sleep infinity (like our tests, a very slow command) inside bash.</p><pre>$ bash<br>$ sleep infinity</pre><p>From another shell, we can see the process tree.</p><pre>$ pstree -p 20147<br>zsh(20147)───bash(65453)───sleep(65904)</pre><p>(pstree is in the psmisc package on Debian/Ubuntu and the pstree formula in brew.) This shows zsh running bash running sleep, as expected. If we now send a SIGINT with ctrl+c, sleep stops.</p><p>Why does that happen? The terminal interprets ctrl+c as <a href="https://en.wikipedia.org/wiki/Line_discipline">“send SIGINT”</a>. zsh receives SIGINT and forwards it to the foreground process which happens to be bash. bash receives the signal and forwards it to sleep. sleep didn’t set up its own signal handler for SIGINT and the default signal handler exits (<a href="https://manpages.org/signal/7">SIGINT has the “term” disposition</a>).</p><p>At the start of the investigation, this was our mental model for shell signal handling.</p><h3>Non-interactive shells</h3><p>The actual problem manifested when a shell script was run with bash (we run the python code above in a bash script).</p><pre>bash<br>  └─test_pipeline<br>      └─pytest</pre><p>Thinking that perhaps interactive shells (which read stdin, among other differences) behaved differently than non-interactive ones or “scripts”, we wrote 2 lines to a file</p><pre>sleep infinity<br>echo done</pre><p>and ran</p><pre>$ ./test.sh</pre><p>In another shell, we could see the same process tree</p><pre>$ pstree -p 20147<br>zsh(20147)───bash(65910)───sleep(65911)</pre><p>Then, we tried directly signaling bash</p><pre>$ kill -s INT 65910</pre><p>but nothing happened. Buried in the bash docs (man bash) is a <a href="https://www.gnu.org/software/bash/manual/html_node/Signals.html">“signals” section</a> that says</p><blockquote>When job control is not enabled, […] the shell and the command are in the same process group as the terminal, and ‘^C’ sends SIGINT to all processes in that process group. […]</blockquote><blockquote>When Bash is running without job control enabled and receives SIGINT […], it waits until that foreground command terminates and then [exits itself]</blockquote><p>Job control is on by default for interactive shells and off for scripts (see the docs about “monitor mode”). So that explains why nothing happened: bash was waiting for sleep (the foreground command) to terminate.</p><p>But there’s also a hint in there about <a href="https://biriukov.dev/docs/fd-pipe-session-terminal/3-process-groups-jobs-and-sessions/">process groups</a>. pstree can show us those too (unless you’re on macOS):</p><pre>$ pstree -pg 20147<br>zsh(20147,20147)───bash(65910,65910)───sleep(65911,65910)</pre><p>So here, we see that bash, which we ran in an interactive zsh, got its own process group. But sleep, which we ran in a non-interactive bash, shares a pgid with bash. We can signal both processes in the group by negating the pid:</p><pre>$ kill -s INT -65910</pre><p>This causes sleep to receive a SIGINT and exit. bash also received a SIGINT and, like the docs say, exits itself. Back in our interactive zsh, we can run</p><pre>$ sleep infinity</pre><p>and see that sleep gets its own pgid, as expected.</p><pre>$ pstree -p 20147<br>zsh(20147,20147)───sleep(65916,65916)</pre><h3>Last command in a non-interactive shell</h3><p>So now we know that sometimes, the shell won’t forward signals to its child process. At one point, someone tried to reproduce this by running bash -c &#39;sleep infinity&#39;. They were able to ctrl+c and stop sleep. But that’s a non-interactive shell, so bash shouldn’t be forwarding SIGINT! What gives?</p><pre>$ bash -c ‘sleep infinity’</pre><p>As usual, in another shell:</p><pre>$ pstree -p 20147<br>zsh(20147)───sleep(65920)</pre><p>Wait, where did bash go? We ran bash! Why does pstree say that zsh is running sleep?</p><p>When we “run” a program, what we generally mean is we <a href="https://lisper.in/fork-exec-python">fork and then exec</a> it. fork sets the new process’ parent pid so that tools like pstree can come along after the fact and draw a pretty tree. exec sets the new process’ command so that tools like pstree can show you something meaningful about what that pid is running.</p><p>But what happened here is that bash simply didn’t fork before exec-ing sleep. We couldn’t find any documentation about this behavior, so instead we offer you some <a href="https://github.com/mirror/busybox/blob/1_36_0/shell/ash.c#L10566-L10568">ash source code</a>:</p><pre>/* Can we avoid forking? For example, very last command<br>* in a script or a subshell does not need forking,<br>* we can just exec it.<br>*/</pre><p>So bash replaced itself with sleep and pstree shows that the parent of the thing that is now running sleep is zsh. We can get the previous behavior by instead running bash -c &#39;sleep infinity &amp;&amp; done&#39;.</p><p>This was particularly exciting because we actually run our bash script with sh -c, so our mental model was</p><pre>sh<br>└─bash<br>    └─test_pipeline<br>        └─pytest</pre><p>for a bit until we realized the sh wasn’t its own pid in the tree.</p><h3>A brief interlude about sh, bash, dash, and ash</h3><p>Wait, what is ash? Did you just link me to some unrelated code? (Yes, sort of; the behavior is the same as bash but the source code is less… abstracted.)</p><p>sh is the Bourne shell (but usually referred to as “POSIX sh”). Bash is the Bourne Again shell. Historically, many systems linked sh to bash, which would check argv[0] and run in sh compatibility mode. On modern Linux systems, <a href="https://en.wikipedia.org/wiki/Almquist_shell#Adoption_in_Debian_and_Ubuntu">sh is now usually dash</a>, but on macOS, it is still bash in sh mode.</p><p>The original ash was the Almquist shell from 1989 written for NetBSD. It was ported to Linux and renamed to dash (Debian Almquist shell). Nowadays, “ash” generally refers to busybox ash, <a href="https://github.com/mirror/busybox/blob/1_36_0/shell/ash.c#L29-L31">which is a derivative of dash</a>. Yes, you read that correctly: the lineage is ash → dash → ash. Shell programmers are not the best at naming things.</p><p>By the way, bash in sh compatibility mode and ash both implement the exec-without-fork behavior described in the previous section, but dash does not. Also, if you try to run sh in the official bash image on Docker Hub (docker run -it --rm bash sh), instead of bash in sh compatibility mode like you’d expect, you get ash (not to be confused with ash).</p><h3>Flowchart</h3><p>Here’s the flowchart that we wish had existed before we started peeling the onion of shell signal handling.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*6xW6hToI3k5NiWsM" /></figure><h3>Back to the crime scene</h3><p>Armed with our handy flowchart, we went to read our ci-agent’s code and found that when a build is canceled, it sends SIGTERM to the running job.</p><pre>ci-agent<br>    └─bash<br>        └─test_pipeline<br>            └─pytest</pre><p>bash was run non-interactively, test_pipeline was not the last command, so no signals are forwarded anyway. Does that explain what happened?</p><p>We tried to cut the bash out of the tree by making it exec test_pipeline.py, but that didn’t fix the problem. That must mean our process tree is still wrong.</p><h3>Containers</h3><p>The ci-agent actually just tells docker to run our script.</p><pre>ci-agent<br>    └─docker<br>        └─bash<br>            └─test_pipeline<br>                └─pytest</pre><p>Are signals being forwarded by docker to bash? Docker creates a new <a href="https://www.redhat.com/sysadmin/pid-namespace">pid namespace</a> for each container, so the command it runs becomes pid 1. 1 is a very special pid (it’s normally the init process) and <a href="https://petermalmgren.com/signal-handling-docker/">doesn’t get default signal handlers</a>. A common trick is to use tini or dumb-init to be pid 1 to solve this problem.</p><p>After investigating our image, it turned out we were already using dumb-init, leaving us with this tree</p><pre>ci-agent<br>    └─docker<br>        └─dumb-init<br>            └─bash<br>                └─test_pipeline<br>                    └─pytest</pre><p>and no explanation for the problem.</p><h3>This is the last tree, I swear</h3><p>Actually, we don’t run the docker container directly; we use docker compose run.</p><pre>ci-agent<br>    └─docker compose<br>        └─docker<br>            └─dumb-init<br>                └─bash<br>                    └─test_pipeline<br>                        └─pytest</pre><p>After finally building this tree, we were able to reproduce the problem. It only occurs on docker compose versions between v2.0.0 and v2.19.0, where docker compose run fails to forward signals. This was fixed <a href="https://github.com/docker/compose/commit/fed8ef6b791813f8b1479cf095a37cb4352fa02d">here</a> after we reported <a href="https://github.com/docker/compose/issues/10586">the issue</a>.</p><p>The bug manifested when we upgraded from docker-compose (v1; note the hyphen) to docker compose (v2). Noticing the missing hyphen was necessary to understanding this problem, but it was tough to notice because both versions take nearly identical arguments and have nearly identical behavior. One takeaway from reading this story should be that naming things, despite being hard, is important. If you ever find yourself writing docs like “<a href="https://docs.docker.com/compose/migrate/#docker-compose-vs-docker-compose">update scripts to use Compose V2 by replacing the hyphen (-) with a space</a>”, you’ve probably made a critical naming error.</p><p>Another thing that made debugging this hairy was needing to understand the full chain of custody. Signals need to be forwarded by each process to their children. Understanding why pytest didn’t receive a signal required constructing the tree up to the point that the forwarding chain was broken, which in this case was quite far.</p><p>We considered downgrading back to docker compose v1, but we instead chose to track containers run by our CI step and docker kill them at the end. Later, after upstream fixed the issue, our mitigation simply never kicked in. With the problem fixed, our CI runs now actually stop when we tell them to again. When someone pushes multiple times in quick succession to a PR branch, we don’t waste cycles running on old commits, resulting in faster runs overall! (We also no longer report metrics about these canceled runs, which helps us greatly in identifying flaky or failing tests.)</p><h3>Bonus about foreground processes</h3><p>Back in the “non-interactive shells” section, we had a process tree of</p><pre>zsh(20147)───bash(65910)───sleep(65911)</pre><p>and directly signaled bash with</p><pre>$ kill -s INT -65910</pre><p>Why didn’t we just signal zsh instead? zsh is running interactively, so shouldn’t it forward SIGINT to bash? We can try</p><pre>$ kill -s INT -20147</pre><p>but nothing happens.</p><p>It turns out when you hit ctrl+c in this situation, the terminal sends the SIGINT to bash, not zsh. This is because zsh is no longer in the foreground process group. We can see this by running</p><pre>$ ps -xO stat<br>   PID STAT S TTY          TIME COMMAND<br> 20147 Ss   S pts/0    00:00:00 zsh<br> 65910 S+   S pts/0    00:00:00 bash<br> 65911 S+   S pts/0    00:00:00 sleep</pre><p>The <a href="https://www.man7.org/linux/man-pages/man1/ps.1.html#PROCESS_STATE_CODES">“process state codes”</a> section of man ps says</p><blockquote>+ is in the foreground process group</blockquote><p>And we can see that bash and sleep are, but zsh isn’t. They can’t both be at the same time anyway, since there can only be one foreground process group and zsh gave bash its own process group (because zsh is running interactively). So when we said “zsh receives SIGINT and forwards it to the foreground process which happens to be bash”, it turns out that was a lie.</p><p>But wherefore is bash’s process group the foreground one? <a href="https://www.man7.org/linux/man-pages/man3/tcsetpgrp.3.html">tcsetpgrp</a>. We can see it being called with ltrace:</p><pre>$ ltrace -e tcsetpgrp bash<br>bash-&gt;tcsetpgrp(255, 0xa9850, 0, 0x7f290bdb2fe4) = 0</pre><p>and when bash exits, the parent shell (zsh, in my case) reclaims foreground status with the same call.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ee592e2b587b" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/signals-shells-and-docker-an-onion-of-footguns-ee592e2b587b">Signals, shells, and docker: an onion of footguns</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[10x faster python test iteration via fork(2)]]></title>
            <link>https://benchling.engineering/10x-faster-python-test-iteration-via-fork-2-3aae52d2f6?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/3aae52d2f6</guid>
            <category><![CDATA[developer-productivity]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[fork]]></category>
            <category><![CDATA[benchling]]></category>
            <dc:creator><![CDATA[raylu]]></dc:creator>
            <pubDate>Thu, 20 Jul 2023 16:01:45 GMT</pubDate>
            <atom:updated>2023-07-20T23:02:38.923Z</atom:updated>
            <content:encoded><![CDATA[<p>It’s ideal to get feedback on your code faster — to make a code change and see the result instantly. But, as projects get larger, reload times get longer. Each incremental dependency or bootstrap code block that adds 200ms feels worth it, but 50 of them later and it takes 10 seconds to see the result of a code change.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kXnodzozvuBFupVH2kYfmA.png" /></figure><p>On the Build team at Benchling, that’s where we found ourselves one day. We used 146 packages which pull in 128 transitive dependencies for a total of 274 packages. We also spent a lot of time waiting for SQLAlchemy models to initialize. The result is our test harness took 10 seconds to set up. After making a code change, you’d start the test runner, wait a few seconds, alt+tab to your browser, get distracted for a few minutes, and then find out you had a typo in your code.</p><p>This is a common challenge for a growing codebase, but it’s something we knew we needed to fix. Here’s the process we arrived at which allowed the second run of tests to start 10x faster — 90% less waiting. While it’ll work a little differently for your codebase depending on the language, dependencies, etc. you’re using, hopefully this can inspire you on your journey to faster feedback and testing.</p><h3>importlib.reload()</h3><p>Since the problem is that we spend so long setting up a bunch of modules just right and then want to see the change in a single file we’re editing, the most obvious solution is to use <a href="https://docs.python.org/3/library/importlib.html#importlib.reload">importlib.reload</a> from the standard library.</p><pre>import importlib<br>import sys<br>import test_harness_stuff  # takes 10 seconds<br>import tests<br>def rerun_tests(changed_path):<br>    for mod in sys.modules.values():<br>        if mod.__file__ == changed_path:<br>            importlib.reload(mod)<br>            tests.run_tests()<br>            break<br>if __name__ == &#39;__main__&#39;:<br>  setup_file_watcher(rerun_tests)<br>  tests.run_tests()</pre><p>This (with some special handling for built-in modules, relative path resolution, and batching to handle editors that perform multiple filesystem operations per save) works alright when the file being changed is a test file (or any other leaf node in the dependency tree).</p><p>However, as you’ve probably guessed from the very long documentation for reload(), this doesn’t work in many other cases. A very common one is if you have animal.py:</p><pre>cow = &quot;woof&quot;</pre><p>and then cow_say.py:</p><pre>from animal import cow</pre><p>If you change cow = &quot;moo&quot;, reloading animal.pyis not enough because cow_say.py has its own global bound to the old str. After reloading animal.py, you must then reload all reverse dependencies in topological order. You must also ensure that if a class definition is changed, all instantiations of that are reinitialized. For projects of almost any complexity, this is not feasible.</p><h3>Not importing</h3><p>Despite reload() not solving our problems, thinking about its issues is helpful in building a more useful solution. The giant list of caveats with reload() means you need to do surgery on the already-loaded modules.</p><p>What if we just didn’t load the code you were going to change until after you changed it? Then we wouldn’t need to do surgery! It’s not too hard to guess what code might be changed. Roughly speaking, our codebase has 3 kinds of modules: 3rd-party dependencies, SQLAlchemy models, and actual app code/tests. More than 90% of the time, we’re working in that last category, so we can just import the 3rd-party dependencies and SQLAlchemy models and not load the app/tests until we’re ready to run a test.</p><h3>zeus, fork()</h3><p>That leaves one problem: after we run a test, the test is loaded. How do we reset back to the state where dependencies and models were loaded but not app/tests? <a href="https://github.com/burke/zeus">zeus</a> actually solved this for Rails: load Rails, fork(), then load app code.</p><blockquote>fork() creates a new process by duplicating the calling process. […] The child process and the parent process run in separate memory spaces. At the time of <em>fork()</em> both memory spaces have the same content. Memory writes […] performed by one of the processes do not affect the other.</blockquote><p>So we can use fork()to snapshot the parent, import some code that is going to change (app/tests), and then rewind back to the snapshot later. Rather than doing surgery on in-memory modules, we can just let the child process exit, re-fork, and re-import any changed code.</p><pre>import os<br>import sys<br>import test_harness_stuff  # takes 10 seconds<br>def run_tests():<br>    pid = os.fork()<br>    if pid == 0:  # child<br>        import tests<br>        tests.run_tests()<br>        sys.exit()<br>    else:  # parent<br>        os.waitpid(pid, 0)<br>if __name__ == &#39;__main__&#39;:<br>    setup_file_watcher(run_tests)<br>    run_tests()</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/493/1*gw3y9mouurXRIBgsxyGoig.png" /></figure><p>Something like this sped up our test iteration time from 10 seconds to 1 second, which is a workflow-altering speed improvement (someone told me “I wouldn’t have bothered writing this tricky test if it weren’t for the fast reloader”).</p><p>zeus actually has a multi-level process tree and, when a file changes, it identifies which level imported it and terminates that process and all its ancestors. We do this too at Benchling: we divide up our modules into tiers based on how often developers work on them and where they fall in our dependency tree and then import each tier after forking. This allows us to discard as little import work as possible when a file closer to the root of our dependency tree changes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/444/1*hN4X4y6xyNsPZ_fTRTXxJA.png" /><figcaption>Our process tree</figcaption></figure><p>We actually ended up with some other components for ergonomics (a terminal forwarder that uses libreadline) and performance (file watcher that can’t fork because it’s threaded).</p><h3>Bonus: memory savings by not garbage collecting</h3><p>Once you start running python code after os.fork(), you start running into the same <a href="https://instagram-engineering.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172">memory usage problems Instagram faced</a>. They run a Django web server and load up all their dependencies before forking the web workers. At first, they tried to solve their runaway memory usage by disabling garbage collection entirely. Later, they came up with a <a href="https://instagram-engineering.com/copy-on-write-friendly-python-garbage-collection-ad6ed5233ddf">more elegant solution</a> and upstreamed it into CPython 3.7.</p><p>But what caused the memory usage? In short, copy-on-write pages and reference counting.</p><h4>Copy-on-write</h4><p>The fork() docs say “At the time of fork() both memory spaces have the same content. Memory writes […] performed by one of the processes do not affect the other”. The simplest way to implement this is to copy all the memory from the parent into the child.</p><p>The Linux kernel doesn’t do that. Instead, it makes new <a href="https://wiki.osdev.org/Memory_management#Paging">page tables</a> for the child process that point back at the parent’s memory and marks them both as read-only. When the child tries to write to any memory, it triggers a page fault. The kernel’s page fault handler looks at the page, sees that it was a copy-on-write page, makes an actual copy of the page, and lets the child retry the write operation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/397/1*pTr64TonnUJkIA6tzjM6nQ.png" /><figcaption>Parent and child processes sharing the same physical memory</figcaption></figure><p>As you can imagine, this saves a lot of memory (and makes fork quite a bit faster). So what’s the problem? The child rarely writes to any modules imported by the parent (the app/tests code rarely makes any changes to SQLAlchemy models or 3rd-party dependencies); it only reads them and calls functions defined in them.</p><h4>gc_refs</h4><p>Python’s garbage collector needs to know which objects are safe to free. To do this, every object has a <a href="https://github.com/python/cpython/blob/8d999cbf4adea053be6dbb612b9844635c4dfb8e/Include/objimpl.h#L256">gc_refs field</a> stored in its header that is incremented whenever it is referred to (for example, added to a list).</p><p>This means that if a module imported by our parent process defines a str and we later <em>read</em> that str in the child (which we do all the time), we will modify its object header to increment the ref count and trigger the kernel’s copy-on-write behavior.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/563/1*3fm2cmM-3WogymwrZDLAVQ.png" /><figcaption>Child process with its own memory after incrementing gc_refs</figcaption></figure><h4>gc.freeze()</h4><p>Instagram’s solution to this problem is to (rewrite all the CPython code that looks at gc_refs, introduce, and then) call <a href="https://docs.python.org/3/library/gc.html#gc.freeze">gc.freeze()</a>. This tells the interpreter that all existing objects should be considered ineligible for garbage collection and future accesses shouldn’t increment the ref counter. (The new object header layout, after Instagram’s changes in 3.7 and after another change in 3.12, is documented <a href="https://github.com/python/cpython/blob/main/Objects/object_layout.md">here</a>.)</p><p>Implementing this is very easy: just call gc.freeze() right before you fork()! Running a typical test, we saw a 160 MiB reduction in <a href="https://en.wikipedia.org/wiki/Unique_set_size">unique set size</a>.</p><h4>Don’t gc.collect()!</h4><p>Now that you’re thinking about the garbage collector, you might be tempted to call gc.collect() right before freezing and forking. It sounds like it would save memory — otherwise, objects with no refs in the parent will stick around forever in both the parent and the child. Unfortunately, that’s a bad idea.</p><p>When the garbage collector actually “collects” something, the <a href="https://github.com/python/cpython/blob/main/Objects/obmalloc.c">object allocator</a> “frees” that object’s memory. This doesn’t return any memory back to the system; it simply marks that memory as unused. It also creates a “hole” in the memory. A later allocation can fill that hole by using that freed memory.</p><p>If we think about what happens in the child after GC has created “holes” in the memory, we realize that the child will fill those holes in copy-on-write pages. In your development environment, your pages are likely <a href="https://dengking.github.io/Linux-OS/Kernel/%E4%B8%BB%E8%A6%81%E5%8A%9F%E8%83%BD/Memory-management/Virtual-memory/Paging/Unix-system-page-size/">4 KiB</a>. If you free a 1 KiB object, in the absolute best case it resides entirely within a page boundary and you replace it with another 1 KiB worth of objects. When the child tries to allocate 1 KiB, the kernel copies the entire 4 KiB page: you spent 3 KiB to save 1 KiB.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*40CzyrfGXQa1A8AAcSjNZg.png" /><figcaption>3 KiB used to save 1 KiB</figcaption></figure><p>This is why Instagram actually <a href="https://bugs.python.org/issue31558#msg302780">disables GC entirely in the parent</a>. In their words, “we’re wasting a bit of memory in the shared pages to save a lot of memory later (that would otherwise be wasted on copying entire pages after forking).”</p><h3>General applicability</h3><p>The approach we’ve described here solves a problem that we think a lot of others face — if you rack up enough dependencies, you probably have slow startup/reload times. It works on any system with fork (everything but Windows sans WSL). There are a few caveats, though:</p><ul><li>You need to be able to fork and then continue executing your code. Some languages’ standard libraries, such as nodejs, don’t offer this out of the box, so you may need platform-specific C extensions.</li><li>Your language needs to be able to dynamically load modules at runtime. This is pretty tricky for most compiled languages.</li><li>If you want this to work for your webserver (like zeus), it’s a bit more work. You need to integrate with your <a href="https://peps.python.org/pep-3333/">WSGI</a>/<a href="https://github.com/rack/rack/blob/main/SPEC.rdoc">rack</a>/etc. server to handle requests in a properly setup child process. Each server is different, so we don’t have any general advice for how to do this.</li></ul><p>Also, the benefits are only realized after you separate out modules based on their position in the dependency tree and frequency of edit. Because this is going to be different for everyone, we don’t have much code to share. We undertook this project because we noticed that SQLAlchemy models were close to the root of our dependency tree and took up the majority of startup time, but your mileage may vary.</p><h3>We’re hiring!</h3><p>If you’re interested in working with us to solve complex engineering problems, check out our <a href="https://www.benchling.com/careers/">careers page</a> or <a href="mailto:jobs@benchling.com">contact us</a>!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UneW9cQzCOETv6sgn-JqHw.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3aae52d2f6" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/10x-faster-python-test-iteration-via-fork-2-3aae52d2f6">10x faster python test iteration via fork(2)</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Exposing AWS KMS Asymmetric Keys as a JWKS]]></title>
            <link>https://benchling.engineering/exposing-aws-kms-asymmetric-keys-as-a-jwks-7f183657f0d9?source=rss----3d4aa8fb07ea---4</link>
            <guid isPermaLink="false">https://medium.com/p/7f183657f0d9</guid>
            <category><![CDATA[oauth2]]></category>
            <category><![CDATA[benchling]]></category>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[security]]></category>
            <dc:creator><![CDATA[Brian Maloney]]></dc:creator>
            <pubDate>Thu, 02 Feb 2023 20:53:31 GMT</pubDate>
            <atom:updated>2023-02-02T20:53:31.900Z</atom:updated>
            <content:encoded><![CDATA[<p>Here at Benchling, interaction with services is a large part of our business, from employees interacting with the software-as-a-service products with which we conduct our daily business, all the way down to interactions between the services that make up the Benchling application platform itself. Secure authentication and authorization to services is a long-standing issue in the industry, but one that has been improving in recent years due to the widespread adoption of modern standards such as OAuth 2.0 and OpenID Connect (OIDC).</p><p>One specific use case for service-to-service authentication that is important to Benchling Security is connecting our Threat Detection Pipeline to our enterprise identity services vendor. We use this connection to connect log and other data provided by the vendor to our centralized Threat Detection Platform, where we correlate this with other sources of intelligence to detect risky or suspicious user activity in near real-time.</p><h4>Modern Authentication with OIDC</h4><p>Our specific identity services vendor offers two options for authenticating to its API: either an API token that an administrator can generate, or interaction by acting as an Application. API tokens, while very easy to use, are a poor choice for two reasons:</p><ol><li>First, they are a static secret that must be handled carefully and rotated frequently to mitigate the risk of a leaked key, which causes significant management overhead.</li><li>Second, the identity services vendor links the privileges and identity of an API token inextricably to the administrator who generated it. This causes actions using the key to be attributed to the administrator and also makes it impossible to implement the principle of least privilege.</li></ol><p>Client authentication when acting as an Application allows the use of OIDC, and this vendor specifically requires the use of the <a href="https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication">private_key_jwt</a> Client Authentication method. Enforcing this requirement is a good choice on the part of the vendor — by using public-key encryption, no secrets need to be shared, only public keys need to be exchanged, and neither party can impersonate the other.</p><p>At this point you may be thinking, “Even though secrets don’t need to be exchanged, isn’t there still overhead for rotating the public key? And what is the best way to manage and safeguard the private keys in a modern cloud environment?” These are legitimate concerns and both have relatively simple solutions.</p><p>For managing private keys in a cloud environment, AWS was kind enough to solve this for us when they <a href="https://aws.amazon.com/blogs/security/digital-signing-asymmetric-keys-aws-kms/">added asymmetric key functionality to KMS</a> in 2019. The KMS asymmetric key functionality allows you to provision public/private keypairs using the same cloud infrastructure tooling you’re already using. The private key never leaves AWS infrastructure, but the public key can be exported and shared. Signing and verification operations must therefore be done using AWS APIs rather than the traditional SSL toolkits, however this functionality is readily available via existing SDKs and command-line tools.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Q2nYtFSQDScp3_ITFPYurA.png" /><figcaption>Client token flow using private_key_jwt with KMS Asymmetric Keys</figcaption></figure><h4>The Challenge: Public Key Rotation</h4><p>The first issue described above is a bit more complex, and isn’t solved for us by our cloud provider. The KMS Asymmetric key must still be rotated for maximum security, but that means that the new public key needs to be shared with the identity provider. The <a href="https://openid.net/specs/openid-connect-discovery-1_0.html">OIDC Discovery</a> standard provides a common method for discovery of information about OpenID Providers using standard web technologies, including providing a URL to a JSON Web Key Set (JWKS defined in <a href="https://tools.ietf.org/html/rfc7517">RFC7517</a>) containing the public keys for the OpenID Provider. Our identity services vendor supports this approach — when creating a new API Services Application, public keys can either be uploaded into the provider <em>or</em> a JWKS URL can be provided. Because our vendor caches the JWKS, it is only loaded infrequently to update the cache, or when a new key is used.</p><p>Given the flexibility gained from dynamic JWKS sharing, we strongly preferred this approach for our integration. With the design selected, implementation can be broken into two major steps:</p><ul><li>Enable signing of JWTs using AWS KMS Asymmetric Keys in the client</li><li>Dynamically generate and serve the JWKS for the relevant KMS Keys</li></ul><p>Signing of JWTs using KMS Asymmetric Keys is a common use case — there are already multiple examples of how to do this available (<a href="https://github.com/sufiyanghori/Python-Asymmetric-JWT-Signing-using-AWS-KMS">Python</a>, <a href="https://www.altostra.com/blog/asymmetric-jwt-signing-using-aws-kms">Node.js</a>). Integrating signing into your client workflow is fairly straightforward following one of these examples, so we won’t dig deeper into that in this article.</p><h4>JWKS Construction and Serving</h4><p>While this isn’t that complex of a task, there are numerous ways to accomplish it with different capabilities and performance characteristics. This article will cover the way we tackled the problem to meet Benchling Security’s specific needs without being prescriptive. Although for this post we are using a very simple design, there are still some challenges to overcome on the way to a functioning solution.</p><p>The basic design of our JWKS service is a Lambda function, fronted by the relatively new <a href="https://aws.amazon.com/blogs/aws/announcing-aws-lambda-function-urls-built-in-https-endpoints-for-single-function-microservices/">Function URLs</a> feature of AWS Lambda. Function URLs allow a single-function microservice (like our JWKS exporter) to be served without the additional infrastructure of an API Gateway. This greatly reduces the amount of infrastructure we have to build to provide this service.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2NIViEGEukftp5wIvD5HQw.png" /><figcaption>JKWS Lambda Flow</figcaption></figure><h4>Key Selection</h4><p>In any AWS region, there may be multiple KMS Asymmetric Keys defined, but we don’t need to export every key in the region in this JWKS. AWS has some great ways to group, select, and filter cloud resources, tags being the best example. Our initial expectation was to tag each of the keys for each use case and construct the JWKS from only those keys. Unfortunately, the response from the AWS API’s <a href="https://docs.aws.amazon.com/kms/latest/APIReference/API_ListKeys.html">ListKeys</a> function response does not include the tags defined on the keys. The only way to filter the list of keys based on tags would be to iterate over each key in the region, calling <a href="https://docs.aws.amazon.com/kms/latest/APIReference/API_ListResourceTags.html">ListResourceTags</a> on each key, which isn’t scalable for a function that needs to return within a few seconds.</p><p>Because of these limitations, the only reasonable approach is to use key aliases to identify the keys to be exported in the JWKS. Using aliases allows the use of the <a href="https://docs.aws.amazon.com/kms/latest/APIReference/API_ListAliases.html">ListAliases</a> API function and filter the results to just the keys which should be exported.</p><h4>Rendering Public Keys as JWKs</h4><p>Now that we have a list of Asymmetric key resources within KMS that we want to export, the public keys need to be converted into JWK structures which can then be combined into a JWKS. Because simplicity is a design goal, we want to use the fewest possible steps to convert the public key returned by the AWS <a href="https://docs.aws.amazon.com/kms/latest/APIReference/API_GetPublicKey.html">GetPublicKey</a> API into a JWK. There are numerous Python libraries available that implement the JOSE (Javascript Object Signing and Encryption) standards, including JWK. Unfortunately, many of the popular options on PyPI have limited JWK functionality. For example, some libraries can only handle JWKs which are already in JWK format but cannot convert an existing public key or certificate to a JWK. Fortunately, the Python ecosystem is large and <a href="https://pypi.org/project/jwcrypto/">JWCrypto</a> has a full suite of JWK-handling functions, including conversion from PEM.</p><p>The only remaining piece of the puzzle is conversion from the DER key delivered by <a href="https://docs.aws.amazon.com/kms/latest/APIReference/API_GetPublicKey.html">GetPublicKey</a> into a PEM formatted public key. While this is a simple operation to do by hand, this functionality is provided by the very popular python <a href="https://pypi.org/project/cryptography/">Cryptography</a> library, which is already used by JWCrypto.</p><p>Finally, all that’s needed is to gather the keys into a JWKS structure and render that as JSON, and write that as the body of your Lambda response.</p><h4>Putting it all together</h4><p>Now that we know how to do all the individual steps, we can assemble them into a pleasingly simple Python Lambda with only 3 functions:</p><pre>import os<br>import logging</pre><pre>import boto3<br>from cryptography.hazmat.primitives import serialization</pre><pre>from jwcrypto import jwk</pre><pre>kms = boto3.client(&#39;kms&#39;)</pre><pre>for var in [&quot;ALIAS_START&quot;]:<br>    if not var in os.environ:<br>        logging.critical(f&#39;{var} environment variable not set&#39;)<br>        exit()</pre><pre># Search for and return the list of enabled keys with aliases<br># that start with our desited string<br>def find_enabled_keys(starts_with):<br>    alias_paginator = kms.get_paginator(&#39;list_aliases&#39;)<br>    alias_iterator = alias_paginator.paginate().search(f&#39;Aliases[?TargetKeyId != null &amp;&amp; starts_with(AliasName, `{starts_with}`)].TargetKeyId&#39;)</pre><pre>    key_ids = [page for page in alias_iterator]</pre><pre>    enabled_keys = [<br>        key_id<br>        for key_id in key_ids<br>        if kms.describe_key(KeyId=key_id)[&#39;KeyMetadata&#39;][&#39;KeyState&#39;]<br>        == &#39;Enabled&#39;<br>    ]</pre><pre>    return enabled_keys</pre><pre># Return a single key, formatted as a JWCrypto JWK object<br>def get_jwk(key_id):<br>    response = kms.get_public_key(KeyId=key_id)</pre><pre>    pubkey = serialization.\<br>        load_der_public_key(response[&#39;PublicKey&#39;])</pre><pre>    pub_pem = pubkey.public_bytes(<br>         encoding=serialization.Encoding.PEM,<br>         format=serialization.PublicFormat.SubjectPublicKeyInfo<br>    )</pre><pre>    key = jwk.JWK.from_pem(pub_pem)<br>    key.update(use=response[&#39;KeyUsage&#39;][0:3].lower(), kid=key_id)</pre><pre>    return key</pre><pre>def lambda_handler(event, context):<br>    # Construct JWKS structure from keys that match ALIAS_START<br>    jwks = {<br>         &quot;keys&quot;: [<br>             get_jwk(key_id)<br>             for key_id<br>             in find_enabled_keys(os.environ[&#39;ALIAS_START&#39;])<br>         ]<br>    }</pre><pre>    # Construct Lambda return structure<br>    return({<br>        &#39;statusCode&#39;: 200,<br>        &#39;headers&#39;: {<br>            &quot;Content-Type&quot;: &quot;application/json&quot;,<br>        },<br>        &#39;body&#39;: jwks<br>    })</pre><h4>Enhancements for Production Use</h4><p>The code presented in this article should be considered a poof-of-concept only — if you intend to use this pattern in production, you should consider your use case. If your needs require frequent reloading of this JWKS, it would be more scalable to write this out to an object store when changes occur, and serve the JWKS directly from the object store or a CDN. If you do not have production-level requirements, we still recommend building additional resilience into the function by expanding error handling.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7f183657f0d9" width="1" height="1" alt=""><hr><p><a href="https://benchling.engineering/exposing-aws-kms-asymmetric-keys-as-a-jwks-7f183657f0d9">Exposing AWS KMS Asymmetric Keys as a JWKS</a> was originally published in <a href="https://benchling.engineering">Benchling Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>