<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Moga Taufiq — Writing</title>
    <link>https://mogataufiq.dev/writing/</link>
    <description>War stories, guides, and best practices from shipping AI, IoT, and web systems in production.</description>
    <language>en</language>
    <atom:link href="https://mogataufiq.dev/writing/rss.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Tue, 22 Sep 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Exactly once, from the customer&apos;s point of view</title>
      <link>https://mogataufiq.dev/writing/exactly-once-from-the-customers-point-of-view/</link>
      <guid isPermaLink="true">https://mogataufiq.dev/writing/exactly-once-from-the-customers-point-of-view/</guid>
      <pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate>
      <description>Double bookings and duplicate confirmation emails are the same bug in two costumes. Patterns from building a booking platform for a real service business.</description>
      <category>reliability</category>
      <category>postgresql</category>
      <category>nextjs</category>
      <category>idempotency</category>
      <content:encoded><![CDATA[<p>Hearth &#x26; Hair started with a familiar problem: a growing service business losing bookings to phone tag and double-bookings. We built a self-serve platform — two of us, in about three months — where clients book against live availability and get confirmed automatically. It has served real customers since launch.</p>
<p>Two failures would have sunk it on day one, and customers notice both instantly:</p>
<ol>
<li>two people booked into the same slot;</li>
<li>the same confirmation email arriving twice.</li>
</ol>
<p>They look unrelated. They are the same bug: <strong>an operation that must happen exactly once, in a system where requests race and jobs retry.</strong></p>
<h2 id="double-bookings-never-trust-what-the-browser-last-saw">Double-bookings: never trust what the browser last saw</h2>
<p>Two clients open the page and both see 10:00 free. Both click <em>Book</em>. If availability is decided from what each browser last saw, both win.</p>
<p>So availability is computed <strong>on the server, from a single source of truth</strong> — the database — rather than from client state. Every check goes through the server; that is the price of removing the race.</p>
<p>Server-side is necessary, not sufficient: two requests can still pass the check at the same moment. The strongest place for the guarantee is the database itself. In PostgreSQL, an exclusion constraint makes overlapping bookings for the same resource impossible, no matter how the requests interleave (an illustrative schema, not the production one):</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE bookings (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  resource_id bigint    NOT NULL,          -- a chair, a stylist, a room…
  during      tstzrange NOT NULL,          -- start/end as real instants
  status      text      NOT NULL DEFAULT 'confirmed',
  EXCLUDE USING gist (resource_id WITH =, during WITH &#x26;&#x26;)
    WHERE (status &#x3C;> 'cancelled')
);
</code></pre>
<p>The losing request gets a constraint violation, which the app turns into <em>"That time was just taken"</em> and a fresh set of slots. No locks to reason about, no window to lose.</p>
<h2 id="duplicate-emails-make-every-send-idempotent">Duplicate emails: make every send idempotent</h2>
<p>Transactional email jobs retry after timeouts — and a duplicate confirmation erodes trust fast. On Hearth &#x26; Hair, confirmations and reminders go through a <strong>queue</strong>, and every send is <strong>idempotent</strong>.</p>
<p>The key idea: derive an idempotency key from the <em>business event</em>, not from the attempt. Every retry of the same event produces the same key:</p>
<pre><code class="language-ts">// One key per business event — every retry computes the same one.
const key = `booking:${booking.id}:confirmation`

const claimed = await db.query(
  `INSERT INTO sent_messages (idempotency_key, status)
   VALUES ($1, 'sending')
   ON CONFLICT (idempotency_key) DO NOTHING
   RETURNING idempotency_key`,
  [key],
)
if (claimed.rowCount === 0) return // already sent, or being sent

await mailer.send({ to: booking.email, template: 'confirmation', idempotencyKey: key })
await db.query(`UPDATE sent_messages SET status = 'sent' WHERE idempotency_key = $1`, [key])
</code></pre>
<p>Be honest about the gap: if the process dies between claiming and sending, that email never goes out. Keeping a <code>status</code> column lets a sweeper retry rows stuck in <code>sending</code>, and many transactional email APIs accept an idempotency key of their own — pass the same one. End to end, "exactly once" is really <strong>at least once, plus deduplication</strong>.</p>
<h2 id="reminders-time-is-the-hardest-input">Reminders: time is the hardest input</h2>
<p>The edge cases that needed the most iteration once real bookings flowed were about time: timezone handling, cancellation flows, and reminder timing. Three rules that hold up:</p>
<ul>
<li><strong>Store instants, display local time.</strong> Keep <code>timestamptz</code> (UTC instants) in the database and convert to the business's timezone only when rendering.</li>
<li><strong>Key reminders by what they are about.</strong> Include the appointment's start time in the reminder's key — <code>booking:42:reminder:2026-10-03T09:00Z</code>. When a booking moves, the new time produces a new key, and the stale job is recognisably stale.</li>
<li><strong>Check before you send.</strong> A reminder job re-reads the booking right before sending and quietly exits if it was cancelled or rescheduled.</li>
</ul>
<h2 id="a-checklist-for-must-happen-once-operations">A checklist for "must happen once" operations</h2>
<ul>
<li>Could two requests both succeed? What actually stops them — application code, or a constraint?</li>
<li>If this job runs twice, what does the customer see?</li>
<li>Is the idempotency key derived from the business event rather than the attempt?</li>
<li>What happens to scheduled work when the thing it is about changes?</li>
</ul>
<p>Putting the platform in front of real users surfaced edge cases no spec ever would have. The patterns above are what made those edge cases survivable.</p>]]></content:encoded>
    </item>
    <item>
      <title>A static site on S3 and CloudFront, without the footguns</title>
      <link>https://mogataufiq.dev/writing/static-site-on-s3-and-cloudfront/</link>
      <guid isPermaLink="true">https://mogataufiq.dev/writing/static-site-on-s3-and-cloudfront/</guid>
      <pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate>
      <description>The decisions behind this site&apos;s original AWS setup — a private bucket, directory URLs at the edge, split cache headers, an atomic visitor counter — and why IAM took the most time.</description>
      <category>aws</category>
      <category>cloudfront</category>
      <category>devops</category>
      <category>nextjs</category>
      <content:encoded><![CDATA[<p>This site is a Next.js static export. From June 2026 it was stored in S3 and served by CloudFront on a custom domain, with a small serverless visitor counter behind it — my take on the Cloud Resume Challenge: AWS primitives only, with security and cost treated as requirements rather than afterthoughts. It now runs on Vercel, but everything below still applies to any static site on S3 and CloudFront.</p>
<p>These are the decisions that mattered, roughly in the order you will run into them.</p>
<h2 id="1-keep-the-bucket-private">1. Keep the bucket private</h2>
<p>S3's public <em>website endpoint</em> is the quickest way to host files, and the wrong one here: it exposes the bucket directly and lets traffic skip the CDN. Instead, the bucket stays private and only CloudFront may read it, through <strong>Origin Access Control</strong> (OAC). The bucket policy grants read access to one distribution and nothing else:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontRead",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::YOUR_BUCKET/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
        }
      }
    }
  ]
}
</code></pre>
<h2 id="2-directory-urls-need-a-rewrite-at-the-edge">2. Directory URLs need a rewrite at the edge</h2>
<p>The catch with a private bucket: the S3 REST origin has no index documents. A request for <code>/projects/neopark-smart-parking/</code> does not magically become <code>index.html</code>.</p>
<p>With <code>trailingSlash: true</code>, Next.js exports every page as <code>&#x3C;path>/index.html</code>, so a tiny <strong>CloudFront Function</strong> on the viewer request completes the mapping — and redirects slash-less URLs, so each page has exactly one canonical address:</p>
<pre><code class="language-js">function handler(event) {
  var request = event.request
  var uri = request.uri

  // /projects/neopark/ → /projects/neopark/index.html
  if (uri.endsWith('/')) {
    request.uri = uri + 'index.html'
    return request
  }

  // /projects/neopark → 301 → /projects/neopark/
  if (!uri.split('/').pop().includes('.')) {
    return {
      statusCode: 301,
      statusDescription: 'Moved Permanently',
      headers: { location: { value: uri + '/' } },
    }
  }

  return request
}
</code></pre>
<h2 id="3-the-certificate-lives-in-us-east-1">3. The certificate lives in us-east-1</h2>
<p>CloudFront only reads TLS certificates from ACM in <strong>us-east-1</strong>, whatever region your bucket is in. Request the certificate there, validate it through DNS, attach it to the distribution, and point Route 53 alias records for both the apex and <code>www</code> at CloudFront.</p>
<h2 id="4-split-cache-headers-by-how-often-files-change">4. Split cache headers by how often files change</h2>
<p>Fingerprinted assets never change; HTML has to reflect each deploy immediately. So the deploy syncs twice with different headers, then invalidates CloudFront:</p>
<pre><code class="language-bash"># Fingerprinted assets: cache for a year
aws s3 sync out/ "s3://$BUCKET" --delete \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.html" --exclude "*.xml" --exclude "*.txt"

# Documents: always revalidate
aws s3 sync out/ "s3://$BUCKET" --delete \
  --cache-control "public, max-age=0, must-revalidate" \
  --exclude "*" --include "*.html" --include "*.xml" --include "*.txt"

aws cloudfront create-invalidation --distribution-id "$DISTRIBUTION_ID" --paths "/*"
</code></pre>
<p>One caveat: <code>immutable</code> is only safe for files whose <strong>name changes when their content does</strong>. Next's <code>_next/static</code> output qualifies; a hand-named <code>cover.webp</code> does not — when you replace an image, give it a new name. (This site's CMS content-hashes every upload for exactly this reason.)</p>
<h2 id="5-count-visitors-without-a-race-condition">5. Count visitors without a race condition</h2>
<p>The obvious counter — read the count, add one, write it back — races when two visits land at once. A single DynamoDB <code>UpdateItem</code> with <code>ADD</code> does the increment atomically on the server:</p>
<pre><code class="language-ts">import { DynamoDBClient, UpdateItemCommand } from '@aws-sdk/client-dynamodb'

const db = new DynamoDBClient({})

export async function handler() {
  const result = await db.send(
    new UpdateItemCommand({
      TableName: process.env.TABLE_NAME,
      Key: { id: { S: 'visitors' } },
      UpdateExpression: 'ADD #count :one',
      ExpressionAttributeNames: { '#count': 'count' },
      ExpressionAttributeValues: { ':one': { N: '1' } },
      ReturnValues: 'UPDATED_NEW',
    }),
  )
  return {
    statusCode: 200,
    body: JSON.stringify({ count: Number(result.Attributes?.count?.N ?? 0) }),
  }
}
</code></pre>
<p>On on-demand billing that is one hot item and no idle cost — plenty for a portfolio, behind API Gateway and a Node.js Lambda.</p>
<h2 id="6-iam-is-the-real-curriculum">6. IAM is the real curriculum</h2>
<p>Most of the friction in the whole project was IAM: OAC bucket policies, Lambda execution roles, and least privilege for the pipeline. Once those clicked, the other services felt like Lego.</p>
<p>For the deploy pipeline, give CI a dedicated identity that can touch this one bucket and this one distribution — nothing more:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::YOUR_BUCKET" },
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::YOUR_BUCKET/*"
    },
    {
      "Effect": "Allow",
      "Action": ["cloudfront:CreateInvalidation"],
      "Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
    }
  ]
}
</code></pre>
<p>Better still, let GitHub Actions assume that role through <strong>OIDC</strong> instead of storing long-lived access keys as secrets: there is nothing to rotate and nothing to leak.</p>
<h2 id="the-result">The result</h2>
<p>From <code>git push</code> to live in under five minutes, for a monthly bill of a couple of dollars (domain aside). None of it is exotic — which is the point. The boring setup, done carefully, is the one that keeps working.</p>]]></content:encoded>
    </item>
    <item>
      <title>Production computer vision is mostly plumbing</title>
      <link>https://mogataufiq.dev/writing/production-computer-vision-is-mostly-plumbing/</link>
      <guid isPermaLink="true">https://mogataufiq.dev/writing/production-computer-vision-is-mostly-plumbing/</guid>
      <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
      <description>Two deployments — PPE compliance on an industrial CCTV network and camera-based parking occupancy — and why the detector was the easy part.</description>
      <category>computer-vision</category>
      <category>edge-ai</category>
      <category>yolo</category>
      <category>reliability</category>
      <content:encoded><![CDATA[<p>Two of my computer-vision deployments started from the same place: a custom-trained YOLOv11 detector. In both, most of the engineering effort went everywhere <em>except</em> the model.</p>
<p>At Pertamina Patra Niaga (through my internship at Telkom Indonesia), the job was to flag missing helmets and vests across a facility's existing CCTV network. With Neopark, it was per-slot parking availability from low-cost ESP32-CAM boards instead of a sensor in every bay. Different problems, same lesson: <strong>production computer vision is roughly 80% data and infrastructure engineering.</strong></p>
<p>Here is what that 80% looked like.</p>
<h2 id="1-assume-every-camera-will-let-you-down">1. Assume every camera will let you down</h2>
<p>Real camera networks are not benchmark datasets. The facility's NVR mixed heterogeneous RTSP streams, and some cameras simply dropped out. A naive loop — read a frame from each camera, run the detector, repeat — has a nasty property: one slow or stalled stream holds up detection for every other camera.</p>
<p>So ingestion and inference became separate concerns:</p>
<ul>
<li>every stream is read independently and <strong>normalised on ingest</strong>, so the detector only ever sees one frame format;</li>
<li>flaky cameras get <strong>reconnection logic</strong> instead of taking the pipeline down with them;</li>
<li>the detector consumes whatever frames are ready, so <strong>no single camera can stall monitoring</strong>.</li>
</ul>
<p>It is more moving parts to operate than one loop. It is also the difference between a demo and a system that runs unattended.</p>
<h2 id="2-stabilise-the-output-not-just-the-model">2. Stabilise the output, not just the model</h2>
<p>Neopark's early dashboard flickered: a parked car would read <em>occupied, free, occupied</em> across adjacent frames. Each frame could be "accurate" and the display still impossible to trust.</p>
<p>The tempting fix is a heavier model. The one that worked was a <strong>temporal smoothing layer</strong> on top of the detector: a slot only changes state once the new evidence holds for a short window. A minimal version of the idea (not the production code):</p>
<pre><code class="language-python">from collections import deque


class SlotSmoother:
    """Flip a slot's state only after the opposite state holds for `window` frames."""

    def __init__(self, window: int = 5) -> None:
        self.window = window
        self.state: dict[str, bool] = {}
        self.recent: dict[str, deque[bool]] = {}

    def update(self, slot_id: str, occupied: bool) -> bool:
        recent = self.recent.setdefault(slot_id, deque(maxlen=self.window))
        recent.append(occupied)
        current = self.state.get(slot_id, occupied)
        if len(recent) == self.window and all(seen != current for seen in recent):
            current = not current
        self.state[slot_id] = current
        return current
</code></pre>
<p>The trade-off: a slot flips slightly after the raw detection does. That is a small price for a display people actually believe.</p>
<h2 id="3-calibrate-per-camera-not-globally">3. Calibrate per camera, not globally</h2>
<p>Lighting and viewing angles differ from camera to camera, so one global confidence threshold misjudged some bays while being fine for others. Two things fixed it:</p>
<ol>
<li>training on a curated dataset that spans varied lighting, and</li>
<li>tuning thresholds <strong>per camera angle</strong>.</li>
</ol>
<p>The cost is operational — every new camera needs a calibration pass before it goes live — so make that pass a documented step, not tribal knowledge.</p>
<h2 id="4-put-the-compute-where-it-can-breathe">4. Put the compute where it can breathe</h2>
<p>ESP32-CAM boards are great frame sources and far too constrained to run a modern detector. Rather than squeezing the model onto the edge, Neopark keeps the edge thin and <strong>batches frames on a GPU-backed inference node</strong>, which held inference latency around 150 ms. The honest trade-off: the system now depends on the camera-to-server link, so that link deserves the same monitoring as the model.</p>
<h2 id="5-own-the-data-own-the-lifecycle">5. Own the data, own the lifecycle</h2>
<p>A detector has to perform on <em>this</em> facility's camera views, not on generic footage. For the PPE system that meant curating and annotating the dataset in-house, then training, deploying, and monitoring the model ourselves. The accuracy you can rely on comes from that loop far more than from architecture tweaks.</p>
<h2 id="6-ship-something-operations-can-run">6. Ship something operations can run</h2>
<p>The PPE system had to be handed over to the facility's operations team, so every service shipped as a <strong>Docker container</strong>. Neopark's stack runs in Docker for the same reason: deployments that are repeatable rather than hand-assembled.</p>
<h2 id="a-checklist-before-you-call-it-done">A checklist before you call it done</h2>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled> What happens when one camera stalls? When it disappears for an hour?</li>
<li class="task-list-item"><input type="checkbox" disabled> Is the displayed state stable, or does it flicker from frame to frame?</li>
<li class="task-list-item"><input type="checkbox" disabled> Can you add a camera without retraining — and is calibration written down?</li>
<li class="task-list-item"><input type="checkbox" disabled> Where does inference run, and what happens when the link to it drops?</li>
<li class="task-list-item"><input type="checkbox" disabled> Can someone else deploy and restart it without you?</li>
</ul>
<p>When those answers are solid, the model usually is the easy part.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
