{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "art.",
  "home_page_url": "https://ssupawat.github.io/",
  "feed_url": "https://ssupawat.github.io/feed.json",
  "language": "en",
  "items": [
    {
      "id": "https://ssupawat.github.io/posts/infinity-has-no-decimal-places/",
      "url": "https://ssupawat.github.io/posts/infinity-has-no-decimal-places/",
      "title": "Infinity has no decimal places",
      "language": "en",
      "date_published": "2026-10-12",
      "summary": "Counting decimal places of a float with Decimal and as_tuple. The exponent is a string for infinity and NaN.",
      "content_html": "<p>I was reviewing a helper in my codebase. It counts decimal places of an\namount. Here it is.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">def</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> _decimal_places</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(value: </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">float</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">) -> </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">int</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    try</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">        decimal_value </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> Decimal(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">str</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(value)).normalize()</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    except</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> (InvalidOperation, </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">ValueError</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">):</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">        return</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">    exponent </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> decimal_value.as_tuple().exponent</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    return</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> max</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">0</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">min</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">6</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">-</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">exponent))</span></span></code></pre><p>To understand how it works, I studied the helper in a Python REPL. <code>5.23</code> came back as 2.\n<code>5</code> and <code>5.0</code> both came back as 0.</p>\n<p><img src=\"/assets/images/decimal-places-5-23.png\" alt=\"Python REPL: Decimal(str(5.23)).normalize() returns Decimal(&#39;5.23&#39;), and as_tuple() returns sign=0, digits=(5, 2, 3), exponent=-2\"></p>\n<p><code>str(value)</code> hands the float to <code>Decimal</code>.\nThen <code>.normalize()</code> strips trailing zeros, and <code>.as_tuple()</code> breaks what is\nleft into sign, digits, and exponent. The helper negates the exponent and\nclamps the result between 0 and 6. The normalize is why <code>5</code> and <code>5.0</code> agree.</p>\n<p>Then I passed <code>float(&#39;inf&#39;)</code>.</p>\n<p><img src=\"/assets/images/decimal-places-infinity.png\" alt=\"Python REPL: Decimal(str(float(&#39;inf&#39;))).normalize() returns Decimal(&#39;Infinity&#39;), as_tuple() returns sign=0, digits=(0,), exponent=&#39;F&#39;, and max(0, min(6, -exponent)) raises TypeError: bad operand type for unary -: &#39;str&#39;\"></p>\n<p><code>Decimal(&#39;Infinity&#39;)</code> parses fine. The exponent came\nback as <code>&#39;F&#39;</code>. A string. One line later the helper ran <code>-exponent</code> on it, and\nPython raised <code>TypeError: bad operand type for unary -: &#39;str&#39;</code>.</p>\n<p>So I went back and looked at the guard. The try/except wraps the construction\nonly. A garbage string dies in there with <code>InvalidOperation</code>, and the helper\nreturns 0. The guard caught the parse and missed the crash.</p>\n<p>NaN is stranger. Its exponent is <code>&#39;n&#39;</code>, and its digits tuple is empty. It\nreaches the return line too.</p>\n<p>My fix checks the type at the crash site.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">    exponent </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> decimal_value.as_tuple().exponent</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    if</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> not</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> isinstance</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(exponent, </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">int</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">):</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#6A737D;--shiki-dark:#6A737D\">        # NaN / Infinity have no decimal places</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">        return</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> 0</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    return</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> max</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">0</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, </span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">min</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">6</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">-</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">exponent))</span></span></code></pre><p>The helper returns 0, the same answer bad input already gets.</p>"
    },
    {
      "id": "https://ssupawat.github.io/posts/mqtt-one-topic-many-consumers/",
      "url": "https://ssupawat.github.io/posts/mqtt-one-topic-many-consumers/",
      "title": "Two ways to split one MQTT topic across consumers",
      "language": "en",
      "date_published": "2026-09-12",
      "summary": "MQTT hands every subscriber a copy of every message. Two ways around it: partition the topic at the publisher, or use an MQTT v5 shared subscription. Where the decision lives is the whole difference.",
      "content_html": "<p>MQTT fans a topic out. Every client subscribed to it gets a copy of every\nmessage. That is the point of the protocol, and most of the time it is what I\nwant too.</p>\n<p>It is not what I wanted here. I had a Kafka consumer group in mind: several\nconsumers on one topic, the messages split between them, each one handled once.\nI went looking for how to do that in MQTT.</p>\n<p>The short answer is no on v3.1.1, which is what I was on. There is no consumer\ngroup in the protocol. So the question turned into what to do\ninstead, and I found two answers and built both, in\n<a href=\"https://github.com/ssupawat/mqtt-client-load-balancing\">mqtt-client-load-balancing</a>,\none on a branch each.</p>\n<h2>Partition the topic yourself</h2>\n<p>The first way needs nothing from the broker. It uses the topic hierarchy as the\npartition key.</p>\n<p>I picked three partitions. My publisher appends a partition number to the topic\non every message, chosen at random. Each consumer then subscribes to a single\npartition. Every message still goes to exactly one subscriber, because no two\nof them subscribe to the same topic.</p>\n<figure class=\"wide-figure\">\n\n<figcaption>The publisher picks the partition, so nothing about the split lives in the broker. <code>demoTopic/partition2</code> reaches consumer2 because it is the only client subscribed to it.</figcaption>\n</figure>\n<p>This is roughly how Kafka partitions a topic. The difference is that in Kafka\nthe partition count is a property of the topic, held in one place. Here it is a\nconvention, and every publisher has to know it.</p>\n<p>That is what makes the approach expensive. The partition count is baked into my\npublishers, so the consumer count is too. I need one consumer per partition,\nand the two counts move together. Scaling out is not a consumer-side change.\nTo go from three partitions to six, every device that publishes to the topic\nhas to start using the new range. On a fleet of devices in the field, that is\na rollout.</p>\n<h2>Let the broker rotate</h2>\n<p>MQTT v5 has the same thing as a protocol feature. From\n<a href=\"https://www.hivemq.com/blog/mqtt5-essentials-part7-shared-subscriptions/\">HiveMQ</a>:</p>\n<blockquote>\n<p>In a standard MQTT subscription, each subscribing client is privy to a copy\nof each message broadcasted to that topic. With shared subscriptions, clients\nsharing a subscription in the same group receive messages in rotation, a\nprocess sometimes referred to as client load balancing. The message load of a\nsingle topic is distributed across all subscribers.</p>\n</blockquote>\n<p>It is a prefix on the subscription:</p>\n<pre><code>$share/&lt;group&gt;/topic\n</code></pre>\n<p><code>$share</code> marks it as shared, <code>&lt;group&gt;</code> names the group, and the rest is the\ntopic I want. Consumers that give the same group and the same topic form one\ngroup. The broker delivers each message to one member of it.</p>\n<p>Which member is the broker&#39;s business. The spec does not name an algorithm.\nMosquitto, which the demo runs on, goes round-robin. So the delivery is a\nrotation. A consumer that is still busy gets its turn anyway.</p>\n<p>A group does not change what anyone outside it sees. The demo keeps a couple of\nsubscribers on plain <code>demoTopic</code> to show it: they go on receiving every\nmessage, while the three in the group split their own copy between them.</p>\n<figure class=\"wide-figure\">\n\n<figcaption>The three in the group hold <code>$share/shared-subscriber-group/demoTopic</code> and split the stream. The plain sub holds <code>demoTopic</code> with no prefix and still gets all of it. The publisher is unchanged either way. It publishes to plain <code>demoTopic</code>, because <code>$share</code> is a subscribe-side prefix.</figcaption>\n</figure>\n<p>Scaling out is now what I wanted. I start another consumer, give it the same\ngroup, and the broker starts including it in the rotation. Nothing on the\npublishing side changes, because it never knew how many consumers there were.</p>\n<p>The cost is a version floor. Shared subscriptions are v5. The prefix lives on\nthe subscribe side, so the consumers and the broker have to speak it.\nPublishers are not in the way. That sounds like a small bill until someone asks\nwho is holding the versions.</p>\n<h2>What it comes down to</h2>\n<p>Both approaches split the same stream. The difference is where the split is\ndecided.</p>\n<p>Partitioning puts the decision in every publisher. The topology is then spread\nacross the fleet, and changing it means changing all of it. A shared\nsubscription puts the decision in the broker, and the publishers never learn\nabout it. That is why the consumer side can be resized on its own.</p>\n<p>So MQTT has no consumer group on v3.1.1, and something close to one on v5.\nThat is the answer to the question I started with.</p>\n<p>We never took it. v5 was out by then, but our devices had already shipped on\nv3.1.1. The broker was not moving either. I no longer remember why. The devices\non their own would not have stopped us, since a publisher never sees the\nprefix. The broker did.</p>\n<p>So the shared subscription stayed a branch in this repository. Partitioning was\nthe only one of the two I could actually have used, at the price above: every\npublisher in on the scheme, and a rollout to the fleet to change it.</p>\n<p>Both branches run under Docker Compose, and the consumer logs show the split:\n<a href=\"https://github.com/ssupawat/mqtt-client-load-balancing\">github.com/ssupawat/mqtt-client-load-balancing</a></p>\n<h3>References</h3>\n<ul>\n<li><a href=\"https://stackoverflow.com/questions/27850819/is-it-possible-to-distribute-reads-of-an-mqtt-topic-over-multiple-consumers\">StackOverflow: Is it possible to distribute reads of an MQTT topic over multiple consumers?</a></li>\n<li><a href=\"https://www.hivemq.com/blog/mqtt5-essentials-part7-shared-subscriptions/\">HiveMQ: Shared Subscriptions in MQTT v5</a></li>\n</ul>"
    },
    {
      "id": "https://ssupawat.github.io/posts/sse-over-post-reconnect/",
      "url": "https://ssupawat.github.io/posts/sse-over-post-reconnect/",
      "title": "Fixing an SSE stream that dies at two minutes",
      "language": "en",
      "date_published": "2026-09-10",
      "summary": "I wired up AG-UI's reference endpoint and it ran. Then my streams started dying at two minutes. Keepalives did nothing, and the timeout was never mine to raise.",
      "content_html": "<p>I got a chance to serve an <a href=\"https://docs.ag-ui.com\">AG-UI</a> endpoint. I started\nwhere the protocol points. AG-UI&#39;s\n<a href=\"https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/langgraph/python/ag_ui_langgraph/endpoint.py\">LangGraph integration</a>\nships a helper that registers the endpoint. It calls <code>@app.post(path)</code> and\nreturns a <code>StreamingResponse</code>. From there I wired it into my backend, and it\nran.</p>\n<p>Then I started losing streams. About two minutes into a run, the stream would\nstop. After that the client sat with half an answer.</p>\n<p>My first guess was an idle timeout. Something in front was hanging up on a\nstream that had gone quiet. The standard fix for that is a keepalive, because\nit stops the connection from looking idle. So I added one. Even so, I still\nlost the stream at two minutes.</p>\n<p>So whatever sits in front counts the age of the connection. It hangs up at two\nminutes, whether or not bytes are moving. Next I asked the infra team to raise\nthe limit, and they turned me down. In the end I never did learn which layer\nsets that number.</p>\n<p>That settled the shape of the problem. From then on, every run longer than two\nminutes gets cut. And I have to survive it from my side.</p>\n<p>Normally the browser would reconnect for me. <code>EventSource</code> does it for free.\nFirst it retries on its own. Then it sends back a <code>Last-Event-ID</code> header. That\nheader names the last event the client saw. Then the server carries on from\nthere. But I could not use any of it. That machinery is GET only, and AG-UI\nanswers on POST. That puts me on <code>fetch</code> and a <code>ReadableStream</code>. Those will\nstream a response. Even so, recovering one is my job.</p>\n<p>The second wall came right behind the first. My endpoint runs on several pods.\nBecause of that, the retry goes through the load balancer. It lands\nwherever it lands. If the half-finished run lives in the process that started\nit, only that process can answer. Then I am back to pinning traffic with\nsticky sessions.</p>\n<p>So I needed two things. First, the work has to keep going after the client is\ngone. Second, any pod has to be able to read a record of it.</p>\n<p>I built <a href=\"https://github.com/ssupawat/reconnectable-sse\">reconnectable-sse</a> to\ntry it out. My production endpoint is Python. But this was a concept, and the\nlanguage was free. So I used it as an excuse to get familiar with Go. In the\nend the shape is the part that matters.</p>\n<p>It comes down to two lines:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">if</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\"> !</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">streamExists</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(r.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">Context</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(), key) {</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">    go</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\"> runEventGenerator</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(streamID, </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">string</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(body))</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">runSSEResponse</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(r.</span><span style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">Context</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(), streamID, lastEventID, w, flusher)</span></span></code></pre><p>I give <code>runSSEResponse</code> the request context. A disconnect stops it. That is\nwhat I want, since there is nobody left to write to. Meanwhile\n<code>runEventGenerator</code> gets <code>context.Background()</code>, and it keeps running. Hand it\nthe request context instead, and the disconnect kills the work itself. Then\nnothing is left to come back to. The run also has to finish and write its\nhistory to the database. Whether anyone is still watching is beside the point.</p>\n<figure class=\"wide-figure\">\n\n<figcaption>The shaded band is api-1&#39;s generator. It starts on the first request and runs to completion on <code>context.Background()</code>. It keeps going through the drop and the reconnect, even though api-2 is the one talking to the client now.</figcaption>\n</figure>\n<p>Resume then came down to an offset. Redis returns an entry id on every <code>XADD</code>.\nThat id goes out as the SSE <code>id</code>. Then the client sends it back on the retry.\nFrom there it goes into <code>XREAD</code> as the starting point. That leaves no cursor\ntable and no bookkeeping. In the end the id the client is holding is the\noffset.</p>\n<p>I ran it. It does what I wanted. After a drop mid-stream, a client comes back\nthrough a different pod and picks up where it stopped.</p>\n<p>Still, I left edges in it. The generator starts under a check-then-act. So two\nconcurrent first requests with the same stream id can both start one.</p>\n<p>Design, endpoints, and how to run it: <a href=\"https://github.com/ssupawat/reconnectable-sse\">github.com/ssupawat/reconnectable-sse</a></p>"
    }
  ]
}