<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Meng Lin</title>
    <description>The latest articles on Forem by Meng Lin (@menglinmaker).</description>
    <link>https://forem.com/menglinmaker</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1180248%2Fc5ae3971-a4e5-44a3-b0ef-2c00db9fcad4.png</url>
      <title>Forem: Meng Lin</title>
      <link>https://forem.com/menglinmaker</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/menglinmaker"/>
    <language>en</language>
    <item>
      <title>Building NewHomie property analytics tool — Part 1</title>
      <dc:creator>Meng Lin</dc:creator>
      <pubDate>Thu, 16 Apr 2026 22:07:48 +0000</pubDate>
      <link>https://forem.com/menglinmaker/building-newhomie-property-analytics-tool-part-1-57oe</link>
      <guid>https://forem.com/menglinmaker/building-newhomie-property-analytics-tool-part-1-57oe</guid>
      <description>&lt;h2&gt;
  
  
  From Scrappy Scraper to Production Pipeline
&lt;/h2&gt;

&lt;p&gt;It all started with a question.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“How am I supposed to afford a house?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffmu93izdbgd7w1nklf6w.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffmu93izdbgd7w1nklf6w.jpeg" alt="captionless image" width="687" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I set out to transfigure my anxiety into a software product.&lt;/p&gt;

&lt;p&gt;But first, I needed data I could trust.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;So I could build this insanity 😉:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fam5ycck0l9xppwgryabj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fam5ycck0l9xppwgryabj.gif" alt="Cross-highlighting tens of thousands of properties across charts and attributes in real time." width="720" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Validate scrapeability
&lt;/h2&gt;

&lt;p&gt;There is no point building a scraper locally only to discover it breaks the moment you deploy it.&lt;/p&gt;

&lt;p&gt;I have chosen to scrape &lt;a href="http://www.domain.com.au" rel="noopener noreferrer"&gt;Domain&lt;/a&gt; for Australian property data; since &lt;a href="https://www.realestate.com.au/" rel="noopener noreferrer"&gt;Realestate&lt;/a&gt; will probably send endless wave of lawyers for bypassing the picky &lt;a href="https://www.kasada.io/" rel="noopener noreferrer"&gt;Kasada bot protection&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Before writing much pipeline code, I wanted to validate two things: whether the site was practically scrapeable, and whether it could be scraped reliably in a deployed environment.&lt;/p&gt;

&lt;p&gt;Scrapeability factors to consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend framework&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Domain uses Next.js client-side rendering.&lt;/li&gt;
&lt;li&gt;Next.js sends a JSON payload in a &lt;em&gt;&amp;lt;script id=“&lt;/em&gt;&lt;em&gt;NEXT_DATA&lt;/em&gt;&lt;em&gt;” type=“application/json”/&amp;gt;&lt;/em&gt; tag.&lt;/li&gt;
&lt;li&gt;Extracting that JSON would be much more stable and structured than parsing rendered HTML, because it was closer to the frontend’s source data.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Bot detection&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Domain uses Akamai which prevents simple HTTP requests unless they come from a real browser.&lt;/li&gt;
&lt;li&gt;I did not observe stronger protections like IP blocking, fingerprinting or human cursor detection.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;To test quickly, I deployed &lt;a href="https://playwright.dev/" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt; on &lt;a href="https://aws.amazon.com/lambda/" rel="noopener noreferrer"&gt;AWS Lambda&lt;/a&gt; using &lt;a href="https://sst.dev/" rel="noopener noreferrer"&gt;SST&lt;/a&gt; so I could iterate against a live environment. For stronger bot protection, I would consider something like Camoufox, but at a performance cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Validate scraped data at boundary
&lt;/h2&gt;

&lt;p&gt;Once I knew I could scrape the site, I needed a way to enforce data integrity.&lt;/p&gt;

&lt;p&gt;Validating data early in the scrape process simplified the rest of the pipeline. My first pass used LLM-generated scraper code, which duplicated validation logic in multiple places and made the transformation layer harder to reason about.&lt;/p&gt;

&lt;p&gt;Introducing a schema validator at the boundary fixed a lot of that. Using &lt;a href="https://zod.dev/" rel="noopener noreferrer"&gt;Zod&lt;/a&gt; made it easier to distinguish expected data from unexpected data, catch bad assumptions early, and keep the downstream transformation logic much simpler.&lt;/p&gt;

&lt;p&gt;After deploying, I noticed some localities were not being scraped because certain paths in the extracted JSON did not exist. It turned out those pages did not exist at all. Once I confirmed that, I added a validator to detect the 404 page shape so the pipeline could handle it gracefully instead of failing deeper in the system.&lt;/p&gt;

&lt;p&gt;Boundary validation made debugging easier and simplified the downstream transformation logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Insert data with raw SQL
&lt;/h2&gt;

&lt;p&gt;SQL abstractions can hide important insertion logic and encourage assumptions that only surface later as dirty data.&lt;/p&gt;

&lt;p&gt;I was tempted to use &lt;a href="https://kysely.dev/" rel="noopener noreferrer"&gt;Kysely&lt;/a&gt; query builder for inserts. It reduced boilerplate, but it also made it too easy to assume every table shared the same conflict-handling logic. In practice, each table needed different upsert and deduplication behaviour.&lt;/p&gt;

&lt;p&gt;That mismatch introduced bad data which only became obvious later when I started exploring the dataset. Cleaning it up was expensive. I had to write careful migration scripts to transform or delete rows that should never have been inserted in the first place.&lt;/p&gt;

&lt;p&gt;One example was property listings that shared the same address and overwrote each other. Another was duplicate listings with different prices, including oddly precise unrounded numbers. These cases occurred less than 1% of the time, but they still produced enough junk data to cost me one to two weeks of cleanup work.&lt;/p&gt;

&lt;p&gt;For this part of the pipeline, raw SQL was more verbose, but it made conflict handling explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Add observability and iterate before scaling
&lt;/h2&gt;

&lt;p&gt;Observability on &lt;a href="https://grafana.com/docs/opentelemetry/docker-lgtm/" rel="noopener noreferrer"&gt;Grafana LGTM&lt;/a&gt; made it possible to see where the pipeline was slow, fragile, or built on bad assumptions. That sped up architectural iteration by exposing bottlenecks and clarifying the real requirements.&lt;/p&gt;

&lt;p&gt;Once the scraper moved into a real deployed environment, I added an SQS queue in front of the workers and started tracking a few key signals:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker duration:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Workers are split by locality.&lt;/li&gt;
&lt;li&gt;  This made it easier to see whether cold starts and setup overhead were dominating runtime.&lt;/li&gt;
&lt;li&gt;  In practice, that pushed me toward batching work where possible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj38gmziz8t8lr89p5a29.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj38gmziz8t8lr89p5a29.png" alt="captionless image" width="800" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CPU and memory usage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  I wanted workers to use available CPU efficiently rather than sit idle.&lt;/li&gt;
&lt;li&gt;  Higher utilization generally meant better cost efficiency, as long as memory stayed within safe limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fosnhbiybpqoordw1f148.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fosnhbiybpqoordw1f148.png" alt="captionless image" width="800" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipeline fragility:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  I wanted to know where the pipeline failed most often, so I added class and method names to the &lt;a href="https://opentelemetry.io/docs/specs/semconv/registry/attributes/code/#code-function-name" rel="noopener noreferrer"&gt;OTEL code_function_name attribute&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;  I also logged ambiguous or partially extracted data so bad assumptions were visible earlier.&lt;/li&gt;
&lt;li&gt;  Error occurrence patterns over time and space can be visualized on a heat map.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5ytge5ao43c8gkpymqn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5ytge5ao43c8gkpymqn.png" alt="captionless image" width="800" height="231"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anomalies:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Unusual worker duration.&lt;/li&gt;
&lt;li&gt;  Unusual resource usage.&lt;/li&gt;
&lt;li&gt;  Unexpected failure patterns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These signals made the pipeline easier to iterate on. Instead of guessing where the bottlenecks and fragile spots were, I could observe them directly and improve the system from there. Once failure modes were better understood and error rates came down, I could scale the pipeline with much more confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Design for iteration speed first
&lt;/h2&gt;

&lt;p&gt;To validate scrapeability quickly, I needed infrastructure that could deploy fast. I intentionally traded long-term flexibility for iteration speed. At this stage, the main requirements were simple: keep scrape speed within rate limits, keep SQL inserts idempotent, and retry workers on failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7v97pf235xmqck9htze9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7v97pf235xmqck9htze9.png" alt="The first version used a cron job to trigger scrape work through an SQS queue." width="800" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the scraper worked reliably on 3 localities, I scaled it to 362 localities near the CBD. At that scale, rarer problems started to appear in the &lt;a href="https://menglinmaker.grafana.net/dashboard/snapshot/wBuTNydyAvyK4VNUV9YjPakuzfWzlkzN?orgId=1&amp;amp;from=2025-09-15T14%3A30%3A00.000Z&amp;amp;to=2025-09-15T20%3A00%3A00.000Z&amp;amp;timezone=browser&amp;amp;var-heatmap_interval=30s&amp;amp;var-log_level=fatal&amp;amp;var-service_name=function-scrape_locality&amp;amp;refresh=1m" rel="noopener noreferrer"&gt;observability dashboard&lt;/a&gt; (showcasing 367 errors):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser process was hanging&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This showed up in the heat map as repeated failures in &lt;code&gt;ScrapeController.tryExtractSuburbPage&lt;/code&gt; across many localities after scrape attempts. Eventually the browser process would restart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5ytge5ao43c8gkpymqn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5ytge5ao43c8gkpymqn.png" alt="Once browser hangs, it affects other workers due to Lambda shared context." width="800" height="231"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;TimeoutError&lt;/code&gt; — &lt;code&gt;BrowserService.getHTML&lt;/code&gt;
Navigation timeout of 10000 ms exceeded&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;ProtocolError&lt;/code&gt; — &lt;code&gt;ScrapeController.tryExtractSuburbPage&lt;/code&gt;
&lt;code&gt;Target.createTarget&lt;/code&gt; timed out. Increase the &lt;code&gt;protocolTimeout&lt;/code&gt; setting in launch/connect calls for a higher timeout if needed.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;ProtocolError&lt;/code&gt; — &lt;code&gt;ScrapeController.tryExtractRentsPage&lt;/code&gt;
&lt;code&gt;Target.createTarget&lt;/code&gt; timed out. Increase the &lt;code&gt;protocolTimeout&lt;/code&gt; setting in launch/connect calls for a higher timeout if needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  I reworked the browser error-handling and retry logic so failures triggered a full browser restart instead of leaving the process in a bad state. This greatly decreased the average scrape worker duration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F962dufjqpkwg8epktepb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F962dufjqpkwg8epktepb.png" alt="Workers with ProtocolError wasted about 3 minutes of compute each." width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price extraction logic was flawed, but caught by database constraint&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Errors*&lt;em&gt;:&lt;/em&gt;*&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;Warn&lt;/code&gt; — &lt;code&gt;DomainListingsService.tryTransformSalePrice&lt;/code&gt;
no price in &lt;code&gt;listing.listingModel.price&lt;/code&gt; — &lt;code&gt;"2bedroom + 1bedroom (study)"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;Warn&lt;/code&gt; — &lt;code&gt;ScrapeModel.tryUpdateSaleListing&lt;/code&gt;
value &lt;code&gt;"640000680000"&lt;/code&gt; is out of range for type integer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  I added tests from production logs so more valid price strings are accepted while invalid price strings are rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Non-existent pages were being scraped without enough context in the logs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;ZodError&lt;/code&gt; — &lt;code&gt;DomainSuburbService.tryExtractProfile&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;ZodError&lt;/code&gt; — &lt;code&gt;DomainListingsService.tryExtractListings&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  The error logs needed to include the locality that caused the failure. Once I added that context, I found that some of the localities did not exist at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This version of the architecture did what it needed to do: it validated scrapeability quickly and exposed real failure modes early. Its weaknesses only became obvious once scale increased, which was acceptable for a design optimized for learning speed.&lt;/p&gt;

&lt;p&gt;Fast validation was the right trade at the start, because it exposed real failure modes before the architecture was worth hardening.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Design for observed requirements next
&lt;/h2&gt;

&lt;p&gt;Once the pipeline became stable with less than 50 errors per run, iteration speed was no longer the main priority. At that point, I could trade some of it away to meet the requirements the system had actually revealed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Orchestration for pre and post processing.&lt;/li&gt;
&lt;li&gt;  Smaller blast radius when scrape workers failed.&lt;/li&gt;
&lt;li&gt;  Independent workflow execution.&lt;/li&gt;
&lt;li&gt;  Scrape workers that were easier to test and deploy.&lt;/li&gt;
&lt;li&gt;  Timeout flexibility above Lambda’s 15 minutes.&lt;/li&gt;
&lt;li&gt;  Full workflow completion within 1 day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next architecture centered around Step Functions for orchestration, with Fargate workers running the scraper in Docker containers. This made the workflow easier to reason about, and the Step Functions visualizer was especially useful for debugging and manually retrying failed runs.&lt;/p&gt;

&lt;p&gt;My original plan was to use Fargate workers running the scraper in Docker containers. However, I could not work out how to inject environment variables into &lt;a href="https://sst.dev/docs/component/aws/task/#containers-environment" rel="noopener noreferrer"&gt;Fargate tasks&lt;/a&gt; from &lt;a href="https://sst.dev/docs/component/aws/step-functions/" rel="noopener noreferrer"&gt;Step Function&lt;/a&gt; using SST, so I temporarily kept Lambda workers despite their limitations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14ilnzye58wiqlxhahu7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14ilnzye58wiqlxhahu7.png" alt="Ideal architecture: Cron job → Step Functions workflow → Fargate task" width="800" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, &lt;a href="https://docs.aws.amazon.com/step-functions/latest/dg/service-quotas.html" rel="noopener noreferrer"&gt;Step Functions introduced its own constraints&lt;/a&gt;. The 256 KB message limit and 25,000 event history limit added complexity to larger runs. The simplest workaround I found at the time was to trigger two workflows in parallel.&lt;/p&gt;

&lt;p&gt;Once this architecture looked stable in a preview branch, I scaled it from 362 to 4,491 localities. That was the point where Step Functions began to hit its practical limits and forced a temporary redesign.&lt;/p&gt;

&lt;p&gt;Once the system revealed its real constraints, the architecture had to evolve around them rather than the assumptions that shaped the first version.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Optimize for cost last
&lt;/h2&gt;

&lt;p&gt;After observing the cost of the previous design, I realized it was more expensive than expected, partly because Lambda’s free tier had hidden some of the true cost earlier on.&lt;/p&gt;

&lt;p&gt;At this stage, I wanted the cheapest compute that still fit the workload. In theory, that meant &lt;a href="https://aws.amazon.com/ec2/spot/pricing/" rel="noopener noreferrer"&gt;spot instance&lt;/a&gt; or &lt;a href="https://aws.amazon.com/fargate/pricing/#Fargate_Spot_Pricing_for_Amazon_ECS" rel="noopener noreferrer"&gt;spot Fargate&lt;/a&gt; on Arm64. In practice, that would reduce scrape worker availability and increase the chance of interrupted runs and forced restarts.&lt;/p&gt;

&lt;p&gt;My target was to keep the overhead of batch locality scraping below 10%. Since AWS Batch on Fargate adds roughly 1 minute of provisioning overhead, each worker needed to run for around 10 minutes to make that overhead acceptable. Based on a median scrape time of 15 seconds per locality, I designed each worker to handle 50 localities.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fucheehnniqnfefav9mdc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fucheehnniqnfefav9mdc.png" alt="Conceptually simple architecture with gnarly IaC definition." width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Although spot instances were cheap, they introduced additional costs and complexity, including &lt;a href="https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/" rel="noopener noreferrer"&gt;public IPv4 charges&lt;/a&gt; and &lt;a href="https://github.com/anomalyco/sst/issues/6244#issuecomment-3704733886" rel="noopener noreferrer"&gt;more complex IaC&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Cost only became worth optimizing once the workload was understood well enough to separate real savings from premature complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;What began as a scrappy scraper gradually became a production pipeline. Each stage exposed a different class of problem: scrapeability, data integrity, insertion rules, observability, and finally architecture itself.&lt;/p&gt;

&lt;p&gt;The main lesson was to validate assumptions early, especially through observability. Production data exposed where those assumptions failed, and each redesign became a response to that reality rather than guesswork.&lt;/p&gt;

&lt;p&gt;The result is a scraper that has been in production since October 2025 and has been operating with an average of fewer than 100 warnings and errors per run.&lt;/p&gt;

&lt;p&gt;The biggest benefit was being able to explore tens of thousands of properties with a single SQL query instead of being constrained by the limited interfaces of property listing websites. That made the engineering effort worthwhile: it turned messy public listing data into something I could reason about quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What’s Next?
&lt;/h2&gt;

&lt;p&gt;I could use &lt;a href="https://aws.amazon.com/fis/" rel="noopener noreferrer"&gt;AWS FIS&lt;/a&gt; to test the resilience of the pipeline by deliberately injecting faults in the spirit of Netflix-style chaos engineering.&lt;/p&gt;

&lt;p&gt;The next obvious step is to explore the data itself, but that is a separate problem.&lt;/p&gt;

&lt;p&gt;That opens the door to a different set of questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  How to design an interactive UX for exploring large property datasets?&lt;/li&gt;
&lt;li&gt;  How much complexity does local-first caching introduce?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dataengineering</category>
      <category>aws</category>
      <category>softwareengineering</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Reconnecting websockets on AWS</title>
      <dc:creator>Meng Lin</dc:creator>
      <pubDate>Mon, 09 Oct 2023 01:11:32 +0000</pubDate>
      <link>https://forem.com/menglinmaker/reconnecting-websockets-on-aws-2gk1</link>
      <guid>https://forem.com/menglinmaker/reconnecting-websockets-on-aws-2gk1</guid>
      <description>&lt;p&gt;AWS API Gateway supports websockets.&lt;/p&gt;

&lt;p&gt;Unfortunately, their service does not provide the ability to persist connection data and reconnect on flaky internet sessions. Nor could I find any example projects with those features. In this article, I will explore a &lt;a href="https://github.com/MengLinMaker/websocket-lambda" rel="noopener noreferrer"&gt;potential solution using Lambda and DynamoDB&lt;/a&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  Firstly, how do API Gateway websockets work?
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3vy6avivo5z7l2q7ho3h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3vy6avivo5z7l2q7ho3h.png" alt="AWS websocket chat application example" width="760" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS uses routes to execute different actions. Consider a chat application workflow, assuming Lambda is used for compute:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Client connects to websocket, firing the &lt;code&gt;$connect&lt;/code&gt; route and associated Lambda.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client sends JSON payload &lt;code&gt;{action: 'sendmessage'}&lt;/code&gt;, firing the &lt;code&gt;sendmessage&lt;/code&gt; route.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Server can send data to client by specifying a &lt;code&gt;socketUrl&lt;/code&gt; with &lt;code&gt;ConnectionId&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If client sends JSON payload without action, the &lt;code&gt;$default&lt;/code&gt; route is fired.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client disconnects, firing the &lt;code&gt;$disconnect&lt;/code&gt; route.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Limitations with API Gateway websocket:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Every time the &lt;code&gt;$connect&lt;/code&gt; route is called, a new &lt;code&gt;ConnectionId&lt;/code&gt; is created. To persist connection on flaky internet, the client must store an ID.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Lambda is ephemeral, so a database like DynamoDB is required to persist connection data — connection data shouldn’t be stored in memory in case of failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The 10 minute connection timeout can be avoided with a ping/pong request.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Max websocket duration of 2 hours, which would require a new connection session.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  A solution to reconnecting websockets on AWS
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fygffam779qvrlj5uuag5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fygffam779qvrlj5uuag5.png" alt=" " width="800" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This solution uses custom actions instead of &lt;code&gt;$connect&lt;/code&gt; and &lt;code&gt;$disconnect&lt;/code&gt; to manage connections, which shifts connection management to the client instead of AWS. DynamoDB will persist connection data and provide an event-driven architecture for returning messages as well as interfacing with external services.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 1: Ideal connection
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;After opening a websocket connection, the client will send an empty socketId to the &lt;code&gt;open&lt;/code&gt; route, which generates a new socketId for the client. The socketUrl, ID and currentId are stored in DynamoDB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client calls external service “ping”, providing &lt;code&gt;socketUrl&lt;/code&gt; and socketId. These details are used to update the DynamoDB table.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Updating DynamoDB will trigger the &lt;code&gt;message&lt;/code&gt; Lambda to post any stored messages in the updated row to the client.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client is responsible for triggering the &lt;code&gt;close&lt;/code&gt; route, which will delete the associated row in DynamoDB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If client fails to close the connection, a &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/howitworks-ttl.html" rel="noopener noreferrer"&gt;TTL (time to live)&lt;/a&gt; data deletion timer can be specified for Dynamo&lt;br&gt;
DB.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Case 2: Flaky connection
&lt;/h3&gt;

&lt;p&gt;If the internet connection is poor, the websocket connection may close on the client side.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;When the client is offline, new messages will be stored in DynamoDB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A “back online” event listener on the client will create a new websocket connection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client will send an existing socketId to the &lt;code&gt;open&lt;/code&gt; route, updating DynamoDB with the new &lt;code&gt;ConnectionID&lt;/code&gt;. This triggers an event to send the last message back to the client.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Case 3: Long connection
&lt;/h3&gt;

&lt;p&gt;An under 10 minute disconnect and connect interval will solve this issue.&lt;/p&gt;




&lt;h1&gt;
  
  
  Limitations to consider
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;DynamoDB stream adds latency for message updates to client.&lt;/li&gt;
&lt;li&gt;Connection data will not persist if client is refreshed. Also, storing socketId in local storage is not ideal when multiple tabs are used.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>dynamodb</category>
      <category>lambda</category>
    </item>
    <item>
      <title>Fast piano transcription on AWS -Part 2</title>
      <dc:creator>Meng Lin</dc:creator>
      <pubDate>Mon, 09 Oct 2023 00:59:53 +0000</pubDate>
      <link>https://forem.com/menglinmaker/fast-piano-transcription-on-aws-part-2-4ei7</link>
      <guid>https://forem.com/menglinmaker/fast-piano-transcription-on-aws-part-2-4ei7</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/menglinmaker/fast-piano-transcription-on-aws-part-1-3jhg"&gt;Previously&lt;/a&gt;, I tested a few architectures for music transcription. I concluded that separating the preprocessing, inferencing and postprocessing steps for parallel processing is crucial to improving model speed — refer to the architecture below:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fencxkr8ql0uhey7f3w8f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fencxkr8ql0uhey7f3w8f.png" alt="Implemented architecture" width="703" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this article, I will explore further optimisations.&lt;/p&gt;




&lt;h1&gt;
  
  
  Improving inference speed with ONNX
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Replacing PyTorch with ONNX improves inference speed by 2x.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The benchmark metric I will use is the average 3 second audio frame inference time (FIT)&lt;/em&gt;&lt;/strong&gt; — The FIT for PyTorch CPU on M1 Pro is around 1.1 seconds, using around 3 CPU cores.&lt;/p&gt;

&lt;p&gt;The 172 Mb PTH model weight file can be reduced to 151 Mb ONNX file, which also embeds some of the preprocessing steps like generating a spectrogram. Running an ONNX file on Microsoft’s &lt;a href="https://onnxruntime.ai" rel="noopener noreferrer"&gt;ONNXruntime&lt;/a&gt; reduces FIT to 0.56 seconds using 3 CPU cores — 2x improvement!&lt;/p&gt;

&lt;p&gt;Converting PTH to ONNX only takes a couple of lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import torch

pytorch_model = ...
dummy_input = ...
modelPath = ...
input_names = ['input']
output_names = [...]
torch.onnx.export(pytorch_model, dummy_input, modelPath,
  input_names=input_names,
  output_names=output_names
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running ONNX on ONNXruntime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import onnxruntime as ort

# Inference with CPU
onnx_path = ...
model = ort.InferenceSession(onnx_path, providers=['CPUExecutionProvider'])

# Outputs will be a list of tensors
input = ...
outputs = model.run(None, {'input': input})

# You can convert the outputs to dicts
output_names = [...]
output_dict = {}
  for i in range(len(output_names)):
    output_dict[output_names[i]] = outputs[I]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quantising a model is also possible. This reduces the model to 72 Mb and uses float16 calculations. However, this did increase FIT to 0.65:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import onnx
from onnxconverter_common import float16

onnx_path = ...
onnx_model = onnx.load(onnx_path)

onnx_f16_path = ...
onnx_model_f16 = float16.convert_float_to_float16(onnx_model)
onnx.save(onnx_model_f16, onnx_f16_path)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ONNXruntime &lt;a href="https://github.com/microsoft/onnxruntime/issues/10038" rel="noopener noreferrer"&gt;ARM Lambda issues&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Ensure the CPU architecture and OS of CI/CD and cloud servers are similar. This overcomes incompatibilities between development environment and cloud environment.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At the time of writing this article, ARM Lambda doesn’t provide CPU info from “/sys/devices” for ONNXruntime calculation optimisation. I suspect that CPU information is not supported for the proprietary Graviton Chip.&lt;/p&gt;

&lt;p&gt;Currently, x86_64 Lambdas do work with ONNXruntime. A CI/CD workflow on an x86_64 server is an easy way to deploy x86_64 Lambdas from Apple Silicon — or any ARM machine.&lt;br&gt;
Some Python libraries like ONNXruntime depend on binaries, so where the code is deployed from matters.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why not GPU acceleration?
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;For on-demand, parallelised inferencing, consider Step Function Maps with CPU.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;GPU acceleration seemed like an obvious solution. However, the bottleneck for inferencing in this application is concurrency. Scaling up hundreds of GPU inferences quickly is challenging. Furthermore, GPUs are expensive.&lt;/p&gt;

&lt;p&gt;A quantised ONNX model on x86_64 Lambda with 1.7 vCPUs achieves a FIT of 2.3 seconds. The cold start FIT is around 10 seconds.&lt;/p&gt;

&lt;p&gt;On Google Colab, a V100 GPU can achieve a fit of around 2.5 seconds. Together with GPU fast scalability issues, serverless CPU inferencing is a more suitable solution.&lt;/p&gt;


&lt;h1&gt;
  
  
  From Docker to zipping static binaries
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Using Docker files for rapid prototyping, then zipped files for optimisation.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Docker speeds up deployments since only changed layers need to be uploaded. But the downside is massive files that increase cold start time. My PyTorch Docker file was 1.9 Gb and ONNX was 1.1 Gb.&lt;/p&gt;

&lt;p&gt;In contrast, zipped deployments are small, leading to small cold start times. However, installing Python packages, zipping and uploading… takes so much time, especially when you are debugging.&lt;/p&gt;
&lt;h3&gt;
  
  
  Packaging static FFmpeg
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Package static binaries instead of apt download where possible to reduce size.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The naive method to installing FFmpeg through apt install, even with no recommended installs. Even with multi-stage builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Adds over 300 Mb to Docker file (Debian)
RUN apt update –no-install-recommends &amp;amp;&amp;amp; \
    apt install -y ffmpeg &amp;amp;&amp;amp; \
    apt clean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While &lt;a href="https://pypi.org/project/static-ffmpeg/" rel="noopener noreferrer"&gt;lazy-loaded FFmpeg libraries&lt;/a&gt; do exist, Lambda restricts file writing to the “/tmp” folder. An alternative strategy is to use a &lt;a href="https://johnvansickle.com/ffmpeg/" rel="noopener noreferrer"&gt;FFmpeg static binary&lt;/a&gt; tailored to OS and CPU architecture. Simply call the path to static binary to use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 300 Mb apt install is equivalent to 78 Mb FFmpeg binary
path/to/ffmpeg/binary ... arg1 arg2

# To use the 'ffmpeg' command, create an alias, assuming it's not taken
echo 'alias ffmpeg="path/to/ffmpeg/binary"' &amp;gt; ~/.bashrc
source ~/.bashrc

ffmpeg ... arg1 arg2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary is around 78 Mb, so be sure to maximise usage. For example, there is no need to write downsampling audio code when FFmpeg can downsample much faster.&lt;/p&gt;




&lt;h1&gt;
  
  
  Double/float64 is faster than float32 or float16
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Prefer a type that has native hardware support.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Float16 is much smaller than float64, so it should be faster? — NOT NECESSARILY! Whatever is supported by hardware is faster. For instance, Lambda x86_64 runs on top of Intel Xeon and AMD EPYC, which natively supports float64 calculations and matrix dot products.&lt;/p&gt;

&lt;p&gt;To illustrate, postprocessing example.mp3 completes in 0.107 seconds using np.float32 on M1 Pro. This is reduced to 0.014 seconds using np.float64. Float64 turned out to be 7.5x faster than float32 or float16.&lt;/p&gt;

&lt;p&gt;Even when deployed onto the cloud, an 8 minute audio is transcribed in 60 seconds using float16. Float64 not only has higher precision but also transcribes in 28 seconds.&lt;/p&gt;




&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;The most important lesson is to measure performance and not blindly listen to others. For all you know, maybe GPU inferencing is faster for one use case, then CPU for another. Questioning assumptions and believing that something can be improved eventually led to 2x inferencing improvement — 5x faster than inferencing on M1 Pro for an 8 minute audio!&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>lambda</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Fast piano transcription on AWS -Part 1</title>
      <dc:creator>Meng Lin</dc:creator>
      <pubDate>Mon, 09 Oct 2023 00:34:14 +0000</pubDate>
      <link>https://forem.com/menglinmaker/fast-piano-transcription-on-aws-part-1-3jhg</link>
      <guid>https://forem.com/menglinmaker/fast-piano-transcription-on-aws-part-1-3jhg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;"The best way to learn jazz is to listen to it.”— Oscar Peterson&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sometimes I marvel at beautiful music, wondering how to reproduce it. This process is called “Playing by Ear” or “Transcription”. Just as a child learns to speak by listening first, music can be learnt the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  But there’s a problem…
&lt;/h3&gt;

&lt;p&gt;Transcribing music is time-consuming. It takes a professional musician roughly 4–60 minutes to transcribe 1 minute of music. But beginning musicians who benefit most from transcriptions may not have the skills to transcribe.&lt;/p&gt;

&lt;h3&gt;
  
  
  We need a computational transcription method
&lt;/h3&gt;

&lt;p&gt;To illustrate the transcription process, I will use &lt;a href="https://www.youtube.com/watch?v=Ie52xH8V2L4" rel="noopener noreferrer"&gt;Bach’s Passacaglia C Minor (BWV 582)&lt;/a&gt; as an example:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmq4i64c3f04j77c822u4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmq4i64c3f04j77c822u4.png" alt="Modified image from David Abbot’s Understanding Sound" width="700" height="67"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We could identify the energy of each frequency through time with a spectrogram. But the actual notes played are in green. Somehow we need to identify the base frequency of the notes played.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcr2gm9f800retyq5tlb5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcr2gm9f800retyq5tlb5.png" alt="Modified image from David Abbot’s Understanding Sound" width="700" height="377"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  AI to the rescue!
&lt;/h1&gt;

&lt;p&gt;Luckily some employees from ByteDance (TikTok’s parent company) were working on a piano transcription algorithm. The result is a Python module for inferencing that converts audio files to midi: &lt;a href="https://github.com/qiuqiangkong/piano_transcription_inference" rel="noopener noreferrer"&gt;https://github.com/qiuqiangkong/piano_transcription_inference&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How does this module work?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Preprocess:&lt;br&gt;
– Downsamples the original audio.&lt;br&gt;
– Separate audio into segments.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Inference:&lt;br&gt;
– Generate a spectrogram from the audio segment&lt;br&gt;
– Perform CNN inferencing on the spectrogram.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Postprocess:&lt;br&gt;
– Stitch rough midi output together.&lt;br&gt;
– Perform regression to find the most likely midi events.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h1&gt;
  
  
  Core backend architecture
&lt;/h1&gt;

&lt;p&gt;An ideal piano transcription service would be &lt;em&gt;blazingly fast&lt;/em&gt;. So this would be the main focus of architecture exploration below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Local server on M1 Pro — v0
&lt;/h3&gt;

&lt;p&gt;A few observations when running locally:&lt;br&gt;
– Enabling torch multicore processing speeds up inferencing.&lt;br&gt;
– Up to 3 cores are used, beyond that there is no improvement and&lt;br&gt;
utilisation.&lt;br&gt;
– Transcription rate of 0.5 seconds per 1 second of audio.&lt;/p&gt;

&lt;p&gt;Running locally gives some idea of how performance could be improved before applying to the cloud. I find that iterating on the cloud takes much longer than local development.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naive server — v1
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14kjg7a2la5ba2keuga9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14kjg7a2la5ba2keuga9.png" alt="Naive server architecture" width="284" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Although this works, there are many limitations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;API Gateway limits payloads to 6MB — equivalent to a 6-minute mp3 file.&lt;/li&gt;
&lt;li&gt;Lambda’s computation speed is slow — 3 seconds per 1 second of audio&lt;/li&gt;
&lt;li&gt;API Gateway enforces a 30-second timeout — can transcribe up to 10 seconds of audio.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Monolithic server — v2
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F509o1j7p3idm64iydnue.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F509o1j7p3idm64iydnue.png" alt="Monolithic server" width="703" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Presigned POST urls can upload up to 5GB files to S3. Lambda can download (to /tmp folder only) and upload to S3 using AWS SDK.&lt;/p&gt;

&lt;p&gt;Lambda has 900-second timeout — can transcribe up to 5 minutes of audio.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step Function server — v3
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkgtx9kuocueoj1609ped.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkgtx9kuocueoj1609ped.png" alt="Step Function server" width="703" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step Function orchestrator can run inference lambdas in parallel — this prevents lambda timeouts, so you can transcribe over 1 hour of audio:&lt;br&gt;
– Inference takes 12.5 seconds for 5-second segment&lt;br&gt;
– Can achieve a transcription rate of 0.25 seconds per 1 second of audio.&lt;/p&gt;




&lt;h1&gt;
  
  
  Credits:
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.meetup.com/en-AU/aws-aus/" rel="noopener noreferrer"&gt;Melbourne AWS User Group&lt;/a&gt; — inspired me to use Step Functions.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://medium.com/@menglinmaker/piano-transcription-saas-on-aws-ed73ac9c51d" rel="noopener noreferrer"&gt;Melbourne Serverless Meetup&lt;/a&gt; — exposed me to serverless architecture.&lt;/li&gt;
&lt;li&gt;Figma — image e
ditor + &lt;a href="https://www.figma.com/community/file/989585391556898521/aws-cloud-diagram-template" rel="noopener noreferrer"&gt;AWS icons&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>stepfunctions</category>
      <category>serverless</category>
    </item>
  </channel>
</rss>
