<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>art.</title>
  <link href="https://ssupawat.github.io/"/>
  <link rel="self" href="https://ssupawat.github.io/atom.xml"/>
  <id>https://ssupawat.github.io/</id>
  <updated>2026-10-12</updated>
  <entry xml:lang="en">
    <title>Infinity has no decimal places</title>
    <id>https://ssupawat.github.io/posts/infinity-has-no-decimal-places/</id>
    <link href="https://ssupawat.github.io/posts/infinity-has-no-decimal-places/"/>
    <updated>2026-10-12</updated>
    <summary>Counting decimal places of a float with Decimal and as_tuple. The exponent is a string for infinity and NaN.</summary>
    <content type="html">&lt;p&gt;I was reviewing a helper in my codebase. It counts decimal places of an
amount. Here it is.&lt;/p&gt;
&lt;pre class=&quot;shiki shiki-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; _decimal_places&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(value: &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;float&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) -&gt; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;int&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    try&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        decimal_value &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Decimal(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;str&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(value)).normalize()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    except&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (InvalidOperation, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;ValueError&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    exponent &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; decimal_value.as_tuple().exponent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; max&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;6&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;exponent))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;To understand how it works, I studied the helper in a Python REPL. &lt;code&gt;5.23&lt;/code&gt; came back as 2.
&lt;code&gt;5&lt;/code&gt; and &lt;code&gt;5.0&lt;/code&gt; both came back as 0.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/images/decimal-places-5-23.png&quot; alt=&quot;Python REPL: Decimal(str(5.23)).normalize() returns Decimal(&amp;#39;5.23&amp;#39;), and as_tuple() returns sign=0, digits=(5, 2, 3), exponent=-2&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;str(value)&lt;/code&gt; hands the float to &lt;code&gt;Decimal&lt;/code&gt;.
Then &lt;code&gt;.normalize()&lt;/code&gt; strips trailing zeros, and &lt;code&gt;.as_tuple()&lt;/code&gt; breaks what is
left into sign, digits, and exponent. The helper negates the exponent and
clamps the result between 0 and 6. The normalize is why &lt;code&gt;5&lt;/code&gt; and &lt;code&gt;5.0&lt;/code&gt; agree.&lt;/p&gt;
&lt;p&gt;Then I passed &lt;code&gt;float(&amp;#39;inf&amp;#39;)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/images/decimal-places-infinity.png&quot; alt=&quot;Python REPL: Decimal(str(float(&amp;#39;inf&amp;#39;))).normalize() returns Decimal(&amp;#39;Infinity&amp;#39;), as_tuple() returns sign=0, digits=(0,), exponent=&amp;#39;F&amp;#39;, and max(0, min(6, -exponent)) raises TypeError: bad operand type for unary -: &amp;#39;str&amp;#39;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Decimal(&amp;#39;Infinity&amp;#39;)&lt;/code&gt; parses fine. The exponent came
back as &lt;code&gt;&amp;#39;F&amp;#39;&lt;/code&gt;. A string. One line later the helper ran &lt;code&gt;-exponent&lt;/code&gt; on it, and
Python raised &lt;code&gt;TypeError: bad operand type for unary -: &amp;#39;str&amp;#39;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So I went back and looked at the guard. The try/except wraps the construction
only. A garbage string dies in there with &lt;code&gt;InvalidOperation&lt;/code&gt;, and the helper
returns 0. The guard caught the parse and missed the crash.&lt;/p&gt;
&lt;p&gt;NaN is stranger. Its exponent is &lt;code&gt;&amp;#39;n&amp;#39;&lt;/code&gt;, and its digits tuple is empty. It
reaches the return line too.&lt;/p&gt;
&lt;p&gt;My fix checks the type at the crash site.&lt;/p&gt;
&lt;pre class=&quot;shiki shiki-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    exponent &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; decimal_value.as_tuple().exponent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; not&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; isinstance&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(exponent, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;int&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6A737D;--shiki-dark:#6A737D&quot;&gt;        # NaN / Infinity have no decimal places&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; max&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;min&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;6&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;exponent))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The helper returns 0, the same answer bad input already gets.&lt;/p&gt;</content>
  </entry>
  <entry xml:lang="en">
    <title>Two ways to split one MQTT topic across consumers</title>
    <id>https://ssupawat.github.io/posts/mqtt-one-topic-many-consumers/</id>
    <link href="https://ssupawat.github.io/posts/mqtt-one-topic-many-consumers/"/>
    <updated>2026-09-12</updated>
    <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.</summary>
    <content type="html">&lt;p&gt;MQTT fans a topic out. Every client subscribed to it gets a copy of every
message. That is the point of the protocol, and most of the time it is what I
want too.&lt;/p&gt;
&lt;p&gt;It is not what I wanted here. I had a Kafka consumer group in mind: several
consumers on one topic, the messages split between them, each one handled once.
I went looking for how to do that in MQTT.&lt;/p&gt;
&lt;p&gt;The short answer is no on v3.1.1, which is what I was on. There is no consumer
group in the protocol. So the question turned into what to do
instead, and I found two answers and built both, in
&lt;a href=&quot;https://github.com/ssupawat/mqtt-client-load-balancing&quot;&gt;mqtt-client-load-balancing&lt;/a&gt;,
one on a branch each.&lt;/p&gt;
&lt;h2&gt;Partition the topic yourself&lt;/h2&gt;
&lt;p&gt;The first way needs nothing from the broker. It uses the topic hierarchy as the
partition key.&lt;/p&gt;
&lt;p&gt;I picked three partitions. My publisher appends a partition number to the topic
on every message, chosen at random. Each consumer then subscribes to a single
partition. Every message still goes to exactly one subscriber, because no two
of them subscribe to the same topic.&lt;/p&gt;
&lt;figure class=&quot;wide-figure&quot;&gt;

&lt;figcaption&gt;The publisher picks the partition, so nothing about the split lives in the broker. &lt;code&gt;demoTopic/partition2&lt;/code&gt; reaches consumer2 because it is the only client subscribed to it.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;This is roughly how Kafka partitions a topic. The difference is that in Kafka
the partition count is a property of the topic, held in one place. Here it is a
convention, and every publisher has to know it.&lt;/p&gt;
&lt;p&gt;That is what makes the approach expensive. The partition count is baked into my
publishers, so the consumer count is too. I need one consumer per partition,
and the two counts move together. Scaling out is not a consumer-side change.
To go from three partitions to six, every device that publishes to the topic
has to start using the new range. On a fleet of devices in the field, that is
a rollout.&lt;/p&gt;
&lt;h2&gt;Let the broker rotate&lt;/h2&gt;
&lt;p&gt;MQTT v5 has the same thing as a protocol feature. From
&lt;a href=&quot;https://www.hivemq.com/blog/mqtt5-essentials-part7-shared-subscriptions/&quot;&gt;HiveMQ&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;In a standard MQTT subscription, each subscribing client is privy to a copy
of each message broadcasted to that topic. With shared subscriptions, clients
sharing a subscription in the same group receive messages in rotation, a
process sometimes referred to as client load balancing. The message load of a
single topic is distributed across all subscribers.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It is a prefix on the subscription:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$share/&amp;lt;group&amp;gt;/topic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;$share&lt;/code&gt; marks it as shared, &lt;code&gt;&amp;lt;group&amp;gt;&lt;/code&gt; names the group, and the rest is the
topic I want. Consumers that give the same group and the same topic form one
group. The broker delivers each message to one member of it.&lt;/p&gt;
&lt;p&gt;Which member is the broker&amp;#39;s business. The spec does not name an algorithm.
Mosquitto, which the demo runs on, goes round-robin. So the delivery is a
rotation. A consumer that is still busy gets its turn anyway.&lt;/p&gt;
&lt;p&gt;A group does not change what anyone outside it sees. The demo keeps a couple of
subscribers on plain &lt;code&gt;demoTopic&lt;/code&gt; to show it: they go on receiving every
message, while the three in the group split their own copy between them.&lt;/p&gt;
&lt;figure class=&quot;wide-figure&quot;&gt;

&lt;figcaption&gt;The three in the group hold &lt;code&gt;$share/shared-subscriber-group/demoTopic&lt;/code&gt; and split the stream. The plain sub holds &lt;code&gt;demoTopic&lt;/code&gt; with no prefix and still gets all of it. The publisher is unchanged either way. It publishes to plain &lt;code&gt;demoTopic&lt;/code&gt;, because &lt;code&gt;$share&lt;/code&gt; is a subscribe-side prefix.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Scaling out is now what I wanted. I start another consumer, give it the same
group, and the broker starts including it in the rotation. Nothing on the
publishing side changes, because it never knew how many consumers there were.&lt;/p&gt;
&lt;p&gt;The cost is a version floor. Shared subscriptions are v5. The prefix lives on
the subscribe side, so the consumers and the broker have to speak it.
Publishers are not in the way. That sounds like a small bill until someone asks
who is holding the versions.&lt;/p&gt;
&lt;h2&gt;What it comes down to&lt;/h2&gt;
&lt;p&gt;Both approaches split the same stream. The difference is where the split is
decided.&lt;/p&gt;
&lt;p&gt;Partitioning puts the decision in every publisher. The topology is then spread
across the fleet, and changing it means changing all of it. A shared
subscription puts the decision in the broker, and the publishers never learn
about it. That is why the consumer side can be resized on its own.&lt;/p&gt;
&lt;p&gt;So MQTT has no consumer group on v3.1.1, and something close to one on v5.
That is the answer to the question I started with.&lt;/p&gt;
&lt;p&gt;We never took it. v5 was out by then, but our devices had already shipped on
v3.1.1. The broker was not moving either. I no longer remember why. The devices
on their own would not have stopped us, since a publisher never sees the
prefix. The broker did.&lt;/p&gt;
&lt;p&gt;So the shared subscription stayed a branch in this repository. Partitioning was
the only one of the two I could actually have used, at the price above: every
publisher in on the scheme, and a rollout to the fleet to change it.&lt;/p&gt;
&lt;p&gt;Both branches run under Docker Compose, and the consumer logs show the split:
&lt;a href=&quot;https://github.com/ssupawat/mqtt-client-load-balancing&quot;&gt;github.com/ssupawat/mqtt-client-load-balancing&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;References&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/questions/27850819/is-it-possible-to-distribute-reads-of-an-mqtt-topic-over-multiple-consumers&quot;&gt;StackOverflow: Is it possible to distribute reads of an MQTT topic over multiple consumers?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.hivemq.com/blog/mqtt5-essentials-part7-shared-subscriptions/&quot;&gt;HiveMQ: Shared Subscriptions in MQTT v5&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content>
  </entry>
  <entry xml:lang="en">
    <title>Fixing an SSE stream that dies at two minutes</title>
    <id>https://ssupawat.github.io/posts/sse-over-post-reconnect/</id>
    <link href="https://ssupawat.github.io/posts/sse-over-post-reconnect/"/>
    <updated>2026-09-10</updated>
    <summary>I wired up AG-UI&apos;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.</summary>
    <content type="html">&lt;p&gt;I got a chance to serve an &lt;a href=&quot;https://docs.ag-ui.com&quot;&gt;AG-UI&lt;/a&gt; endpoint. I started
where the protocol points. AG-UI&amp;#39;s
&lt;a href=&quot;https://github.com/ag-ui-protocol/ag-ui/blob/main/integrations/langgraph/python/ag_ui_langgraph/endpoint.py&quot;&gt;LangGraph integration&lt;/a&gt;
ships a helper that registers the endpoint. It calls &lt;code&gt;@app.post(path)&lt;/code&gt; and
returns a &lt;code&gt;StreamingResponse&lt;/code&gt;. From there I wired it into my backend, and it
ran.&lt;/p&gt;
&lt;p&gt;Then I started losing streams. About two minutes into a run, the stream would
stop. After that the client sat with half an answer.&lt;/p&gt;
&lt;p&gt;My first guess was an idle timeout. Something in front was hanging up on a
stream that had gone quiet. The standard fix for that is a keepalive, because
it stops the connection from looking idle. So I added one. Even so, I still
lost the stream at two minutes.&lt;/p&gt;
&lt;p&gt;So whatever sits in front counts the age of the connection. It hangs up at two
minutes, whether or not bytes are moving. Next I asked the infra team to raise
the limit, and they turned me down. In the end I never did learn which layer
sets that number.&lt;/p&gt;
&lt;p&gt;That settled the shape of the problem. From then on, every run longer than two
minutes gets cut. And I have to survive it from my side.&lt;/p&gt;
&lt;p&gt;Normally the browser would reconnect for me. &lt;code&gt;EventSource&lt;/code&gt; does it for free.
First it retries on its own. Then it sends back a &lt;code&gt;Last-Event-ID&lt;/code&gt; header. That
header names the last event the client saw. Then the server carries on from
there. But I could not use any of it. That machinery is GET only, and AG-UI
answers on POST. That puts me on &lt;code&gt;fetch&lt;/code&gt; and a &lt;code&gt;ReadableStream&lt;/code&gt;. Those will
stream a response. Even so, recovering one is my job.&lt;/p&gt;
&lt;p&gt;The second wall came right behind the first. My endpoint runs on several pods.
Because of that, the retry goes through the load balancer. It lands
wherever it lands. If the half-finished run lives in the process that started
it, only that process can answer. Then I am back to pinning traffic with
sticky sessions.&lt;/p&gt;
&lt;p&gt;So I needed two things. First, the work has to keep going after the client is
gone. Second, any pod has to be able to read a record of it.&lt;/p&gt;
&lt;p&gt;I built &lt;a href=&quot;https://github.com/ssupawat/reconnectable-sse&quot;&gt;reconnectable-sse&lt;/a&gt; to
try it out. My production endpoint is Python. But this was a concept, and the
language was free. So I used it as an excuse to get familiar with Go. In the
end the shape is the part that matters.&lt;/p&gt;
&lt;p&gt;It comes down to two lines:&lt;/p&gt;
&lt;pre class=&quot;shiki shiki-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;streamExists&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(r.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;Context&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(), key) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    go&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; runEventGenerator&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(streamID, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;string&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(body))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;runSSEResponse&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(r.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;Context&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(), streamID, lastEventID, w, flusher)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I give &lt;code&gt;runSSEResponse&lt;/code&gt; the request context. A disconnect stops it. That is
what I want, since there is nobody left to write to. Meanwhile
&lt;code&gt;runEventGenerator&lt;/code&gt; gets &lt;code&gt;context.Background()&lt;/code&gt;, and it keeps running. Hand it
the request context instead, and the disconnect kills the work itself. Then
nothing is left to come back to. The run also has to finish and write its
history to the database. Whether anyone is still watching is beside the point.&lt;/p&gt;
&lt;figure class=&quot;wide-figure&quot;&gt;

&lt;figcaption&gt;The shaded band is api-1&amp;#39;s generator. It starts on the first request and runs to completion on &lt;code&gt;context.Background()&lt;/code&gt;. It keeps going through the drop and the reconnect, even though api-2 is the one talking to the client now.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Resume then came down to an offset. Redis returns an entry id on every &lt;code&gt;XADD&lt;/code&gt;.
That id goes out as the SSE &lt;code&gt;id&lt;/code&gt;. Then the client sends it back on the retry.
From there it goes into &lt;code&gt;XREAD&lt;/code&gt; as the starting point. That leaves no cursor
table and no bookkeeping. In the end the id the client is holding is the
offset.&lt;/p&gt;
&lt;p&gt;I ran it. It does what I wanted. After a drop mid-stream, a client comes back
through a different pod and picks up where it stopped.&lt;/p&gt;
&lt;p&gt;Still, I left edges in it. The generator starts under a check-then-act. So two
concurrent first requests with the same stream id can both start one.&lt;/p&gt;
&lt;p&gt;Design, endpoints, and how to run it: &lt;a href=&quot;https://github.com/ssupawat/reconnectable-sse&quot;&gt;github.com/ssupawat/reconnectable-sse&lt;/a&gt;&lt;/p&gt;</content>
  </entry>
</feed>