<?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>DEV Community: Tim Collinson</title>
    <description>The latest articles on DEV Community by Tim Collinson (@trcollinson).</description>
    <link>https://dev.to/trcollinson</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4155385%2F90299d5e-bc9b-44c7-a4ee-7996622eaa35.jpg</url>
      <title>DEV Community: Tim Collinson</title>
      <link>https://dev.to/trcollinson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/trcollinson"/>
    <language>en</language>
    <item>
      <title>Your ERP extract is 31% wrong. Nothing errored.</title>
      <dc:creator>Tim Collinson</dc:creator>
      <pubDate>Mon, 26 Oct 2026 22:51:34 +0000</pubDate>
      <link>https://dev.to/trcollinson/your-erp-extract-is-31-wrong-nothing-errored-2hf8</link>
      <guid>https://dev.to/trcollinson/your-erp-extract-is-31-wrong-nothing-errored-2hf8</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update, Oct 28:&lt;/strong&gt; The day after this went up, two readers found a hole in the pipeline, and it’s patched. A sweep that ended early with a clean &lt;code&gt;200&lt;/code&gt; would have been treated as complete, and the merge would have deleted everything it missed. Now every full read has to prove it’s complete before it can delete anything, and every merge writes down exactly what it deleted. Every number below still stands. The story is in “The night my pipeline deleted itself,” and the fix is tag &lt;code&gt;post-02.1&lt;/code&gt;. Thanks, Karan Pulyala (&lt;a class="mentioned-user" href="https://dev.to/karanpulyala"&gt;@karanpulyala&lt;/a&gt;) and Alex Shev (&lt;a class="mentioned-user" href="https://dev.to/alexshev"&gt;@alexshev&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll get out of this
&lt;/h2&gt;

&lt;p&gt;In about 12 minutes, you'll walk away with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why the obvious way to pull data from an ERP gets it quietly wrong.&lt;/strong&gt; Our first extractor got 30.8% of rows wrong and never threw an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The six defenses that took it to zero&lt;/strong&gt;, and which mess each one fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A serverless pattern for ERP extraction on AWS:&lt;/strong&gt; Amazon EventBridge Scheduler, AWS Step Functions, AWS Lambda, raw pages in Amazon Simple Storage Service (Amazon S3), and curated Apache Iceberg tables in the AWS Glue Data Catalog, built with Amazon Athena &lt;code&gt;MERGE&lt;/code&gt;. About $0 when idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gotchas that cost us time:&lt;/strong&gt; Lambda function URLs that rewrite your &lt;code&gt;$&lt;/code&gt;, a &lt;code&gt;Date&lt;/code&gt; header that isn't the server's, why we dropped Amazon S3 Tables, and the night our pipeline deleted half its own data and reported success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An honest comparison of API polling, change data capture (CDC), and scheduled exports&lt;/strong&gt;, so you can pick the right one for your plant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A repo you can deploy&lt;/strong&gt; and break on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plant leaders: read the opening, the headline numbers, and the closing questions. Builders: jump to "The robust extractor."&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: The problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  I cheated last time
&lt;/h3&gt;

&lt;p&gt;Confession time. In the last post, I proved that Crestline's ERP was hiding a money-losing press brake. Grumpy PB-02, second shift. It made a nice story.&lt;/p&gt;

&lt;p&gt;But I cheated. The ERP data in that post came out as one clean bulk export, perfectly consistent, taken at a single instant. Real ERPs don't hand you that. Real ERPs make you ask nicely, through an API, a page at a time, while the plant keeps running and the data keeps changing underneath you.&lt;/p&gt;

&lt;p&gt;Why does that matter? Great question. Because every number in post 1 was only as good as the copy of the ERP it ran on. And nobody checks the copy.&lt;/p&gt;

&lt;p&gt;So this time I built an ERP that fights back, wrote the extractor most of us write first, and measured how wrong it was.&lt;/p&gt;

&lt;p&gt;It was 30.8% wrong. It never threw an error. Not one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Crestline's fake ERP now changes while you read it, and has nine realistic messes: expiring tokens, throttling, drifting pages, a &lt;code&gt;LastModified&lt;/code&gt; that lies, and more.&lt;/li&gt;
&lt;li&gt;A typical incremental extractor got 5,762 of 18,707 rows wrong (30.8%) and kept 55 rows the ERP had deleted. Against a slow ERP, 93.3% wrong.&lt;/li&gt;
&lt;li&gt;A robust extractor got zero wrong in every scenario. The key defense: a nightly full sweep, because some changes never move the timestamp.&lt;/li&gt;
&lt;li&gt;On AWS it's serverless and costs about $0 when idle. The curated Iceberg tables match the ERP exactly, and post 1's margin query runs on them unchanged.&lt;/li&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/trcollinson/crestline-supply" rel="noopener noreferrer"&gt;https://github.com/trcollinson/crestline-supply&lt;/a&gt; (branch &lt;code&gt;tag post-02.1&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  An ERP that changes while you read it
&lt;/h3&gt;

&lt;p&gt;The fake ERP is a REST API in the style of mid-market vendors: an OAuth token, OData-style &lt;code&gt;$filter&lt;/code&gt;, &lt;code&gt;$top&lt;/code&gt;, &lt;code&gt;$skip&lt;/code&gt;, and &lt;code&gt;nextLink&lt;/code&gt; paging. Its data comes from the plant simulation, so there's always a known right answer.&lt;/p&gt;

&lt;p&gt;The important part is time. The simulator emits the ERP's change log: orders entered, jobs released, and a nightly 22:00 labor batch. The API serves the ERP as of a replay clock. So the first page of your read and the fiftieth can come from slightly different versions of the ERP, the way they do against a live system.&lt;/p&gt;

&lt;p&gt;Then I added nine messes, each one a switch in &lt;code&gt;mess.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things the API does to you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tokens that expire mid-extract (&lt;code&gt;401&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Throttling (&lt;code&gt;429&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Random &lt;code&gt;500&lt;/code&gt;s&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$skip&lt;/code&gt; paging that drifts when data changes between pages&lt;/li&gt;
&lt;li&gt;Numbers served as &lt;code&gt;"250.00000000"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Legacy &lt;code&gt;/Date(1759420800000)/&lt;/code&gt; dates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Things the ERP itself gets wrong:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LastModified&lt;/code&gt; doesn't move when labor posts against a job operation and changes its quantities&lt;/li&gt;
&lt;li&gt;Duplicate orders, cancelled the next morning: the order line is soft-deleted and its job is hard-deleted&lt;/li&gt;
&lt;li&gt;Jobs split into &lt;code&gt;J&lt;/code&gt; and &lt;code&gt;J-1&lt;/code&gt; after release&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've integrated with a mid-market ERP, you've probably met most of these. (If you've met all nine, I'm sorry. Let's get coffee.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The extractor we all write first
&lt;/h3&gt;

&lt;p&gt;Here's the naive extractor. Be honest, you've written this one. I have. Several times.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign in once.&lt;/li&gt;
&lt;li&gt;Ask for everything with &lt;code&gt;LastModified&lt;/code&gt; newer than the newest stamp you already have.&lt;/li&gt;
&lt;li&gt;Follow &lt;code&gt;nextLink&lt;/code&gt; until it runs out.&lt;/li&gt;
&lt;li&gt;If a request fails, retry immediately, up to three times.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's simple, it's fast, and it passes every test you'd think to write. Here's what it did over 90 days against an ERP at mess level 2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Rows wrong (of 18,707)&lt;/th&gt;
&lt;th&gt;Share wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clean ERP (control)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realistic ERP&lt;/td&gt;
&lt;td&gt;5,762, plus 55 deleted rows kept&lt;/td&gt;
&lt;td&gt;30.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client polling hard (10 requests a second)&lt;/td&gt;
&lt;td&gt;5,991, plus 51 deleted rows kept&lt;/td&gt;
&lt;td&gt;32.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow ERP (15 minutes a page)&lt;/td&gt;
&lt;td&gt;17,457&lt;/td&gt;
&lt;td&gt;93.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The control row matters. On a clean ERP, the naive extractor is perfect, so every error below it is owed to a mess, not a bug in my code.&lt;/p&gt;

&lt;p&gt;And almost all of the 30.8% is one mess. When the nightly labor batch posts against a job operation, it updates that operation's quantities but doesn't move &lt;code&gt;LastModified&lt;/code&gt;. So the naive extractor never sees the update. Every operation that labor touched stays frozen at the values it had when the job was released.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwptwaba8erveei5t81ci.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwptwaba8erveei5t81ci.png" alt="Labor posts at 22:00 and changes a job operation's quantity, but its LastModified stays at 08:00. The next morning, the naive incremental read returns nothing and the copy stays at 0; the nightly sweep reads every row and fixes it." width="800" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One job operation, start to finish: labor posts at 22:00 and changes the quantities, but the stamp stays at 08:00. The next morning, the naive read asks for anything newer than 08:00 and gets nothing back. Only a read that looks at every row catches it.&lt;/p&gt;

&lt;p&gt;Think about what that means for post 1. The margin analysis needed actual labor against every operation. A copy built this way would have shown standard numbers everywhere, and the PB-02 problem would have disappeared. Not because the plant fixed it. Because the extract hid it.&lt;/p&gt;

&lt;p&gt;The slow-ERP number is a different failure. At 15 minutes a page, the token expires partway through, and the naive client never signs in again. It just stops, quietly, with whatever it had.&lt;/p&gt;

&lt;p&gt;If you run the eval, you'll see a fourth scenario in between: an ERP that takes five minutes per request. The naive extractor lands at 31.1% wrong. Most of that is the same frozen operations, but on 23 nights the token expired partway through and it quietly skipped the job cost table, leaving about 50 jobs with stale actual costs. Those are exactly the numbers post 1's margin analysis runs on.&lt;/p&gt;

&lt;p&gt;No errors. In any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: The fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The robust extractor
&lt;/h3&gt;

&lt;p&gt;The robust extractor got &lt;strong&gt;zero rows wrong in every scenario&lt;/strong&gt;: 0 missed, 0 lost, 0 stale, 0 deleted rows kept, 0 pulled twice. Six defenses got it there, and each one answers a specific mess. A seventh, from a reader, keeps the delete rule honest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;th&gt;Mess it fixes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sign in again on &lt;code&gt;401&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tokens that expire mid-extract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wait out &lt;code&gt;429&lt;/code&gt;s per &lt;code&gt;Retry-After&lt;/code&gt;; back off with jitter on &lt;code&gt;5xx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Throttling, random errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyset paging: order by key, ask for keys past the last one seen&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;$skip&lt;/code&gt; drift when data changes between pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watermarks with a one-hour overlap&lt;/td&gt;
&lt;td&gt;Changes stamped just before your last read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read labor by sequence number, not timestamp&lt;/td&gt;
&lt;td&gt;Labor posted in a late nightly batch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A nightly full sweep of the four job tables&lt;/td&gt;
&lt;td&gt;Changes whose &lt;code&gt;LastModified&lt;/code&gt; never moved, and hard deletes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Count before and after every full read &lt;em&gt;(added in &lt;code&gt;post-02.1&lt;/code&gt;)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;A read that stops early with no error, then deletes what it missed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The sweep is the one that matters most, and the one people skip. Incremental reads can only see changes that move the timestamp. When the ERP doesn't move it, no amount of clever filtering helps. You have to look at everything, compare, and correct. Over the 90 days, the sweeps corrected &lt;strong&gt;5,878 operation versions&lt;/strong&gt; whose stamps never moved.&lt;/p&gt;

&lt;p&gt;It's also the only way to notice a hard delete. A row that vanished doesn't show up in "what changed since yesterday." It only shows up as missing in a full read.&lt;/p&gt;

&lt;p&gt;One more rule: every page gets written to S3 exactly as the API served it, with its metadata (entity, page, request time, server time). Nothing gets cleaned on the way in. When you find a bug later, and you will, you can reprocess from the original.&lt;/p&gt;

&lt;h3&gt;
  
  
  On AWS: serverless, and about $0 when idle
&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa86en7wl7wguqsoma01j.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa86en7wl7wguqsoma01j.png" alt="Amazon EventBridge Scheduler starts AWS Step Functions hourly. Per entity, AWS Lambda extracts from the fake ERP to raw pages in Amazon S3, then an Amazon Athena MERGE curates them into Iceberg tables that match the ERP exactly and feed post 1's margin query." width="799" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every hour, EventBridge Scheduler starts a Step Functions run. For each ERP entity, three at a time, a Lambda function extracts to S3 raw, then a curate step merges the new raw files into a typed Iceberg table. The fake ERP itself runs in Lambda behind a function URL, so nothing in this design runs when nothing is happening.&lt;/p&gt;

&lt;h3&gt;
  
  
  From raw pages to tables you can trust
&lt;/h3&gt;

&lt;p&gt;Raw pages are the record of what the ERP said. They aren't something you want to query. So after each poll, a curate step runs one Athena &lt;code&gt;MERGE&lt;/code&gt; per entity into a typed Iceberg table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Newest version per key wins.&lt;/strong&gt; Unchanged rows aren't rewritten, so the table's history shows only real changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sweep is a complete picture.&lt;/strong&gt; Any key it no longer contains was deleted from the ERP, so the merge deletes it too. Cancelled order lines keep their row with &lt;code&gt;deleted = true&lt;/code&gt;, the way the ERP shows them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Types are fixed on the way in.&lt;/strong&gt; &lt;code&gt;"250.00000000"&lt;/code&gt; becomes a number, &lt;code&gt;/Date(ms)/&lt;/code&gt; becomes a date, and every timestamp lands in UTC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One commit per merge.&lt;/strong&gt; Updates, inserts, and deletes land together, so nobody ever reads a half-applied change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The proof is the copy, not the counts. At the end of the window, the curated tables match the ERP's final state exactly: &lt;strong&gt;18,707 rows, 0 missing, 0 extra, 0 different.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then the real test. Post 1's margin query, unchanged, now reads its ERP data from the curated tables through the same views. It matches the original in every cell, on all 1,252 finished jobs. PB-02 second shift still shows setups at 2.84 times standard, and an ERP margin of 20.5% against a rebuilt 14.8%.&lt;/p&gt;

&lt;p&gt;(Sharp-eyed readers: post 1 said 20.4%. The tenth of a point comes from the job-split mess added for this post, which shrinks the parent job's material cost. Messy source data moves your numbers even when your pipeline is perfect. That's sort of the whole point.)&lt;/p&gt;

&lt;p&gt;One honest caveat: a run reads each entity at a slightly different moment, so for a few minutes the tables can disagree with each other. The next run closes the gap. If you need a single consistent instant across every table, that's an argument for CDC or a snapshot export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: Lessons and choices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What building it on AWS taught us
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A Lambda function URL isn't a transparent pipe.&lt;/strong&gt; It rejected a bare &lt;code&gt;$&lt;/code&gt; in the query string, which is a problem when your API is OData and every parameter starts with &lt;code&gt;$top&lt;/code&gt; or &lt;code&gt;$filter&lt;/code&gt;. The extractor sends &lt;code&gt;%24top&lt;/code&gt; instead. It also replaces your server's &lt;code&gt;Date&lt;/code&gt; header with its own clock. The watermarks depended on the ERP's clock, not Lambda's, so the API now sends an &lt;code&gt;X-Server-Time&lt;/code&gt; header. Without that, every watermark would have been quietly wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EventBridge Scheduler fires the first run the moment you create the schedule.&lt;/strong&gt; Not at the next hour. Plan for a deploy that immediately starts working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 Tables was the plan, and we dropped it.&lt;/strong&gt; Athena ran &lt;code&gt;MERGE&lt;/code&gt; against it fine, but &lt;code&gt;CREATE VIEW&lt;/code&gt; over an S3 Tables table failed with "not yet supported for Cross-Account Glue DataCatalogs" (October 2026). It isn't a permissions gap: &lt;a href="https://docs.aws.amazon.com/athena/latest/ug/gdc-register-s3-table-bucket-cat.html" rel="noopener noreferrer"&gt;Athena's documentation&lt;/a&gt; lists &lt;code&gt;CREATE VIEW&lt;/code&gt; as unsupported for S3 Tables. Every query in this project reads its sources through views, so the curated tables are plain Iceberg in the Glue catalog instead. The cost difference is a few cents a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the merge window from S3 file names, not inside SQL.&lt;/strong&gt; The curate step lists the new raw files and puts their exact dates into the &lt;code&gt;MERGE&lt;/code&gt;. When we computed the window inside the SQL, Athena couldn't skip old partitions, so every run rescanned the entity's entire raw history, which grows about 2 MB an hour. Now a Jobs merge reads one sweep file: 0.2 MB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small tables need upkeep.&lt;/strong&gt; Hourly merges leave lots of small files behind, so once a day each table gets &lt;code&gt;OPTIMIZE&lt;/code&gt; and &lt;code&gt;VACUUM&lt;/code&gt;. (Also: those two commands reject double-quoted table names. Ask me how I know.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries are safe by design.&lt;/strong&gt; Step Functions retries a failed merge, re-merging the same files changes nothing, and the "last merged" marker only moves after a merge succeeds. The marker lives on the poller's existing per-entity state record, so there's no extra state to manage.&lt;/p&gt;

&lt;h3&gt;
  
  
  The night my pipeline deleted itself
&lt;/h3&gt;

&lt;p&gt;Remember the thesis? A wrong copy never throws an error. I'd love to tell you I learned that from other people's pipelines.&lt;/p&gt;

&lt;p&gt;Storytime. To make the AWS run finish in days instead of months, the fake ERP replays its history on a fast clock. I left it running. It was running faster than I'd written down (240 times real time, not 24), and it had no end. So it ran straight past October, through 2027, and into 2028.&lt;/p&gt;

&lt;p&gt;The raw table in Athena used partition projection with a date range of 2026 through 2027. New pages kept landing safely in S3, under dates Athena couldn't see. So the next nightly sweep looked empty to the merge. And a sweep is a complete picture, remember? Any key it doesn't contain has been deleted from the ERP. So the &lt;code&gt;MERGE&lt;/code&gt; did exactly what I told it to do. It deleted &lt;strong&gt;9,962 of 18,707 rows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then it reported success.&lt;/p&gt;

&lt;p&gt;The same week, a Lambda function deployed before a fix made &lt;strong&gt;69 of 95 hourly runs fail&lt;/strong&gt; over three days. Nothing told anyone.&lt;/p&gt;

&lt;p&gt;Before you laugh too hard: the replay clock only got there early. On a real ERP running on real time, that date range runs out on January 1, 2028. Same deletion. Same success message.&lt;/p&gt;

&lt;p&gt;Here's what changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The date range now runs from 2020 to 2099.&lt;/strong&gt; The merge always names its dates, so a wide range costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check runs before every &lt;code&gt;MERGE&lt;/code&gt;.&lt;/strong&gt; If Athena doesn't see exactly the rows the raw files hold, the run fails before anything changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A merge can't delete more than 5% of a table&lt;/strong&gt; unless someone explicitly allows it, after checking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An email alarm fires on any failed run&lt;/strong&gt;, through an EventBridge rule and Amazon Simple Notification Service (Amazon SNS).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An AWS step isn't done until &lt;code&gt;cdk diff&lt;/code&gt; shows nothing&lt;/strong&gt; for every deployed stack, after the last commit. No more Lambdas older than the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the raw pages were never touched, the fix was a rebuild from raw alone: 18,707 rows, 0 wrong, and post 1's margin query unchanged. That's the other reason to keep raw.&lt;/p&gt;

&lt;p&gt;The lesson isn't "add more guards." It's that "delete whatever the sweep doesn't see" is the most powerful rule in this pipeline. It's the rule that makes the copy exact, and it's the rule that emptied half of it. Rules that powerful need a check that fails loudly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update: the hole a reader found the next day
&lt;/h3&gt;

&lt;p&gt;I published this post on a Monday. By Tuesday, Karan Pulyala (&lt;a class="mentioned-user" href="https://dev.to/karanpulyala"&gt;@karanpulyala&lt;/a&gt;) had found a hole in it.&lt;/p&gt;

&lt;p&gt;Remember the most powerful rule in the pipeline? A sweep is a complete picture, so anything it doesn't contain gets deleted. Karan's question: who checks that the sweep is complete?&lt;/p&gt;

&lt;p&gt;Nobody did. A dying token was already safe (the extractor throws the whole read away). But if the ERP sent back a page with no &lt;code&gt;nextLink&lt;/code&gt; halfway through, or my paging code had a bug, the extractor would stop quietly and call the sweep complete. Then the merge would delete every row after the cut, and report success. Again.&lt;/p&gt;

&lt;p&gt;Here's what &lt;code&gt;post-02.1&lt;/code&gt; adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every full read counts the ERP's rows at the start and the end&lt;/strong&gt; (&lt;code&gt;$count=true&lt;/code&gt;). If the rows collected don't land in that range, the read is filed as short. A short read can update rows but can't delete any. The range gets ten rows of room for an ERP that's busy mid-read (Karan's second catch), which is far less than a 500-row page, so a lost page can't hide in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A read that ends on a full 500-row page asks one more time&lt;/strong&gt; whether anything comes after its last key. Three real sweeps in the eval ended on exactly 500 rows. Without that extra question, all three would have failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every merge that can delete writes down exactly what it deleted&lt;/strong&gt;, diffed against the table's Iceberg snapshot from before the merge. A refused merge emails the count, the snapshot, and where the list lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then Alex Shev (&lt;a class="mentioned-user" href="https://dev.to/alexshev"&gt;@alexshev&lt;/a&gt;) asked the right follow-up: have you actually seen it fire? So I planted a broken sweep in the raw bucket, one that skipped the extractor's count check entirely. The merge refused it before touching anything. It would have deleted 65 of 565 jobs. The table's snapshot didn't move, and the email named the count, the snapshot, and the file. An hour later the next real sweep took over and ran clean. Its audit listed the six rows, across four tables, of the one cancelled duplicate it was supposed to delete.&lt;/p&gt;

&lt;p&gt;One honest caveat: none of my runs read while the ERP was mid-write, so the ten rows of room comes from reasoning and a unit test, not a measurement. I'll measure it in the next milestone.&lt;/p&gt;

&lt;p&gt;This is the best part of writing in public. Two readers made this pipeline safer in a day.&lt;/p&gt;

&lt;h3&gt;
  
  
  API polling, CDC, or a scheduled export?
&lt;/h3&gt;

&lt;p&gt;There are three common ways to get data out of an ERP. I built the first one. Here's how they compare, because the right answer depends on your plant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;API polling (built here)&lt;/th&gt;
&lt;th&gt;CDC from the database&lt;/th&gt;
&lt;th&gt;Scheduled export&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Freshness&lt;/td&gt;
&lt;td&gt;Hourly, or whatever you schedule&lt;/td&gt;
&lt;td&gt;Near real time&lt;/td&gt;
&lt;td&gt;Daily, usually&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sees deletes&lt;/td&gt;
&lt;td&gt;Only with a full sweep&lt;/td&gt;
&lt;td&gt;Yes, from the log&lt;/td&gt;
&lt;td&gt;Only if you compare full files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives a lying &lt;code&gt;LastModified&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Only with a sweep&lt;/td&gt;
&lt;td&gt;Yes, it reads the log, not stamps&lt;/td&gt;
&lt;td&gt;Yes, if each export is complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access you need&lt;/td&gt;
&lt;td&gt;API credentials&lt;/td&gt;
&lt;td&gt;Database-level access, often not allowed by the vendor or your hosting&lt;/td&gt;
&lt;td&gt;A job someone sets up in the ERP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What breaks it&lt;/td&gt;
&lt;td&gt;API changes, throttling, tokens&lt;/td&gt;
&lt;td&gt;Schema changes, vendor upgrades&lt;/td&gt;
&lt;td&gt;Someone changes the report&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running cost&lt;/td&gt;
&lt;td&gt;Near zero when idle&lt;/td&gt;
&lt;td&gt;An always-on replication instance, and often a read replica: tens to hundreds of dollars a month, depending on size&lt;/td&gt;
&lt;td&gt;Near zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Effort to do well&lt;/td&gt;
&lt;td&gt;Medium (this post)&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low, but limited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most mid-market plants, API polling with a nightly sweep is the right default. You usually can't get database access to a hosted ERP anyway, and "hourly and exactly right" beats "real time and expensive" for nearly every question finance and operations actually ask. CDC earns its cost when you truly need minutes, not hours, and you control the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: Your turn
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;

&lt;p&gt;Everything deploys into your own AWS account and runs serverless, so it costs about nothing when it's idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You'll need:&lt;/strong&gt; uv, Node.js with the AWS Cloud Development Kit (AWS CDK) CLI at version 2.1143 or later, the AWS Command Line Interface (AWS CLI), and an AWS profile with a region configured.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--tag&lt;/span&gt; post-02.1 https://github.com/trcollinson/crestline-supply.git
&lt;span class="nb"&gt;cd &lt;/span&gt;crestline-supply
uv &lt;span class="nb"&gt;sync&lt;/span&gt;

&lt;span class="c"&gt;# On your laptop: simulate 90 days, then score both extractors against the ERP&lt;/span&gt;
uv run crestline sim backfill &lt;span class="nt"&gt;--days&lt;/span&gt; 90 &lt;span class="nt"&gt;--end&lt;/span&gt; 2026-10-01 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;--out&lt;/span&gt; ./out
uv run python &lt;span class="nb"&gt;eval&lt;/span&gt;/extraction.py

&lt;span class="c"&gt;# On AWS (set AWS_PROFILE first; cdk bootstrap is once per account and region)&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk bootstrap &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk deploy CrestlineFoundation&lt;span class="o"&gt;)&lt;/span&gt;
uv run crestline sim backfill &lt;span class="nt"&gt;--days&lt;/span&gt; 90 &lt;span class="nt"&gt;--end&lt;/span&gt; 2026-10-01 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;--upload&lt;/span&gt;
uv run crestline erp-api secrets
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk deploy CrestlineErpApi &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;erpApiMess&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;../mess.yaml &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;erpApiClockStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2026-08-03T05:00:00Z &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;erpApiClockSpeed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk deploy CrestlineCurated&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# Optional: add -c erpIngestAlarmEmail=you@example.com to get an email on any failed run&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk deploy CrestlineErpIngest&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# the first poll starts right away, then hourly&lt;/span&gt;

&lt;span class="c"&gt;# About two and a half days later, once the ERP's history has played out (one ERP day per hour):&lt;/span&gt;
uv run crestline athena setup
uv run python &lt;span class="nb"&gt;eval&lt;/span&gt;/curated.py &lt;span class="nt"&gt;--margin&lt;/span&gt;
uv run crestline query margin-gap &lt;span class="nt"&gt;--engine&lt;/span&gt; athena
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The experiment that makes the point:&lt;/strong&gt; give the ERP honest timestamps. Add &lt;code&gt;erp: lastmodified_skips: []&lt;/code&gt; to &lt;code&gt;mess.yaml&lt;/code&gt;, then rerun the backfill and the eval (the simulator reads that setting, so the eval alone isn't enough). The naive extractor drops to 0 rows wrong. But it still keeps the 55 deleted rows, because only a full read can see a delete. The eval's slow-ERP scenario shows the other failure: a token that dies mid-read, and 93.3% wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; a poll run is about a tenth of a cent in Step Functions plus a few seconds of Lambda. Billed, with every layer tagged, the whole pipeline comes to about $1 a month at hourly polling. Almost all of that is Athena (each query bills at least 10 MB, and every run now does a check plus a merge per entity) and S3 requests. Lambda and Step Functions fit inside the free tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teardown:&lt;/strong&gt; destroy the poller's stack first. It holds the hourly schedule, so polling stops as soon as it's gone. Then the rest, and the two secrets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra
cdk destroy CrestlineErpIngest
cdk destroy CrestlineErpApi CrestlineCurated CrestlineFoundation
&lt;span class="nb"&gt;cd&lt;/span&gt; ..
uv run crestline erp-api secrets &lt;span class="nt"&gt;--delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The buckets are emptied for you. As in post 1, the small Lambda functions that empty them leave their log groups behind (&lt;code&gt;/aws/lambda/CrestlineFoundation-*&lt;/code&gt; and &lt;code&gt;/aws/lambda/CrestlineCurated-*&lt;/code&gt;); delete those with &lt;code&gt;aws logs delete-log-group&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this means for your plant
&lt;/h3&gt;

&lt;p&gt;Every report, dashboard, and AI agent you build on ERP data inherits the quality of the copy underneath it. And a bad copy doesn't announce itself. It just makes your numbers quietly look like the plan.&lt;/p&gt;

&lt;p&gt;Three questions worth asking your team this week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When did we last compare our copy of the ERP to the ERP itself?&lt;/strong&gt; Not the row counts. The rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does our extract trust &lt;code&gt;LastModified&lt;/code&gt;?&lt;/strong&gt; If so, find out which ERP operations don't update it. There's almost always at least one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Would we notice a hard delete?&lt;/strong&gt; If your process only asks "what changed," the answer is no.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you don't know the answers, you're in good company. Go find out. Do it this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you remember one thing from this post: &lt;strong&gt;your data is only as right as your copy of it, and a wrong copy never throws an error.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The obvious extractor got 30.8% of rows wrong, almost all from one ERP habit: changes that don't move &lt;code&gt;LastModified&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Six defenses took it to zero, and the one that mattered most was the least clever: look at everything, every night, and compare.&lt;/li&gt;
&lt;li&gt;On AWS, the whole thing is serverless, costs about $0 when idle, and produces a copy we can prove is exact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Post 1 found a money-losing press brake. This post makes sure the data you'd find it with is actually true.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's next
&lt;/h3&gt;

&lt;p&gt;So far, every number in this series has come from systems people type into: an ERP and an MES. Next up is the data nobody types. The machines.&lt;/p&gt;

&lt;p&gt;In the next post, Crestline's shop floor starts talking: a modern laser speaking Sparkplug B, an old press brake with a dumb 16-bit counter, and a powder coat line that knows its oven temperatures but has no idea which parts are inside. Most discrete shops have no historian at all, so we'll build the one they don't have, on AWS, and see what it costs per machine.&lt;/p&gt;

&lt;p&gt;P.S. If you run it and your naive extractor does better than mine, open an issue. I want to know what you did. (And if it does worse, I &lt;em&gt;really&lt;/em&gt; want to know.)&lt;/p&gt;

&lt;p&gt;Tim Collinson has spent nearly 30 years building software, much of it on AWS, across manufacturing, oil and gas, and enterprise IT. Reach him at &lt;a href="mailto:trcollinson@gmail.com"&gt;trcollinson@gmail.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dataengineering</category>
      <category>manufacturing</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Athena read my timestamps as UTC. It didn’t tell me.</title>
      <dc:creator>Tim Collinson</dc:creator>
      <pubDate>Tue, 13 Oct 2026 21:19:36 +0000</pubDate>
      <link>https://dev.to/trcollinson/athena-read-my-timestamps-as-utc-it-didnt-tell-me-oah</link>
      <guid>https://dev.to/trcollinson/athena-read-my-timestamps-as-utc-it-didnt-tell-me-oah</guid>
      <description>&lt;p&gt;Nobody gets an error. Somebody gets blamed.&lt;/p&gt;

&lt;p&gt;I built a &lt;a href="https://dev.to/trcollinson/your-erp-says-the-job-was-profitable-your-machines-disagree-57hm"&gt;fake metal fab plant&lt;/a&gt; to find this kind of bug before a real customer does. Last week it caught one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The MES in the fake plant writes timestamps the way real MES systems do: local time, no time zone. &lt;code&gt;14:30&lt;/code&gt; means 2:30 PM at the plant. The plant is in Mountain Time.&lt;/p&gt;

&lt;p&gt;I wrote the obvious SQL to convert plant-local timestamps to UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;scan_on&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DuckDB, running locally, read that as: “this value is in Denver time; give me the UTC moment that corresponds to it.” Correct for what I meant.&lt;/p&gt;

&lt;p&gt;Athena read that as: “this value is UTC; give me the Denver moment that corresponds to it.” Also correct, but for a different interpretation.&lt;/p&gt;

&lt;p&gt;Same line of SQL. Two engines. Two answers. Six hours apart. No warning on either side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trino’s behavior is the SQL standard: &lt;code&gt;AT TIME ZONE&lt;/code&gt; says what zone to convert to. If the value doesn’t already carry a time zone, it’s assumed to be UTC. DuckDB is more lenient. In this case, DuckDB’s leniency is what let me write sloppy SQL on my laptop and have it quietly change meaning in production.&lt;/p&gt;

&lt;p&gt;Trino isn’t the one being weird. DuckDB is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Say explicitly what time zone the value is IN, then convert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;with_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas on the Athena side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;with_timezone&lt;/code&gt; returns a &lt;code&gt;timestamp with time zone&lt;/code&gt;. Athena views can’t store zoned timestamps, so cast back to plain &lt;code&gt;timestamp&lt;/code&gt; at the view boundary.&lt;/li&gt;
&lt;li&gt;If you want the same SQL to run in both engines, define a DuckDB macro that aliases &lt;code&gt;with_timezone(ts, zone)&lt;/code&gt; to DuckDB’s native &lt;code&gt;timezone(zone, ts)&lt;/code&gt;. Then one line of SQL works everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a plant, six hours is a whole shift. Get the conversion wrong and second shift’s downtime lands on first shift’s report. Nobody gets an error. Somebody gets blamed.&lt;/p&gt;

&lt;p&gt;Bonus: it’s six hours in summer and seven in winter. Daylight saving time ends in Mountain Time on November 1. I wrote a test specifically for that boundary, and it caught a sneaky off-by-one on fall-back. If you aren’t testing against DST transitions, you have a bug and don’t know it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The SQL, both the broken version in the comment and the fix: &lt;a href="https://github.com/trcollinson/crestline-supply/blob/main/queries/margin_gap.sql" rel="noopener noreferrer"&gt;margin_gap.sql&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The DuckDB macro that makes the SQL portable: &lt;a href="https://github.com/trcollinson/crestline-supply/blob/main/queries/sources.sql" rel="noopener noreferrer"&gt;sources.sql&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The DST transition test: &lt;a href="https://github.com/trcollinson/crestline-supply/blob/main/tests/test_shifts.py" rel="noopener noreferrer"&gt;test_shifts.py&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>dataengineering</category>
      <category>manufacturing</category>
      <category>athena</category>
    </item>
    <item>
      <title>Your ERP says the job was profitable. Your machines disagree.</title>
      <dc:creator>Tim Collinson</dc:creator>
      <pubDate>Thu, 01 Oct 2026 17:29:58 +0000</pubDate>
      <link>https://dev.to/trcollinson/your-erp-says-the-job-was-profitable-your-machines-disagree-57hm</link>
      <guid>https://dev.to/trcollinson/your-erp-says-the-job-was-profitable-your-machines-disagree-57hm</guid>
      <description>&lt;h2&gt;
  
  
  What you'll get out of this
&lt;/h2&gt;

&lt;p&gt;In about 12 minutes, you'll walk away with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why your ERP can hide a money-losing machine&lt;/strong&gt;, in plain terms. Standard costing charges machine time at standard hours, so long setups vanish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A working pattern for joining ERP, MES, and machine data on AWS&lt;/strong&gt;: raw files in S3, cataloged in Glue, queried with Athena, deployed with one CDK stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gotchas that cost us time&lt;/strong&gt;, so they don't cost you any: Athena time zones, JSON SerDe key casing, 16-bit counters, and a &lt;code&gt;cdk diff&lt;/code&gt; that isn't read-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three questions to take to your own plant&lt;/strong&gt; this week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A repo you can deploy in your own AWS account&lt;/strong&gt; for a fraction of a cent, and tear down cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plant leaders: read the story, "Why the ERP can't see it," and the three questions at the end. Builders: jump straight to "The rebuild."&lt;/p&gt;

&lt;h2&gt;
  
  
  The margin report says everything is fine
&lt;/h2&gt;

&lt;p&gt;Let's talk about the monthly margin review. Why would I write about a finance meeting on a technical blog? Great question. Stick with me; there's SQL at the end of this rainbow.&lt;/p&gt;

&lt;p&gt;Picture it. Finance puts up the job cost report, and the bracket and enclosure work is holding its numbers. Everyone nods.&lt;/p&gt;

&lt;p&gt;The plant manager doesn't nod. She knows second shift on the older press brake is a mess. Setups drag, jobs finish late, and the operators on that shift are always fighting the machine. But the report says those jobs made money, and she has no numbers to argue with. Nothing she can put on a slide will beat the ERP.&lt;/p&gt;

&lt;p&gt;If you've worked in a mid-market plant, you've probably sat in that meeting. I have sat in versions of it for years, from pushing plant data into a PI historian at BP to building APIs for manufacturing software. (If you don't know what a PI historian is, count yourself lucky.) The plant manager is usually right. The problem is that proving it means joining three systems that were never designed to talk to each other.&lt;/p&gt;

&lt;p&gt;So we built a plant, gave it that exact problem, and set out to prove it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We simulated 90 days at Crestline Supply, a fictional metal fabricator: 1,252 finished jobs across ERP, MES, and press brake data, each realistically imperfect.&lt;/li&gt;
&lt;li&gt;The ERP overstates margin on one brake's second-shift jobs by 4.4 points against the true figure, about $5,800 a quarter from a single machine on a single shift. Setups there run 2.84 times standard, and the ERP can't see it.&lt;/li&gt;
&lt;li&gt;The ERP is wrong everywhere else too, but in the pessimistic direction. It's only flattering where the real problem lives.&lt;/li&gt;
&lt;li&gt;We rebuilt job cost from all three sources with one SQL file running on Amazon Athena. It lands within 1% of the true cost for 91.9% of jobs.&lt;/li&gt;
&lt;li&gt;Everything is open source and runs in your own AWS account. The Athena spend for the whole project was about two-tenths of a cent. &lt;a href="https://github.com/trcollinson/crestline-supply" rel="noopener noreferrer"&gt;https://github.com/trcollinson/crestline-supply&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Meet Crestline Supply
&lt;/h2&gt;

&lt;p&gt;Crestline Supply doesn't exist. I made it up. That's the point. It's a simulated metal fabrication plant that makes electrical enclosures, mounting brackets, and custom panels for HVAC and industrial customers. It runs two shifts. Parts go through a fiber laser, deburring, two press brakes, hardware insertion, powder coat, and assembly.&lt;/p&gt;

&lt;p&gt;Why simulate instead of using real data? Because real plants never know the truth. When your rebuilt numbers disagree with the ERP, you can't tell who's right. In a simulation, you can.&lt;/p&gt;

&lt;p&gt;The simulator works in two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ground truth.&lt;/strong&gt; A discrete-event model records what actually happened: every setup, every stroke, every scrapped part, every operator's real time. This is the answer key, and it stays hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three imperfect views.&lt;/strong&gt; From that truth, the simulator produces what each business system would actually record, complete with the gaps and distortions real systems have.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The analysis only ever sees the imperfect views. A test in the repo enforces that the query code can't read the answer key. The answer key is used for one thing: grading our work at the end.&lt;/p&gt;

&lt;p&gt;The data shapes follow patterns common across mid-market ERPs and PLCs. If you've worked in these systems, they'll look familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three systems, three partial truths
&lt;/h2&gt;

&lt;p&gt;Every system at Crestline is telling the truth about something. None of them is telling the whole truth.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;What it knows&lt;/th&gt;
&lt;th&gt;What it doesn't know&lt;/th&gt;
&lt;th&gt;How it reports&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ERP&lt;/td&gt;
&lt;td&gt;The job, quantity, price, and standard cost&lt;/td&gt;
&lt;td&gt;How long setup and run actually took&lt;/td&gt;
&lt;td&gt;Labor rounded to 0.1 hr, posted the next day; machine burden at standard hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MES&lt;/td&gt;
&lt;td&gt;Who scanned onto which job, and when&lt;/td&gt;
&lt;td&gt;Setup vs. run; whether the operator was really working&lt;/td&gt;
&lt;td&gt;Nightly CSV files in local time with no time zone; about 3% of scans never closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Press brakes&lt;/td&gt;
&lt;td&gt;When each stroke happened&lt;/td&gt;
&lt;td&gt;Which job it belonged to&lt;/td&gt;
&lt;td&gt;PB-01: JSON on every change. PB-02: a 16-bit counter polled every 30 seconds that wraps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The answer to the plant manager's question lives in the gaps between these three. The machines know &lt;em&gt;when&lt;/em&gt; setup ended. The MES knows &lt;em&gt;who&lt;/em&gt; was on the job and for how long. The ERP knows &lt;em&gt;what it was worth&lt;/em&gt;. Nobody has joined them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ERP can't see it
&lt;/h2&gt;

&lt;p&gt;The ERP isn't broken. It's doing exactly what standard costing is designed to do, and that design hides long setups.&lt;/p&gt;

&lt;p&gt;A job's cost has three main parts: material, labor, and burden. Burden is the cost of running the machine itself: depreciation, power, maintenance, floor space. At Crestline, the ERP's own work center rates put an hour on a press brake at $85 of burden and $38 of labor. The burden is the bigger number.&lt;/p&gt;

&lt;p&gt;Here's the catch. Crestline's ERP, like most, takes &lt;strong&gt;actual labor&lt;/strong&gt; from the MES but applies &lt;strong&gt;burden at standard hours&lt;/strong&gt;. If a setup is supposed to take 20 minutes, the ERP charges 20 minutes of machine time, no matter how long the machine actually sat in setup.&lt;/p&gt;

&lt;p&gt;Walk through one setup on PB-02, second shift, running 36 minutes over standard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The operator stays scanned in the whole time, so the extra 36 minutes of labor (about $23) eventually reaches the ERP.&lt;/li&gt;
&lt;li&gt;The extra 36 minutes of machine time (about $51 of burden) never does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More than two-thirds of the cost of that overrun is invisible to finance. Finance isn't hiding it from you. The ERP is hiding it from finance. Now multiply. Over the quarter, PB-02 ran 179 setups on second shift, averaging 43 minutes over standard. That's about 128 machine-hours and roughly $10,900 of burden the ERP never charged. Even elsewhere in the plant, the ERP only sees about 90% of true burden, from ordinary setup variation and downtime. On PB-02 second shift, it sees 72%. A real problem turns into a healthy-looking margin.&lt;/p&gt;

&lt;p&gt;There's a second effect pulling the other way. Operators stay scanned in through breaks, forgotten scans are only trimmed back to the end of the shift, and nobody scans off during downtime. So everywhere in the plant, the ERP charges about a third more labor than anyone actually worked. Labor is the smaller cost, but over-reporting it still charges each job more than it should, so margins look 2 to 4 points &lt;em&gt;pessimistic&lt;/em&gt;. Keep that in mind; it matters for the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rebuild
&lt;/h2&gt;

&lt;p&gt;The rebuild is one SQL file with six commented views and a final select. It runs on Amazon Athena, directly over the raw files in S3.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F884xcuh0fqrpxmg8v10y.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F884xcuh0fqrpxmg8v10y.png" alt="Three source boxes at the top: the ERP (job, price, standard cost; burden at standard hours), the MES (who scanned on and when; local time, no offset), and the press brakes (when each stroke happened; no idea which job). Arrows lead into a box labeled " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each system hands the rebuild a different piece of the story. Four steps in one Athena query join them, and the join surfaces the one place the ERP flatters itself.&lt;/p&gt;

&lt;p&gt;The raw files land in S3 exactly as each system would have produced them, untouched. That matters: when you find a mistake later, you can always reprocess from the original.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Put everything on one clock
&lt;/h3&gt;

&lt;p&gt;The MES writes local wall-clock time with no offset. The ERP and machines use UTC. Before anything can be joined, every timestamp needs to mean the same instant.&lt;/p&gt;

&lt;p&gt;This is where the first surprise showed up. &lt;code&gt;scan_on AT TIME ZONE 'America/Denver'&lt;/code&gt; looks like it says "this is plant time." In Athena, it doesn't. Athena reads a bare timestamp as UTC and converts it, so 2:30 PM comes back six hours early (seven in winter). No error, just a wrong answer. We only caught it because our local test engine read the same line the opposite way.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;with_timezone()&lt;/code&gt;, which states which zone the value is in. Athena views also can't store a zoned timestamp, so each view casts back to plain UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- The MES writes local time with no offset, so the conversion has to say&lt;/span&gt;
&lt;span class="c1"&gt;-- which zone it is in: with_timezone(). Not `at time zone`, which the two&lt;/span&gt;
&lt;span class="c1"&gt;-- engines read differently: DuckDB takes a bare 14:30 as plant time&lt;/span&gt;
&lt;span class="c1"&gt;-- (20:30 UTC), but Athena takes it as UTC and converts, six hours early.&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;job_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;op_seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;work_center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;scan_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan_off&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'America/Denver'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;scan_off&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Figure out which brake did the work, and when setup ended
&lt;/h3&gt;

&lt;p&gt;The brakes don't know what job they're running. But a job's brake operation has an MES scan window, and a brake produces strokes. The first stroke after an operator scans on marks the end of setup.&lt;/p&gt;

&lt;p&gt;PB-02 makes this harder. PB-02 is old. PB-02 is grumpy. PB-02 gives you a 16-bit stroke counter, polled every 30 seconds, and nothing else. The counter rolls over at 65,535 (ours wrapped once, on August 26 at 2:14 PM), so the strokes since the last poll are the difference mod 65,536:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PB-02's counter is 16 bits wide and rolls over at 65,535, so the&lt;/span&gt;
&lt;span class="c1"&gt;-- strokes since the last poll are the difference mod 65,536.&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="s1"&gt;'PB-02'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;brake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;gw_ts&lt;/span&gt;   &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;counted_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cnt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;gw_ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;strokes&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pb02_counts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That handles rollover, not a counter that resets to zero when someone power-cycles the machine. A reset looks like a wrap and would count phantom strokes. That mess shows up in a later post.&lt;/p&gt;

&lt;p&gt;The 30-second poll caused a subtler bug. PB-02 reports a stroke up to 30 seconds after it happens, so the previous job's last stroke often landed just after the next operator scanned on. Setups looked instant. The fix is to look one poll late at both ends of the scan window.&lt;/p&gt;

&lt;p&gt;Then there's the question in this step's title: which brake? The query counts each brake's strokes during the scan window and picks the brake whose count comes closest to the pieces the operator reported. Ties go to the brake whose last stroke came nearest the operator's scan-off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PB-02 reports strokes up to 30 s late: look 30 s late at both ends.&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;brake_strokes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counted_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30'&lt;/span&gt; &lt;span class="k"&gt;second&lt;/span&gt;
 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counted_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;   &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30'&lt;/span&gt; &lt;span class="k"&gt;second&lt;/span&gt;

&lt;span class="c1"&gt;-- Closest stroke count to the reported pieces wins.&lt;/span&gt;
&lt;span class="c1"&gt;-- On a tie, the brake whose last stroke was nearest scan-off.&lt;/span&gt;
&lt;span class="n"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;op_seq&lt;/span&gt;
    &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strokes&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pieces&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
             &lt;span class="k"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'second'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_stroke_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full version is in &lt;code&gt;queries/margin_gap.sql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Against the answer key, all 935 brake operations were placed, 933 on the right brake and all 935 on the right setup shift. The two misses are two separate exact ties: a pair of 16-piece jobs and a pair of 46-piece jobs, each pair running at the same time, one job on each brake, so the counts matched either way.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdh1iw5ns9aqw4nv9djmm.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdh1iw5ns9aqw4nv9djmm.png" alt="A timeline of job J100795 (part BRK-1050, press brake PB-02, second shift, August 28, 2026) from 7:30 to 8:45 PM in three lanes. MES lane: operator Tasha Bloom is scanned onto the job from 7:36 to 8:40 PM. PB-02 lane: no strokes for 55 minutes, then 23 strokes between 8:32 and 8:41 PM, with the last stroke reported 28 seconds after scan-off. ERP lane: the ERP charges a 20-minute standard setup starting at scan-on, followed by a 9.2-minute standard run. A shaded band from the end of the ERP's standard setup to the first stroke marks 35 minutes of machine time, about $50 of burden, that the ERP never charges." width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's one real second-shift job from the run. Tasha scanned onto J100795 at 7:36 PM, and PB-02 didn't make its first stroke until 8:32. The ERP charged 20 minutes of setup; the shaded stretch is the machine time it never sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Clean up the labor the way the plant should
&lt;/h3&gt;

&lt;p&gt;About 3% of MES scans are never closed. Someone forgets to scan off, and a supervisor closes the scan at 06:00 the next morning. Posted as-is, a 20-minute operation becomes a 14-hour one. (Nobody bends brackets for 14 hours straight. Nobody.)&lt;/p&gt;

&lt;p&gt;Crestline handles this the way many plants do: a supervisor's labor exception report trims forgotten scans back to the end of the shift. That's better than nothing, but it still charges hours the operator spent on other work.&lt;/p&gt;

&lt;p&gt;The query does one thing the supervisor can't do by hand across thousands of rows: it caps each scan at the same operator's &lt;strong&gt;next scan-on&lt;/strong&gt;. If Tasha scanned onto another job at 3:10, she wasn't still on the first one at 11:00. That single rule is most of the difference between the ERP's labor and the rebuilt labor. And notice who was right all along: the supervisor already knew this data was garbage, and the exception report is a workaround someone built because the systems failed them. With the right join, their daily forensics becomes something the query does continuously, on every job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Recompute cost the way it actually happened
&lt;/h3&gt;

&lt;p&gt;With clean hours and real setup times, cost is straightforward: actual labor hours times the labor rate, actual machine hours times the burden rate, plus material. Then compare the ERP's margin to the rebuilt margin, grouped by brake and by the shift the setup happened on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rebuild found
&lt;/h2&gt;

&lt;p&gt;The ERP is wrong about every group of jobs in the plant. It's only flattering in one place, and that's exactly where the problem is.&lt;/p&gt;

&lt;p&gt;Margin by brake and setup shift, 90 days, 1,252 finished jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Brake and shift&lt;/th&gt;
&lt;th&gt;ERP margin&lt;/th&gt;
&lt;th&gt;Rebuilt margin&lt;/th&gt;
&lt;th&gt;True margin&lt;/th&gt;
&lt;th&gt;Setups vs. standard&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PB-01, 1st shift&lt;/td&gt;
&lt;td&gt;24.3%&lt;/td&gt;
&lt;td&gt;25.5%&lt;/td&gt;
&lt;td&gt;27.1%&lt;/td&gt;
&lt;td&gt;1.06×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PB-01, 2nd shift&lt;/td&gt;
&lt;td&gt;25.9%&lt;/td&gt;
&lt;td&gt;27.8%&lt;/td&gt;
&lt;td&gt;28.6%&lt;/td&gt;
&lt;td&gt;1.07×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PB-02, 1st shift&lt;/td&gt;
&lt;td&gt;23.0%&lt;/td&gt;
&lt;td&gt;23.7%&lt;/td&gt;
&lt;td&gt;25.0%&lt;/td&gt;
&lt;td&gt;1.14×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PB-02, 2nd shift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.84×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No brake operation&lt;/td&gt;
&lt;td&gt;23.6%&lt;/td&gt;
&lt;td&gt;26.7%&lt;/td&gt;
&lt;td&gt;27.7%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"True margin" comes from the hidden answer key. In a real plant you'd have the other three columns and not that one. If you run the query yourself, its gap column shows 5.7 points for PB-02 second shift. That's the ERP against the rebuild, the gap a plant manager would actually see; the 4.4 points in this post is the ERP against the truth.&lt;/p&gt;

&lt;p&gt;Three things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everywhere else, the ERP is pessimistic.&lt;/strong&gt; It understates margin by 2 to 4 points against the truth, because time nobody worked gets charged as job labor. A finance team that learns to discount the ERP a little would be right almost everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On PB-02 second shift, it flips.&lt;/strong&gt; The ERP overstates margin by 4.4 points against the truth. Measured against the plant's usual pessimistic bias, the real gap is bigger than it looks. A finance team that corrected the ERP up by its usual 3 points would put this group at 23.4% against a true 16.0%, a 7.4-point miss. On that group's roughly $131,000 of quarterly revenue, the overstatement is worth about $5,800 a quarter, or roughly $23,000 a year, from one machine on one shift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rebuild errs conservative.&lt;/strong&gt; It lands 1.3 points below the truth on PB-02 second shift, and below the truth in every group by 0.8 to 1.6 points, because the few scans it can't fully clean err high, not low. That's the direction you want a management number to miss in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grumpy PB-02, second shift, exactly where the plant manager knew to look. That's the conversation she needed. Not "second shift feels slow," but "setups on PB-02 second shift run 2.84 times standard, and the ERP can't see it because burden is charged at standard hours."&lt;/p&gt;

&lt;h2&gt;
  
  
  How we know it's right
&lt;/h2&gt;

&lt;p&gt;The rebuild lands within 1% of the true job cost for 91.9% of jobs. The ERP's own "actual" cost does that for 14.9%.&lt;/p&gt;

&lt;p&gt;Because we have the answer key, we can grade the rebuild the way you never can in a real plant. An evaluation script compares every job's rebuilt cost to its true cost:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Rebuilt cost&lt;/th&gt;
&lt;th&gt;ERP "actual" cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jobs within 1% of true cost&lt;/td&gt;
&lt;td&gt;91.9%&lt;/td&gt;
&lt;td&gt;14.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jobs within 5% of true cost&lt;/td&gt;
&lt;td&gt;94.6%&lt;/td&gt;
&lt;td&gt;53.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median error&lt;/td&gt;
&lt;td&gt;0.03%&lt;/td&gt;
&lt;td&gt;4.52%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The more useful part is the misses. All 101 jobs outside 1% are explained: 91 are forgotten scan-offs the query can't fully clean, 9 are operators who stepped away during downtime while still scanned in, and 1 is a machine sitting idle while scanned. None are unexplained.&lt;/p&gt;

&lt;p&gt;One honest note: the first version of the simulator was too clean. The rebuild matched the truth almost perfectly, which told us the plant wasn't messy enough to be believable. Adding forgotten scan-offs made it realistic, and that's also when we had to model the supervisor's exception report, because posting those scans as-is wrecked the ERP's numbers in a way no real plant would tolerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What building it on AWS taught us
&lt;/h2&gt;

&lt;p&gt;The AWS side is deliberately small: an S3 raw bucket, an Athena results bucket, a Glue database, and an Athena workgroup with a 1 GB scan cap, all deployed by one CDK stack. Small didn't mean free of surprises. It never does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Athena reads a bare timestamp as UTC.&lt;/strong&gt; Covered above, and the most likely thing to bite anyone working with plant data. Use &lt;code&gt;with_timezone()&lt;/code&gt; before &lt;code&gt;AT TIME ZONE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The OpenX JSON SerDe lowercases keys, even inside nested objects it returns as text.&lt;/strong&gt; JSON paths are case-sensitive, so fields silently came back null. The fix was reading each PB-01 message as a single text line and using &lt;code&gt;json_extract_scalar&lt;/code&gt;. PLC tag names like &lt;code&gt;N7:12&lt;/code&gt; weren't going to be valid Glue column names anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition projection didn't fit.&lt;/strong&gt; The &lt;code&gt;ingest_date&lt;/code&gt; partition is dated October 1, the extract date, which was still in the future when we built this. A projection range ending at NOW skipped it, so setup runs &lt;code&gt;MSCK REPAIR TABLE&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain &lt;code&gt;cdk diff&lt;/code&gt; isn't read-only.&lt;/strong&gt; It uploads the template and creates a change set. (Yes, really. I was surprised too.) Use &lt;code&gt;cdk diff --method=template&lt;/code&gt; when you want a purely local comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack tags changed.&lt;/strong&gt; With the &lt;code&gt;@aws-cdk/core:explicitStackTags&lt;/code&gt; feature flag, &lt;code&gt;Tags.of()&lt;/code&gt; no longer sets stack tags, so tags are applied both ways on purpose. A Glue database can't be tagged through CloudFormation at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent uploads came almost free.&lt;/strong&gt; For a single-part upload with SSE-S3, the ETag is the content's MD5, so rerunning the upload skips unchanged files. That wouldn't hold with KMS encryption or multipart uploads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is small. Together they're the difference between a demo that works on my machine and one that works in yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The whole thing deploys into your own AWS account in a few minutes, costs well under a dollar, and tears down cleanly. The simulation is deterministic, so you'll get exactly the numbers in this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You'll need:&lt;/strong&gt; uv, Node.js with the CDK CLI at version 2.1143 or later, the AWS CLI, and an AWS profile with a region configured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy and run:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--branch&lt;/span&gt; post-01-margin-gap https://github.com/trcollinson/crestline-supply.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;crestline-supply
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_PROFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;profile&amp;gt;
uv &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk bootstrap &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk deploy&lt;span class="o"&gt;)&lt;/span&gt;
uv run crestline sim backfill &lt;span class="nt"&gt;--days&lt;/span&gt; 90 &lt;span class="nt"&gt;--end&lt;/span&gt; 2026-10-01 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;--upload&lt;/span&gt;
uv run crestline athena setup
uv run crestline query margin-gap &lt;span class="nt"&gt;--engine&lt;/span&gt; athena
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cdk deploy&lt;/code&gt; stops once to ask you to approve the IAM role it creates. Type &lt;code&gt;y&lt;/code&gt; and it carries on.&lt;/p&gt;

&lt;p&gt;Then try the experiment that makes the point. Set &lt;code&gt;enabled: false&lt;/code&gt; under &lt;code&gt;pb02_second_shift_setups&lt;/code&gt; in &lt;code&gt;scenarios.yaml&lt;/code&gt;, rerun the backfill with &lt;code&gt;--upload&lt;/code&gt;, and rerun the query. PB-02 second shift comes back at 25.2% in the ERP and 25.5% rebuilt, with setups at 1.07× standard, right in line with everything else. Bonus detail: with the scenario off, PB-02 second shift handles 219 jobs instead of 174. The slow brake was also doing less work, because each job goes to whichever brake frees up first.&lt;/p&gt;

&lt;p&gt;No AWS account handy? The README has a local option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; the upload is 99 files and 46.6 MB, so storage rounds to zero. Each query run scans about 100 MB, around a twentieth of a cent, so rerun as much as you like. Athena spend for the entire project, including every test query, was about $0.002.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teardown:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cdk destroy&lt;span class="o"&gt;)&lt;/span&gt;
aws logs describe-log-groups &lt;span class="nt"&gt;--log-group-name-prefix&lt;/span&gt; /aws/lambda/CrestlineFoundation &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'logGroups[].logGroupName'&lt;/span&gt;
aws logs delete-log-group &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &amp;lt;name from above&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cdk destroy&lt;/code&gt; removes the stack and empties the buckets. The two &lt;code&gt;aws logs&lt;/code&gt; commands clean up the log group CDK's auto-delete Lambda leaves behind. If you bootstrapped CDK just for this, also delete the &lt;code&gt;CDKToolkit&lt;/code&gt; CloudFormation stack, then empty and delete the staging bucket it keeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for your plant
&lt;/h2&gt;

&lt;p&gt;You don't need a perfect data platform to find this kind of problem. You need the join, and you need to trust it.&lt;/p&gt;

&lt;p&gt;Three questions worth asking your team this week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do we know actual setup time by machine and shift, or only standard?&lt;/strong&gt; If the answer is "standard," your long setups are hiding in your margins right now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can we connect a machine's output to the job it was running?&lt;/strong&gt; Even a dumb stroke counter and an MES scan window are enough to answer "when did setup actually end?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does finance know burden is applied at standard hours?&lt;/strong&gt; Most people outside cost accounting have never thought about it, and it's the reason the ERP looked healthy here.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answers are "no," you're in good company. That's most mid-market plants I've worked with. The good news is that the data to answer them is usually already sitting in systems you own. Go find it. Do it this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;This post cheated a little. The ERP data arrived as a clean bulk export. In real life, getting data out of a mid-market ERP means tokens that expire mid-extract, paging that shifts under you, and a &lt;code&gt;LastModified&lt;/code&gt; field that doesn't always get updated. That's the next post in the Crestline series, in two weeks.&lt;/p&gt;

&lt;p&gt;Tim Collinson has spent nearly 30 years building software, much of it on AWS, across manufacturing, oil and gas, and enterprise IT. Reach him at &lt;a href="mailto:trcollinson@gmail.com"&gt;trcollinson@gmail.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;P.S. If you deploy it and your numbers don't match mine, open an issue. I want to know. Deterministic simulations are only fun until they aren't.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>manufacturing</category>
      <category>dataengineering</category>
      <category>athena</category>
    </item>
  </channel>
</rss>
