<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Blog – S2, the durable stream API</title>
        <link>https://s2.dev/blog</link>
        <description>S2 is the serverless API for unlimited, durable, real-time streams, built for event streaming, AI agents, realtime apps, and stream processing.</description>
        <lastBuildDate>Sat, 25 Apr 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <image>
            <title>Blog – S2, the durable stream API</title>
            <url>https://s2.dev/og/image.jpg</url>
            <link>https://s2.dev/blog</link>
        </image>
        <copyright>All rights reserved 2026, Bandar Systems Inc</copyright>
        <item>
            <title><![CDATA[Your data, your keys]]></title>
            <link>https://s2.dev/blog/encryption</link>
            <guid isPermaLink="false">https://s2.dev/blog/encryption</guid>
            <pubDate>Sat, 25 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[S2 now supports client-supplied encryption keys for durable streams, giving teams control over key material without changing record APIs.]]></description>
            <content:encoded><![CDATA[<p>S2 now supports <strong>client-supplied encryption keys</strong>: you hand S2 a key per request, S2 uses it to encrypt or decrypt records in memory, and then forgets it. The keys are never persisted, never logged, and zeroed when the request completes. Without the key, the stored records are unrecoverable — even by us.</p>
<p>This is a good fit for streams carrying sensitive tenant data, audit logs, agent transcripts, or regulated records such as protected health information <sup><a href="https://s2.dev/blog/encryption#user-content-fn-hipaa" id="user-content-fnref-hipaa" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup>. Your application supplies keys from your own infrastructure, while S2 handles request-time encryption behind the durable stream API.</p>
<h2 id="where-this-fits"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/encryption#where-this-fits">Where this fits</a></h2>
<p>There are three places encryption operations can happen — in your application, in S2 with keys you supply per request, or in S2 with keys governed by a cloud key management system (KMS).</p>
<p><strong>Client-side record encryption</strong> is the right model when your hard requirement is that S2 never sees plaintext. You encrypt each record before append and decrypt after read. This gives you end-to-end control, but every producer and consumer must handle encryption, decryption, and encoding correctly. You also lose compression on the wire, since encrypted bytes don't compress.</p>
<p><strong>KMS-integrated encryption (CMEK)</strong> gives you key governance through your cloud provider's KMS, IAM policies, and audit logs you already manage. However, it is less portable, since you are limited to that provider's KMS and the services that integrate with it.</p>
<p><strong>Client-supplied encryption keys (CSEK)</strong> are the middle ground, in the same family as <a href="https://cloud.google.com/storage/docs/encryption/customer-supplied-keys">Google Cloud CSEK</a> and <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html">Amazon S3 SSE-C</a>. Encryption and decryption happen inside S2 at request time, so producers and consumers stay simple. Client↔S2 compression still works since it runs before encryption on writes and after decryption on reads. You decide where keys live and which workloads can use them, independent of any specific KMS.</p>
<p>The tradeoff is S2 sees plaintext and key material in memory while serving. If your compliance posture requires that the service operator never have access to plaintext, use client-side encryption instead.</p>
<h2 id="how-it-works"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/encryption#how-it-works">How it works</a></h2>
<video src="https://s2.dev/blog/encryption-demo.mp4" controls muted autoplay playsinline style="width: 100%; border-radius: 8px;">
  Your browser does not support the video tag.
</video>
<p><a href="https://s2.dev/docs/concepts/basins">Basins</a> can now be configured with an encryption algorithm <sup><a href="https://s2.dev/blog/encryption#user-content-fn-ciphers" id="user-content-fnref-ciphers" data-footnote-ref="" aria-describedby="footnote-label">2</a></sup>. Every new stream created in that basin then requires an encryption key to append or read <a href="https://s2.dev/docs/concepts/records">records</a>.</p>
<p>Keys are sent as base64-encoded key material in the <code>s2-encryption-key</code> request header over TLS. The <a href="https://s2.dev/docs/cli">CLI</a> and all <a href="https://s2.dev/docs/sdk">SDKs</a> support specifying it easily at the stream level.</p>
<p>In the <a href="https://s2.dev/docs/platform/architecture">s2.dev data plane</a>, writes are encrypted at the edge service before being forwarded downstream. On reads, encrypted records are decrypted only after they return to the edge service, after any caching and coalescing.</p>
<p>The core encryption path lives in the <a href="https://github.com/s2-streamstore/s2">open source s2 repo</a>, so you can verify how keys are handled. <a href="https://s2.dev/docs/s2-lite"><code>s2-lite</code></a>, the self-hostable form factor of S2, supports the same mechanism.</p>
<p>There is no meaningful impact to performance, as the supported ciphers are designed for high throughput and are hardware-accelerated in practice.</p>
<p><em>For operational guidance on key management, including envelope encryption and rotation, see the <a href="https://s2.dev/docs/concepts/encryption#key-management">encryption docs</a>.</em></p>
<h2 id="try-it"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/encryption#try-it">Try it</a></h2>
<p>Let's configure a basin to encrypt new streams:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> create-basin</span><span style="color:#9ECBFF"> logs-prod</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --create-stream-on-append</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --stream-cipher</span><span style="color:#9ECBFF"> aegis-256</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 create-basin logs-prod \
  --create-stream-on-append \
  --stream-cipher aegis-256" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>You can also reconfigure an existing basin – only new streams are affected:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> reconfigure-basin</span><span style="color:#9ECBFF"> logs-prod</span><span style="color:#79B8FF"> --stream-cipher</span><span style="color:#9ECBFF"> aegis-256</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 reconfigure-basin logs-prod --stream-cipher aegis-256" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Now, when a new stream is created in this basin, it will use the configured cipher. An encryption key must be provided with all data plane requests that read or write records.</p>
<p>For the CLI, generate a key and pass it explicitly, or use the <code>S2_ENCRYPTION_KEY</code> environment variable.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> export</span><span style="color:#9ECBFF"> S2_ENCRYPTION_KEY="$(</span><span style="color:#B392F0">openssl</span><span style="color:#9ECBFF"> rand </span><span style="color:#79B8FF">-base64</span><span style="color:#79B8FF"> 32</span><span style="color:#9ECBFF">)"</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> printf</span><span style="color:#9ECBFF"> 'hello from an encrypted stream\n'</span><span style="color:#F97583"> |</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#B392F0">    s2</span><span style="color:#9ECBFF"> append</span><span style="color:#9ECBFF"> s2://logs-prod/app/node-foo</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> read</span><span style="color:#9ECBFF"> s2://logs-prod/app/node-foo</span><span style="color:#79B8FF"> --seq-num</span><span style="color:#79B8FF"> 0</span><span style="color:#79B8FF"> --count</span><span style="color:#79B8FF"> 1</span></span>
<span data-line=""><span style="color:#B392F0">hello</span><span style="color:#9ECBFF"> from</span><span style="color:#9ECBFF"> an</span><span style="color:#9ECBFF"> encrypted</span><span style="color:#9ECBFF"> stream</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ export S2_ENCRYPTION_KEY=&#x22;$(openssl rand -base64 32)&#x22;

$ printf &#x27;hello from an encrypted stream\n&#x27; | \
    s2 append s2://logs-prod/app/node-foo

$ s2 read s2://logs-prod/app/node-foo --seq-num 0 --count 1
hello from an encrypted stream" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>With the TypeScript SDK, the key is scoped to the stream handle:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ts" data-theme="github-dark"><code data-language="ts" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { AppendInput, AppendRecord, S2 } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@s2-dev/streamstore'</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> s2</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> S2</span><span style="color:#E1E4E8">({ accessToken: process.env.</span><span style="color:#79B8FF">S2_ACCESS_TOKEN</span><span style="color:#F97583">!</span><span style="color:#E1E4E8"> });</span></span>
<span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> stream</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> s2.</span><span style="color:#B392F0">basin</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'logs-prod'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">stream</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'app/node-foo'</span><span style="color:#E1E4E8">, {</span></span>
<span data-line=""><span style="color:#E1E4E8">  encryptionKey: process.env.</span><span style="color:#79B8FF">S2_ENCRYPTION_KEY</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">await</span><span style="color:#E1E4E8"> stream.</span><span style="color:#B392F0">append</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#E1E4E8">  AppendInput.</span><span style="color:#B392F0">create</span><span style="color:#E1E4E8">([AppendRecord.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">({ body: </span><span style="color:#9ECBFF">'hello'</span><span style="color:#E1E4E8"> })])</span></span>
<span data-line=""><span style="color:#E1E4E8">);</span></span><button type="button" title="Copy code" aria-label="Copy code" data="import { AppendInput, AppendRecord, S2 } from &#x27;@s2-dev/streamstore&#x27;;

const s2 = new S2({ accessToken: process.env.S2_ACCESS_TOKEN! });
const stream = s2.basin(&#x27;logs-prod&#x27;).stream(&#x27;app/node-foo&#x27;, {
  encryptionKey: process.env.S2_ENCRYPTION_KEY!,
});

await stream.append(
  AppendInput.create([AppendRecord.string({ body: &#x27;hello&#x27; })])
);" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Client-supplied encryption keys are available today on the s2.dev <a href="https://s2.dev/dashboard">cloud service</a> as well as <a href="https://s2.dev/docs/s2-lite"><code>s2-lite</code></a>, at no additional cost.</p>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/encryption#footnote-label">Footnotes</a></h2>
<ol>
<li id="user-content-fn-hipaa">
<p><a href="mailto:trust@s2.dev">Contact us</a> for a HIPAA Business Associate Agreement (BAA). <a href="https://s2.dev/blog/encryption#user-content-fnref-hipaa" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-ciphers">
<p>We recommend <code>aegis-256</code> in general; <code>aes-256-gcm</code> is available for organizations that standardize on AES-GCM. <a href="https://s2.dev/blog/encryption#user-content-fnref-ciphers" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content:encoded>
            <author>shikhar@s2.dev (Shikhar Bhushan)</author>
            <category>announce</category>
            <category>tutorial</category>
            <enclosure url="https://s2.dev/blog/encryption-hero.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Video Conferencing with Durable Streams]]></title>
            <link>https://s2.dev/blog/video-conferencing</link>
            <guid isPermaLink="false">https://s2.dev/blog/video-conferencing</guid>
            <pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Build a video conferencing demo on S2 streams with live media reads, replay, retention controls, and durable frame history.]]></description>
            <content:encoded><![CDATA[<p><a href="https://spacetimedb.com/">SpacetimeDB</a> recently shared what they called, <a href="https://github.com/Lethalchip/SpaceChatDB">"the world's first video call over a database"</a> 😅: capturing camera and mic in the browser, encoding frames as JPEG + PCM, and routing them through its real-time subscriptions. <a href="https://planetscale.com/blog/video-conferencing-with-postgres">PlanetScale</a> later followed with Postgres: encoding frames in <code>BYTEA</code> columns, delivery via WAL logical replication, and a cleanup job pruning frames after 5 seconds.</p>
<p>Both are very impressive! And I wanted to take a stab at it by using the infrastructure <em>designed</em> for ordered, real-time, durable data streams, so I built a full video conferencing app on <a href="https://s2.dev/">S2</a> streams.</p>
<p>You can try it <strong><a href="https://s2-video.fly.dev/">here</a></strong>. Open it in two tabs or share the link with someone! The source code is on <a href="https://github.com/s2-streamstore/s2-video">GitHub</a>.</p>
<p><img src="https://s2.dev/blog/video-conf-demo.gif" alt="Two browser tabs joined to the same S2-powered video conferencing room"></p>
<h2 id="architecture"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/video-conferencing#architecture">Architecture</a></h2>
<p>S2 turns the humble log, the stream, into a first-class cloud storage primitive. Instead of storing entire objects, applications append and read records on named streams using its focused API.</p>
<p>Every record is durably sequenced at the stream tail. Consumers can read streams live as new records arrive or replay history from any earlier position. This allows a stream to act as both durable storage and reliable transport for ordered data.</p>
<p>Each room uses a small set of named streams:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="text" data-theme="github-dark"><code data-language="text" data-theme="github-dark" style="display: grid;"><span data-line=""><span>rooms/{room}/media/{user}   -> video + audio + screen, interleaved</span></span>
<span data-line=""><span>rooms/{room}/chat           -> persistent chat history</span></span>
<span data-line=""><span>rooms/{room}/meta           -> join/leave + control events (like hand raises)</span></span><button type="button" title="Copy code" aria-label="Copy code" data="rooms/{room}/media/{user}   -> video + audio + screen, interleaved
rooms/{room}/chat           -> persistent chat history
rooms/{room}/meta           -> join/leave + control events (like hand raises)" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Audio and video are sent over a WebSocket connection to a Go server which makes them durable in S2 using an <a href="https://s2.dev/docs/sdk/appending#append-session"><code>AppendSession</code></a> and fans out to multiple readers over a <a href="https://s2.dev/docs/sdk/reading#read-session"><code>ReadSession</code></a>.</p>
<p><img src="https://s2.dev/blog/video-conf-arch.svg" alt="Architecture overview"></p>
<p>The key simplification here is that:</p>
<ul>
<li>live media is a stream read</li>
<li>recording is a no-op, the stream is durable by design</li>
<li>replay is another stream read</li>
<li>MP4 export is another stream read</li>
</ul>
<p>There is no separate recording pipeline, replay database, or post-processing step to assemble files!</p>
<p><img src="https://s2.dev/blog/video-conf-pipeline.svg" alt="Media capture pipeline"></p>
<h2 id="reading-live-media"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/video-conferencing#reading-live-media">Reading live media</a></h2>
<p>The Go server writes media using an <a href="https://s2.dev/docs/sdk/appending#append-session"><code>AppendSession</code></a>, batching records in 5ms windows for low latency and high throughput. Each record body is the raw media payload, with the media type stored as an S2 <a href="https://s2.dev/docs/concepts/records">record header</a>.</p>
<p>For live viewing, each participant reads each remote media stream from the current tail, following new records as they arrive.</p>
<p>So the live path looks like this:</p>
<p><img src="https://s2.dev/blog/video-conf-live.svg" alt="Live read stream"></p>
<p>The same pattern is used for other features too:</p>
<ul>
<li><code>chat</code> starts from <code>SeqNum: 0</code>, so new users get old messages first and then new ones</li>
<li><code>meta</code> tails live control events, so "hand raises" work like any other record</li>
<li>replay finds past participants by reading <code>join</code> events from <code>meta</code></li>
</ul>
<h2 id="replay"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/video-conferencing#replay">Replay</a></h2>
<p>Replay is not a special file format. The server just reads the room streams again!</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="text" data-theme="github-dark"><code data-language="text" data-theme="github-dark" style="display: grid;"><span data-line=""><span>/api/rooms/{room}/timeline</span></span>
<span data-line=""><span>    ├─ read meta stream for participant history + join/leave events</span></span>
<span data-line=""><span>    ├─ read first media record for start timestamp</span></span>
<span data-line=""><span>    └─ CheckTail() for end timestamp</span></span>
<span data-line=""> </span>
<span data-line=""><span>/ws?room=...&#x26;replay=true&#x26;from=T</span></span>
<span data-line=""><span>    ├─ replay media/{alice} from T</span></span>
<span data-line=""><span>    ├─ replay media/{bob}   from T</span></span>
<span data-line=""><span>    ├─ replay meta          from T</span></span>
<span data-line=""><span>    └─ replay chat          from T</span></span><button type="button" title="Copy code" aria-label="Copy code" data="/api/rooms/{room}/timeline
    ├─ read meta stream for participant history + join/leave events
    ├─ read first media record for start timestamp
    └─ CheckTail() for end timestamp

/ws?room=...&#x26;replay=true&#x26;from=T
    ├─ replay media/{alice} from T
    ├─ replay media/{bob}   from T
    ├─ replay meta          from T
    └─ replay chat          from T" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Playback speed is derived from record timestamps, so the replay UI is mostly a thin layer over stream reads:</p>
<p><img src="https://s2.dev/blog/video-conf-replay.svg" alt="Replay timeline"></p>
<p>For MP4 export, the server reads each participant's media stream, pipes audio and video directly into ffmpeg for compositing, and streams the result to the browser with no intermediate files.</p>
<p>If you don't care about saving the video, you can just set the retention policy on the streams to be short, e.g. 5 seconds, instead of having a <a href="https://planetscale.com/blog/video-conferencing-with-postgres#accumulating-rows">background job</a>. Or to save it forever, you can set it to be <code>infinite</code>.</p>
<h2 id="thoughts"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/video-conferencing#thoughts">Thoughts</a></h2>
<p>This might just become our go to meeting spot given how smooth it was. Every feature that I thought of
could be simply mapped as a read or write on S2's durable streams. It is an unusual architecture for a video conferencing app, but it points to a broader idea that when streams are treated as a storage primitive rather than merely a messaging layer, many real-time applications become far simpler to build and operate.</p>]]></content:encoded>
            <author>mehul@s2.dev (Mehul Arora)</author>
            <category>use-case</category>
            <enclosure url="https://s2.dev/blog/video-conf-thumb.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[General availability & our seed round]]></title>
            <link>https://s2.dev/blog/ga</link>
            <guid isPermaLink="false">https://s2.dev/blog/ga</guid>
            <pubDate>Wed, 25 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[S2 is generally available, backed by a $3.85M seed round led by Accel, and ready for production durable stream workloads.]]></description>
            <content:encoded><![CDATA[<p>The s2.dev cloud service is now <strong>Generally Available</strong>!</p>
<p>We're also thrilled to share that we raised <strong>$3.85M</strong> in a seed round led by <strong>Accel</strong>, with participation from <strong>Y Combinator</strong>, a terrific group of angel investors like Theo Browne (t3.gg), Charles Zedlewski (Together AI), Paul Masurel (Quickwit), and more. This brings our total capital raised to <strong>$5.5M</strong>.</p>
<h2 id="what-we-do"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/ga#what-we-do">What we do</a></h2>
<p>S2 is making real-time, serverless data infra for AI builders. We give users an API for durable streams, unlimited in number and storage.</p>
<p>The need for real-time infra is everywhere today: token streaming, live observability, agent communication. This has only reinforced our <a href="https://s2.dev/blog/intro">original thesis</a> that streams need to be as simple, reliable, and scalable as object storage.</p>
<p>Our customers are now creating millions of durable streams and pushing terabytes of data, each week.</p>
<blockquote><p>S2 is such a useful primitive for building realtime features. We use it to stream telemetry data so that users can see in realtime things like logs and metrics. It’s been rock solid and reliable—the kind of infra that just works™.</p><p>— Rafael Garcia, CTO, <a href="https://www.kernel.sh/">Kernel</a></p></blockquote>
<blockquote><p>S2 solved our problem with reliably streaming long-running AI sessions. Before, connection drops could cause lost data and broken streams. S2's bottomless storage means our customers can stream for hours and network hiccups don’t matter.</p><p>— Matt Aitken, CEO, <a href="https://trigger.dev/">Trigger.dev</a></p></blockquote>
<h2 id="the-gap-we-fill"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/ga#the-gap-we-fill">The gap we fill</a></h2>
<p>Companies have been stitching complex systems together to try to acommodate the demands of real-time AI applications.</p>
<p>LLMs return token streams. Sandboxed execution involves remote I/O streams. Agent sessions evolve as a sequence of events. How do you ensure real-time visibility with long-term history? What does distributed plumbing for multi-agent architectures look like?</p>
<p>With traditional infra like Kafka, NATS, or Redis Streams, you are <a href="https://s2.dev/blog/agent-sessions#landscape">forced to choose</a> between cardinality of streams and durability.</p>
<p>What if one serverless resource could persist every agent action at the session-level — and make it instantly visible to any number of readers?</p>
<p>That's S2.</p>
<h2 id="general-availability"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/ga#general-availability">General availability</a></h2>
<p>GA reflects our confidence in the maturity of the service, and our commitment to stay highly available, durable, and consistent. As a data infra company, this is existential for us.</p>
<p>So many aspects of our work have given us this confidence: we gained experience running production workloads for our customers; focused on clarifying, simplifying, and hardening <a href="https://s2.dev/docs/platform/architecture">our architecture</a>; <a href="https://s2.dev/blog/dst">invested</a> <a href="https://s2.dev/blog/linearizability">heavily</a> in verification through simulation.</p>
<h2 id="where-we-are-going"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/ga#where-we-are-going">Where we are going</a></h2>
<p>Reliability, scalability, performance, and security will always be a priority.</p>
<p>What we have in mind as we evolve the product, shaped by what we have heard from users —</p>
<ul>
<li><strong>Features for agent builders:</strong> first-class support for forking, integrations</li>
<li><strong>Security:</strong> bring-your-own-key encryption, stateless <a href="https://s2.dev/blog/access-control">access tokens</a></li>
<li><strong>Expansion:</strong> additional cloud regions, and customer VPCs</li>
<li><strong>Higher layers:</strong> queuing, large messages, and more to come</li>
</ul>
<p>We are so excited to continue the work of <strong>making streams a cloud storage primitive</strong>. Thank you to all of our customers, testers, and <a href="https://github.com/s2-streamstore/s2">open source</a> adopters.</p>
<hr>
<p>If you are new to S2, <a href="https://s2.dev/docs/quickstart">try it out</a> with free credits! You can also connect with us on <a href="mailto:hi@s2.dev">email</a> or <a href="https://discord.gg/JfTWJ5xxZ6">Discord</a>.</p>
<h2 id="all-our-investors"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/ga#all-our-investors">All our investors</a></h2>
<p>Accel, Adam Suskin, Alessandro Puppo, Aman Sidhant, Amitav Chakravartty, Andrew Stanton, Benjamin Bryant, Brian Kim, Chalmers Brown, Charles Zedlewski, Deep Kapur, Dennis Beatty, Eight Capital, Fredrik Björk, Grayscale Ventures, Hemanth Soni, James Wu, Jeff Ling, Jeremy Hindle, JJ Fliegelman, Kevin Li, Keyur Govande, Li Sabhaya Capital, Lyon Wong, Manju Rajashekhar, Materialized View Capital, Micah Wylde, Michael Shimeles, Mokhtar Bacha, Nikitha Suryadevara, Nimit Maru, Orange Collective, Paul Masurel, Pioneer Fund, Race Capital, Rafael Garcia, Ritual Capital, Rush Sadiwala, Satish Talluri, Shane Barratt, Spot VC, Theo Browne, Transpose Platform, Twenty Two Ventures, Uncorrelated Ventures, Victor Mota, Y Combinator</p>]]></content:encoded>
            <author>shikhar@s2.dev (Shikhar Bhushan)</author>
            <author>stephen@s2.dev (Stephen Balogh)</author>
            <author>dwarak@s2.dev (Dwarak Govind Parthiban)</author>
            <category>announce</category>
        </item>
        <item>
            <title><![CDATA[Coordinating adversarial AI agents]]></title>
            <link>https://s2.dev/blog/distributed-ai-agents</link>
            <guid isPermaLink="false">https://s2.dev/blog/distributed-ai-agents</guid>
            <pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Use durable streams to coordinate adversarial AI agents, isolate context, replay reasoning, and scale parallel moderation or forecasting.]]></description>
            <content:encoded><![CDATA[<p>When you ask a single AI model to examine both sides of a difficult question, it can usually produce something that, at first glance, appears reasonable. Often the structure is there with a claim, counterclaims, tradeoffs, but the balance can seem artificial. This is understandable as the generation originates from the same context window.</p>
<p>In practice, many of us have learned to compensate for this. For example, after dealing with repeated mistakes and oversights from Claude Code, I've fallen into a pattern where one Claude session writes most code and other independent Codex (or Claude) sessions review it.</p>
<p>The underlying goal is complete context separation. My reviewer is not constrained by the same intermediate assumptions that shaped the original output, it is unbiased by the context, even if not the training data. What we are doing informally is creating parallel reasoning paths, separating generation from critique to produce structural disagreement.</p>
<p>We are rediscovering in small, practical ways what has long existed in other disciplines. In science, it appears as <a href="https://en.wikipedia.org/wiki/Peer_review">blind peer review</a>. In forecasting, it is the <a href="https://en.wikipedia.org/wiki/Delphi_method">Delphi method</a>. In risk analysis, it becomes competitive hypothesis testing. In law, as the <a href="https://en.wikipedia.org/wiki/Adversarial_system">adversarial system</a>, where structured opposition precedes judgment.</p>
<p>These systems are all predicated on the idea that it is beneficial for participants to generate perspectives independently, and thus prevent cross-contamination, and only afterwards work toward consensus.</p>
<h2 id="independence-requires-separate-contexts"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/distributed-ai-agents#independence-requires-separate-contexts">Independence requires separate contexts</a></h2>
<p>Telling a model to "consider the opposing view" doesn't create independence. By the time that instruction runs, it's already committed to a framing. The attention mechanism within a single forward pass may reweigh information, but it's all happening inside a shared state. Independence requires distinct contexts with no shared memory or conversational history.</p>
<p><img src="https://s2.dev/blog/distributed-ai-agents-image1.png" alt="Multi-cohort agent architecture overview"></p>
<p>To make that separation concrete, the unit of reasoning really can't be a single model invocation. It should be some bounded execution context with its own internal memory. We can consider the agents who share a bounded context to be forming a cohort. A single cohort might contain multiple agents — say, one that researches and another that critiques — but the key is that while they share context with each other, that context is isolated from other cohorts.</p>
<p>Only after cohorts have independently reached some stopping point should reconciliation occur.</p>
<h2 id="enforcing-isolation-at-the-infrastructure-layer"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/distributed-ai-agents#enforcing-isolation-at-the-infrastructure-layer">Enforcing isolation at the infrastructure layer</a></h2>
<p>Capturing all context for a cohort on S2 streams allows us to enforce this independence at the infra layer.</p>
<p>In my setup, each cohort treats a stream as its shared, append-only journal. All agents within a cohort can append and read from their stream, allowing it to serve as both a message bus between agents, and as a longer-term context store. Streams can be created on demand, meaning cohort definitions and membership can also vary over the course of an experiment.</p>
<p>A record in a stream named <code>group/growth</code> is invisible to readers of <code>group/risk</code>. This makes isolation not an instruction but an intrinsic property of the system.</p>
<p>Within a cohort, agents maintain a persistent read session on their stream. When one agent appends a finding, it is pushed immediately to the others. Discussion unfolds as a live, ordered exchange grounded in specific prior records, enabling genuine multi-turn reasoning inside a cohort.</p>
<p>If a process crashes midway, the prior records remain. You can resume from the tail, or replay from the beginning to audit how a conclusion formed. Since S2 <a href="https://s2.dev/blog/timestamping#time-in-s2">assigns timestamps</a> to records, you can compare what different groups believed at a moment in time without merging their histories.</p>
<p><img src="https://s2.dev/blog/distributed-ai-agents-image5.png" alt="S2 stream"></p>
<h2 id="parallax"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/distributed-ai-agents#parallax"><code>parallax</code></a></h2>
<p>To explore this architecture in practice, I built a simple tool. <a href="https://github.com/s2-streamstore/parallax">parallax</a> is a CLI that accepts a research question, instantiates multiple cohorts of agents (Claude, Codex, or any agent capable of emitting structured output), and assigns each cohort its own stream in S2.</p>
<p>Then it kinda just lets it rip.</p>
<p>It also handles convergence. A moderator agent reads across cohort streams during the run, deciding when to steer groups, spawn breakouts, or transition phases. Once the run concludes, <code>parallax</code> performs a synthesis pass across the selected streams and writes a final report.</p>
<p>As it turns out, using streams makes topology experimentation effectively plug and play. Rather than hard-coding coordination patterns into prompts or agents, you can experiment with new architectures simply by reconfiguring the stream graph that connects them. The architecture admits patterns such as:</p>
<p><strong>Dynamic moderation:</strong> A moderating agent can build coordination strategy on the fly by rewiring the stream topology as the discussion evolves. Since streams are just append-only logs, you can create, fork, or merge them at any point — the coordination structure doesn't have to be decided upfront.</p>
<p><img src="https://s2.dev/blog/distributed-ai-agents-image3.png" alt="Dynamic moderation topology"></p>
<p><strong>Applied epistemology:</strong> Run thought experiments across AI models and observe how opinions evolve across independently formed positions and structured convergence rounds. This can serve as an empirical lab for testing moral hypotheses.</p>
<p><img src="https://s2.dev/blog/distributed-ai-agents-image4.png" alt="Epistemology topology"></p>
<p><strong>Parallelized engineering workflows:</strong> Make distributed debugging, building, and researching tractable. Because S2 streams are durable and shared, agents running across different machines, operating systems, or worktrees can coordinate without any additional infrastructure, and even join an active run via <code>parallax join</code>. The stream is the coordination layer, with concurrency controls like <a href="https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html">fencing tokens</a> and conditional appends baked in. You can also monitor those streams in real time and observe what each agent knows and when, which makes it possible to catch a group drifting off-course before the reasoning compounds into bad output.</p>
<p><img src="https://s2.dev/blog/distributed-ai-agents-image2.png" alt="Parallelized engineering topology"></p>
<h2 id="examples"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/distributed-ai-agents#examples">Examples</a></h2>
<h3 id="adversarial-cohorts"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/distributed-ai-agents#adversarial-cohorts"><a href="https://en.wikipedia.org/wiki/Adversarial_system">Adversarial cohorts</a></a></h3>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">parallax</span><span style="color:#9ECBFF"> research</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#9ECBFF">  "What is S2's (s2.dev) competitive positioning in the </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">streaming infrastructure market, and what adjacent </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">opportunities should they pursue?"</span></span><button type="button" title="Copy code" aria-label="Copy code" data="parallax research \
  &#x22;What is S2&#x27;s (s2.dev) competitive positioning in the \
streaming infrastructure market, and what adjacent \
opportunities should they pursue?&#x22;" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>The idea is that different personas, even on the same underlying model, can influence how reasoning unfolds. <code>parallax</code> assigns each cohort a distinct personality, so you get genuinely different angles rather than cosmetic variation.</p>
<br>
<video src="https://s2.dev/blog/distributed-ai-agents-demo.mp4" controls muted loop autoplay style="width: 100%; border-radius: 8px;">
  Your browser does not support the video tag.
</video>
<h3 id="delphi-forecasting"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/distributed-ai-agents#delphi-forecasting"><a href="https://en.wikipedia.org/wiki/Delphi_method">Delphi forecasting</a></a></h3>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">parallax</span><span style="color:#9ECBFF"> research</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#9ECBFF">  "What percentage of production AI agent deployments will </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">require durable stream infrastructure (not just ephemeral </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">message passing) for coordination, memory, and auditability </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">by 2028?"</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --hint</span><span style="color:#9ECBFF"> "delphi forecasting, 3 rounds. Include one codex </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">panelist that analyzes existing open-source agent frameworks </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">to check what infrastructure patterns they actually use today, </span><span style="color:#79B8FF">\</span></span>
<span data-line=""><span style="color:#9ECBFF">grounding the forecast in code rather than speculation"</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --groups</span><span style="color:#79B8FF"> 5</span></span><button type="button" title="Copy code" aria-label="Copy code" data="parallax research \
  &#x22;What percentage of production AI agent deployments will \
require durable stream infrastructure (not just ephemeral \
message passing) for coordination, memory, and auditability \
by 2028?&#x22; \
  --hint &#x22;delphi forecasting, 3 rounds. Include one codex \
panelist that analyzes existing open-source agent frameworks \
to check what infrastructure patterns they actually use today, \
grounding the forecast in code rather than speculation&#x22; \
  --groups 5" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Five independent panels converged on 73% across three rounds starting from a wider spread. The Codex panelist focused on actual open-source agent frameworks and was the most bullish. It turns out agent frameworks already reach for queues and logs when things get stateful.</p>
<p>But anyway, we are betting on 100%, AI agents can sit this one out.</p>
<video src="https://s2.dev/blog/distributed-ai-agents-demo-delphi.mp4" controls muted loop autoplay style="width: 100%; border-radius: 8px;">
  Your browser does not support the video tag.
</video>
<h2 id="conclusion"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/distributed-ai-agents#conclusion">Conclusion</a></h2>
<p>So what was the point of all this?</p>
<p>It was not just to introduce a tool or describe an architecture – but to also argue about how intelligence can be built and revive something older: the Socratic idea that understanding emerges from disagreement, not from a single uncontested source. Capability alone is not enough as structure also determines whether a result is convenient or credible. Distributed agents change the conditions under which conclusions are formed, as they can turn disagreement and parallel reasoning into a design choice.</p>
<p>If AI is going to shape complex engineering decisions, forecasts, or moral trade-offs, then how it arrives at those conclusions matters just as much as the conclusions themselves. Did the reasoning hold up when challenged from an independent context? Can you replay the full chain of thought? Streams give you that — every record is durable, ordered, and attributable. That's not a feature of the tool, it's the point of building on this kind of infrastructure.</p>]]></content:encoded>
            <author>mehul@s2.dev (Mehul Arora)</author>
            <category>ai</category>
            <category>use-case</category>
            <category>distsys</category>
        </item>
        <item>
            <title><![CDATA[Drawing the rest of the owl]]></title>
            <link>https://s2.dev/blog/patterns</link>
            <guid isPermaLink="false">https://s2.dev/blog/patterns</guid>
            <pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Practical S2 patterns for typed records, large messages, duplicate-safe retries, and application-level stream processing in TypeScript.]]></description>
            <content:encoded><![CDATA[<p>Anyone who has designed an API knows the tension between keeping it simple and making it work out of the box for as many use cases as possible.</p>
<p>With S2, we've tried to keep the <a href="https://s2.dev/docs/api/records/overview">core data plane API</a> as simple as possible. This is deliberate – we're trying to be a new type of storage primitive, and a big part of that is being clear about the interface and therefore which guarantees you can and cannot expect from S2.</p>
<p>We feel that some functionality is best left to users. But I can see how this sounds a bit like learning how to draw an owl:</p>
<br>
<img src="https://s2.dev/blog/owl.jpg" width="500" height="500" alt="How to draw an owl meme">
<p>To help bridge the gap, I want to explore some patterns for dealing with complexities that may come up when building systems around S2.</p>
<h2 id="s2s-data-plane"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#s2s-data-plane">S2's data plane</a></h2>
<p>To start, let's first consider what S2 gives you. The central data structure is a stream: an append-only sequence of records. S2 makes streams durable.</p>
<p>If you have not seen S2 before, the <a href="https://s2.dev/docs/concepts/records">concepts documentation</a> walks through basins, streams, and records in more detail.</p>
<p>Records in S2 are immutable and receive a sequence number expressing their ordering within the stream. Streams can be bottomless – they can grow indefinitely in size – or can be trimmed explicitly or based on TTLs.</p>
<p>All appends to a stream are fully durable on object storage before acknowledgement, and writers can coordinate with <a href="https://s2.dev/docs/api/records/append#concurrency-control">concurrency controls</a>.</p>
<h2 id="what-functionality-is-missing"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#what-functionality-is-missing">What functionality is missing?</a></h2>
<h3 id="schemas-and-typing"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#schemas-and-typing">Schemas and typing</a></h3>
<p>Records in S2 are a very minimalistic abstraction. A record has a sequence number, a timestamp, optional key-value headers, and a binary body. S2 is entirely agnostic about what you store in those headers and body.<sup><a href="https://s2.dev/blog/patterns#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup></p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="typescript" data-theme="github-dark"><code data-language="typescript" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> s2</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> S2</span><span style="color:#E1E4E8">({</span></span>
<span data-line=""><span style="color:#E1E4E8">  accessToken: process.env.</span><span style="color:#79B8FF">S2_ACCESS_TOKEN</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> stream</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> s2.</span><span style="color:#B392F0">basin</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'my-basin'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">stream</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'my-stream'</span><span style="color:#E1E4E8">);</span></span>
<span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> session</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> stream.</span><span style="color:#B392F0">appendSession</span><span style="color:#E1E4E8">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#6A737D">// Append a single record.</span></span>
<span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> ack1</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> session.</span><span style="color:#B392F0">submit</span><span style="color:#E1E4E8">([AppendRecord.</span><span style="color:#B392F0">make</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'record 1'</span><span style="color:#E1E4E8">)]);</span></span><button type="button" title="Copy code" aria-label="Copy code" data="const s2 = new S2({
  accessToken: process.env.S2_ACCESS_TOKEN!,
});

const stream = s2.basin(&#x27;my-basin&#x27;).stream(&#x27;my-stream&#x27;);
const session = await stream.appendSession();

// Append a single record.
const ack1 = await session.submit([AppendRecord.make(&#x27;record 1&#x27;)]);" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>This just means that you need to bring your own serialization and deserialization logic for appending to and reading from S2 streams. S2 only speaks bytes. There are many ways of doing this, as we will see.</p>
<p>Here, <code>S2</code> and <code>AppendRecord</code> come from the TypeScript SDK (<code>@s2-dev/streamstore</code>).</p>
<h3 id="large-messages"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#large-messages">Large messages</a></h3>
<p>Depending on the type of data you are serializing, you may run into another limit: the maximum size of a record on S2 <a href="https://s2.dev/docs/api/records/append">is 1MiB</a>.</p>
<p>This is big enough to not be a concern for many use cases, but it adds complexity. Maybe you are storing messages from an AI assistant, for instance – typically these are tokens, but occasionally they might be entire files, or images, or audio clips, which could easily exceed 1MiB. What to do?</p>
<p>There are two general approaches for dealing with this:</p>
<p>You can store a pointer to the data. Instead of putting the image in an S2 record, you could store a URL to an S3 object or similar. The advantage to this is that your data can still be represented by a single message. The downside is that now there is another service in the mix, which also has to be accessible both by your stream writer and any readers.</p>
<p>Alternatively, you can implement a framing scheme, and essentially spread your data across multiple records. S2 can continue to be the single source of data for your application, but there's the complexity of implementing framing logic, which will also need to be resilient to failures.</p>
<h3 id="duplicates-from-retries"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#duplicates-from-retries">Duplicates from retries</a></h3>
<p>S2's SDKs provide transparent retry logic for transient failures. But, this gets tricky with datastores.</p>
<p>What happens if you retry an append which failed in an "indefinite" way – for example, due to a network timeout? Perhaps your original attempt did succeed, even though the call ultimately failed. Retrying this append would therefore result in record duplication on the stream.</p>
<p>For some applications, the possibility of duplication is acceptable. For others, it's not. This can be particularly treacherous if you are framing large messages over multiple records! What options do we have?</p>
<p>Well, you could simply not retry any failed appends. The SDKs specifically allow you to configure whether or not to retry appends. But then it shifts the burden onto the user for inspecting if their prior attempt succeeded or not.</p>
<p>You could use <a href="https://s2.dev/docs/concepts/concurrency-controls#match-sequence-number"><code>match_seq_num</code></a> as a concurrency control. This is the S2 equivalent of a <a href="https://en.wikipedia.org/wiki/Compare-and-swap">CAS</a> operation. This works, though it again throws retries back to the writer.</p>
<p>Alternatively, you could inject data that allows readers to perform deduplication. This can be the simplest option, as it allows you to configure the SDK to retry as much as it wants. If duplicate records are stored on the stream, readers can disambiguate them by inspecting the data.</p>
<h2 id="patterns-for-dealing-with-these-issues"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#patterns-for-dealing-with-these-issues">Patterns for dealing with these issues</a></h2>
<p>We want a strongly typed S2 client. We want to be able to store large messages safely. And we want to avoid duplicate records while still allowing retries for the smoothest possible user experience.</p>
<p>There are many ways of accomplishing this. Here's one! We will start in reverse.</p>
<h3 id="dealing-with-duplicates"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#dealing-with-duplicates">Dealing with duplicates</a></h3>
<p>Duplicates are fine so long as any reader of our stream can identify and remove them. A simple and efficient way of doing this is simply for the stream writer to include an idempotency key.</p>
<p>A really simple key could just be a writer-assigned index, stored in a header.</p>
<p>From the perspective of the stream writer, this would look like:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ndjson" data-theme="github-dark"><code data-language="ndjson" data-theme="github-dark" style="display: grid;"><span data-line=""><span>{ "headers": [["_index", "0"]], "body": "Hello" }</span></span>
<span data-line=""><span>{ "headers": [["_index", "1"]], "body": "world!" }</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{ &#x22;headers&#x22;: [[&#x22;_index&#x22;, &#x22;0&#x22;]], &#x22;body&#x22;: &#x22;Hello&#x22; }
{ &#x22;headers&#x22;: [[&#x22;_index&#x22;, &#x22;1&#x22;]], &#x22;body&#x22;: &#x22;world!&#x22; }" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>If a retry occurs, it would be possible for a reader to see a duplicate:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ndjson" data-theme="github-dark"><code data-language="ndjson" data-theme="github-dark" style="display: grid;"><span data-line=""><span>{ "headers": [["_index", "0"]], "body": "Hello" }</span></span>
<span data-line=""><span>{ "headers": [["_index", "0"]], "body": "Hello" }</span></span>
<span data-line=""><span>{ "headers": [["_index", "1"]], "body": "world!" }</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{ &#x22;headers&#x22;: [[&#x22;_index&#x22;, &#x22;0&#x22;]], &#x22;body&#x22;: &#x22;Hello&#x22; }
{ &#x22;headers&#x22;: [[&#x22;_index&#x22;, &#x22;0&#x22;]], &#x22;body&#x22;: &#x22;Hello&#x22; }
{ &#x22;headers&#x22;: [[&#x22;_index&#x22;, &#x22;1&#x22;]], &#x22;body&#x22;: &#x22;world!&#x22; }" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>In the reader code, we just need a filter. It can increment its internal counter every time it sees a new record with a higher index. If it ever sees a record with the same or lower index, it can assume it's a duplicate, and simply ignore it.</p>
<p>What if a writer crashes entirely and needs to append to the same stream though? Or if there are multiple writers? We need an extra bit of information to identify the writer, such as a UUID.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ndjson" data-theme="github-dark"><code data-language="ndjson" data-theme="github-dark" style="display: grid;"><span data-line=""><span>{ "headers": [["_writer", "x-B1At9xVppm"], ["_index", "0"]], "body": "Hello" }</span></span>
<span data-line=""><span>{ "headers": [["_writer", "x-B1At9xVppm"], ["_index", "0"]], "body": "Hello" }</span></span>
<span data-line=""><span>{ "headers": [["_writer", "x-B1At9xVppm"], ["_index", "1"]], "body": "world!" }</span></span>
<span data-line=""><span>{ "headers": [["_writer", "uSEFRpXqIldw"], ["_index", "0"]], "body": "How cool." }</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{ &#x22;headers&#x22;: [[&#x22;_writer&#x22;, &#x22;x-B1At9xVppm&#x22;], [&#x22;_index&#x22;, &#x22;0&#x22;]], &#x22;body&#x22;: &#x22;Hello&#x22; }
{ &#x22;headers&#x22;: [[&#x22;_writer&#x22;, &#x22;x-B1At9xVppm&#x22;], [&#x22;_index&#x22;, &#x22;0&#x22;]], &#x22;body&#x22;: &#x22;Hello&#x22; }
{ &#x22;headers&#x22;: [[&#x22;_writer&#x22;, &#x22;x-B1At9xVppm&#x22;], [&#x22;_index&#x22;, &#x22;1&#x22;]], &#x22;body&#x22;: &#x22;world!&#x22; }
{ &#x22;headers&#x22;: [[&#x22;_writer&#x22;, &#x22;uSEFRpXqIldw&#x22;], [&#x22;_index&#x22;, &#x22;0&#x22;]], &#x22;body&#x22;: &#x22;How cool.&#x22; }" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Using an idempotency key like this allows the reader to reconstruct the actual flow of records, even across retries and writer crashes: <code>["Hello", "world!", "How cool."]</code>. With both <code>_writer</code> and <code>_index</code> present, the reader just keeps track of the highest <code>_index</code> it has seen for each <code>_writer</code>, and drops any record whose <code>_index</code> is not greater than that per-writer maximum.</p>
<p>In the TypeScript patterns library (<a href="https://www.npmjs.com/package/@s2-dev/streamstore-patterns">@s2-dev/streamstore-patterns</a>), this idea shows up as <code>_dedupe_seq</code> and <code>_writer_id</code> headers added to each record, and a <code>DedupeFilter</code> that drops duplicates based on that pair.</p>
<h3 id="storing-large-messages"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#storing-large-messages">Storing large messages</a></h3>
<p>With the dedupe strategy, readers can confidently reassemble the exact sequence of messages that were appended, even if the appender experienced failures and generated duplicate records.</p>
<p>Similarly, writers and readers can co-operate to store large messages over multiple records. This is "framing" generally. Again, many ways of doing this, but a simple one is to use a framing header.</p>
<p>When a stream writer appends a message larger than 1MiB, it can split it into multiple &#x3C;= 1MiB records, and include headers for bookkeeping. In our example implementation, whenever we map a message into multiple records, we inject headers on the first record to capture the total number of records that make up the message, plus the size in bytes of the payload.</p>
<p>Readers can consume the stream, and start assembling a new message whenever they see these frame headers. The size hint can be used for allocating a buffer, and also for detecting truncation.</p>
<p>Truncation might happen if the stream writer crashes before it completes sending all of the records comprising a full framed message – it is fair to simply ignore any messages that can't be completely reassembled.</p>
<p>This is a very simple framing scheme, but it's a good starting point. If you plan to have multiple concurrent writers to the same stream, you'll need to add additional logic for parsing interleaved messages – but for single writer / multiple reader architectures, this works well.</p>
<h3 id="strong-types"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#strong-types">Strong types</a></h3>
<p>If our stream writer and readers are collaborating to dedupe any repeats, and to frame large messages, then it's pretty simple to add support for strong types. We just need some way for our type to be serialized to bytes (for the appender), and be deserialized from bytes (for the reader).</p>
<p>If you are working with largely textual data, this could just be JSON. You could also use binary formats like <a href="https://msgpack.org/">MessagePack</a> or <a href="https://developers.google.com/protocol-buffers">Protocol Buffers</a>. Anything works!</p>
<h3 id="putting-it-together-in-typescript"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#putting-it-together-in-typescript">Putting it together in TypeScript</a></h3>
<p>To make this a bit more concrete, here is a small example that combines all three ideas — strong typing, framing for large messages, and deduplication — using the helpers in <a href="https://www.npmjs.com/package/@s2-dev/streamstore-patterns">@s2-dev/streamstore-patterns</a>.</p>
<p>While the examples here are in TypeScript, the patterns themselves are S2-level ideas. You can apply the same approach with any of the S2 SDKs or even directly against the REST API by managing framing headers and dedupe keys yourself.</p>
<p>First, we define a multimodal message type and a <code>ChatMessage</code> that wraps it. This is the message type we want stored in our stream.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="typescript" data-theme="github-dark"><code data-language="typescript" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { S2 } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@s2-dev/streamstore'</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { serialization } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@s2-dev/streamstore-patterns'</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { encode, decode } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@msgpack/msgpack'</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">type</span><span style="color:#B392F0"> MultimodalMessage</span><span style="color:#F97583"> =</span></span>
<span data-line=""><span style="color:#F97583">  |</span><span style="color:#E1E4E8"> { </span><span style="color:#FFAB70">type</span><span style="color:#F97583">:</span><span style="color:#9ECBFF"> 'text'</span><span style="color:#E1E4E8">; </span><span style="color:#FFAB70">content</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8"> }</span></span>
<span data-line=""><span style="color:#F97583">  |</span><span style="color:#E1E4E8"> { </span><span style="color:#FFAB70">type</span><span style="color:#F97583">:</span><span style="color:#9ECBFF"> 'code'</span><span style="color:#E1E4E8">; </span><span style="color:#FFAB70">language</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8">; </span><span style="color:#FFAB70">snippet</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8"> }</span></span>
<span data-line=""><span style="color:#F97583">  |</span><span style="color:#E1E4E8"> { </span><span style="color:#FFAB70">type</span><span style="color:#F97583">:</span><span style="color:#9ECBFF"> 'image'</span><span style="color:#E1E4E8">; </span><span style="color:#FFAB70">bytes</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Uint8Array</span><span style="color:#E1E4E8"> };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">type</span><span style="color:#B392F0"> ChatMessage</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#FFAB70">  userId</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#FFAB70">  message</span><span style="color:#F97583">:</span><span style="color:#B392F0"> MultimodalMessage</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#E1E4E8">};</span></span><button type="button" title="Copy code" aria-label="Copy code" data="import { S2 } from &#x27;@s2-dev/streamstore&#x27;;
import { serialization } from &#x27;@s2-dev/streamstore-patterns&#x27;;
import { encode, decode } from &#x27;@msgpack/msgpack&#x27;;

type MultimodalMessage =
  | { type: &#x27;text&#x27;; content: string }
  | { type: &#x27;code&#x27;; language: string; snippet: string }
  | { type: &#x27;image&#x27;; bytes: Uint8Array };

type ChatMessage = {
  userId: string;
  message: MultimodalMessage;
};" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Now we can write messages to an S2 stream. The <code>SerializingAppendSession</code> will take care of serializing to bytes, splitting large payloads into frames, and tagging each record with a dedupe sequence so retries are safe:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="typescript" data-theme="github-dark"><code data-language="typescript" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> writeMultimodalConversation</span><span style="color:#E1E4E8">() {</span></span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#79B8FF"> s2</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> S2</span><span style="color:#E1E4E8">({ accessToken: process.env.</span><span style="color:#79B8FF">S2_ACCESS_TOKEN</span><span style="color:#F97583">!</span><span style="color:#E1E4E8"> });</span></span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#79B8FF"> stream</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> s2.</span><span style="color:#B392F0">basin</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'my-basin'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">stream</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'my-stream'</span><span style="color:#E1E4E8">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#79B8FF"> appendSession</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#E1E4E8"> serialization.</span><span style="color:#B392F0">SerializingAppendSession</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">ChatMessage</span><span style="color:#E1E4E8">>(</span></span>
<span data-line=""><span style="color:#F97583">    await</span><span style="color:#E1E4E8"> stream.</span><span style="color:#B392F0">appendSession</span><span style="color:#E1E4E8">(),</span></span>
<span data-line=""><span style="color:#E1E4E8">    (</span><span style="color:#FFAB70">msg</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#B392F0"> encode</span><span style="color:#E1E4E8">(msg),</span></span>
<span data-line=""><span style="color:#E1E4E8">    { dedupeSeq: </span><span style="color:#79B8FF">0</span><span style="color:#F97583">n</span><span style="color:#E1E4E8"> }</span></span>
<span data-line=""><span style="color:#E1E4E8">  );</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">  await</span><span style="color:#E1E4E8"> appendSession.</span><span style="color:#B392F0">submit</span><span style="color:#E1E4E8">({</span></span>
<span data-line=""><span style="color:#E1E4E8">    userId: </span><span style="color:#9ECBFF">'alice'</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    message: { type: </span><span style="color:#9ECBFF">'text'</span><span style="color:#E1E4E8">, content: </span><span style="color:#9ECBFF">'hello world'</span><span style="color:#E1E4E8"> },</span></span>
<span data-line=""><span style="color:#E1E4E8">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">  await</span><span style="color:#E1E4E8"> appendSession.</span><span style="color:#B392F0">submit</span><span style="color:#E1E4E8">({</span></span>
<span data-line=""><span style="color:#E1E4E8">    userId: </span><span style="color:#9ECBFF">'alice'</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    message: {</span></span>
<span data-line=""><span style="color:#E1E4E8">      type: </span><span style="color:#9ECBFF">'image'</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#6A737D">      // Imagine this is a large screenshot or image; the patterns</span></span>
<span data-line=""><span style="color:#6A737D">      // helpers will automatically frame it across multiple records.</span></span>
<span data-line=""><span style="color:#E1E4E8">      bytes: </span><span style="color:#F97583">new</span><span style="color:#B392F0"> Uint8Array</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">5</span><span style="color:#F97583"> *</span><span style="color:#79B8FF"> 1024</span><span style="color:#F97583"> *</span><span style="color:#79B8FF"> 1024</span><span style="color:#E1E4E8">),</span></span>
<span data-line=""><span style="color:#E1E4E8">    },</span></span>
<span data-line=""><span style="color:#E1E4E8">  });</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="async function writeMultimodalConversation() {
  const s2 = new S2({ accessToken: process.env.S2_ACCESS_TOKEN! });
  const stream = s2.basin(&#x27;my-basin&#x27;).stream(&#x27;my-stream&#x27;);

  const appendSession = new serialization.SerializingAppendSession<ChatMessage>(
    await stream.appendSession(),
    (msg) => encode(msg),
    { dedupeSeq: 0n }
  );

  await appendSession.submit({
    userId: &#x27;alice&#x27;,
    message: { type: &#x27;text&#x27;, content: &#x27;hello world&#x27; },
  });

  await appendSession.submit({
    userId: &#x27;alice&#x27;,
    message: {
      type: &#x27;image&#x27;,
      // Imagine this is a large screenshot or image; the patterns
      // helpers will automatically frame it across multiple records.
      bytes: new Uint8Array(5 * 1024 * 1024),
    },
  });
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>On the read side, <code>DeserializingReadSession</code> will dedupe, reassemble frames into full payloads, and hand you back strongly-typed <code>ChatMessage</code> values that you can branch on:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="typescript" data-theme="github-dark"><code data-language="typescript" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> readMultimodalConversation</span><span style="color:#E1E4E8">() {</span></span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#79B8FF"> s2</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> S2</span><span style="color:#E1E4E8">({ accessToken: process.env.</span><span style="color:#79B8FF">S2_ACCESS_TOKEN</span><span style="color:#F97583">!</span><span style="color:#E1E4E8"> });</span></span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#79B8FF"> stream</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> s2.</span><span style="color:#B392F0">basin</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'my-basin'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">stream</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'my-stream'</span><span style="color:#E1E4E8">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#79B8FF"> readSession</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#E1E4E8"> serialization.</span><span style="color:#B392F0">DeserializingReadSession</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">ChatMessage</span><span style="color:#E1E4E8">>(</span></span>
<span data-line=""><span style="color:#F97583">    await</span><span style="color:#E1E4E8"> stream.</span><span style="color:#B392F0">readSession</span><span style="color:#E1E4E8">({ tail_offset: </span><span style="color:#79B8FF">0</span><span style="color:#E1E4E8">, as: </span><span style="color:#9ECBFF">'bytes'</span><span style="color:#E1E4E8"> }),</span></span>
<span data-line=""><span style="color:#E1E4E8">    (</span><span style="color:#FFAB70">bytes</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#B392F0"> decode</span><span style="color:#E1E4E8">(bytes) </span><span style="color:#F97583">as</span><span style="color:#B392F0"> ChatMessage</span></span>
<span data-line=""><span style="color:#E1E4E8">  );</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">  for</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">const</span><span style="color:#79B8FF"> msg</span><span style="color:#F97583"> of</span><span style="color:#E1E4E8"> readSession) {</span></span>
<span data-line=""><span style="color:#F97583">    switch</span><span style="color:#E1E4E8"> (msg.message.type) {</span></span>
<span data-line=""><span style="color:#F97583">      case</span><span style="color:#9ECBFF"> 'text'</span><span style="color:#E1E4E8">:</span></span>
<span data-line=""><span style="color:#E1E4E8">        console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">`[${</span><span style="color:#E1E4E8">msg</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">userId</span><span style="color:#9ECBFF">}] ${</span><span style="color:#E1E4E8">msg</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">message</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">content</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8">);</span></span>
<span data-line=""><span style="color:#F97583">        break</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#F97583">      case</span><span style="color:#9ECBFF"> 'code'</span><span style="color:#E1E4E8">:</span></span>
<span data-line=""><span style="color:#E1E4E8">        console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#9ECBFF">          `[${</span><span style="color:#E1E4E8">msg</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">userId</span><span style="color:#9ECBFF">}] (${</span><span style="color:#E1E4E8">msg</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">message</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">language</span><span style="color:#9ECBFF">} code) ${</span><span style="color:#E1E4E8">msg</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">message</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">snippet</span><span style="color:#9ECBFF">}`</span></span>
<span data-line=""><span style="color:#E1E4E8">        );</span></span>
<span data-line=""><span style="color:#F97583">        break</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#F97583">      case</span><span style="color:#9ECBFF"> 'image'</span><span style="color:#E1E4E8">:</span></span>
<span data-line=""><span style="color:#E1E4E8">        console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#9ECBFF">          `[${</span><span style="color:#E1E4E8">msg</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">userId</span><span style="color:#9ECBFF">}] &#x3C;image bytes=${</span><span style="color:#E1E4E8">msg</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">message</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">bytes</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">byteLength</span><span style="color:#9ECBFF">}>`</span></span>
<span data-line=""><span style="color:#E1E4E8">        );</span></span>
<span data-line=""><span style="color:#F97583">        break</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#E1E4E8">  }</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="async function readMultimodalConversation() {
  const s2 = new S2({ accessToken: process.env.S2_ACCESS_TOKEN! });
  const stream = s2.basin(&#x27;my-basin&#x27;).stream(&#x27;my-stream&#x27;);

  const readSession = new serialization.DeserializingReadSession<ChatMessage>(
    await stream.readSession({ tail_offset: 0, as: &#x27;bytes&#x27; }),
    (bytes) => decode(bytes) as ChatMessage
  );

  for await (const msg of readSession) {
    switch (msg.message.type) {
      case &#x27;text&#x27;:
        console.log(&#x60;[${msg.userId}] ${msg.message.content}&#x60;);
        break;
      case &#x27;code&#x27;:
        console.log(
          &#x60;[${msg.userId}] (${msg.message.language} code) ${msg.message.snippet}&#x60;
        );
        break;
      case &#x27;image&#x27;:
        console.log(
          &#x60;[${msg.userId}] <image bytes=${msg.message.bytes.byteLength}>&#x60;
        );
        break;
    }
  }
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>You can derive your own patterns from here: for example, using different streams per conversation, or layering your own schema/versioning metadata on top of the framed messages.</p>
<h2 id="conclusion"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#conclusion">Conclusion</a></h2>
<p>You can find a <code>SerializingAppendSession&#x3C;Message></code> and <code>DeserializingReadSession&#x3C;Message></code> in TypeScript that implement the strategies discussed above in the <a href="https://www.npmjs.com/package/@s2-dev/streamstore-patterns">@s2-dev/streamstore-patterns</a> package.</p>
<p>We've just scratched the surface of what's possible to build on top of S2. If you have a use case that doesn't fit nicely into the pattern above, <a href="mailto:founders@s2.dev">reach out</a> and we would be happy to help see if S2 might still be a good fit.</p>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/patterns#footnote-label">Footnotes</a></h2>
<ol>
<li id="user-content-fn-1">
<p>Almost entirely. Technically, command records use a reserved shape, described in more detail in the <a href="https://s2.dev/docs/api/records/overview#command-records">command records documentation</a>. <a href="https://s2.dev/blog/patterns#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content:encoded>
            <author>stephen@s2.dev (Stephen Balogh)</author>
            <category>use-case</category>
            <category>tutorial</category>
            <category>typescript</category>
        </item>
        <item>
            <title><![CDATA[Behind y-s2: serverless multiplayer rooms]]></title>
            <link>https://s2.dev/blog/durable-yjs-rooms</link>
            <guid isPermaLink="false">https://s2.dev/blog/durable-yjs-rooms</guid>
            <pubDate>Tue, 02 Sep 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[How y-s2 uses S2 streams, checkpoints, and leases to make serverless Yjs multiplayer rooms durable and easier to debug.]]></description>
            <content:encoded><![CDATA[<p>Real-time "multiplayer" collaboration powers some of the best web experiences we use every day — think Google Docs, Figma, Notion. Under the hood, these products solve hard distributed systems problems: keeping many users in sync even when edits arrive out of order, connections drop, or servers crash.</p>
<p>A common approach to building such a collaborative "room" is to have a designated coordinator that every client talks to, paired with a background worker that periodically takes the in-memory live state and persists a checkpoint to storage. However, if the coordinator crashes, the latest checkpoint becomes the only recoverable state. This could result in losing a significant amount of work!</p>
<p><img src="https://s2.dev/blog/y-s2-central-coord.png" alt="Collaborative rooms with a central coordinator" title="Architecture of building collaborative rooms with a central coordinator"></p>
<p>To fix this issue of lost state, we can introduce a durable log or a journal into the mix, which is more optimal for incremental writes. So, even in case of a crash the recovery process can catch-up from the backlog in the journal which was not yet persisted as a checkpoint. As an example, <a href="https://www.figma.com/blog/making-multiplayer-more-reliable/">Figma took this approach</a> to make their multiplayer editing more reliable.</p>
<p>In the open source world, <a href="https://yjs.dev/">Yjs</a> is a foundational <a href="https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type">CRDT</a> framework that solves the hard problem of merging edits without conflicts. What it does leave open is how those edits are transported, stored, and replayed — but thankfully with pluggable backends!</p>
<p>Many Yjs backends are available, covering the gamut from <a href="https://github.com/yjs/y-redis">y-redis</a> to <a href="https://github.com/napolab/y-durableobjects">y-durableobjects</a>. And now, there is <a href="https://github.com/s2-streamstore/y-s2">y-s2</a> — but, really, y S2?</p>
<h2 id="durable-streams-for-multiplayer"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/durable-yjs-rooms#durable-streams-for-multiplayer">Durable streams for multiplayer</a></h2>
<p>With S2, we took the humble log — the stream — and turned it into a first-class cloud storage primitive. Instead of working with entire objects, you interact at the granularity of records using a simple API akin to object storage: <code>Append</code>, <code>Read</code>, and <code>Trim</code> on a <strong>named Stream inside a Basin</strong>. Every record is durably sequenced at the current "tail" of the stream, no matter how many writers are active.</p>
<p>This means an S2 stream can serve both as storage and reliable transport: you can not only follow live updates, but also replay history from any position.</p>
<p><img src="https://s2.dev/blog/y-s2-explain-s2works.png" alt="How records are sequenced on an S2 stream" title="How records are sequenced on an S2 stream"></p>
<p>These properties makes S2 a natural fit for collaborative systems — and pairing S2 with serverless functions like Cloudflare Workers makes it straightforward to separate client vs server-side concerns. This is the magical combination that allows <code>y-s2</code> to be a serverless yet durable Yjs backend.</p>
<p>To see it in action, head over to <a href="https://s2.dev/demos/y-s2">this demo</a> and start a sharable collaborative editor.</p>
<video src="https://s2.dev/blog/y-s2-collab.mp4" controls muted loop autoplay style="width: 100%; max-width: 800px; border-radius: 8px;">
  Your browser does not support the video tag.
</video>
<p>A client connects to a worker through a WebSocket connection, and in turn the worker establishes an SSE (<a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events">Server-sent events</a>) session to receive live updates from the S2 stream. Every worker invocation effectively acts like a thread bound to a client: it reads from the log to propagate updates downstream, and appends any new updates from that client back to the stream.</p>
<p><img src="https://s2.dev/blog/y-s2-invocations.png" alt="Worker invocations interacting with a S2 Stream" title="How worker invocations interact with an S2 stream"></p>
<p>When a client connects, the worker performs a catch-up to restore the latest state:</p>
<ol>
<li><strong>Load the checkpoint:</strong> Fetch the most recent snapshot from object storage (<a href="https://www.cloudflare.com/en-ca/developer-platform/products/r2/">R2</a> in this case), which includes the last processed sequence number in its metadata.</li>
<li><strong>Replay recent updates:</strong> Read all records from the S2 stream between the checkpoint and the <a href="https://s2.dev/docs/api/records/check-tail">current tail</a>, applying them to rebuild the current state.</li>
</ol>
<p>At this point, the worker synchronizes the materialized document with the Yjs client, and switches to live mode: streaming document updates in real-time.</p>
<p>Further, each worker reactively attempts to create checkpoints after buffering updates in memory until a threshold is reached. Since every update is already durably written to the S2 stream, the buffer is only an efficiency hack — it saves the worker from re-reading those same records when composing the snapshot. But if every invocation races to checkpoint at the same time, we waste resources! Additionally, checkpointing needs to be coupled with trimming the prefix of the stream up to that point — otherwise, the stream will grow unbounded even after data has been safely persisted.</p>
<p>How do we ensure that only one worker at a time is allowed to write a checkpoint and trim the log, while the rest continue serving clients?</p>
<h2 id="a-distributed-mutex"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/durable-yjs-rooms#a-distributed-mutex">A distributed mutex?</a></h2>
<p>Martin Kleppman discusses <strong>fencing</strong> in his excellent article, <a href="https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html">how to do distributed locking</a>. As he posits, the store <strong>needs</strong> to be involved for this approach to be robust — and fencing is a <a href="https://s2.dev/docs/api/records/append#concurrency-control">native capability</a> in S2.</p>
<p>A fencing token can be set on a stream by appending a special <a href="https://s2.dev/docs/api/records/overview#command-records">"command record"</a>. Fencing in S2 is cooperative: an append that does not specify a fencing token will still be allowed. However if an append does include a fencing token that does not match, this results in the request failing with a <code>412 Precondition Failed</code>. We can leverage fencing in this way to give each worker invocation an opportunity to obtain a unique "lease", or a time-bounded license, to create a checkpoint.</p>
<p>Another enabler to the design is that a batch of records is always appended atomically to a stream, and explicitly trimming a stream is <em>also</em> a command record. This means resetting the fencing token and trimming the stream can be committed <strong>together</strong> when a checkpoint has been completed, ensuring state stays consistent.</p>
<p>To create a checkpoint, an invocation attempts to set a unique fencing token on the stream with a unique ID and deadline (<code>"{uuidInBase64} {deadlineEpochSecAsStr}"</code>). The worker always provides its knowledge of the current fencing token, and this way if another worker won the race, the attempt will fail.</p>
<p>If a snapshot takes exceptionally long, or a worker invocation fails to release the lease due to a failure, the deadline acts as an auto-expiration that allows other invocations to attempt to grab the lease again. While it is possible that multiple invocations end up attempting a snapshot in this case, only one will manage to commit.</p>
<p><img src="https://s2.dev/blog/y-s2-checkpoint.png" alt="How does lease acquisistion work" title="How does lease acquisistion work in y-s2 implementation"></p>
<h2 id="side-quest-making-workers-easier-to-debug"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/durable-yjs-rooms#side-quest-making-workers-easier-to-debug">Side quest: making workers easier to debug</a></h2>
<p>In a distributed scenario like this where we have multiple invocations trying to coordinate over checkpoints, we wanted to see logs in two different ways:</p>
<ol>
<li>
<p><strong>Logs per invocation:</strong> Useful for debugging issues specific to one worker's behavior — like understanding why a particular invocation failed to acquire a lease, how it processed its buffered records, etc.</p>
</li>
<li>
<p><strong>Interleaved logs for all invocations:</strong> To get a chronological, comprehensive view of logs from all worker invocations. This would help understand the system-wide coordination and timing between different workers — like seeing the sequence of lease acquisitions, which worker successfully set a fencing token, how race conditions play out in real-time, and the overall flow of the distributed checkpointing process across all active invocations.</p>
</li>
</ol>
<p>It takes a while for worker logs to appear on the Cloudflare dashboard and even toggling into its "Live" mode, the logs lag and sometimes error out. When it does succeed, the total volume of events is <a href="https://developers.cloudflare.com/changelog/2025-04-07-increase-trace-events-limit/">limited to only 256 KB</a> which is not enough for long-running sessions. Moreover, locally when using <code>wrangler tail</code> or <code>wrangler dev</code>, worker logs for WebSocket connections never seem to show up! The overall experience was not pleasant.</p>
<p><img src="https://s2.dev/blog/y-s2-cloudflare.png" alt="Cloudflare logs are not great to use when you need real-time logging" title="Cloudflare logs are not great to use when you need real-time logging"></p>
<p>I found it much more ergonomic to use S2 as the log sink and tail logs in real-time to see how workers coordinate over checkpoints. For traceability, it was easy to name individual streams by their assigned Cloudflare Ray ID. S2 supports creating unlimited streams, so every invocation can have its own dedicated log for inspection.</p>
<p>And it is just as easy to interleave them on a shared stream:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> read</span><span style="color:#9ECBFF"> s2://hello-world/logs/workers-shared</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 read s2://hello-world/logs/workers-shared" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p><img src="https://s2.dev/blog/y-s2-terminal.png" alt="Pushing worker logs to S2 and tailing them" title="Pushing worker logs to S2 and tailing them"></p>
<hr>
<p>We are only scratching the surface of possibilities here. If you are curious about building with S2, experimenting with collaborative backends, or helping improve <code>y-s2</code>, join us on <a href="https://discord.com/invite/vTCs7kMkAf">Discord</a>!</p>
<hr>
<p><em>A prior version of this post included an inaccurate cost comparison with Cloudflare Durable Objects that was not apples-to-apples.</em></p>]]></content:encoded>
            <author>mehul@s2.dev (Mehul Arora)</author>
            <category>distsys</category>
            <category>typescript</category>
            <category>eng</category>
            <category>use-case</category>
        </item>
        <item>
            <title><![CDATA[Linearizability testing S2 with deterministic simulation]]></title>
            <link>https://s2.dev/blog/linearizability</link>
            <guid isPermaLink="false">https://s2.dev/blog/linearizability</guid>
            <pubDate>Mon, 25 Aug 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[How S2 validates strong consistency with Porcupine linearizability checking and deterministic simulation testing.]]></description>
            <content:encoded><![CDATA[<p>With S2, it is a hard requirement that our <a href="https://s2.dev/docs/api/records/overview">Stream API</a> operations exhibit linearizability.
Linearizable systems are far simpler to reason about, and many applications are only possible to build on top of data platforms that offer strong consistency guarantees like this.</p>
<p>Because it's important, we also need to test it! We can gain confidence that S2 is linearizable by taking an empirical validation approach, using a model checker like <a href="https://github.com/jepsen-io/knossos">Knossos</a>, or <a href="https://github.com/anishathalye/porcupine">Porcupine</a>.</p>
<p>Last week I wired up a system for validating this property using S2 logs collected from our deterministic simulator. This post is a summary of that work.</p>
<h2 id="context"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#context">Context</a></h2>
<p>We’ve written a bit about our approach to <a href="https://s2.dev/blog/dst">testing S2 for correctness</a> using deterministic simulation testing ("DST").
In short: we use <a href="https://github.com/tokio-rs/turmoil">turmoil</a> to run the components of our distributed system — which, in production, are separate processes on different network hosts — within one, single threaded, Tokio runtime. The core of S2 is already in Rust, and we make use of Tokio, so turmoil is perfect (though we do
have to mock external dependencies, like FoundationDB and S3).</p>
<p>Our setup provides both a deterministic environment for testing our
system, as well as levers for injecting faults. We can run randomized workloads on it — which we do both as a CI step, and in much larger volumes in a nightly suite — and monitor for configurations (specific inputs, types of network stress, etc) that trigger violations of important invariants.</p>
<p>When an assertion is failed, we can
easily recreate the <em>exact</em> steps which led to it just by restarting the simulation using the same seed.</p>
<p>It's hard to overstate how revolutionary this has been for our ability to build S2! At this point I can't imagine building infra without a DST framework.</p>
<p>While we assert on a lot of different conditions in our simulations, some correctness properties aren't feasible to evaluate <em>during</em>
test runs as Rust assertions, and require additional tooling to verify after the fact.</p>
<p>Linearizability is an example of one such property —
it’s a strong consistency model for distributed systems. If you aren’t familiar with it, a lot of great material has
been written about what it entails. <a href="https://anishathalye.com/testing-distributed-systems-for-linearizability/">This post</a>, by the author of the Porcupine checker, is a great place to start.</p>
<p>I’ll borrow from Martin Kleppmann’s summary in <a href="https://www.oreilly.com/library/view/designing-data-intensive-applications/9781491903063/">DDIA</a>:</p>
<blockquote><p>In a linearizable system, as soon as one client successfully completes a write, all clients reading from the database
must be able to see the value just written. Maintaining the illusion of a single copy of the data means guaranteeing
that the value read is the most recent, up-to-date value, and doesn’t come from a stale cache or replica.
In other words, linearizability is a <em>recency guarantee</em>.</p></blockquote>
<h2 id="what-to-test"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#what-to-test">What to test</a></h2>
<p>If you're not familiar with S2 (hello then, in that case!), the core data structure is the <em>stream</em>, which is an append-only log.</p>
<p>The API is very simple: you can <code>append</code> batches of records to the "tail" of a stream, <code>read</code> sequenced records starting at any position in the stream, and <code>check-tail</code> to see what the next assigned sequence number will be on the stream (i.e., the value of the "tail").</p>
<h3 id="s2-stream-operations"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#s2-stream-operations">S2 Stream Operations</a></h3>
<div class="operation-card">
<p><strong><a href="https://s2.dev/docs/api/records/append">Append</a></strong>: Adds a batch of records to the end of a stream. Either the entire batch becomes durable, or none of it does.</p>
<ul>
<li>Input: <code>append([a, b, c])</code></li>
<li>Output: <code>ack: seq 0..3</code></li>
</ul>
</div>
<div class="operation-card">
<p><strong><a href="https://s2.dev/docs/api/records/read">Read</a></strong>: Reads from any position in the stream by sequence number, timestamp, or offset from tail.</p>
<ul>
<li>Input: <code>read(from: seq 1)</code></li>
<li>Output: <code>[(seq: 1, data: b), (seq: 2, data: c)]</code></li>
</ul>
</div>
<div class="operation-card">
<p><strong><a href="https://s2.dev/docs/api/records/check-tail">Check-tail</a></strong>: Returns the current tail sequence number — the position where the next record will be appended.</p>
<ul>
<li>Input: <code>check_tail()</code></li>
<li>Output: <code>tail: seq 3</code></li>
</ul>
</div>
<p>For an S2 stream to be linearizable, it just means:</p>
<div class="highlight">All records from an acknowledged <code>append</code> must be visible to any <code>append</code>, <code>read</code>, or <code>check-tail</code> operation that occurs afterwards.</div>
<p>(We also make strong consistency guarantees about S2's concurrency control mechanisms, like fencing tokens, but we’ll get back to that later.)</p>
<h2 id="verifying-linearizability"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#verifying-linearizability">Verifying linearizability</a></h2>
<p>We have a sense of what linearizability in S2 entails — how would we actually verify it?</p>
<p>I decided to try out <a href="https://github.com/anishathalye/porcupine">Porcupine</a>. It evaluates whether a client-observed log of concurrent calls to a system can be assembled into a total ordering in a way that satisfies linearizability.</p>
<p>Porcupine doesn’t test a live system directly, but rather evaluates a <em>model</em> of the system using a log collected from the real thing.</p>
<p>So first we need to represent S2 as a Porcupine model, then we need a way to collect relevant access logs from S2.</p>
<h3 id="modelling-s2-in-porcupine"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#modelling-s2-in-porcupine">Modelling S2 in Porcupine</a></h3>
<p>Linearizability validators are often demonstrated on "register" objects — key-value pairs essentially.</p>
<p>If you squint, an S2 stream can also be viewed as a type of register, where:</p>
<ul>
<li>Key = the name of the stream</li>
<li>Value = a tuple of <code>(tail, last_written_record)</code></li>
</ul>
<p>For simplicity, we can store a hash of the last record, instead of the content itself.</p>
<p>To flesh this out a bit, we can see how the state of an actual S2 stream, and our associated register model, change in response to some successful <code>append</code> calls:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ascii" data-theme="github-dark"><code data-language="ascii" data-theme="github-dark" style="display: grid;"><span data-line=""><span>Stream State:                                    | Register State:</span></span>
<span data-line=""><span>┌──────────────────────────────────────────────┐ │</span></span>
<span data-line=""><span>│              Empty Stream                    │ │ (tail=0, content=0)</span></span>
<span data-line=""><span>│                                              │ │</span></span>
<span data-line=""><span>└──────────────────────────────────────────────┘ │</span></span>
<span data-line=""><span>                         │                       │</span></span>
<span data-line=""><span>                         ▼ append([a,b,c])       │</span></span>
<span data-line=""><span>┌──────────────────────────────────────────────┐ │</span></span>
<span data-line=""><span>│    seq:0    seq:1    seq:2                   │ │ (tail=3, content=hash(c))</span></span>
<span data-line=""><span>│      a        b        c                     │ │</span></span>
<span data-line=""><span>└──────────────────────────────────────────────┘ │</span></span>
<span data-line=""><span>                         │                       │</span></span>
<span data-line=""><span>                         ▼ append([d,e])         │</span></span>
<span data-line=""><span>┌──────────────────────────────────────────────┐ │</span></span>
<span data-line=""><span>│    seq:0    seq:1    seq:2    seq:3    seq:4 │ │ (tail=5, content=hash(e))</span></span>
<span data-line=""><span>│      a        b        c        d        e   │ │</span></span>
<span data-line=""><span>└──────────────────────────────────────────────┘ │</span></span>
<span data-line=""> </span><button type="button" title="Copy code" aria-label="Copy code" data="Stream State:                                    | Register State:
┌──────────────────────────────────────────────┐ │
│              Empty Stream                    │ │ (tail=0, content=0)
│                                              │ │
└──────────────────────────────────────────────┘ │
                         │                       │
                         ▼ append([a,b,c])       │
┌──────────────────────────────────────────────┐ │
│    seq:0    seq:1    seq:2                   │ │ (tail=3, content=hash(c))
│      a        b        c                     │ │
└──────────────────────────────────────────────┘ │
                         │                       │
                         ▼ append([d,e])         │
┌──────────────────────────────────────────────┐ │
│    seq:0    seq:1    seq:2    seq:3    seq:4 │ │ (tail=5, content=hash(e))
│      a        b        c        d        e   │ │
└──────────────────────────────────────────────┘ │
" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Since batches are appended atomically, we never expect to see intermediate states like <code>(tail=4, content=hash(d))</code> — either the entire batch (<code>[d,e]</code>) would commit, or none of it does.</p>
<h4 id="step-function"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#step-function">Step function</a></h4>
<p>A model requires an initial state — for us, that’s just <code>(tail=0, content=0)</code> — and a step function which describes how to transform the register state, given an observed call input and output.</p>
<p>The model's <a href="https://github.com/anishathalye/porcupine/blob/v1.0.3/model.go#L103">step function</a> signature looks like: <code>step(current_state, observed_input, observed_output) → (is_legal, new_state)</code>.
In other words, we need to provide a pure function that maps our prior register state, and the observed call/response, into a response indicating if a new state can legally be constructed — and if so, providing that resulting state.</p>
<p>The logic for the function depends on the input type (i.e., the type of call being made):</p>
<div class="operation-card">
<p><strong>Append</strong>: Mutates the register state</p>
<ul>
<li>Input: <code>append([r1,r2,...,rN])</code></li>
<li>Output: <code>ack: end=T</code></li>
<li>Next State: <code>(tail=T, content=hash(rN))</code> (or <code>∅</code> if <code>T != (N + current_state.tail)</code>)</li>
</ul>
</div>
<div class="operation-card">
<p><strong>Read</strong>: Read-only operation</p>
<ul>
<li>Input: <code>read(from: pos)</code></li>
<li>Output: <code>[(seq1, data1), ..., (seqN, dataN)]</code></li>
<li>Next State: No change (or <code>∅</code> if <code>seqN + 1 != current_state.tail</code>)</li>
</ul>
</div>
<div class="operation-card">
<p><strong>Check-tail</strong>: Read-only operation</p>
<ul>
<li>Input: <code>check_tail()</code></li>
<li>Output: <code>tail=T</code></li>
<li>Next State: No change (or <code>∅</code> if <code>T != current_state.tail</code>)</li>
</ul>
</div>
<p>Note that only <code>append</code> will actually alter the register! Both <code>read</code> and <code>check-tail</code> will simply return the original <code>current_state</code> unchanged, unless an inconsistency is detected.</p>
<p>Porcupine uses this model to essentially solve for a linearizable sequence of transformations to our state — and will let us know if it’s impossible to do so.</p>
<h3 id="definite-and-indefinite-failures"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#definite-and-indefinite-failures">Definite and indefinite failures</a></h3>
<p>The model sketch above assumes that calls observed from S2 always succeed, but of course that’s not the case.</p>
<p>With calls that don’t cause side-effects, namely <code>read</code> and <code>check-tail</code>, failures are pretty simple to handle — we can simply change our model to reflect that an output’s <code>tail</code> or <code>content</code> values are optional, and won’t be provided if the call fails.</p>
<p>Appends are trickier, since they mutate the register. In S2, if an <code>append</code> call fails, we don’t necessarily know if the records became durable or not — and therefore, in our model, we don't know if we should update the tail and hash in the register or not.</p>
<p>Some failures may be <strong>definite</strong>, of course. For example, a validation error from S2, which tells us concretely that the batch was rejected; for these, we know there should be no change to the register.</p>
<p>But there's a wide class of <strong>indefinite</strong> failures for which we can’t know, just by looking at the output, if they took effect.</p>
<p>Porcupine lets us deal with this ambiguity via the <a href="https://pkg.go.dev/github.com/anishathalye/porcupine#NondeterministicModel">NondeterministicModel</a> type. Step functions in these models return a set of all possible state transformations, meaning that an append which receives an indefinite failure can, in our model, be witnessed as either producing an unchanged original state, or a state in which the batch was made durable and added to the tail. Both options can legally be part of a linearizable history.</p>
<p>An updated step function for indefinite failures would then look like this:</p>
<div class="operation-card">
<p><strong>Append</strong> (indefinite failure): Returns multiple possible states</p>
<ul>
<li>Input: <code>append([r1,r2,...,rN])</code></li>
<li>Output: <code>Timeout</code></li>
<li>Next State Set: <strong>Two possibilities:</strong>
<ul>
<li><code>current_state</code> (records didn't become durable)</li>
<li><code>(tail=current_tail+N, content=hash(rN))</code> (records did become durable)</li>
</ul>
</li>
</ul>
</div>
<p>A linearizable history may pass through either one of these possibilities, but no others.</p>
<h3 id="is-the-model-too-basic"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#is-the-model-too-basic">Is the model too basic?</a></h3>
<p>In S2, a read needs to be capable of returning all prior acknowledged writes, not just the latest one. People aren’t using S2 as a KV store after all. Yet the model I’ve sketched above treats reads simply as a mechanism to receive the <em>last</em> record on the stream.</p>
<p>Our model would be more robust if the register value reflected the entire state of the stream, and each read returned that state.</p>
<p>When I originally thought about this, I decided that would be overkill, since S2 already contains many assertions around checksumming, and continuity of returned sequence numbers. These assertions in our code would halt and fail the DST even before we could collect a set of logs for Porcupine.
But it’s true, this model could be made stronger by capturing the entire stream in the register.<sup><a href="https://s2.dev/blog/linearizability#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup></p>
<h3 id="collecting-concurrent-histories"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#collecting-concurrent-histories">Collecting concurrent histories</a></h3>
<p>Porcupine can deal with either timed, or relative orderings for start/end events. To that end, I made a basic Rust library that performs a randomized workload on a single stream.</p>
<p>Rust is significant because it allows us to run the code within our internal turmoil-based DST — but anything could be used to collect these histories.</p>
<p>My code simply specifies a set of concurrent clients (Tokio tasks), which randomly alternate between <code>read</code>, <code>check-tail</code>, and <code>append</code> calls.</p>
<p>Any time a call starts or ends, the client emits a log event.
These logs look like this:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="json" data-theme="github-dark"><code data-language="json" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#E1E4E8">{</span></span>
<span data-line=""><span style="color:#79B8FF">  "event"</span><span style="color:#E1E4E8">: {</span></span>
<span data-line=""><span style="color:#79B8FF">    "Start"</span><span style="color:#E1E4E8">: {</span></span>
<span data-line=""><span style="color:#79B8FF">      "Append"</span><span style="color:#E1E4E8">: {</span></span>
<span data-line=""><span style="color:#79B8FF">        "num_records"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">3</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#79B8FF">        "last_record_xxh3"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">375918985</span></span>
<span data-line=""><span style="color:#E1E4E8">      }</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#E1E4E8">  },</span></span>
<span data-line=""><span style="color:#79B8FF">  "client_id"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">19</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#79B8FF">  "op_id"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6603</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{
  &#x22;event&#x22;: {
    &#x22;Start&#x22;: {
      &#x22;Append&#x22;: {
        &#x22;num_records&#x22;: 3,
        &#x22;last_record_xxh3&#x22;: 375918985
      }
    }
  },
  &#x22;client_id&#x22;: 19,
  &#x22;op_id&#x22;: 6603
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>This is followed, some time later, with the corresponding <code>op_id</code>'s return:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="json" data-theme="github-dark"><code data-language="json" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#E1E4E8">{</span></span>
<span data-line=""><span style="color:#79B8FF">  "event"</span><span style="color:#E1E4E8">: {</span></span>
<span data-line=""><span style="color:#79B8FF">    "Finish"</span><span style="color:#E1E4E8">: {</span></span>
<span data-line=""><span style="color:#79B8FF">      "AppendSuccess"</span><span style="color:#E1E4E8">: {</span></span>
<span data-line=""><span style="color:#79B8FF">        "tail"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">1683</span></span>
<span data-line=""><span style="color:#E1E4E8">      }</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#E1E4E8">  },</span></span>
<span data-line=""><span style="color:#79B8FF">  "client_id"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">19</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#79B8FF">  "op_id"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">6603</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{
  &#x22;event&#x22;: {
    &#x22;Finish&#x22;: {
      &#x22;AppendSuccess&#x22;: {
        &#x22;tail&#x22;: 1683
      }
    }
  },
  &#x22;client_id&#x22;: 19,
  &#x22;op_id&#x22;: 6603
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h3 id="model-outputs"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#model-outputs">Model outputs</a></h3>
<p>Our porcupine model can then try to assemble a linearizable history from our log. This even yields a neat visualization, where each step also displays the specific next possible states.</p>











<table><thead><tr><th align="center"><a href="https://s2.dev/blog/porcupine-linearizable-output.png"><img src="https://s2.dev/blog/porcupine-linearizable-output.png" alt="Porcupine visualization" title="Porcupine visualization"></a></th></tr></thead><tbody><tr><td align="center">Example of a Porcupine visualization. The line displays a valid linearizable sequencing of events through the observed histories of concurrent callers.</td></tr></tbody></table>
<h2 id="deterministic-simulation"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#deterministic-simulation">Deterministic simulation</a></h2>
<p>At this point, we have the capability of collecting concurrent histories, and validating them for linearizability with a Porcupine model of S2.
But, given this is an empirical test, we really want to be systematic in how we collect these histories.</p>
<p>For instance, it's important to validate that histories collected even when the system is under extreme stress are linearizable. Our DST allows us to do that, by giving us a platform for:</p>
<ul>
<li>Running parallel test invocations under different workload configurations</li>
<li>Injecting network faults</li>
<li>Causing component crashes or other loss of availability</li>
</ul>
<p>And, thankfully, in a reproducible environment, where reusing the same seed lets us experience the same sequence of events.</p>
<p>Linearizability validation now happens as a step in our nightly suite.</p>











<table><thead><tr><th align="center"><a href="https://s2.dev/blog/dst-linearizable-trials.png"><img src="https://s2.dev/blog/dst-linearizable-trials.png" alt="Nightly test interface" title="Nightly test interface"></a></th></tr></thead><tbody><tr><td align="center">Our sleek nightly test UI.</td></tr></tbody></table>
<h3 id="an-interesting-gotcha"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#an-interesting-gotcha">An interesting gotcha</a></h3>
<p>When I first wired this up and actually started to run the Porcupine model in the context of our nightly suite, I discovered a number of trials which were reporting violations of linearizability. I started to see my professional life flashing across my eyes.</p>
<p>In reality, I was just discovering a flaw in my model, regarding how I was handling clients which experienced indefinite failures while appending — one which is probably familiar to followers of Jepsen content, but which I had failed to internalize and had to discover via the DST.</p>
<p>Recall that the concurrent clients used to collect logs for Porcupine will randomly switch between operations. In my initial setup, a client would continue making requests, even after experiencing an indefinite failure for an <code>append</code> (e.g., due to hitting a timeout). The client would simply report the indefinite failure, then move on to the next randomly selected operation — but this is unsafe!</p>
<p>Consider this sequence of events. For simplicity, assume there is only one concurrent client interacting with the system, and all operations are sequential:</p>
<div class="sequence-container">
<ol>
<li>A <code>read</code> (or <code>check-tail</code>) succeeds, and shows the stream's current tail as <code>t</code></li>
<li>An <code>append</code>, of a batch of <code>n</code> records, fails indefinitely</li>
<li>We move on, and a new <code>read</code> (or <code>check-tail</code>) indicates that the current tail is still <code>t</code>
<ul>
<li>In other words, the <code>append</code> from Step 2 did not become durable</li>
</ul>
</li>
<li>But now, another <code>read</code> or <code>check-tail</code> shows the tail is at <code>t + n</code></li>
</ol>
</div>
<p>This is totally possible to experience in my setup! In Step 3, it is true that the prior append had not become durable; but it is not true that it <em>would not</em> become so at a later moment. If we considered Step 2 to have ended with the indefinite failure, then this looks like a violation of linearizability.</p>
<p>How can this happen in the first place?</p>
<p>It’s possible that the prior indefinitely failed <code>append</code> was able to submit the batch to S2 before a network failure caused the client error — but S2 did receive it, it just hadn’t flushed it to object storage yet and thus made it durable.</p>
<p>Or alternatively, even if S2 had never received the batch at all, it’s possible that a prior <code>append</code>’s payload is caught in some network switch somewhere, indefinitely delayed, only to become unstuck in the future.</p>
<h4 id="what-to-make-of-this"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#what-to-make-of-this">What to make of this</a></h4>
<p>This isn’t actually a problem for linearizability of S2! The solution is just to consider any client that experiences an indefinite failure as essentially unsafe to continue using. In the history we feed to Porcupine, the end time of any indefinitely failed <code>append</code>s should be set to a moment after all other operations complete. This models the fact that these calls could still take effect on the register at any moment in the future. In a sense, the call can never be witnessed to have ended, because there is never a point at which we can definitively say it will no longer be possible to have an effect.</p>
<p>In a practical sense, if you are a user of S2, this might sound like a technicality: sure, it's linearizable — but if you experience a timeout, how do you actually proceed?</p>
<p>If you find yourself in this position, and need to know precisely if a failed <code>append</code> can or cannot become durable in the future, S2’s <a href="https://s2.dev/docs/api/records/append#concurrency-control">concurrency control mechanisms</a> can achieve this.<sup><a href="https://s2.dev/blog/linearizability#user-content-fn-2" id="user-content-fnref-2" data-footnote-ref="" aria-describedby="footnote-label">2</a></sup></p>
<h2 id="additional-steps"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#additional-steps">Additional steps</a></h2>
<p>With pretty minimal tweaks to our model, we can also incorporate logic around our concurrency control mechanisms — i.e., that any append which specifies a <code>match_seq_num</code> (our version of a <a href="https://en.wikipedia.org/wiki/Compare-and-swap">CAS</a> essentially) must only succeed if the register’s tail matches the provided sequence number. We can also expand the register to include fencing tokens. Establishing a token via a <a href="https://s2.dev/docs/api/records/overview#command-records"><code>fence</code> record</a> is a strongly consistent operation in S2, and by modeling that as part of the state we can be more confident that this is always the case.</p>
<p>In addition to our homegrown DST, we've also recently started experimenting with <a href="https://antithesis.com/">Antithesis</a>'s deterministic testing environment, and run this model as part of the workloads sent there. More about our experience with Antithesis soon!</p>
<p>If you're interested in the Porcupine model, check out the code <a href="https://github.com/s2-streamstore/s2-verification">on GitHub</a>!</p>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/linearizability#footnote-label">Footnotes</a></h2>
<ol>
<li id="user-content-fn-1">
<p>In the model I’ve described so far, Porcupine will be able to catch situations where: 1) An ack’ed append isn’t visible to subsequent read, or 2) A read only exposes a prefix of an appended batch. But the same observed <code>(tail, content)</code> state could theoretically be the product of different histories. <a href="https://s2.dev/blog/linearizability#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-2">
<p>For instance, if the indefinitely failed <code>append</code> had specified a <code>match_seq_num</code> value, you could simply retry the append (with that same condition). If it succeeds, you know that the prior attempt didn't become durable — and, since the new attempt did, even if the prior one ends up arriving later, it will be rejected, as the <code>match_seq_num</code> will no longer match. <a href="https://s2.dev/blog/linearizability#user-content-fnref-2" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content:encoded>
            <author>stephen@s2.dev (Stephen Balogh)</author>
            <category>testing</category>
            <category>distsys</category>
            <category>rust</category>
            <category>eng</category>
        </item>
        <item>
            <title><![CDATA[Stream per agent session]]></title>
            <link>https://s2.dev/blog/agent-sessions</link>
            <guid isPermaLink="false">https://s2.dev/blog/agent-sessions</guid>
            <pubDate>Tue, 01 Jul 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Why AI agent sessions need their own durable streams for memory, auditability, isolation, branching, and reliable resume.]]></description>
            <content:encoded><![CDATA[<blockquote><p>S2 delivers streams as a cloud storage primitive, which means no limit on the number of streams you can create and access, with a <a href="https://s2.dev/docs/api/records/overview">simple, serverless API</a> and <a href="https://s2.dev/pricing">pricing model</a>.</p></blockquote>
<p>The dominant pattern in durable streaming today is to use sharded topics as a "firehose". This works fine for transporting, say, clickstream data into a data lake. But it is the wrong fidelity for a rising class of applications: <strong>agents</strong>.</p>
<p>Unlike a synchronous and stateless request-response cycle, sessions are often deeply stateful and long-running. For example, consider AI agents handling travel booking, customer support, or legal assistance; balancing learned preferences over multi-turn conversations.</p>
<p>Each session charts a distinct path with its own context, decisions, and outcomes. It sure quacks like a stream!</p>
<h2 id="landscape"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#landscape">Landscape</a></h2>
<p>Traditional streaming will have you choose between:</p>
<ul>
<li><strong>Small number of durable topics</strong> (Kafka<sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-kafka" id="user-content-fnref-kafka" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup>, Kinesis<sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-kinesis" id="user-content-fnref-kinesis" data-footnote-ref="" aria-describedby="footnote-label">2</a></sup>) — multiplexing unrelated sessions through shared pipes</li>
<li><strong>Expensive ephemeral state</strong> (Redis<sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-redis" id="user-content-fnref-redis" data-footnote-ref="" aria-describedby="footnote-label">3</a></sup>, NATS<sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-nats" id="user-content-fnref-nats" data-footnote-ref="" aria-describedby="footnote-label">4</a></sup>) — sacrificing durability for granularity</li>
</ul>
<p>You might then turn towards a more general database with a row-per-event model. For a streaming workload, OLTP databases (like Postgres) imply high costs <sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-oltp" id="user-content-fnref-oltp" data-footnote-ref="" aria-describedby="footnote-label">5</a></sup>, while OLAP databases (like ClickHouse) come with very high write latencies <sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-olap" id="user-content-fnref-olap" data-footnote-ref="" aria-describedby="footnote-label">6</a></sup>. Neither will let you tail a fine-grained event stream.</p>
<p>Most databases are designed for where you landed up — streams are the journey. And with agents, the specific journeys matter.</p>
<h2 id="why-agents-need-granular-streams"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#why-agents-need-granular-streams">Why agents need granular streams</a></h2>
<h3 id="memory"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#memory">Memory</a></h3>
<p>Write-ahead logs (WALs) are a fundamental technique for databases, and have a parallel in the practice of <strong>event sourcing</strong> for applications. It applies perfectly to building agents you can rely on.</p>
<p>The high-level idea is to use what is in effect an "agent WAL". All activity is modelled as events on a session-specific durable stream — so this includes user inputs, prompts, model responses, and tool calls.</p>
<p>This gives us a solid foundation for agent memory. A sketch:</p>
<p align="center">
  <img src="https://s2.dev/blog/agent-memory.png" alt="Sketch of agent memory with granular streams.">
</p>
<h4 id="working-memory"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#working-memory">Working memory</a></h4>
<p>The session-specific stream serves as a replayable source of working memory for an agent. When an agent experiences any kind of interruption and needs to be reincarnated, the <strong>context</strong> can be completely restored quickly.</p>
<p>However, the perspective of working memory as merely being context for an AI model is narrow. A unified treatment includes <strong>resilience</strong> to crashes, i.e. can the agent truly remember and resume where it left things off? What if the latest we know, based on the stream, is that the agent noted its intent to call an API, but the result was not recorded?</p>
<p>The service being invoked must cooperate to facilitate idempotent retries, usually by allowing the client to provide a unique token. Then this idempotence token can be stored alongside the intent, and voilà, it is safe to retry and resolve the result.</p>
<p>If you have come across <em>durable execution</em> frameworks/runtimes, this is essentially what is going on, with varying degrees of intrusiveness. None will magically make a third-party API idempotent, and it is important to peek under the layers of abstraction to question if they are <a href="https://www.anthropic.com/engineering/building-effective-agents#when-and-how-to-use-frameworks">helping or obscuring</a>.</p>
<h4 id="long-term-memory"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#long-term-memory">Long-term memory</a></h4>
<p>The working memory stream can be used to identify <strong>insights worth preserving</strong>. This may happen inline by instructing the primary model, or by replaying the stream into another model.</p>
<p>As long-term memories are extracted, they can be made available across sessions by writing them to an entity-level (per user, per project, etc.) stream. This naturally preserves the order in which those memories were made, which is meaningful as facts may be overridden.</p>
<p>While context window sizes continue to grow, they are not infinite, and model performance starts to degrade. A stream as a source of truth for long-term memories enables us to asynchronously and reliably build specialized indexes — lexical, vector, graph, or a combination — for efficient memory retrieval<sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-rag" id="user-content-fnref-rag" data-footnote-ref="" aria-describedby="footnote-label">7</a></sup> without loading the entire history.</p>
<p>Moreover, memories may be consolidated and reindexed as they grow in size, by using a versioning approach to stream and index resources. The previous versions serve as raw input for this compaction process — yet another time-tested pattern in data systems <sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-lsm" id="user-content-fnref-lsm" data-footnote-ref="" aria-describedby="footnote-label">8</a></sup>, that is being referred to as <em>recursive summarization</em> in this, er, context.</p>
<h3 id="auditability"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#auditability">Auditability</a></h3>
<p>Immutability is a cornerstone of AI applications that can be trusted with real-world consequences.</p>
<p>With agency in the mix, you need to be able to audit exactly what context went into going down a certain path. Why the $450 United flight was rejected for the $520 Delta option, how the user's "I need to arrive before 3pm" constraint eliminated 60% of options.</p>
<p>Streams are append-only, and the history is immutable by design. This unlocks a whole lot:</p>
<ul>
<li>trace a session for debugging</li>
<li>detect patterns of abuse or manipulation, such as prompt injection attacks</li>
<li>transparently share with your users how decisions affecting them were made</li>
<li>comply with laws and regulations, such as non-discrimination</li>
<li>construct evaluation datasets based on actual interactions</li>
</ul>
<h3 id="isolation"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#isolation">Isolation</a></h3>
<p>Building secure multi-tenant agentic applications requires strict boundaries. We don't want context from one session bleeding into another due to accident or abuse — only by design, such as long-term memory for the same user.</p>
<p>Isolation is natural with granular streams. If you use S2, you can even enforce <a href="https://s2.dev/blog/access-control">fine-grained access control</a>.</p>
<h3 id="branching"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#branching">Branching</a></h3>
<p>When an agent reaches a critical decision point, you can:</p>
<ol>
<li>Clone<sup><a href="https://s2.dev/blog/agent-sessions#user-content-fn-clone" id="user-content-fnref-clone" data-footnote-ref="" aria-describedby="footnote-label">9</a></sup> the stream at that point</li>
<li>Explore alternative paths in parallel</li>
<li>Compare outcomes</li>
<li>Merge the most promising path back into the main timeline</li>
</ol>
<p>This enables sophisticated planning and "what-if" analysis without corrupting the primary flow. <code>git checkout</code> for agents!</p>
<p>In <a href="https://www.anthropic.com/engineering/built-multi-agent-research-system">how we built our multi-agent research system</a>, Anthropic note:</p>
<blockquote><p>Asynchronous execution would enable additional parallelism: agents working concurrently and creating new subagents when needed. But this asynchronicity adds challenges in result coordination, state consistency, and error propagation across the subagents. As models can handle longer and more complex research tasks, we expect the performance gains will justify the complexity.</p></blockquote>
<p>Granular, durable streams are a simplifying primitive to tackle these challenges.</p>
<h2 id="patterns-over-frameworks"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#patterns-over-frameworks">Patterns over frameworks</a></h2>
<p>We don't have a framework to sell you on. You can be as minimalist with these ideas as your application demands.</p>
<p>Event sourcing as a pattern has been around forever. It matches how state actually evolves: as a sequence of events over time. Agent sessions are no different, and AI models provide inference APIs expecting <a href="https://platform.openai.com/docs/guides/conversation-state?api-mode=responses#manually-manage-conversation-state">exactly this framing</a>.</p>
<p>But here's what's different now: the infrastructure finally matches the pattern. You can create a durable stream for every session, every user journey, every parallel exploration — without worrying about extreme costs or operational complexity.</p>
<p>If you are excited to count on streams as a primitive, we would love to help! You can join our <a href="https://discord.gg/vTCs7kMkAf">Discord</a>, <a href="mailto:hi@s2.dev">email us</a>, or jump straight into the <a href="https://s2.dev/docs/quickstart">quickstart</a>.</p>
<hr>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/agent-sessions#footnote-label">Footnotes</a></h2>
<ol>
<li id="user-content-fn-kafka">
<p>Kafka protocol and implementations are not designed from the lens of large numbers of lightweight streams; partitions are treated as provisioned resources. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-kafka" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-kinesis">
<p>Kinesis on-demand costs $29 per month per stream, so like Kafka it treats them as a resource to be provisioned. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-kinesis" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-redis">
<p>AWS MemoryDB may be the only Redis implementation whose durability can be relied on, but also requires expensive provisioned nodes bundling compute and storage. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-redis" data-footnote-backref="" aria-label="Back to reference 3" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nats">
<p>Core NATS is not durable, and JetStream is more <a href="https://github.com/nats-io/nats-server/discussions/5128#discussioncomment-8587561">limited</a> than most Kafkas on number of streams. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-nats" data-footnote-backref="" aria-label="Back to reference 4" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-oltp">
<p>Storage cost for OLTP stores is typically upwards of $0.25/GiB, vs $0.05/GiB with S2. Compute and I/O costs will also be significantly higher — OLTP databases are geared at flexible transactional workloads, rather than purely append-only writes and sequential reads. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-oltp" data-footnote-backref="" aria-label="Back to reference 5" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-olap">
<p>OLAP stores require accumulating large write batches for cost efficiency of storage and query-time compute, and this translates into high write latencies. In fact, streams are often used as a buffer in front of an OLAP destination. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-olap" data-footnote-backref="" aria-label="Back to reference 6" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-rag">
<p>While this resembles <em>retrieval-augmented generation</em> (RAG), it is fundamentally different: instead of augmenting with external knowledge, we are selectively retrieving the agent's own memories to reconstruct relevant context. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-rag" data-footnote-backref="" aria-label="Back to reference 7" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-lsm">
<p><a href="https://arxiv.org/abs/2310.08560">MemGPT</a> makes the connection to an operating system's virtual memory model, and I think there is also a fair analog to <a href="https://en.wikipedia.org/wiki/Log-structured_merge-tree">log-structured merge-tree</a> construction. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-lsm" data-footnote-backref="" aria-label="Back to reference 8" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-clone">
<p>Straightforward to DIY, but we are thinking along the lines of a native <code>clone</code> API so we can make it faster and cheaper. <a href="https://s2.dev/blog/agent-sessions#user-content-fnref-clone" data-footnote-backref="" aria-label="Back to reference 9" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content:encoded>
            <author>shikhar@s2.dev (Shikhar Bhushan)</author>
            <category>ai</category>
            <category>distsys</category>
            <category>use-case</category>
        </item>
        <item>
            <title><![CDATA[Postgres CDC made simple]]></title>
            <link>https://s2.dev/blog/sequin</link>
            <guid isPermaLink="false">https://s2.dev/blog/sequin</guid>
            <pubDate>Tue, 24 Jun 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[A tutorial for streaming Postgres change data capture into S2 with Sequin for low-latency, durable CDC pipelines.]]></description>
            <content:encoded><![CDATA[<p>Postgres plays a crucial role in the modern OLTP landscape. However, there are lots of good reasons to synchronize PG rows elsewhere — it is common to need the same data in an OLAP store, or derive state like caches and indexes.</p>
<h2 id="avoiding-delays-by-design"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/sequin#avoiding-delays-by-design">Avoiding delays by design</a></h2>
<p>Traditional data integration approaches have relied on batch processing, where data pipelines periodically extract full or partial datasets and load them into downstream processors.</p>
<p>The apparent simplicity is a mirage:</p>
<ul>
<li>Bloated full dumps introduce significant overheads and latency as updates are only reflected with each batch cycle which may only be feasible hourly, daily, or even weekly.</li>
<li>Periodic incremental loads with timestamp-based queries are certain to go wrong over time, as clocks are never perfectly synchronized.</li>
<li>Real-time use cases that require reacting to specific state changes as they happen are not feasible, and this leads to managing application-level triggers.</li>
</ul>
<p>Change Data Capture (CDC) flips this model by capturing changes as they occur in the database.</p>
<p>The idea is to leverage the database’s write-ahead logging, and produce a stream of logical changes. Instead of moving entire datasets, CDC streams deltas — insert, update, and delete operations — in real-time.</p>
<p>This is exactly what you need in a modern event-driven architecture.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ascii" data-theme="github-dark"><code data-language="ascii" data-theme="github-dark" style="display: grid;"><span data-line=""><span>        Batch Processing             │              Event-Driven</span></span>
<span data-line=""><span>═════════════════════════════════════│════════════════════════════════════════════════</span></span>
<span data-line=""><span>  Order Created                      │    Order Created ───▶ Stream ──▶ Services</span></span>
<span data-line=""><span>  Payment Processed                  │    Payment Processed ─▶ Stream ──▶ Services</span></span>
<span data-line=""><span>  Inventory Updated                  │    Inventory Updated ─▶ Stream ──▶ Services</span></span>
<span data-line=""><span>  User Registered                    │    User Registered ──▶ Stream ──▶ Services</span></span>
<span data-line=""><span>  Status Changed                     │    Status Changed ──▶ Stream ──▶ Services</span></span>
<span data-line=""><span>       │                             │                            │</span></span>
<span data-line=""><span>       │ (accumulate...)             │                            ▼</span></span>
<span data-line=""><span>       ▼                             │                     ┌─────────────┐</span></span>
<span data-line=""><span>  ┌─────────────┐                    │                     │  Real-time  │</span></span>
<span data-line=""><span>  │ Process all │                    │                     └─────────────┘</span></span>
<span data-line=""><span>  │  at once    │                    │</span></span>
<span data-line=""><span>  └─────────────┘                    │</span></span>
<span data-line=""><span>                                     │</span></span>
<span data-line=""><span>Flow:                                │  Flow:</span></span>
<span data-line=""><span>[──wait──][process][──wait──]        │  [→][→][→][→][→][→][→][→]</span></span>
<span data-line=""><span>                                     │</span></span>
<span data-line=""><span>Resources:                           │  Resources:</span></span>
<span data-line=""><span>Idle ──── Spike ──── Idle            │  ~~~~~~ Steady ~~~~~~</span></span><button type="button" title="Copy code" aria-label="Copy code" data="        Batch Processing             │              Event-Driven
═════════════════════════════════════│════════════════════════════════════════════════
  Order Created                      │    Order Created ───▶ Stream ──▶ Services
  Payment Processed                  │    Payment Processed ─▶ Stream ──▶ Services
  Inventory Updated                  │    Inventory Updated ─▶ Stream ──▶ Services
  User Registered                    │    User Registered ──▶ Stream ──▶ Services
  Status Changed                     │    Status Changed ──▶ Stream ──▶ Services
       │                             │                            │
       │ (accumulate...)             │                            ▼
       ▼                             │                     ┌─────────────┐
  ┌─────────────┐                    │                     │  Real-time  │
  │ Process all │                    │                     └─────────────┘
  │  at once    │                    │
  └─────────────┘                    │
                                     │
Flow:                                │  Flow:
[──wait──][process][──wait──]        │  [→][→][→][→][→][→][→][→]
                                     │
Resources:                           │  Resources:
Idle ──── Spike ──── Idle            │  ~~~~~~ Steady ~~~~~~" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h2 id="sequin-just-makes-sense"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/sequin#sequin-just-makes-sense">Sequin just makes sense</a></h2>
<p>In the current ecosystem of CDC tools, <a href="https://debezium.io/"><strong>Debezium</strong></a> is quite established. However, it comes with significant operational overhead due to being built around Kafka Connect, a heavy-weight framework that has not kept up with the cloud-native times.</p>
<p>You may also know about <strong>Postgres</strong> <a href="https://www.postgresql.org/docs/8.1/triggers.html">triggers</a> or <a href="https://blog.sequinstream.com/all-the-ways-to-capture-changes-in-postgres/#listennotify">LISTEN/NOTIFY</a><strong>.</strong> Both seem alluring, but come with gotchas:</p>
<ul>
<li>Postgres triggers can execute code in response to database changes, but they are constrained to <code>PL/pgSQL</code> and run synchronously within the transaction, impacting database performance.</li>
<li>Similarly, <code>LISTEN/NOTIFY</code> provides a lightweight pub/sub mechanism, but offers only at-most-once delivery — if your consumer is offline or fails, notifications are lost forever, and there is no way to replay missed events.</li>
</ul>
<p><a href="https://sequinstream.com/"><strong>Sequin</strong></a> has been developed as a modern, focused system that addresses these pain points. It eliminates the Kafka dependency and gives you the freedom to choose your ideal sink. It can run as a single Docker container that connects directly to a Postgres database, and stream changes to many destinations like SQS, HTTP endpoints, Redis – yes Kafka too – <strong>and now S2!</strong></p>
<p>What makes Sequin particularly compelling is its focus on operational use cases - the scenarios where you need to trigger workflows, invalidate caches, send notifications, or keep services synchronized based on database changes. Unlike ETL tools like <a href="https://www.fivetran.com/">Fivetran</a> or <a href="https://airbyte.com/">Airbyte</a> that excel at analytical workloads but operate in batch intervals, Sequin delivers real-time streaming.</p>
<h2 id="making-streams-first-class"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/sequin#making-streams-first-class">Making streams first-class</a></h2>
<p>We have been <a href="https://s2.dev/blog/intro">building S2</a> as a true counterpart to object storage for fast data streams. S2 gives you streams on tap behind a <a href="https://s2.dev/docs/concepts/records">very simple API</a>, with no clusters to deal with whatsoever.</p>
<p>Each stream is totally ordered, and elastic to very high throughputs – 10-100x higher than most cloud streaming systems. So if you wanted to capture the precise order of all operations in a write-heavy table on a single stream, S2 can keep up!</p>
<p>On the other end of the spectrum from a high-throughput firehose, you can get as granular as you need to: streams are a first-class, unlimited resource in S2. We take responsibility for ensuring a high quality of service, such that a variety of streaming workloads can operate seamlessly.</p>
<p>Elastic throughput, bottomless storage, unlimited streams, and a serverless model where you only pay for usage – all make S2 an especially good fit for CDC from serverless Postgres databases, like <a href="https://supabase.com/">Supabase</a>, <a href="https://neon.com/">Neon</a>, and <a href="https://www.thenile.dev/">Nile</a>.</p>
<h2 id="trying-it-out"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/sequin#trying-it-out">Trying it out</a></h2>
<p>Let's look at how we can architect a simple and fast CDC pipeline from Postgres to S2 with Sequin.</p>
<p>Imagine you're managing an item inventory where you need to update prices and quantities, as new stock arrives or in response to changes in demand and supply. These updates need to be propagated to users in real-time, which can be a complex process as it may involve:</p>
<ul>
<li><strong>Triggering notifications or alerts</strong> e.g. "Avocados are now $4.99!"</li>
<li><strong>Revalidating caches</strong>, as a product going out of stock may require invalidating various caches or updating search indexes, so users don’t end up trying to check out a product that is no longer available.</li>
<li><strong>Internal updates</strong> to adjust profit margins and pricing, which may be owned by other microservices.</li>
</ul>
<blockquote><p>You can also follow along the same example via Sequin's <a href="https://sequinstream.com/docs/quickstart/s2">S2 quickstart</a>.</p></blockquote>
<p>Consider the following schema for our inventory:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="sql" data-theme="github-dark"><code data-language="sql" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">create</span><span style="color:#F97583"> table</span><span style="color:#B392F0"> products</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#E1E4E8">  id </span><span style="color:#F97583">serial</span><span style="color:#F97583"> primary key</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#F97583">  name</span><span style="color:#F97583"> varchar</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">100</span><span style="color:#E1E4E8">),</span></span>
<span data-line=""><span style="color:#E1E4E8">  price </span><span style="color:#F97583">decimal</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">10</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">),</span></span>
<span data-line=""><span style="color:#E1E4E8">  inserted_at </span><span style="color:#F97583">timestamp</span><span style="color:#F97583"> default</span><span style="color:#F97583"> now</span><span style="color:#E1E4E8">(),</span></span>
<span data-line=""><span style="color:#E1E4E8">  updated_at </span><span style="color:#F97583">timestamp</span><span style="color:#F97583"> default</span><span style="color:#F97583"> now</span><span style="color:#E1E4E8">()</span></span>
<span data-line=""><span style="color:#E1E4E8">);</span></span><button type="button" title="Copy code" aria-label="Copy code" data="create table products(
  id serial primary key,
  name varchar(100),
  price decimal(10, 2),
  inserted_at timestamp default now(),
  updated_at timestamp default now()
);" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>We will start by creating an S2 basin and generating an <a href="https://s2.dev/docs/concepts/access-tokens">access token</a>.</p>
<p>To create a basin, navigate to the basins tab in the <a href="https://s2.dev/dashboard">S2 dashboard</a> and click on <strong>Create Basin</strong>:</p>
<br>
<div align="center">
    <img src="https://s2.dev/blog/cdc.new-basin.png" alt="Create a new basin on the S2 dashboard" width="450">
</div>
<p><em>Enable the <code>on append</code> option in the <code>Create Streams Automatically</code> section so that you don’t have to explicitly create streams.</em></p>
<p>Now, create an access token by navigating to the <strong>Access tokens</strong> tab, and then clicking on <strong>Issue.</strong></p>
<br>
<div align="center">
    <img src="https://s2.dev/blog/cdc.new-acc-token.png" alt="Generate a new access token on the S2 dashboard" width="450">
</div>
<p><em>Use a fine-grained access token scoped to the exact basin or the stream you want to use as a sink for CDC, and restrict the allowed operations to <code>Append</code> only, or <code>Append</code> and <code>CreateStream</code> if you enabled automatic creation of streams.</em></p>
<p>Next, follow the <a href="https://sequinstream.com/docs/connect-postgres">Sequin Postgres guide</a> on how to connect your database to Sequin. After connecting your Sequin dashboard, wait for the connection to be healthy, and then add a new sink by choosing S2.</p>
<p><img src="https://s2.dev/blog/cdc.choose-sink.png" alt="Choose S2 as the Sink for CDC with Sequin"></p>
<p>Fill out the S2 configuration and click on <strong>Create Sink</strong>. Sequin will start sending CDC events to our S2 stream!</p>
<p><img src="https://s2.dev/blog/cdc.sequin-dash.png" alt="Healthy dashboard with S2 as sink on Sequin"></p>
<p>We can verify this by tailing our S2 stream to see updates sent to the <code>products</code> stream in the <a href="https://sequinstream.com/docs/reference/messages">Sequin message format</a>:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="plain" data-theme="github-dark"><code data-language="plain" data-theme="github-dark" style="display: grid;"><span data-line=""><span>$ s2 read s2://sequin-quickstart/products</span></span>
<span data-line=""><span>⦿ 3500 bytes (6 records in range 6..=11)</span></span>
<span data-line=""><span>{"record":{"id":1,"inserted_at":"2025-06-11T01:19:22.402358","name":"Avocados (3 pack)","price":"5.99","updated_at":"2025-06-11T01:19:22.402358"},"metadata":{"consumer":{"id":"282713d8-76c4-42db-8e16-3e0df04cc5f6","name":"sequin-playground-s2-sink","annotations":null},"table_name":"products","commit_timestamp":"2025-06-17T19:19:51.955551Z","commit_lsn":null,"commit_idx":null,"transaction_annotations":null,"table_schema":"public","idempotency_key":"ODQ5NWNhMzEtYTM0MC00MDI5LThjODItMTcxYmRjMmY4YjI2OjE=","database_name":"sequin-playground"},"action":"read","changes":null}</span></span>
<span data-line=""><span>{"record":{"id":2,"inserted_at":"2025-06-11T01:19:22.402358","name":"Flank Steak (1 lb)","price":"8.99","updated_at":"2025-06-11T01:19:22.402358"},"metadata":{"consumer":{"id":"282713d8-76c4-42db-8e16-3e0df04cc5f6","name":"sequin-playground-s2-sink","annotations":null},"table_name":"products","commit_timestamp":"2025-06-17T19:19:51.955968Z","commit_lsn":null,"commit_idx":null,"transaction_annotations":null,"table_schema":"public","idempotency_key":"ODQ5NWNhMzEtYTM0MC00MDI5LThjODItMTcxYmRjMmY4YjI2OjI=","database_name":"sequin-playground"},"action":"read","changes":null}</span></span>
<span data-line=""><span>...</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 read s2://sequin-quickstart/products
⦿ 3500 bytes (6 records in range 6..=11)
{&#x22;record&#x22;:{&#x22;id&#x22;:1,&#x22;inserted_at&#x22;:&#x22;2025-06-11T01:19:22.402358&#x22;,&#x22;name&#x22;:&#x22;Avocados (3 pack)&#x22;,&#x22;price&#x22;:&#x22;5.99&#x22;,&#x22;updated_at&#x22;:&#x22;2025-06-11T01:19:22.402358&#x22;},&#x22;metadata&#x22;:{&#x22;consumer&#x22;:{&#x22;id&#x22;:&#x22;282713d8-76c4-42db-8e16-3e0df04cc5f6&#x22;,&#x22;name&#x22;:&#x22;sequin-playground-s2-sink&#x22;,&#x22;annotations&#x22;:null},&#x22;table_name&#x22;:&#x22;products&#x22;,&#x22;commit_timestamp&#x22;:&#x22;2025-06-17T19:19:51.955551Z&#x22;,&#x22;commit_lsn&#x22;:null,&#x22;commit_idx&#x22;:null,&#x22;transaction_annotations&#x22;:null,&#x22;table_schema&#x22;:&#x22;public&#x22;,&#x22;idempotency_key&#x22;:&#x22;ODQ5NWNhMzEtYTM0MC00MDI5LThjODItMTcxYmRjMmY4YjI2OjE=&#x22;,&#x22;database_name&#x22;:&#x22;sequin-playground&#x22;},&#x22;action&#x22;:&#x22;read&#x22;,&#x22;changes&#x22;:null}
{&#x22;record&#x22;:{&#x22;id&#x22;:2,&#x22;inserted_at&#x22;:&#x22;2025-06-11T01:19:22.402358&#x22;,&#x22;name&#x22;:&#x22;Flank Steak (1 lb)&#x22;,&#x22;price&#x22;:&#x22;8.99&#x22;,&#x22;updated_at&#x22;:&#x22;2025-06-11T01:19:22.402358&#x22;},&#x22;metadata&#x22;:{&#x22;consumer&#x22;:{&#x22;id&#x22;:&#x22;282713d8-76c4-42db-8e16-3e0df04cc5f6&#x22;,&#x22;name&#x22;:&#x22;sequin-playground-s2-sink&#x22;,&#x22;annotations&#x22;:null},&#x22;table_name&#x22;:&#x22;products&#x22;,&#x22;commit_timestamp&#x22;:&#x22;2025-06-17T19:19:51.955968Z&#x22;,&#x22;commit_lsn&#x22;:null,&#x22;commit_idx&#x22;:null,&#x22;transaction_annotations&#x22;:null,&#x22;table_schema&#x22;:&#x22;public&#x22;,&#x22;idempotency_key&#x22;:&#x22;ODQ5NWNhMzEtYTM0MC00MDI5LThjODItMTcxYmRjMmY4YjI2OjI=&#x22;,&#x22;database_name&#x22;:&#x22;sequin-playground&#x22;},&#x22;action&#x22;:&#x22;read&#x22;,&#x22;changes&#x22;:null}
..." class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Your applications can now easily consume these changes using an S2 <a href="https://s2.dev/docs/sdk">SDK</a> or its <a href="https://s2.dev/docs/api/records/read">REST API</a>.</p>
<p>It is worth highlighting that Sequin is not a dumb pipe – it also allows you to easily <a href="https://sequinstream.com/docs/reference/filters">filter</a>, <a href="https://sequinstream.com/docs/reference/transforms">transform</a>, and <a href="https://sequinstream.com/docs/reference/routing">route</a> events!</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ascii" data-theme="github-dark"><code data-language="ascii" data-theme="github-dark" style="display: grid;"><span data-line=""><span>   Postgres Tables           S2 Streams</span></span>
<span data-line=""> </span>
<span data-line=""><span>    ┌─────────┐   ┌──────┐</span></span>
<span data-line=""><span>    │  users  │──▶│      │─── user_auth_events</span></span>
<span data-line=""><span>    └─────────┘   │      │─── user_profile_updates</span></span>
<span data-line=""><span>                  │      │</span></span>
<span data-line=""><span>    ┌─────────┐   │SEQUIN│</span></span>
<span data-line=""><span>    │products │──▶│      │─── inventory_updates</span></span>
<span data-line=""><span>    └─────────┘   │      │─── price_alerts</span></span>
<span data-line=""><span>                  │      │─── category_updates</span></span>
<span data-line=""><span>    ┌─────────┐   │      │</span></span>
<span data-line=""><span>    │ orders  │──▶│      │─── payment_notifications</span></span>
<span data-line=""><span>    └─────────┘	  │      │─── order_processing</span></span>
<span data-line=""><span>                  └──────┘</span></span><button type="button" title="Copy code" aria-label="Copy code" data="   Postgres Tables           S2 Streams

    ┌─────────┐   ┌──────┐
    │  users  │──▶│      │─── user_auth_events
    └─────────┘   │      │─── user_profile_updates
                  │      │
    ┌─────────┐   │SEQUIN│
    │products │──▶│      │─── inventory_updates
    └─────────┘   │      │─── price_alerts
                  │      │─── category_updates
    ┌─────────┐   │      │
    │ orders  │──▶│      │─── payment_notifications
    └─────────┘	  │      │─── order_processing
                  └──────┘" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h2 id="your-cdc-pipeline-awaits"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/sequin#your-cdc-pipeline-awaits">Your CDC pipeline awaits</a></h2>
<p>Modern applications are expected to be reactive. CDC with the right tools makes change propagation from databases a fundamental building block, rather than an advanced capability reserved for companies with large infrastructure teams.</p>
<p>Now you can combine Sequin's simplified Postgres CDC approach with S2's serverless streaming experience, and ship real-time features faster.</p>
<p><em>Join us on <a href="https://discord.com/invite/vTCs7kMkAf">Discord</a>.</em></p>]]></content:encoded>
            <author>mehul@s2.dev (Mehul Arora)</author>
            <category>announce</category>
            <category>tutorial</category>
            <category>use-case</category>
        </item>
        <item>
            <title><![CDATA[Multi-player, serverless, durable terminals]]></title>
            <link>https://s2.dev/blog/s2-term</link>
            <guid isPermaLink="false">https://s2.dev/blog/s2-term</guid>
            <pubDate>Tue, 10 Jun 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[How S2 can power multiplayer, replayable terminal sessions with durable stream I/O and no SSH server to configure.]]></description>
            <content:encoded><![CDATA[<p><img src="https://s2.dev/blog/s2.term-demo.gif" alt="Browser-based multiplayer terminal session powered by S2 streams"></p>
<p>S2 provides a serverless stream, or <em>log</em> primitive, backed by object storage. You can think of it as the core of Kafka, just... liberated from all the infra associated with it.</p>
<p>We've been enjoying plugging S2 into <a href="https://s2.dev/blog/iot">anywhere</a> <a href="https://s2.dev/blog/kv-store">streams</a> <a href="https://bsky.app/profile/infiniteregrets.bsky.social/post/3lqamqbrdq22q">appear</a>, just to see what happens. Which leads us to:</p>
<h2 id="what-if-we-implemented-a-terminal-on-s2"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#what-if-we-implemented-a-terminal-on-s2">What if we implemented a terminal on S2?</a></h2>
<p>Terminals are basically streams, right?</p>
<p>Suppose I want a shell on a remote system. I'd probably run <code>sshd</code> on that system – <code>sshd</code> would serve the role of <a href="https://en.wikipedia.org/wiki/Pseudoterminal">pseudoterminal</a> (or PTY), by interacting with a local shell process – and <code>sshd</code> would then broker access to this shell for any clients that connect to the system, via the SSH protocol.</p>
<p>So what if we replaced <code>sshd</code> with our own pseudoterminal, which instead of running as a server daemon communicates entirely through S2?</p>
<p>As it turns out, this is pretty easy to hack together! Shoutout to Claude for vibing the frontend in particular.</p>
<h2 id="how-does-it-work"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#how-does-it-work">How does it work?</a></h2>
<p>For a pseudo-terminal, we just need to read inputs (keystrokes, window resize events, mouse clicks) from an S2 stream, delegate them to a shell process, and then stream the terminal output back onto another S2 stream.</p>
<p>In this demo, the client is simply a webpage with <a href="https://xtermjs.org/">xterm.js</a>, and we can coordinate with S2 streams directly from the browser over HTTP. I used the <a href="https://github.com/s2-streamstore/s2-sdk-typescript">S2 TypeScript SDK</a>, but plain old REST works too.</p>
<p>The PTY process is a small Rust binary, which interacts with S2 via (you guessed it) the <a href="https://github.com/s2-streamstore/s2-sdk-rust">Rust SDK</a>, and makes use of the <a href="https://docs.rs/portable-pty/latest/portable_pty/">portable_pty</a> crate.</p>
<p>See a video of the demo setup in action <a href="https://www.youtube.com/watch?v=anQYeegDaSQ">here</a>. Or <a href="https://github.com/sgbalogh/s2.term">check out the repo</a> and try it yourself.</p>
<h2 id="this-has-to-be-incredibly-slow-right"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#this-has-to-be-incredibly-slow-right">This has to be incredibly slow, right?</a></h2>
<p>Interactive latency in a setup like this is <em>for sure</em> higher than connecting directly to my server over SSH.</p>
<p>In my case, I'm running the PTY process on a VM in <code>us-east-1</code>, and I want to get a shell on it from my home in California, where I am running the frontend locally.</p>
<p>With an <code>Express</code> storage-class stream on S2, p50 end-to-end latencies are around 25-30ms, with p99 &#x3C; 50ms – but this is for a client in the same region as S2. While we are in preview, S2 is only in AWS's <code>us-east-1</code> – and since I live in California, I have to pay a pretty significant "speed-of-light tax" to get bytes to and from Northern Virginia. At least the weather's nice!</p>
<p>Let's consider what has to happen on every interaction (e.g. a single keystroke) for this S2 terminal:</p>
<ol>
<li>Frontend appends the keystroke to an S2 stream (California -> <code>us-east-1</code> + time to make a write durable within a region).</li>
<li>The Rust PTY, on the VM, needs to read the (now completely durable) keystroke (same region, on an already open streaming read session).
<ul>
<li>The PTY sends the keystroke to its follower process (a shell); that process may simply be echoing, e.g. if I'm just typing in a command prompt.</li>
</ul>
</li>
<li>The PTY writes any output from the shell to an S2 stream (the VM running this PTY is in the same region as S2, so this is just time to make a write durable within a region).</li>
<li>The output needs to be read, by the frontend, from an S2 stream (<code>us-east-1</code> -> California, on an already open streaming read session).</li>
</ol>
<p>Here's what it looks like put together:</p>











<table><thead><tr><th align="center"><img src="https://s2.dev/blog/s2.term-network-flowchart.svg" alt="Diagram of s2.term" title="Diagram of s2.term"></th></tr></thead><tbody><tr><td align="center">Flow of the <code>s2.term</code> setup</td></tr></tbody></table>
<p>We can approximate the latency for steps 1 and 4 at the same time by running the <a href="https://github.com/s2-streamstore/s2-cli">S2 CLI's <code>ping</code> function</a>, from my laptop in California, and looking at the end-to-end latency.</p>
<p>This ends up being around <strong>124ms</strong>, p50.</p>











<table><thead><tr><th align="center"><img src="https://s2.dev/blog/s2.term-california-e2e.gif" alt="S2 ping" title="S2 ping"></th></tr></thead><tbody><tr><td align="center">Home (in California) to S2 in <code>us-east-1</code> ping test.</td></tr></tbody></table>
<p>And similarly, we can approximate latency for steps 2 and 3 by running that command from an EC2 VM in <code>us-east-1</code>.</p>
<p>This is around <strong>31ms</strong>, p50:</p>











<table><thead><tr><th align="center"><img src="https://s2.dev/blog/s2.term-us-east-1-e2e.gif" alt="S2 ping" title="S2 ping"></th></tr></thead><tbody><tr><td align="center">Intra-region ping test.</td></tr></tbody></table>
<p>This means each keystroke I make on the terminal frontend takes about <strong>155 milliseconds</strong> to be reflected back to me, given my distance. This latency is dominated by the input and output stream appends. If I were just using <code>ssh</code>, the latency would be closer to 70 or 80ms.</p>
<p>... but 155ms is actually not terrible for a terminal? Definitely not the snappiest I've ever used, but probably not the worst either.</p>
<h2 id="why-do-this"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#why-do-this">Why do this?</a></h2>
<p>The honest answer is: I was bored last Friday and thought it would be fun to try out. And it was!</p>
<p>But there are some cool properties which emerge when you do this. For instance:</p>
<h3 id="-the-terminal-is-automatically-multi-player"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#-the-terminal-is-automatically-multi-player">... the terminal is automatically multi-player</a></h3>
<p>It's surprisingly good for pair programming.<sup><a href="https://s2.dev/blog/s2-term#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup> You can have <a href="https://youtu.be/anQYeegDaSQ?si=XhdX38mmVOybwQZ7&#x26;t=29">multiple people controlling a terminal</a> without having to spin up a server, or grant SSH access (e.g. if sharing via <code>tmux</code>).</p>
<p>You also get <a href="https://s2.dev/docs/concepts/access-tokens">fine-grained access-controls</a> at a per-stream (or per-stream prefix) level. So you could easily choose to only grant read-only access (i.e., terminal viewers), or read-write but limit certain operations like <code>trim</code>.</p>
<p>Plus, you could even make use of <a href="https://s2.dev/docs/api/records/append#concurrency-control">concurrency primitives</a> for further safety (e.g., ensure all terminal users are fully caught-up with the tail of the terminal before their keystroke can be appended).</p>
<p>These types of things become easy when your terminal is essentially a shared write-ahead log.</p>
<h3 id="-all-io-is-automatically-saved"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#-all-io-is-automatically-saved">... all I/O is automatically saved</a></h3>
<p>Every S2 write has to be regionally durable in object storage before it can be delivered to readers, and you can control how long you want to <a href="https://s2.dev/docs/api/records/overview#retention">retain stream history</a> for (or just trim it explicitly).</p>
<p>This has some neat implications – for instance, you can share a "replay" (<a href="https://asciinema.org/">Asciinema</a>-style) of a pair programming session for someone who couldn't make it when it was going on live.</p>
<p>Or, maybe you want to broadcast content from a TUI dashboard running on a server, and allow people to scrub around and see values from earlier. Records are <a href="https://s2.dev/blog/timestamping">indexed by timestamp</a> in addition to their sequence number, so you can easily hop around to points of interest in a terminal session, or replay at different speeds, like having a "rewind" button on your terminal. See a <a href="https://www.youtube.com/watch?v=huyhEe5CLcU">demo video of that here</a>.</p>
<h3 id="-no-servers-to-configure"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#-no-servers-to-configure">... no servers to configure</a></h3>
<p>With <code>sshd</code>, you need to make sure users can connect to the daemon – meaning you might have to forward ports in a gateway, or otherwise deal with NAT traversal. With S2 serving as the streaming medium, both the client and the server just need to be able to connect to the S2 API (which can be done via <a href="https://s2.dev/docs/api">REST</a>, or <a href="https://s2.dev/docs/sdk/languages">one of our SDKs</a>).</p>
<h2 id="what-would-it-cost"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#what-would-it-cost">What would it cost?</a></h2>
<p>It really depends on what you are doing in your terminal!</p>
<p>S2 is <a href="https://s2.dev/pricing">priced as a serverless commodity</a>, along dimensions that will be familiar to anyone who uses object storage, making it easy to do some math based on expected usage patterns.</p>
<p>As a somewhat maximalist example, I wanted to see what it would cost if I turned <code>btop</code> – which is a dynamic TUI dashboard for monitoring system activity – running on a computer of mine into a shared dashboard by broadcasting that process over S2 (e.g., as I did in this <a href="https://youtu.be/huyhEe5CLcU?si=nNHr3V7HiwuuUmom&#x26;t=30">video</a>).</p>
<ul>
<li><code>btop</code> produces around 800KiB of terminal output per minute
<ul>
<li>About 1.1GiB of data written in a day.
<ul>
<li>0.066 for <code>Express</code> writes (as I opted for the lower-latency storage class)</li>
<li>0.044 for storage for 24 hours</li>
<li>~= <strong>11 cents / day</strong></li>
</ul>
</li>
<li>Reads require us to estimate usage (how many people will be connected)
<ul>
<li>3 users tailing continuously, all day long
<ul>
<li>$0.08 (per GiB over public internet) _ 1.1GiB _ 3 users</li>
<li>~= <strong>26 cents / day</strong></li>
</ul>
</li>
</ul>
</li>
<li>Per-op costs
<ul>
<li>Total on the order of &#x3C;1 cent per day.</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>So around <strong>38 cents per day</strong> to share my <code>btop</code> and a day's worth of history with the rest of the office!</p>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/s2-term#footnote-label">Footnotes</a></h2>
<ol>
<li id="user-content-fn-1">
<p>Though, when using TUIs that resize dynamically based on the cols/rows of the client's window, you have the same problem as with <code>tmux</code>, where everyone needs to agree on a single virtual window size. <a href="https://s2.dev/blog/s2-term#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content:encoded>
            <author>stephen@s2.dev (Stephen Balogh)</author>
            <category>use-case</category>
            <category>tutorial</category>
            <category>rust</category>
            <category>typescript</category>
        </item>
        <item>
            <title><![CDATA[Streams as web resources with access controls]]></title>
            <link>https://s2.dev/blog/access-control</link>
            <guid isPermaLink="false">https://s2.dev/blog/access-control</guid>
            <pubDate>Tue, 03 Jun 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Learn how S2 access tokens scope stream operations by resource, prefix, and expiration for browser, app, and agent clients.]]></description>
            <content:encoded><![CDATA[<p>One of the things we have learned since <a href="https://s2.dev/blog/intro">introducing S2</a> is that fine-grained access control is essential to bringing streaming data truly <strong>online</strong>.</p>
<p>We have seen a lot of excitement around our <a href="https://s2.dev/docs/concepts/records">object-storage-like API</a>, and a recurring ask has been a parallel mechanism to <em>pre-signed URLs</em>.</p>
<p>If you are not familiar with it, pre-signed URLs are a neat way to grant temporary, controlled access to objects in your storage bucket. The URL embeds time-bounded authentication and authorization information, allowing anyone with the URL to perform a specific action (like <code>PUT</code> or <code>GET</code>) on a specific object until the URL expires.</p>
<p>You can see where this is going — S2 streams are already available at public endpoints <a href="https://s2.dev/docs/api/protocol">over HTTP</a>. The API simply needed to support more granular access control.</p>
<p><a href="https://s2.dev/docs/concepts/access-tokens">Now it does!</a></p>
<blockquote><p>S2 supports an <strong>unlimited</strong> number of <strong>revokable</strong> access tokens that can be <strong>scoped</strong> to a set of resources and the operations that are allowed on those resources.</p><p>You can issue <strong>permanent</strong> access tokens for services, or <strong>time-bounded</strong> ones for ephemeral usage, e.g. for end users to access an S2 stream.</p></blockquote>
<p>Just like streams, there is no limit on the number of access tokens. After all, a key use case is being able to grant access to streams for "edge" clients — be they browsers, apps, or agents.</p>
<p>You get the flexibility to scope resources by an exact name, or a name prefix. In fact, when scoping streams with a prefix, you can enable transparent namespacing with a knob telling S2 to <em>auto-prefix</em>.</p>
<p>To make this concrete, let's take it for a spin!</p>
<h2 id="usage-example"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/access-control#usage-example">Usage example</a></h2>
<p>First, a quick review of S2 <a href="https://s2.dev/docs/concepts/records">concepts</a> would be helpful.</p>
<p>We will assume we have a basin storing data from headless browser sessions, with streams like this:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> ls</span><span style="color:#9ECBFF"> browser-instances-wtvr</span></span>
<span data-line=""><span style="color:#B392F0">s2://browser-instances-wtvr/session/g7wrzr9t/console</span><span style="color:#9ECBFF"> 2025-06-03T18:05:19Z</span></span>
<span data-line=""><span style="color:#B392F0">s2://browser-instances-wtvr/session/g7wrzr9t/dom-events</span><span style="color:#9ECBFF"> 2025-06-03T18:05:26Z</span></span>
<span data-line=""><span style="color:#B392F0">s2://browser-instances-wtvr/session/g7wrzr9t/telemetry</span><span style="color:#9ECBFF"> 2025-06-03T18:05:31Z</span></span>
<span data-line=""><span style="color:#B392F0">s2://browser-instances-wtvr/session/rhc2q95x/console</span><span style="color:#9ECBFF"> 2025-06-03T18:05:35Z</span></span>
<span data-line=""><span style="color:#6A737D"># ...</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 ls browser-instances-wtvr
s2://browser-instances-wtvr/session/g7wrzr9t/console 2025-06-03T18:05:19Z
s2://browser-instances-wtvr/session/g7wrzr9t/dom-events 2025-06-03T18:05:26Z
s2://browser-instances-wtvr/session/g7wrzr9t/telemetry 2025-06-03T18:05:31Z
s2://browser-instances-wtvr/session/rhc2q95x/console 2025-06-03T18:05:35Z
# ..." class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>While we could issue a new access token with the <a href="https://s2.dev/docs/quickstart">CLI</a> too, let's try with good old <code>curl</code> for a change. (Of course, you will need an existing access token, which was perhaps issued from the <a href="https://s2.dev/dashboard">dashboard</a>, or itself using the <a href="https://s2.dev/docs/api">API</a>.)</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> curl</span><span style="color:#79B8FF"> --request</span><span style="color:#9ECBFF"> POST</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --url</span><span style="color:#9ECBFF"> https://aws.s2.dev/v1/access-tokens</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --header</span><span style="color:#9ECBFF"> "Authorization: Bearer ${</span><span style="color:#E1E4E8">S2_ACCESS_TOKEN</span><span style="color:#9ECBFF">}"</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --header</span><span style="color:#9ECBFF"> "Content-Type: application/json"</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> '{</span></span>
<span data-line=""><span style="color:#9ECBFF">  "id": "agent/g7wrzr9t",</span></span>
<span data-line=""><span style="color:#9ECBFF">  "scope": {</span></span>
<span data-line=""><span style="color:#9ECBFF">    "basins": { "exact": "browser-instances-wtvr" },</span></span>
<span data-line=""><span style="color:#9ECBFF">    "ops": ["list-streams", "append"],</span></span>
<span data-line=""><span style="color:#9ECBFF">    "streams": { "prefix": "session/g7wrzr9t/" }</span></span>
<span data-line=""><span style="color:#9ECBFF">  },</span></span>
<span data-line=""><span style="color:#9ECBFF">  "auto_prefix_streams": true,</span></span>
<span data-line=""><span style="color:#9ECBFF">  "expires_at": "2025-06-04T08:00:00Z"</span></span>
<span data-line=""><span style="color:#9ECBFF">}'</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"access_token"</span><span style="color:#79B8FF">:</span><span style="color:#B392F0">"YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma"</span><span style="color:#B392F0">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ curl --request POST \
  --url https://aws.s2.dev/v1/access-tokens \
  --header &#x22;Authorization: Bearer ${S2_ACCESS_TOKEN}&#x22; \
  --header &#x22;Content-Type: application/json&#x22; \
  --data &#x27;{
  &#x22;id&#x22;: &#x22;agent/g7wrzr9t&#x22;,
  &#x22;scope&#x22;: {
    &#x22;basins&#x22;: { &#x22;exact&#x22;: &#x22;browser-instances-wtvr&#x22; },
    &#x22;ops&#x22;: [&#x22;list-streams&#x22;, &#x22;append&#x22;],
    &#x22;streams&#x22;: { &#x22;prefix&#x22;: &#x22;session/g7wrzr9t/&#x22; }
  },
  &#x22;auto_prefix_streams&#x22;: true,
  &#x22;expires_at&#x22;: &#x22;2025-06-04T08:00:00Z&#x22;
}&#x27;
{&#x22;access_token&#x22;:&#x22;YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma&#x22;}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>We can confirm we got that right in the dashboard:</p>
<br>
<img src="https://s2.dev/blog/scoped-access-token.png" alt="Generated access token in dashboard">
<p>Note the scoping above, and how if we hand this access token to our beloved agent <code>g7wrzr9t</code>, it can only observe streams under its prefix:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> S2_ACCESS_TOKEN="YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma"</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> ls</span><span style="color:#9ECBFF"> browser-instances-wtvr</span></span>
<span data-line=""><span style="color:#B392F0">s2://browser-instances-wtvr/console</span><span style="color:#9ECBFF"> 2025-06-03T18:05:19Z</span></span>
<span data-line=""><span style="color:#B392F0">s2://browser-instances-wtvr/dom-events</span><span style="color:#9ECBFF"> 2025-06-03T18:05:26Z</span></span>
<span data-line=""><span style="color:#B392F0">s2://browser-instances-wtvr/telemetry</span><span style="color:#9ECBFF"> 2025-06-03T18:05:31Z</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ S2_ACCESS_TOKEN=&#x22;YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma&#x22; s2 ls browser-instances-wtvr
s2://browser-instances-wtvr/console 2025-06-03T18:05:19Z
s2://browser-instances-wtvr/dom-events 2025-06-03T18:05:26Z
s2://browser-instances-wtvr/telemetry 2025-06-03T18:05:31Z" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>... but it is completely oblivious to that naming scheme, the prefix has been stripped.</p>
<p>We did allow it to write to those streams ✅</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> curl</span><span style="color:#79B8FF"> --request</span><span style="color:#9ECBFF"> POST</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --url</span><span style="color:#9ECBFF"> https://browser-instances-wtvr.b.s2.dev/v1/streams/console/records</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --header</span><span style="color:#9ECBFF"> 'Authorization: Bearer YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma'</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --header</span><span style="color:#9ECBFF"> 'Content-Type: application/json'</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --data</span><span style="color:#9ECBFF"> '{ "records": [ { "body": "hello world" }, { "body": "nice to be online" } ] }'</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"start"</span><span style="color:#79B8FF">:</span><span style="color:#9ECBFF">{</span><span style="color:#E1E4E8">"</span><span style="color:#B392F0">seq_num</span><span style="color:#B392F0">":0,"</span><span style="color:#B392F0">timestamp</span><span style="color:#B392F0">":1748976301339},"</span><span style="color:#B392F0">end</span><span style="color:#B392F0">":{"</span><span style="color:#B392F0">seq_num</span><span style="color:#B392F0">":2,"</span><span style="color:#B392F0">timestamp</span><span style="color:#B392F0">":1748976301339},"</span><span style="color:#B392F0">tail</span><span style="color:#B392F0">":{"</span><span style="color:#B392F0">seq_num</span><span style="color:#B392F0">":2,"</span><span style="color:#B392F0">timestamp</span><span style="color:#B392F0">":1748976301339}}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ curl --request POST \
  --url https://browser-instances-wtvr.b.s2.dev/v1/streams/console/records \
  --header &#x27;Authorization: Bearer YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma&#x27; \
  --header &#x27;Content-Type: application/json&#x27; \
  --data &#x27;{ &#x22;records&#x22;: [ { &#x22;body&#x22;: &#x22;hello world&#x22; }, { &#x22;body&#x22;: &#x22;nice to be online&#x22; } ] }&#x27;
{&#x22;start&#x22;:{&#x22;seq_num&#x22;:0,&#x22;timestamp&#x22;:1748976301339},&#x22;end&#x22;:{&#x22;seq_num&#x22;:2,&#x22;timestamp&#x22;:1748976301339},&#x22;tail&#x22;:{&#x22;seq_num&#x22;:2,&#x22;timestamp&#x22;:1748976301339}}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>... but not delete them 🚫</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> curl</span><span style="color:#79B8FF"> --request</span><span style="color:#9ECBFF"> DELETE</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --url</span><span style="color:#9ECBFF"> https://browser-instances-wtvr.b.s2.dev/v1/streams/console</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --header</span><span style="color:#9ECBFF"> 'Authorization: Bearer YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma'</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">  --fail-with-body</span></span>
<span data-line=""><span style="color:#B392F0">curl:</span><span style="color:#E1E4E8"> (22) The requested URL returned error: 403</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"message"</span><span style="color:#79B8FF">:</span><span style="color:#B392F0">"Operation not permitted"</span><span style="color:#B392F0">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ curl --request DELETE \
  --url https://browser-instances-wtvr.b.s2.dev/v1/streams/console \
  --header &#x27;Authorization: Bearer YAAAAAAAAABoPzx+yjvI25QSuwCzq9nnFna3rZF2tSkqRnma&#x27; \
  --fail-with-body
curl: (22) The requested URL returned error: 403
{&#x22;message&#x22;:&#x22;Operation not permitted&#x22;}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h2 id="new-possibilities"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/access-control#new-possibilities">New possibilities</a></h2>
<p>What would be the alternative to an edge client directly accessing a stream? The answer, as always in computing, is a layer of indirection. You would have to implement a proxy that takes care of authenticating and authorizing, and all data flows through.</p>
<p>For many use cases, this can now be obviated, and you can offload your streaming data plane to S2!</p>
<p>With horizontal building blocks like granular access control — and S2 itself, designed to deliver streams as a cloud storage primitive — the possibilities are endless. Nevertheless, let's consider some examples:</p>
<ul>
<li>
<p>Running jobs on behalf of your users, such as builds? Let their browser <strong>tail logs directly</strong>, without having to implement a streaming endpoint yourself. You can simply hand out a read-only access token to a stream that you write to from the build runner. Long-term storage with real-time tailing, converged.</p>
</li>
<li>
<p>Multi-player applications like <strong>collaborative document editing</strong> — allow reads and writes against a shared stream, but forbid trimming. The stream can serve as an immutable ledger, and the backend can retain responsibility to checkpoint state and trim.</p>
</li>
<li>
<p>Building an application powered by a <strong>sync engine</strong> — the tech used by Linear, Figma, and Notion to power amazing UX? You may want change logs that your clients get read-only access to, while accepting updates over a write-only stream.</p>
</li>
<li>
<p>In the business of <strong>personalization</strong>? Ingest user events from your customers as they happen over a write-only stream, and provide them read-only access to user-specific recommendation streams.</p>
</li>
<li>
<p>Exercising S2's <a href="https://s2.dev/docs/integrations/mcp">MCP server</a>, or developing an agentic app that reacts to real-time events? You probably want some <strong>guardrails for your AI</strong> with tight scoping.</p>
</li>
</ul>
<h2 id="whats-next"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/access-control#whats-next">What's next</a></h2>
<p>S2 will keep solving for bringing streams online. We have some immediate followups on our roadmap:</p>
<ul>
<li>
<p><strong>Massive read fanout:</strong> one stream being read by thousands of clients? We intend to be up to the challenge!</p>
</li>
<li>
<p><strong>Usage visibility and limits per access token:</strong> so you can grant untrusted code access without letting it go amok.</p>
</li>
</ul>
<p>Reach out by <a href="mailto:hi@s2.dev">email</a> or our <a href="https://discord.gg/vTCs7kMkAf">Discord</a> if you want to find out more or share your requirements.</p>]]></content:encoded>
            <author>shikhar@s2.dev (Shikhar Bhushan)</author>
            <category>announce</category>
            <category>tutorial</category>
            <category>use-case</category>
        </item>
        <item>
            <title><![CDATA[Keeping time on a stream]]></title>
            <link>https://s2.dev/blog/timestamping</link>
            <guid isPermaLink="false">https://s2.dev/blog/timestamping</guid>
            <pubDate>Wed, 14 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[How S2 indexes stream records by timestamp so readers can seek by time, replay event history, and handle real-world event time.]]></description>
            <content:encoded><![CDATA[<p>An S2 stream is a log, so logical time with sequence numbers is a given.</p>
<p>But what about good old physical time? How long it's been since <a href="https://en.wikipedia.org/wiki/Unix_time">Jan 1, 1970</a>? (<em>Hundreds of years from now, I reckon that will still be a pretty important reference point.)</em></p>
<p>In our internal tooling, we had been rolling with inserting a header to propagate timestamps, which felt like a reasonable pattern to recommend. But real-world applications care about real-world time, and it became clear we should make timestamping first-class.</p>
<p>Storing a timestamp on each record wouldn’t be too useful if you can’t also consume by it — for example, if you want to use an S2 stream as a long-term source of truth on location data like vehicle movements. With cheap enough stream storage and a querying pattern of linear scans from a point in time, it is appealing to not involve another indexed data store.</p>
<p>If we assume that we can count on monotonicity — time marching inexorably forwards with each record — S2 wouldn’t even need a separate secondary index!</p>
<p>Searching for the right place to start reading by timestamp can be framed very similarly to reading from a sequence number: the backend is essentially navigating a distributed <a href="https://en.wikipedia.org/wiki/Skip_list">skip list</a> over metadata storage, object storage, and cache of recent writes.</p>
<p>However, in a distributed system, time depends on who you ask. Are we going to go with what the client says or the service? They have different clocks, and network delays between them.</p>
<p>Further, what if you are primarily concerned not with the time of writing to the stream, but as an attribute of when events <em>actually</em> happened — like a fitness tracker squirting data points when it comes back online?</p>
<h2 id="other-systems"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/timestamping#other-systems">Other systems</a></h2>
<p>Let’s take a look at what some other systems do:</p>
<ul>
<li>Kinesis assigns an <code>ApproximateArrivalTimestamp</code> to records, and you can use this as a starting position for reads. It does not have any special knowledge of client-specified timestamps – those can be made part of the record if needed.</li>
<li>Pulsar tracks broker-assigned <code>publishTime</code> and optionally client-specified <code>eventTime</code>. You can only 'seek' by the former – event time does not get indexed.</li>
<li>Kafka allows for either a client-specified (<code>CreateTime</code>) or broker-assigned (<code>LogAppendTime</code>) timestamp per record. If the client does not provide a timestamp when using <code>CreateTime</code>, it gets silently replaced with the <code>LogAppendTime</code>. Record timestamps are also used in retention, so certain knobs to clamp the timestamp can be a good idea if using <code>CreateTime</code>.</li>
</ul>
<h2 id="time-in-s2"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/timestamping#time-in-s2">Time in S2</a></h2>
<p>Coming back to what we have landed on for S2, in the simple case, you just let the service assign monotonic record timestamps of the arrival time in milliseconds since our favorite epoch.</p>
<p>How does the backend ensure monotonicity? We force it! It is tracking the highest timestamp per stream, and uses that if the new timestamp is <em>somehow</em> (<em>cough</em> distributed systems <em>cough</em>) smaller.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">inputs:</span><span style="color:#9ECBFF">       42,</span><span style="color:#9ECBFF"> 44,</span><span style="color:#9ECBFF"> 42,</span><span style="color:#9ECBFF"> 50,</span><span style="color:#9ECBFF"> 50,</span><span style="color:#9ECBFF"> 48,</span><span style="color:#9ECBFF"> 55,</span><span style="color:#9ECBFF"> ..</span></span>
<span data-line=""><span style="color:#B392F0">adjusted:</span><span style="color:#9ECBFF">     42,</span><span style="color:#9ECBFF"> 44,</span><span style="color:#9ECBFF"> 44,</span><span style="color:#9ECBFF"> 50,</span><span style="color:#9ECBFF"> 50,</span><span style="color:#9ECBFF"> 50,</span><span style="color:#9ECBFF"> 55,</span><span style="color:#9ECBFF"> ..</span></span><button type="button" title="Copy code" aria-label="Copy code" data="inputs:       42, 44, 42, 50, 50, 48, 55, ..
adjusted:     42, 44, 44, 50, 50, 50, 55, .." class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Unlike the sequence numbers assigned by S2, timestamps are allowed to be identical between consecutive records.</p>
<p>A key decision point was whether to allow client-specified timestamps. We decided some essential complexity here was worthwhile, because event time is far too useful when you consider scenarios like offline devices or backfills.</p>
<p>A <code>timestamping.mode</code> knob made sense to introduce in stream configuration, so users can know with confidence whether client or arrival time is used:</p>
<ul>
<li><code>client-prefer</code> – (the default) use client-specified timestamp if present, otherwise the arrival time</li>
<li><code>client-require</code> – require clients to specify a timestamp that will be used</li>
<li><code>arrival</code> – use arrival time regardless of whether or not the client specifies a timestamp</li>
</ul>
<p>By default the arrival time acts as a cap to prevent out-of-whack values messing with the stream’s notion of time. You can enable <code>timestamping.uncapped</code> and get full fidelity within the 64-bit range of possibilities, just remember that there is no going back: the automatic adjustment to ensure monotonicity applies to client-specified timestamps too!</p>
<p>Append acknowledgments contain the first and last timestamps for the batch, so you can know if an adjustment was made. <em>If a use case demands, we can add a way to opt-out of this behavior and reject such an append instead.</em></p>
<p>Given the monotonicity constraint, we end up with not-quite-an-arbitrary index — but whether you stick with arrival time or propagate your own timestamps, the service stays cost-effective and lets you efficiently read records along this additional time axis.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> read</span><span style="color:#9ECBFF"> s2://perso-ingest-mnbk/user/foo</span><span style="color:#79B8FF"> --ago</span><span style="color:#9ECBFF"> 1h</span><span style="color:#79B8FF"> --format</span><span style="color:#9ECBFF"> json</span><span style="color:#79B8FF"> --count</span><span style="color:#79B8FF"> 3</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"seq_num"</span><span style="color:#B392F0">:8,</span><span style="color:#B392F0">"timestamp"</span><span style="color:#B392F0">:1747225273458,</span><span style="color:#B392F0">"body"</span><span style="color:#79B8FF">:</span><span style="color:#B392F0">"search </span><span style="color:#79B8FF">\"</span><span style="color:#B392F0">hats</span><span style="color:#79B8FF">\"</span><span style="color:#B392F0">"</span><span style="color:#B392F0">}</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"seq_num"</span><span style="color:#B392F0">:9,</span><span style="color:#B392F0">"timestamp"</span><span style="color:#B392F0">:1747225343334,</span><span style="color:#B392F0">"body"</span><span style="color:#79B8FF">:</span><span style="color:#B392F0">"view-listing 42"</span><span style="color:#B392F0">}</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"seq_num"</span><span style="color:#B392F0">:10,</span><span style="color:#B392F0">"timestamp"</span><span style="color:#B392F0">:1747225380872,</span><span style="color:#B392F0">"body"</span><span style="color:#79B8FF">:</span><span style="color:#B392F0">"add-to-cart 42"</span><span style="color:#B392F0">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> read</span><span style="color:#9ECBFF"> s2://perso-ingest-mnbk/user/foo</span><span style="color:#79B8FF"> --timestamp</span><span style="color:#79B8FF"> 1747225340000</span><span style="color:#79B8FF"> --format</span><span style="color:#9ECBFF"> json</span><span style="color:#79B8FF"> --count</span><span style="color:#79B8FF"> 3</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"seq_num"</span><span style="color:#B392F0">:9,</span><span style="color:#B392F0">"timestamp"</span><span style="color:#B392F0">:1747225343334,</span><span style="color:#B392F0">"body"</span><span style="color:#79B8FF">:</span><span style="color:#B392F0">"view-listing 42"</span><span style="color:#B392F0">}</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#B392F0">"seq_num"</span><span style="color:#B392F0">:10,</span><span style="color:#B392F0">"timestamp"</span><span style="color:#B392F0">:1747225380872,</span><span style="color:#B392F0">"body"</span><span style="color:#79B8FF">:</span><span style="color:#B392F0">"add-to-cart 42"</span><span style="color:#B392F0">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 read s2://perso-ingest-mnbk/user/foo --ago 1h --format json --count 3
{&#x22;seq_num&#x22;:8,&#x22;timestamp&#x22;:1747225273458,&#x22;body&#x22;:&#x22;search \&#x22;hats\&#x22;&#x22;}
{&#x22;seq_num&#x22;:9,&#x22;timestamp&#x22;:1747225343334,&#x22;body&#x22;:&#x22;view-listing 42&#x22;}
{&#x22;seq_num&#x22;:10,&#x22;timestamp&#x22;:1747225380872,&#x22;body&#x22;:&#x22;add-to-cart 42&#x22;}

$ s2 read s2://perso-ingest-mnbk/user/foo --timestamp 1747225340000 --format json --count 3
{&#x22;seq_num&#x22;:9,&#x22;timestamp&#x22;:1747225343334,&#x22;body&#x22;:&#x22;view-listing 42&#x22;}
{&#x22;seq_num&#x22;:10,&#x22;timestamp&#x22;:1747225380872,&#x22;body&#x22;:&#x22;add-to-cart 42&#x22;}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>We hope our approach will serve users well! <a href="mailto:hi@s2.dev">Feedback</a> very welcome.</p>]]></content:encoded>
            <author>shikhar@s2.dev (Shikhar Bhushan)</author>
            <category>announce</category>
            <category>distsys</category>
            <category>eng</category>
        </item>
        <item>
            <title><![CDATA[Deterministic simulation testing for async Rust]]></title>
            <link>https://s2.dev/blog/dst</link>
            <guid isPermaLink="false">https://s2.dev/blog/dst</guid>
            <pubDate>Wed, 02 Apr 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[How S2 uses deterministic simulation testing for async Rust to find distributed-systems bugs before they reach production.]]></description>
            <content:encoded><![CDATA[<p>Deterministic simulation testing (DST) is a really powerful technique to gain confidence in a system by shaking out edge cases and reliably reproducing the bugs that turn up — and you thank the DST gods when they do, because that’s a bug a user did not have to encounter!</p>
<p>We knew DST was the way to go in building <a href="https://s2.dev/blog/intro">S2</a>, inspired by the prior art of <a href="https://www.youtube.com/watch?v=fFSPwJFXVlw">FoundationDB</a> and <a href="https://www.youtube.com/watch?v=sC1B3d9C_sI">TigerBeetle</a>. But how could we practically implement it for our Rust codebase that uses Tokio as a runtime? My goal in this post is to share some of what we learned along the way.</p>
<p>Unit tests can be made plenty deterministic. What’s different here is we are trying to exercise <em>a lot</em> of different scenarios (like a property test) and with a whole-of-system mindset (like an end-to-end integration test).</p>
<p>The construction should be such that with each run, you <em>randomly</em> chart a path through the huge state space. Randomness and determinism may sound contradictory, but each choice flows from a single point – the random number generator (RNG) seed.</p>
<h2 id="the-test-subject"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/dst#the-test-subject">The test subject</a></h2>
<p>DST works best when you not only check invariants as a client, but also more granularly in the mainline code – a practice in defense-in-depth. Databases in particular should be liberal with assertions; far better to loudly crash when <a href="https://en.wikipedia.org/wiki/ACID">ACID</a>ic properties are involved.</p>
<p>Incidentally, while some languages may treat assertions as something to be optimized away for production, I think Rust picked the right defaults here – it is a rare expensive check that we allow getting compiled away with <code>debug_assert!</code>.</p>
<p>But of course, we don’t <em>want</em> panics in production! To supercharge the long journey of building a robust distributed data system, the core of the actual test is synthesizing chaotic interactions over an extended period of simulated time. We run our DSTs on every PR, commit, and in thousands of nightly trials.</p>
<p>Beyond internal checks, the system must also maintain externally observable invariants that are best verified from a client's perspective as the simulation progresses, such as durability after a crash and API semantics around concurrent operations.</p>
<h2 id="making-it-deterministic"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/dst#making-it-deterministic">Making it deterministic</a></h2>
<p>What must be tamed to create a truly deterministic simulation?</p>
<ul>
<li><strong>Execution</strong> – Single-threaded to eliminate scheduler noise</li>
<li><strong>Entropy</strong> – All RNGs should have a known seed</li>
<li><strong>Time</strong> – No physical clocks</li>
<li><strong>I/O</strong> – No dependency on any external IO with components not part of the simulation, and subject to all the same constraints</li>
</ul>
<p>That’s a lot of variables to control for. Happily, we were able to leverage a lot of existing open source work done by the community!</p>
<p>Tokio does have first-class support for running with a single-threaded scheduler. Internally, its clock is abstracted, and can run “paused” for testing, where time only advances on calls to <code>sleep()</code>. By using <code>tokio::time::Instant</code> instead of <code>std::time::Instant</code>, you can ensure any measurement of elapsed time is aligned with this clock. The runtime also has an internal RNG used in making scheduling decisions such as picking a branch for <code>tokio::select!</code> – but this can be seeded.</p>
<p>Where does this leave us with regard to IO? Here we adopted the <a href="https://github.com/tokio-rs/turmoil">Turmoil</a> project, which presumes Tokio as a runtime.</p>
<blockquote><p>Turmoil is a framework for testing distributed systems. It provides deterministic execution by running multiple concurrent hosts within a single thread. It introduces “hardship” into the system via changes in the simulated network. The network can be controlled manually or with a seeded rng.</p></blockquote>
<p>Simulated networking is precisely what we needed, between logical 'hosts' in the same physical process. The ability to inject issues like latency and crashed processes is immensely useful for teasing out behavior under the unhappy paths.</p>
<p>Each of our networked services runs as one or more hosts. Turmoil provides counterparts to Tokio’s <code>TcpListener</code> / <code>TcpStream</code> which we use when in DST mode, behind a compile-time feature.</p>
<p>There are also external dependencies to consider, like metadata and object storage. The practice of coding to minimal interfaces allowed us to easily substitute the implementation. We have in-memory emulators that can be accessed over the simulated turmoil network, and also run as hosts in the simulation.</p>
<p>We managed to get simulations running – but as we dug deeper, they were not completely deterministic. We experienced failures in CI that we could not reproduce on our Macs, and in some cases even between runs on the same platform.</p>
<p>What were we failing to control for? Looking at <code>TRACE</code>-level logs, all kinds of differences stood out. Stuff like timestamps in HTTP packets 🤦.</p>
<p align="center">
<img src="https://s2.dev/blog/dst-slack.png" width="400" alt="Discussion in internal Slack on possible sources of non-determinism">
</p>
<p>Part of what makes Rust a productive language is the ecosystem of high-quality libraries. But every single one of them represents a possible source of multi-threading, reliance on an RNG, current system time, or external IO. Controlling the world is hard – and hence the space for a startup like <a href="https://antithesis.com/">Antithesis</a> to make this easy. However, it felt like we were closing in, and we weren’t ready to fold.</p>
<p>It was easy enough to tell no threads were being spun up, all work happened on the main thread. We knew there were no unexpected network calls, all communications were strictly over the simulated network. But for time and randomness – turmoil’s approach was not comprehensive enough to address what arbitrary dependencies may be doing!</p>
<p>We also realized subtleties like Rust’s <code>HashMap</code>s being <a href="https://github.com/rust-fuzz/book/issues/35">randomized for DOS prevention</a>. We could perhaps make sure our own app used a seeded <code>RandomState</code> for each hash map, but our dependencies..?</p>
<h2 id="blending-turmoil-and-madsim"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/dst#blending-turmoil-and-madsim">Blending Turmoil and MadSim</a></h2>
<p>This is where we started poking around <a href="https://github.com/madsim-rs/madsim">MadSim</a> – the <code>Magical Deterministic Simulator for distributed systems in Rust</code> – <a href="https://www.risingwave.com/blog/deterministic-simulation-a-new-era-of-distributed-system-testing/">used in RisingWave</a>. <em>What’s the magic?</em></p>
<p align="center">
  <a href="https://github.com/madsim-rs/madsim/discussions/159">
    <img src="https://s2.dev/blog/dst-madsim.png" width="600" alt="Github discussion in madsim repo about its approach to overriding libc functions">
  </a>
</p>
<p>We liked the overall ergonomics of a turmoil-based DST, but a bit of madness seemed like the missing ingredient – <code>libc</code> symbol overrides to control time and entropy. You can checkout the MadSim-derived crate we just pushed to Github, <a href="https://github.com/s2-streamstore/mad-turmoil?tab=readme-ov-file#setup">mad-turmoil</a>.</p>
<ul>
<li>The <code>rand</code> module overrides <code>getrandom</code>, <code>getentropy</code>, and (Mac-only) <code>CCRandomGenerateBytes</code>. The new implementations utilize an RNG we statically initialize with <code>set_rng()</code>.</li>
<li>The <code>time</code> module overrides <code>clock_gettime</code> using turmoil’s clock. This is scoped using a <code>SimClocksGuard</code> to avoid issues when the process is tearing down.</li>
</ul>
<h2 id="takeaways"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/dst#takeaways">Takeaways</a></h2>
<p><strong>So, are we deterministic yet?</strong> YES! To avoid repeating the scars of non-determinism, we also added a “meta test” in CI that reruns the same seed, and compares <code>TRACE</code>-level logs. Down to the last bytes on the wire, we have conformity. We can take a failing seed from CI, and easily reproduce it on our Macs.</p>
<p><strong>Was the effort worth it?</strong> It goes without saying, distributed data systems are complex and production usage will always bring surprises. But there is a tremendous sense of relief in knowing we can mature our system in simulated time, and catch a lot of problems early.</p>
<p>We have a running doc of all kinds of gnarly issues our DSTs have helped us find, and the tally stands at 17 that were notable. Everything from nuances of our external APIs and internal protocols, to plain old deadlocks. Many of these would make for interesting stories, so watch out for future posts!</p>]]></content:encoded>
            <author>shikhar@s2.dev (Shikhar Bhushan)</author>
            <category>testing</category>
            <category>distsys</category>
            <category>rust</category>
            <category>eng</category>
        </item>
        <item>
            <title><![CDATA[Blazing-fast IoT data pipeline for infrared monitoring]]></title>
            <link>https://s2.dev/blog/iot</link>
            <guid isPermaLink="false">https://s2.dev/blog/iot</guid>
            <pubDate>Fri, 31 Jan 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Build a low-latency IoT pipeline with a Raspberry Pi thermal sensor, S2 streams, and TypeScript for real-time monitoring.]]></description>
            <content:encoded><![CDATA[<p>Real-time IoT devices need real-time action, and wrangling hog loads of data from these devices requires a simple and fast streaming pipeline. Make the data durable, and you can easily have multiple consumers that don’t skip a beat.</p>
<p><strong>POV:</strong> You have a shared space with your housemates and want to know if it's empty, but in the least privacy-invasive way.</p>
<p>The Recipe:</p>
<ul>
<li><strong>AMG8833</strong> an infrared thermal imaging sensor – 8x8 array of infrared sensors, totaling <code>64 pixels</code> measuring temperatures ranging from <code>0°C to 80°C (32°F to 176°F) ±2.5°C (4.5°F)</code> up to <code>7 meters (23 feet)</code> away at a max frame rate of <code>10 Hz</code>. Cheap, and best for our use case as it covers a large area with a relatively high accuracy.</li>
<li><strong>Raspberry Pi 4B</strong> or just any low-cost, single-board computer.</li>
<li>An <strong>S2 account</strong> as our secret sauce! Follow along <a href="https://s2.dev/docs/quickstart">here</a>.</li>
</ul>
<p>For schematics, or to purchase and assemble the electronic components, I recommend following this <a href="https://makersportal.com/blog/thermal-camera-analysis-with-raspberry-pi-amg8833">MakerPortal article</a>, or this <a href="https://www.adafruit.com/product/3538">Adafruit page</a>.</p>
<p>Once you have everything set up, it should look something like this:</p>
<br>






<table><thead><tr><th align="center"><img src="https://s2.dev/blog/amg8833.jpg" alt="AMG8833 and Raspberry PI assembled" title="AMG8833 and Raspberry PI assembled"></th></tr></thead></table>
<p>The S2 API will allow us to model our raw sensor output as a <strong><code>Stream</code></strong>, which is just an unbounded sequence of records – in other words, our data in motion! The number of streams you can create is unlimited, all namespaced in a globally unique <strong><code>Basin</code></strong>.</p>
<p>Let's use the <a href="https://github.com/s2-streamstore/s2-cli">S2 CLI</a> to create a basin called <code>monitors</code>, with a stream named <code>amg8833</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> create-basin</span><span style="color:#9ECBFF"> monitors</span></span>
<span data-line=""><span style="color:#B392F0">✓</span><span style="color:#9ECBFF"> Basin</span><span style="color:#9ECBFF"> created</span></span>
<span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> create-stream</span><span style="color:#9ECBFF"> s2://monitors/amg8833</span></span>
<span data-line=""><span style="color:#B392F0">✓</span><span style="color:#9ECBFF"> Stream</span><span style="color:#9ECBFF"> created</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 create-basin monitors
✓ Basin created
$ s2 create-stream s2://monitors/amg8833
✓ Stream created" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>For building our backend I will use the <a href="https://github.com/s2-streamstore/s2-sdk-python">S2 Python SDK</a> and the <a href="https://github.com/adafruit/Adafruit_CircuitPython_AMG88xx">adafruit-circuitpython-amg88xx</a> library. Python will help us reduce friction and iterate faster.</p>
<p>Each frame from the sensor reshaped into a <code>2D 8x8 array</code> can be written as a record at the tail of the stream. Since we want to keep pushing data as we read from the sensor, we will use the streaming <a href="https://streamstore.readthedocs.io/en/stable/api-reference.html#streamstore.Stream.append_session"><code>AppendSession</code></a>, which gives us acknowledgments in the same order that records were sent.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="py" data-theme="github-dark"><code data-language="py" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> def</span><span style="color:#B392F0"> read_amg8833</span><span style="color:#E1E4E8">(sensor) -> AsyncIterable[AppendInput]:</span></span>
<span data-line=""><span style="color:#F97583">    while</span><span style="color:#79B8FF"> True</span><span style="color:#E1E4E8">:</span></span>
<span data-line=""><span style="color:#F97583">        try</span><span style="color:#E1E4E8">:</span></span>
<span data-line=""><span style="color:#E1E4E8">            loop </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> asyncio.get_running_loop()</span></span>
<span data-line=""><span style="color:#E1E4E8">            pixels </span><span style="color:#F97583">=</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> loop.run_in_executor(</span><span style="color:#79B8FF">None</span><span style="color:#E1E4E8">, </span><span style="color:#F97583">lambda</span><span style="color:#E1E4E8">: sensor.pixels)</span></span>
<span data-line=""><span style="color:#E1E4E8">            pixels </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> np.array(pixels)</span></span>
<span data-line=""><span style="color:#E1E4E8">            body </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> {</span><span style="color:#9ECBFF">"grid"</span><span style="color:#E1E4E8">: pixels.tolist()}</span></span>
<span data-line=""><span style="color:#F97583">            yield</span><span style="color:#E1E4E8"> AppendInput(</span><span style="color:#FFAB70">records</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">[Record(</span><span style="color:#FFAB70">body</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">json.dumps(body).encode(</span><span style="color:#9ECBFF">"utf-8"</span><span style="color:#E1E4E8">))])</span></span>
<span data-line=""><span style="color:#F97583">        except</span><span style="color:#79B8FF"> Exception</span><span style="color:#F97583"> as</span><span style="color:#E1E4E8"> e:</span></span>
<span data-line=""><span style="color:#79B8FF">            print</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">f</span><span style="color:#9ECBFF">"Sensor error: </span><span style="color:#79B8FF">{str</span><span style="color:#E1E4E8">(e)</span><span style="color:#79B8FF">}</span><span style="color:#9ECBFF">"</span><span style="color:#E1E4E8">)</span></span>
<span data-line=""><span style="color:#F97583">            await</span><span style="color:#E1E4E8"> asyncio.sleep(</span><span style="color:#79B8FF">5.0</span><span style="color:#E1E4E8">)</span></span><button type="button" title="Copy code" aria-label="Copy code" data="async def read_amg8833(sensor) -> AsyncIterable[AppendInput]:
    while True:
        try:
            loop = asyncio.get_running_loop()
            pixels = await loop.run_in_executor(None, lambda: sensor.pixels)
            pixels = np.array(pixels)
            body = {&#x22;grid&#x22;: pixels.tolist()}
            yield AppendInput(records=[Record(body=json.dumps(body).encode(&#x22;utf-8&#x22;))])
        except Exception as e:
            print(f&#x22;Sensor error: {str(e)}&#x22;)
            await asyncio.sleep(5.0)" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="py" data-theme="github-dark"><code data-language="py" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> def</span><span style="color:#B392F0"> producer</span><span style="color:#E1E4E8">(sensor):</span></span>
<span data-line=""><span style="color:#F97583">    async</span><span style="color:#F97583"> with</span><span style="color:#E1E4E8"> S2(</span><span style="color:#FFAB70">auth_token</span><span style="color:#F97583">=</span><span style="color:#79B8FF">AUTH_TOKEN</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">as</span><span style="color:#E1E4E8"> s2:</span></span>
<span data-line=""><span style="color:#E1E4E8">        stream </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> s2[</span><span style="color:#9ECBFF">"monitors"</span><span style="color:#E1E4E8">][</span><span style="color:#9ECBFF">"amg8833"</span><span style="color:#E1E4E8">]</span></span>
<span data-line=""><span style="color:#F97583">        async</span><span style="color:#F97583"> for</span><span style="color:#E1E4E8"> output </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> stream.append_session(sensor_data_gen(sensor)):</span></span>
<span data-line=""><span style="color:#79B8FF">            print</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">f</span><span style="color:#9ECBFF">"appended </span><span style="color:#79B8FF">{</span><span style="color:#E1E4E8">output.end_seq_num </span><span style="color:#F97583">-</span><span style="color:#E1E4E8"> output.start_seq_num</span><span style="color:#79B8FF">}</span><span style="color:#9ECBFF"> records"</span><span style="color:#E1E4E8">)</span></span>
<span data-line=""> </span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">if</span><span style="color:#79B8FF"> __name__</span><span style="color:#F97583"> ==</span><span style="color:#9ECBFF"> "__main__"</span><span style="color:#E1E4E8">:</span></span>
<span data-line=""><span style="color:#E1E4E8">    sensor </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> initialize_sensor()</span></span>
<span data-line=""><span style="color:#E1E4E8">    asyncio.run(producer(sensor))</span></span>
<span data-line=""> </span><button type="button" title="Copy code" aria-label="Copy code" data="async def producer(sensor):
    async with S2(auth_token=AUTH_TOKEN) as s2:
        stream = s2[&#x22;monitors&#x22;][&#x22;amg8833&#x22;]
        async for output in stream.append_session(sensor_data_gen(sensor)):
            print(f&#x22;appended {output.end_seq_num - output.start_seq_num} records&#x22;)


if __name__ == &#x22;__main__&#x22;:
    sensor = initialize_sensor()
    asyncio.run(producer(sensor))
" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>The S2 REST API <a href="https://s2.dev/docs/api/records/read#sse">supports SSE</a> through which it can push real-time updates to a client over a persistent HTTP connection. SSE is perfectly encapsulated by the <a href="https://github.com/s2-streamstore/s2-sdk-typescript">S2 Typescript SDK</a> generated using <a href="https://www.speakeasy.com/">Speakeasy</a>. This will power our little Next.js app, where I can view the "live footage" and know if someone is in the shared space 👀.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="ts" data-theme="github-dark"><code data-language="ts" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> basinUrl</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> 'https://monitors.b.s2.dev/v1alpha'</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#F97583">const</span><span style="color:#79B8FF"> s2</span><span style="color:#F97583"> =</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> S2</span><span style="color:#E1E4E8">({</span></span>
<span data-line=""><span style="color:#E1E4E8">  bearerAuth: </span><span style="color:#79B8FF">AUTH_TOKEN</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">interface</span><span style="color:#B392F0"> SensorData</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#FFAB70">  occupied</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> boolean</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#FFAB70">  grid</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> number</span><span style="color:#E1E4E8">[][];</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> AMG8833</span><span style="color:#E1E4E8">() {</span></span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#E1E4E8"> [</span><span style="color:#79B8FF">occupied</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">setOccupied</span><span style="color:#E1E4E8">] </span><span style="color:#F97583">=</span><span style="color:#B392F0"> useState</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">);</span></span>
<span data-line=""><span style="color:#F97583">  const</span><span style="color:#E1E4E8"> [</span><span style="color:#79B8FF">temperatureGrid</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">setTemperatureGrid</span><span style="color:#E1E4E8">] </span><span style="color:#F97583">=</span><span style="color:#B392F0"> useState</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#79B8FF">number</span><span style="color:#E1E4E8">[][]>([]);</span></span>
<span data-line=""><span style="color:#B392F0">  useEffect</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">    const</span><span style="color:#B392F0"> fetchStream</span><span style="color:#F97583"> =</span><span style="color:#F97583"> async</span><span style="color:#E1E4E8"> () </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">      try</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">        const</span><span style="color:#79B8FF"> tail</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> s2.stream.</span><span style="color:#B392F0">checkTail</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#E1E4E8">          { stream: </span><span style="color:#9ECBFF">'amg8833'</span><span style="color:#E1E4E8"> },</span></span>
<span data-line=""><span style="color:#E1E4E8">          { serverURL: basinUrl }</span></span>
<span data-line=""><span style="color:#E1E4E8">        );</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">        const</span><span style="color:#79B8FF"> result</span><span style="color:#F97583"> =</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> s2.stream.</span><span style="color:#B392F0">read</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#E1E4E8">          {</span></span>
<span data-line=""><span style="color:#E1E4E8">            stream: </span><span style="color:#9ECBFF">'amg8833'</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">            startSeqNum: tail.checkTailResponse</span><span style="color:#F97583">!!</span><span style="color:#E1E4E8">.nextSeqNum,</span></span>
<span data-line=""><span style="color:#E1E4E8">          },</span></span>
<span data-line=""><span style="color:#E1E4E8">          {</span></span>
<span data-line=""><span style="color:#E1E4E8">            serverURL: basinUrl,</span></span>
<span data-line=""><span style="color:#E1E4E8">            acceptHeaderOverride: ReadAcceptEnum.textEventStream,</span></span>
<span data-line=""><span style="color:#E1E4E8">          }</span></span>
<span data-line=""><span style="color:#E1E4E8">        );</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">        const</span><span style="color:#79B8FF"> stream</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> result.readResponse;</span></span>
<span data-line=""><span style="color:#F97583">        if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">(stream </span><span style="color:#F97583">instanceof</span><span style="color:#B392F0"> EventStream</span><span style="color:#E1E4E8">)) </span><span style="color:#F97583">return</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">        for</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">const</span><span style="color:#79B8FF"> event</span><span style="color:#F97583"> of</span><span style="color:#E1E4E8"> stream) {</span></span>
<span data-line=""><span style="color:#F97583">          const</span><span style="color:#79B8FF"> outputData</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> (event </span><span style="color:#F97583">as</span><span style="color:#B392F0"> ReadResponseOutput</span><span style="color:#E1E4E8">).data;</span></span>
<span data-line=""><span style="color:#F97583">          if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">outputData) </span><span style="color:#F97583">continue</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">          const</span><span style="color:#79B8FF"> batch</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> outputData </span><span style="color:#F97583">as</span><span style="color:#B392F0"> Batch</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#F97583">          if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">batch.batch?.records) </span><span style="color:#F97583">continue</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">          for</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">const</span><span style="color:#79B8FF"> record</span><span style="color:#F97583"> of</span><span style="color:#E1E4E8"> batch.batch.records) {</span></span>
<span data-line=""><span style="color:#F97583">            try</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">              const</span><span style="color:#79B8FF"> sensorData</span><span style="color:#F97583"> =</span><span style="color:#79B8FF"> JSON</span><span style="color:#E1E4E8">.</span><span style="color:#B392F0">parse</span><span style="color:#E1E4E8">(record.body) </span><span style="color:#F97583">as</span><span style="color:#B392F0"> SensorData</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#B392F0">              setOccupied</span><span style="color:#E1E4E8">(sensorData.occupied);</span></span>
<span data-line=""><span style="color:#B392F0">              setTemperatureGrid</span><span style="color:#E1E4E8">(sensorData.grid);</span></span>
<span data-line=""><span style="color:#E1E4E8">            } </span><span style="color:#F97583">catch</span><span style="color:#E1E4E8"> (parseError) {</span></span>
<span data-line=""><span style="color:#E1E4E8">              console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Error parsing sensor data:'</span><span style="color:#E1E4E8">, parseError);</span></span>
<span data-line=""><span style="color:#E1E4E8">            }</span></span>
<span data-line=""><span style="color:#E1E4E8">          }</span></span>
<span data-line=""><span style="color:#E1E4E8">        }</span></span>
<span data-line=""><span style="color:#E1E4E8">      } </span><span style="color:#F97583">catch</span><span style="color:#E1E4E8"> (error) {</span></span>
<span data-line=""><span style="color:#E1E4E8">        console.</span><span style="color:#B392F0">error</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Error fetching stream:'</span><span style="color:#E1E4E8">, error);</span></span>
<span data-line=""><span style="color:#E1E4E8">      }</span></span>
<span data-line=""><span style="color:#E1E4E8">    };</span></span>
<span data-line=""><span style="color:#B392F0">    fetchStream</span><span style="color:#E1E4E8">();</span></span>
<span data-line=""><span style="color:#E1E4E8">  }, []);</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="const basinUrl = &#x27;https://monitors.b.s2.dev/v1alpha&#x27;;
const s2 = new S2({
  bearerAuth: AUTH_TOKEN,
});

interface SensorData {
  occupied: boolean;
  grid: number[][];
}

export default function AMG8833() {
  const [occupied, setOccupied] = useState(false);
  const [temperatureGrid, setTemperatureGrid] = useState<number[][]>([]);
  useEffect(() => {
    const fetchStream = async () => {
      try {
        const tail = await s2.stream.checkTail(
          { stream: &#x27;amg8833&#x27; },
          { serverURL: basinUrl }
        );

        const result = await s2.stream.read(
          {
            stream: &#x27;amg8833&#x27;,
            startSeqNum: tail.checkTailResponse!!.nextSeqNum,
          },
          {
            serverURL: basinUrl,
            acceptHeaderOverride: ReadAcceptEnum.textEventStream,
          }
        );

        const stream = result.readResponse;
        if (!(stream instanceof EventStream)) return;

        for await (const event of stream) {
          const outputData = (event as ReadResponseOutput).data;
          if (!outputData) continue;

          const batch = outputData as Batch;
          if (!batch.batch?.records) continue;

          for (const record of batch.batch.records) {
            try {
              const sensorData = JSON.parse(record.body) as SensorData;
              setOccupied(sensorData.occupied);
              setTemperatureGrid(sensorData.grid);
            } catch (parseError) {
              console.error(&#x27;Error parsing sensor data:&#x27;, parseError);
            }
          }
        }
      } catch (error) {
        console.error(&#x27;Error fetching stream:&#x27;, error);
      }
    };
    fetchStream();
  }, []);
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>I prompted <a href="https://v0.dev/">v0</a> to help me plot this temperature grid as real pixels converting temperature to <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl">hsl</a>, and I was able to <a href="https://github.com/s2-streamstore/iot-amg8833">just ship it!</a></p>






<table><thead><tr><th align="center"><img src="https://s2.dev/blog/thermal.gif" alt="Thermal image based on AMG8833 readings, plotted by v0 generated code" title="AMG8833 thermal image"></th></tr></thead></table>
<p>I added an extra field to the records (see the small bar at the top of the thermal image) to determine whether someone is really in the frame or not by doing some naïve <a href="https://en.wikipedia.org/wiki/Connected-component_labeling">connected-component labeling</a> to notify me in a text message.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="py" data-theme="github-dark"><code data-language="py" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#E1E4E8">binary_mask </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> pixels </span><span style="color:#F97583">></span><span style="color:#79B8FF"> 28</span></span>
<span data-line=""><span style="color:#E1E4E8">structure </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> generate_binary_structure(</span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">, </span><span style="color:#79B8FF">2</span><span style="color:#E1E4E8">)</span></span>
<span data-line=""><span style="color:#E1E4E8">labeled_array, num_features </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> label(binary_mask, </span><span style="color:#FFAB70">structure</span><span style="color:#F97583">=</span><span style="color:#E1E4E8">structure)</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E1E4E8">connections </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> sum</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#E1E4E8">    [np.count_nonzero(labeled_array </span><span style="color:#F97583">==</span><span style="color:#E1E4E8"> i) </span><span style="color:#F97583">></span><span style="color:#79B8FF"> 4</span><span style="color:#F97583"> for</span><span style="color:#E1E4E8"> i </span><span style="color:#F97583">in</span><span style="color:#79B8FF"> range</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">1</span><span style="color:#E1E4E8">, num_features </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">)]</span></span>
<span data-line=""><span style="color:#E1E4E8">)</span></span>
<span data-line=""><span style="color:#E1E4E8">occupied </span><span style="color:#F97583">=</span><span style="color:#79B8FF"> bool</span><span style="color:#E1E4E8">(connections </span><span style="color:#F97583">></span><span style="color:#79B8FF"> 0</span><span style="color:#E1E4E8">)</span></span>
<span data-line=""><span style="color:#E1E4E8">occupied </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> {</span><span style="color:#9ECBFF">"occupied"</span><span style="color:#E1E4E8">: occupied, </span><span style="color:#9ECBFF">"grid"</span><span style="color:#E1E4E8">: pixels.tolist()}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="binary_mask = pixels > 28
structure = generate_binary_structure(2, 2)
labeled_array, num_features = label(binary_mask, structure=structure)

connections = sum(
    [np.count_nonzero(labeled_array == i) > 4 for i in range(1, num_features + 1)]
)
occupied = bool(connections > 0)
occupied = {&#x22;occupied&#x22;: occupied, &#x22;grid&#x22;: pixels.tolist()}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>A sweet spot temperature threshold was 28-29°C, and positioning the sensor at a height around 7-8 feet in a cooler area tends to work quite accurately!</p>
<p>The obvious next step that comes to my mind is to train a machine learning model, using supervised learning, for determining the number of people in the frame. Here, S2 stands out since we can not only consume data at the time it is published by the device, but have it available as a durable stream of records to train my model.</p>
<p>So how much does this project cost us in a month? S2 is free for now, but we can refer to the <a href="https://s2.dev/pricing">intended pricing</a> and figure out a monthly estimate.</p>
<p>Each record is ~470 bytes and at 10 Hz over the course of a month this would be ~12 GiB of data.</p>
<ul>
<li>Initial operations to <code>CreateBasin</code> and <code>CreateStream</code> are fractions-of-a-cent.</li>
<li><code>AppendSession</code> and <code>ReadSession</code> have a per-minute cost of $0.0000001, adding up to $0.00864.</li>
<li>If we also keep record retention at a month, at $0.04/GiB-month that will cost us $0.48.</li>
<li>Data transfer into S2 depends on the storage class. We'll go for the faster <code>Express</code> at $0.06 per GiB, which works out to $0.72.</li>
<li>Data transfer out from S2 depends on where we are accessing it from, with the most cost-efficient being same cloud region. We'll be accessing the stream from home so at $0.08 per GiB for internet egress – assuming we kept the footage up at all times! – this works out to $0.96.</li>
</ul>
<p>All together, around $2 a month. Not having to worry about high costs, and pushing that side project with a simple API for your data pipeline? Win!</p>
<p>S2 is currently in preview. Come join us on <a href="https://discord.gg/vTCs7kMkAf">Discord</a> and get hacking!</p>]]></content:encoded>
            <author>mehul@s2.dev (Mehul Arora)</author>
            <category>use-case</category>
            <category>iot</category>
            <category>tutorial</category>
            <category>typescript</category>
        </item>
        <item>
            <title><![CDATA[One weird trick to durably replicate your KV store]]></title>
            <link>https://s2.dev/blog/kv-store</link>
            <guid isPermaLink="false">https://s2.dev/blog/kv-store</guid>
            <pubDate>Fri, 10 Jan 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[A deep dive on using S2 as a shared log to replicate a strongly consistent KV store with append, read, and check-tail operations.]]></description>
            <content:encoded><![CDATA[<div dir="auto" class="callout" style="--callout-color-light: rgb(8, 109, 221); --callout-color-dark: rgb(2, 122, 255);"><div class="callout-title"><div class="callout-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg></div><div class="callout-title-inner">Note</div></div><div class="callout-content"><p>The KV-store described in this post was also touched upon in a talk at <a href="https://www.hytradboi.com/2025">HYTRADBOI 2025</a>. Check out the <a href="https://www.hytradboi.com/2025/f2cc03cb-14fc-42f4-ad38-b4b15a15815f-serverless-primitives-for-the-shared-log-architecture">video</a>!</p></div></div>
<h2 id="overview"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#overview">Overview</a></h2>
<p>S2 is fundamentally a building block. We believe it is an excellent fit for a variety of use cases that require fast, scalable, and cost-effective access to sequenced data: change data capture, feature transformation, observability events, and click streams, to name just a few. A lot of the time, you really just want an object-storage-like interface for durable streams of records.</p>
<p>We are continuing to iterate on the core capabilities of S2, such as supporting record timestamps in addition to sequence numbers (on its way), and key-based compaction (roadmap). We also have our sights on offering higher-level layers for compatibility with protocols like Kafka that offer features like consumer groups and queue semantics, via software that runs <em>on top of</em> S2.</p>
<p>As we embark on building these additional layers, we are making the core S2 service available for <strong>anyone</strong> to use as a foundation for their own data-intensive systems. This post explores one concrete example of what that can look like, using a beloved "hello world" distributed data system: a <strong>replicated key-value store</strong>.</p>
<p>The S2 API is <a href="https://s2.dev/docs/concepts/records">deliberately simple</a>; however, it is by no means limited to basic use cases. S2 is designed to provide a first-class implementation of the <strong>shared log</strong> abstraction, a powerful tool for distributed systems engineers that has gained traction in recent years.</p>
<p>This post is structured in three parts:</p>
<ol>
<li><a href="https://s2.dev/blog/kv-store#part-1-the-shared-log-abstraction">An intro to the concept of shared logs.</a></li>
<li><a href="https://s2.dev/blog/kv-store#part-2-designing-a-replicated-kv-store">Exploring how S2's stream API can be used to design a multi-primary replicated KV store with strong consistency properties.</a> <em>(This part won't be language specific at all.)</em></li>
<li><a href="https://s2.dev/blog/kv-store#part-3-implementing-the-kv-store-using-the-rust-sdk">Digging into a sample implementation of the system which uses S2's Rust SDK.</a></li>
</ol>
<div dir="auto" class="callout" style="--callout-color-light: rgb(8, 109, 221); --callout-color-dark: rgb(2, 122, 255);"><div class="callout-title"><div class="callout-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></div><div class="callout-title-inner">Code example</div></div><div class="callout-content"><p>The code example we'll be walking through is <a href="https://github.com/s2-streamstore/s2-kv-demo">on GitHub</a>. If you want to jump right into that, head over to the <a href="https://github.com/s2-streamstore/s2-kv-demo/blob/main/README.md"><code>README</code></a> where you can find some quickstart materials for getting it up and running locally, as well as sample <code>curl</code> invocations to exercise it.</p></div></div>
<h2 id="part-1-the-shared-log-abstraction"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#part-1-the-shared-log-abstraction">Part 1: The shared log abstraction</a></h2>
<p><a href="https://en.wikipedia.org/wiki/Write-ahead_logging">Write-ahead logs</a> (or "WAL"s) have been a cornerstone of database durability for decades. Traditionally, WALs are stored on non-volatile storage that is co-located with compute, and are used to provide crash recovery for database nodes. As soon as a mutation has been persisted in the WAL, it can be considered durable — its effects can be deterministically recovered after a crash — and the database is free to asynchronously complete more expensive actions, like rewriting a page in a B-tree.</p>
<p>In the distributed context, maintaining copies of a database for scalability, availability, and performance is notoriously tricky but well-researched. There are many types of consistency guarantees that a system may target, but an underlying challenge in a replicated setup is making nodes agree about the content of the database at a given moment. Strong <a href="https://jepsen.io/consistency/models">consistency models</a> promise that even a highly distributed database appears as if we are communicating with one that were only running on a single machine.</p>
<p>This is where consensus protocols like Paxos or Raft enter the picture to power <a href="https://en.wikipedia.org/wiki/State_machine_replication">state machine replication</a> (or "SMR"). Generally, the database implementor has to take on a lot of the heavy-lifting involved in SMR, which has a whole <a href="https://transactional.blog/blog/2024-data-replication-design-spectrum">spectrum of design possibilities</a>.</p>
<p>Suppose that instead of residing on a block storage device attached to a single machine, the WAL were distributed — able to be written to and read from multiple nodes on different hosts concurrently, and independently durable across node failures.</p>
<p>If we had something like this, we could use the WAL itself, <a href="https://blog.schmizz.net/disaggregated-wal">"disaggregated" from local compute</a>, as the mechanism for distributing state across our replicas. Our core database code could then be much more focused on indexing and querying semantics.</p>
<p>This is the concept of the shared log, which Mahesh Balakrishnan explores in depth in his 2024 paper, <a href="https://maheshba.bitbucket.io/papers/osr2024.pdf">Taming Consensus in the Wild</a>:</p>
<blockquote><p>As an analogy, think of the SMR platform as a filesystem and the shared log as a block device. In much the same way that a block device makes it easier to build a filesystem without worrying about hardware internals (e.g., HDD vs. SSD), a shared log helps us write an SMR layer without reasoning about the internals of the consensus protocol. The SMR layer is then free to focus on the complexity of materialization, snapshot management, query scalability, single-node failure atomicity, etc., in much the same way that a filesystem can focus on file-grain multiplexing, directories, crash consistency, etc.</p></blockquote>
<p>This idea is at the heart of the <a href="https://engineering.fb.com/2019/06/06/data-center-engineering/delos/">Delos</a> system at Meta, used for powering control plane services.</p>
<p>More recently, Amazon has shared <a href="https://assets.amazon.science/e0/1b/ba6c28034babbc1b18f54aa8102e/amazon-memorydb-a-fast-and-durable-memory-first-cloud-database.pdf">how they built MemoryDB</a>, a strongly-consistent replicated Redis, using this same technique:</p>
<blockquote><p>MemoryDB offloads durability concerns to a separate low-latency, durable transaction log service, allowing us to scale performance, availability, and durability independently from the in-memory execution engine.</p></blockquote>
<p>Access to a system that provides sufficient durability, performance, and a suitable API for using as a shared log service is, alas, pretty hard to come by unless you happen to find yourself at Meta or Amazon.</p>
<p>We want S2 to be the <strong>public implementation</strong> of exactly this type of service, which has to date been a super-power mostly just for super-scalers.</p>
<h2 id="part-2-designing-a-replicated-kv-store"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#part-2-designing-a-replicated-kv-store">Part 2: Designing a replicated KV store</a></h2>
<p>Let's see how a key-value database can be implemented using S2 as a shared log. Our example will not have nearly as sophisticated of an API as Redis — to start with, we'll just support <code>get</code>/<code>put</code>/<code>delete</code>.</p>
<h3 id="goals"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#goals">Goals</a></h3>
<p>Some things we would like to see in our KV store:</p>
<ul>
<li>HTTP interface over a <code>get</code>/<code>put</code>/<code>delete</code> REST API: it should be easy to interact with the system using <code>curl</code>.</li>
<li>Durability: specifically, in multiple availability zones of a cloud region. Writes modifying the state of the store cannot be lost once acknowledged.</li>
<li>Horizontal scalability: we aim to distribute our requests across an arbitrary number of replicas, for improved performance and availability.</li>
<li>Multi-primary: each of our nodes should be capable of servicing both reads and writes. S2 does support concurrency control mechanisms<sup><a href="https://s2.dev/blog/kv-store#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup> which simplify the robust implementation of leader/follower schemes, but we will dive into that in a future post.</li>
<li>Strongly consistent, <a href="https://jepsen.io/consistency/models/linearizable">linearizable</a> reads and writes: we want every read or write to reflect all prior writes that have completed before it (and also <em>in the order</em> in which those writes occurred). For clients that don't require strong consistency, we can provide better performance with an opt-in "eventual consistency" mode.</li>
</ul>
<h3 id="non-goals"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#non-goals">Non-goals</a></h3>
<p>Some aspects we will consider acceptable limitations, but all of which make for good <em>"exercises left for the reader"</em> 😄:</p>
<ul>
<li>Returning prior values of a key from <code>put</code> and <code>delete</code>.</li>
<li>Supporting values larger than 1 MiB, which is the limit on an S2 record's size.</li>
<li>Snapshotting to allow for restoring state via a combination of a snapshot and, only for recent writes, the log.</li>
<li>Sharding or any sort of data partitioning.</li>
</ul>
<h3 id="connecting-to-s2-concepts"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#connecting-to-s2-concepts">Connecting to S2 concepts</a></h3>
<p>To build this system, we need a shared log, stored durably, where each entry in that log provides some payload representing a change to our KV store. These log entries will need to be totally ordered, so that there is no ambiguity about the sequence of events, and we can deterministically reconstruct state from the log.</p>
<p>Maybe you can already see where this is going... this is exactly what we get with <a href="https://s2.dev/docs/concepts/records">an S2 stream</a>! We are going to use "log entry" and "record" interchangeably for the rest of this post.</p>
<h3 id="appendsession-for-log-writes"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#appendsession-for-log-writes"><code>AppendSession</code> for log writes</a></h3>
<p>S2 streams can be written to via unary or streaming RPCs. Since our KV store is always going to need access to its log, each node can maintain a streaming <code>AppendSession</code> for its lifetime.</p>
<p>Only two of the routes in our KV store's API actually "write": <code>put</code> which supplies a new value, and <code>delete</code> which removes the existing value. Any time a node receives a request for one of these, it will also need to prepare a log entry corresponding to those actions, and send that as an append over the session.</p>
<p>The actual format of this entry can be virtually any lossless representation of the key it applies to and the value being supplied (in the case of <code>put</code>).</p>
<p>Once we have encoded our entry and sent it to S2, we have to wait to receive a corresponding acknowledgement that it has become durably sequenced within the log before we can in turn send an acknowledgment to the KV store requestor.</p>
<p>The <code>AppendSession</code> RPC is full-duplex. We can send records to it and concurrently also receive acknowledgements (or an error) from it. The acknowledgements will always arrive in the same order as the inputs.</p>











<table><thead><tr><th align="center"><img src="https://s2.dev/blog/mermaid-put.svg" alt="Sequence diagram for a put" title="A KV store put"></th></tr></thead><tbody><tr><td align="center">Sequence diagram for a <code>put</code> to the KV store</td></tr></tbody></table>
<p>S2 append latency is in the critical path of <code>put</code> and <code>delete</code> calls on our store, so we can never return faster than the "round-trip time" to append a record and receive its acknowledgment. Accordingly, these write-triggering calls on our KV store will typically be in the low tens of milliseconds (50 ms at p99) — assuming our KV store is also running in the same cloud region as S2,<sup><a href="https://s2.dev/blog/kv-store#user-content-fn-2" id="user-content-fnref-2" data-footnote-ref="" aria-describedby="footnote-label">2</a></sup> and that we elect to use S2's <a href="https://s2.dev/blog/intro#serverless--at-what-cost">Express storage class</a>. <em>(We do plan to offer a faster storage class in the future for single-digit millisecond latencies.)</em></p>
<h3 id="readsession-and-checktail-for-log-reads"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#readsession-and-checktail-for-log-reads"><code>ReadSession</code> and <code>CheckTail</code> for log reads</a></h3>
<p>We have not covered the actual "materialized state" of our KV store yet.</p>
<p>If we were not going for horizontal scalability, and could guarantee that there would only ever be one node running, then serving reads would simply be a matter of consulting with the node's internal state stored in an in-memory dictionary of some sort. Writes could update that state directly, after receiving an acknowledgment from S2. <code>Get</code> requests would also be incredibly fast, as there would be no external service to communicate with.<sup><a href="https://s2.dev/blog/kv-store#user-content-fn-3" id="user-content-fnref-3" data-footnote-ref="" aria-describedby="footnote-label">3</a></sup></p>
<p>In a multi-primary setup, however, any of the KV store replicas might be concurrently writing to the shared log, so updating the state directly in response to an append wouldn't work. We could be missing any writes that occurred on other nodes, and replicas would quickly get out of sync.</p>
<p>Instead, we can have each node keep a tailing <code>ReadSession</code> open for its lifetime. This is a half-duplex RPC that will let us read all entries on the shared log, in the same order in which they were appended, with minimal latency overhead. If all nodes then apply those entries to their local copies of the materialized state, they will end up as identical replicas of our KV store, and any of them can service <code>get</code> requests by consulting their local copy.</p>
<p>Almost, anyway. <code>ReadSession</code>s do tail updates to the log in real time, but to provide strong consistency we also have to ensure that the node which is servicing the <code>get</code> is fully caught up with the log.</p>
<p>Suppose a <code>get</code> request comes in to one of our nodes just after a <code>put</code> completed (either on the same node or a different one), but our read session hasn't quite caught up with it yet. If we didn't do any extra coordination, and simply served the <code>get</code> using the current materialized state, we could return a stale value, and our system wouldn't actually be linearizable.</p>
<p>This is where <code>CheckTail</code> comes in. This operation gives us the current sequence number representing the tail of the stream (i.e., the sequence number that would be assigned to the next record appended). When a <code>get</code> arrives, our KV store just needs to check the current tail of its log, and compare it to the last sequence number which has been reflected in the local state. If the applied local state lags from the tail, we simply need to wait until we've caught up to it over the <code>ReadSession</code>.</p>











<table><thead><tr><th align="center"><img src="https://s2.dev/blog/mermaid-strong-get.svg" alt="Sequence diagram for a strongly consistent get" title="A KV store strongly consistent get"></th></tr></thead><tbody><tr><td align="center">Sequence diagram for a strongly consistent <code>get</code> to the KV store</td></tr></tbody></table>
<p>Similar to <code>put</code> and <code>delete</code>, which are bounded below by the time it takes to append and receive an S2 acknowlegement, any <code>get</code> on our KV store is bounded by the time it takes for <code>check_tail</code> to return. Unlike appends, this latency is not a function of the stream's storage class, <a href="https://s2.dev/docs/api/records/check-tail">and should be quite fast</a>. Nevertheless, clients that can tolerate <em>eventual consistency</em> reads (potentially stale values) may elect to forego the <code>check_tail</code> operation for better latency.</p>
<h4 id="wrapping-it-up"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#wrapping-it-up">Wrapping it up</a></h4>
<p>If we do all of this, we will end up with a KV store that can satisfy our lofty <a href="https://s2.dev/blog/kv-store#goals">goals</a>. We only have to deal with three fundamental operations on a stream: <a href="https://s2.dev/docs/api/records/append">append</a>, <a href="https://s2.dev/docs/api/records/read">read</a>, and <a href="https://s2.dev/docs/api/records/check-tail">check_tail</a>. This is the power of decoupled storage and compute!</p>
<h2 id="part-3-implementing-the-kv-store-using-the-rust-sdk"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#part-3-implementing-the-kv-store-using-the-rust-sdk">Part 3: Implementing the KV store using the Rust SDK</a></h2>
<p>Now we'll walk through <a href="https://github.com/s2-streamstore/s2-kv-demo/tree/main">an actual implementation</a> of this system that is built around the <a href="https://github.com/s2-streamstore/s2-sdk-rust">S2 Rust SDK</a>.</p>
<p>If Rust is not your jam, SDKs for other languages are on their way — starting with <a href="https://github.com/s2-streamstore/s2-sdk-go">Go</a>, <a href="https://github.com/s2-streamstore/s2-sdk-python">Python</a>, and <a href="https://github.com/s2-streamstore/s2-sdk-java">Java</a> — as well as a REST API.</p>
<h3 id="setup"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#setup">Setup</a></h3>
<h4 id="data-formats"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#data-formats">Data formats</a></h4>
<p>Keys in our system can simply be <code>String</code>s. For values, we'd like to support several common datatypes, for convenience.</p>
<p>A reasonable approach to this is to create a new <code>Value</code> enum which wraps other types, e.g.:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">enum</span><span style="color:#B392F0"> Value</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#B392F0">    Str</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">String</span><span style="color:#E1E4E8">),</span></span>
<span data-line=""><span style="color:#B392F0">    UInt</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">u64</span><span style="color:#E1E4E8">),</span></span>
<span data-line=""><span style="color:#B392F0">    List</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">Vec</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">Value</span><span style="color:#E1E4E8">>)</span></span>
<span data-line=""><span style="color:#6A737D">    // ...</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="enum Value {
    Str(String),
    UInt(u64),
    List(Vec<Value>)
    // ...
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Our materialized state can be stored in just about any map datastructure. In the demo, we use a <code>BTreeMap&#x3C;String, Value></code>.</p>
<p>Similarly, log entries will need to represent <code>Put</code> and <code>Delete</code> actions:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#E1E4E8">#[derive(</span><span style="color:#B392F0">Clone</span><span style="color:#E1E4E8">, </span><span style="color:#B392F0">Debug</span><span style="color:#E1E4E8">, </span><span style="color:#B392F0">Deserialize</span><span style="color:#E1E4E8">, </span><span style="color:#B392F0">Serialize</span><span style="color:#E1E4E8">)]</span></span>
<span data-line=""><span style="color:#F97583">enum</span><span style="color:#B392F0"> LogEntry</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#B392F0">    Put</span><span style="color:#E1E4E8"> { key</span><span style="color:#F97583">:</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8">, value</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Value</span><span style="color:#E1E4E8"> },</span></span>
<span data-line=""><span style="color:#B392F0">    Delete</span><span style="color:#E1E4E8"> { key</span><span style="color:#F97583">:</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8"> },</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="#[derive(Clone, Debug, Deserialize, Serialize)]
enum LogEntry {
    Put { key: String, value: Value },
    Delete { key: String },
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>S2 streams expect to receive data as batches of <a href="https://docs.rs/streamstore/latest/s2/types/struct.AppendRecord.html"><code>AppendRecord</code>s</a>. These consist of a binary body, as well an optional set of <a href="https://docs.rs/streamstore/latest/s2/types/struct.Header.html">headers</a>. Since we're working with bytes for the body, we have complete control over how to encode our log entries as records. The only real constraint is that, since no record can exceed 1MiB on S2, our <code>Value</code>s must also be small enough to be serialized within that budget.</p>
<p>For convenience and debug-ability, we can simply encode our log entries as JSON bytes using the <a href="https://serde.rs/">serde</a> library, so that we get a nice human-readable represetation when reading the log via the <a href="https://s2.dev/docs/quickstart#get-started-with-the-cli">CLI</a>. In a production system, we would probably want to adopt an efficient binary format.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">s2</span><span style="color:#9ECBFF"> read</span><span style="color:#9ECBFF"> "s2://${</span><span style="color:#E1E4E8">MY_BASIN</span><span style="color:#9ECBFF">}/${</span><span style="color:#E1E4E8">MY_STREAM</span><span style="color:#9ECBFF">}"</span></span><button type="button" title="Copy code" aria-label="Copy code" data="s2 read &#x22;s2://${MY_BASIN}/${MY_STREAM}&#x22;" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>... might for example produce something like:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="json" data-theme="github-dark"><code data-language="json" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#79B8FF">"Put"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"key"</span><span style="color:#E1E4E8">:</span><span style="color:#9ECBFF">"hello"</span><span style="color:#E1E4E8">,</span><span style="color:#79B8FF">"value"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"Str"</span><span style="color:#E1E4E8">:</span><span style="color:#9ECBFF">"world"</span><span style="color:#E1E4E8">}}}</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#79B8FF">"Put"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"key"</span><span style="color:#E1E4E8">:</span><span style="color:#9ECBFF">"s2"</span><span style="color:#E1E4E8">,</span><span style="color:#79B8FF">"value"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"Set"</span><span style="color:#E1E4E8">:[{</span><span style="color:#79B8FF">"Str"</span><span style="color:#E1E4E8">:</span><span style="color:#9ECBFF">"is really cool"</span><span style="color:#E1E4E8">},{</span><span style="color:#79B8FF">"UInt"</span><span style="color:#E1E4E8">:</span><span style="color:#79B8FF">1337</span><span style="color:#E1E4E8">}]}}}</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#79B8FF">"Delete"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"key"</span><span style="color:#E1E4E8">:</span><span style="color:#9ECBFF">"hello"</span><span style="color:#E1E4E8">}}</span></span>
<span data-line=""><span style="color:#E1E4E8">{</span><span style="color:#79B8FF">"Put"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"key"</span><span style="color:#E1E4E8">:</span><span style="color:#9ECBFF">"map-sample"</span><span style="color:#E1E4E8">,</span><span style="color:#79B8FF">"value"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"Map"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"k1"</span><span style="color:#E1E4E8">:{</span><span style="color:#79B8FF">"Str"</span><span style="color:#E1E4E8">:</span><span style="color:#9ECBFF">"hello"</span><span style="color:#E1E4E8">}}}}}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{&#x22;Put&#x22;:{&#x22;key&#x22;:&#x22;hello&#x22;,&#x22;value&#x22;:{&#x22;Str&#x22;:&#x22;world&#x22;}}}
{&#x22;Put&#x22;:{&#x22;key&#x22;:&#x22;s2&#x22;,&#x22;value&#x22;:{&#x22;Set&#x22;:[{&#x22;Str&#x22;:&#x22;is really cool&#x22;},{&#x22;UInt&#x22;:1337}]}}}
{&#x22;Delete&#x22;:{&#x22;key&#x22;:&#x22;hello&#x22;}}
{&#x22;Put&#x22;:{&#x22;key&#x22;:&#x22;map-sample&#x22;,&#x22;value&#x22;:{&#x22;Map&#x22;:{&#x22;k1&#x22;:{&#x22;Str&#x22;:&#x22;hello&#x22;}}}}}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h4 id="http-server"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#http-server">HTTP server</a></h4>
<p>We can use the <a href="https://github.com/tokio-rs/axum"><code>axum</code> library</a> for our actual HTTP server, and set up routes corresponding to our KV store's <code>get</code>, <code>put</code>, and <code>delete</code> API by using the RESTful <code>GET</code>, <code>PUT</code>, and <code>DELETE</code> HTTP methods respectively. The README in the <a href="https://github.com/s2-streamstore/s2-kv-demo">demo repo</a> has some sample <code>curl</code> invocations for testing these routes out with actual values.</p>
<p>For additional debug context, we'll also have each route return the shared log range reflected by the corresponding action. Any consumer of this specified portion of the log would see the value that was returned in the response, or in the case of <code>put</code>, the value which was provided in the request. The range values are simply S2 stream sequence numbers.</p>
<p>For example, the acknowledgment from this <code>put</code>:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> curl</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">    --silent</span><span style="color:#79B8FF"> -H</span><span style="color:#9ECBFF"> 'Content-Type: application/json'</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">    -X</span><span style="color:#9ECBFF"> PUT</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#79B8FF">    -d</span><span style="color:#9ECBFF"> '{"key": "hello", "value": {"Str": "world"}}'</span><span style="color:#79B8FF"> \</span></span>
<span data-line=""><span style="color:#9ECBFF">    "localhost:4001/api"</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ curl \
    --silent -H &#x27;Content-Type: application/json&#x27; \
    -X PUT \
    -d &#x27;{&#x22;key&#x22;: &#x22;hello&#x22;, &#x22;value&#x22;: {&#x22;Str&#x22;: &#x22;world&#x22;}}&#x27; \
    &#x22;localhost:4001/api&#x22;" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>... might look like this:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="json" data-theme="github-dark"><code data-language="json" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#E1E4E8">{ </span><span style="color:#79B8FF">"Ok"</span><span style="color:#E1E4E8">: { </span><span style="color:#79B8FF">"end"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">272</span><span style="color:#E1E4E8"> } }</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{ &#x22;Ok&#x22;: { &#x22;end&#x22;: 272 } }" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>A subsequent <code>get</code> for that key might return this:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="json" data-theme="github-dark"><code data-language="json" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#E1E4E8">{ </span><span style="color:#79B8FF">"Ok"</span><span style="color:#E1E4E8">: [{ </span><span style="color:#79B8FF">"end"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">272</span><span style="color:#E1E4E8"> }, { </span><span style="color:#79B8FF">"Str"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"world"</span><span style="color:#E1E4E8"> }] }</span></span><button type="button" title="Copy code" aria-label="Copy code" data="{ &#x22;Ok&#x22;: [{ &#x22;end&#x22;: 272 }, { &#x22;Str&#x22;: &#x22;world&#x22; }] }" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>... which would signify that no additional <code>put</code> or <code>delete</code> occurred anywhere in the KV store in the meantime, as the reflected log region is still <code>(..272)</code>.</p>
<p>Let's dive into how the actual KV store functions will work.</p>
<h3 id="main-event-loop"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#main-event-loop">Main event loop</a></h3>
<p>Earlier, we touched on the main components of our system, and generally how the KV store API will interact with S2's stream API.</p>
<p>There are many ways to structure an actual implementation of this system. In the demo, the main "engine" of our KV store is modelled as an event loop, which is a common pattern for I/O bound workloads.</p>
<p>The idea is that we can <a href="https://docs.rs/tokio/latest/tokio/task/fn.spawn.html">spawn a Tokio task</a> which will be responsible for executing this loop, and therefore responding to our various input and output streams, and managing the internal materialized state. This task will run for the lifetime of our node. By giving that task ownership of the materialized state, as well as of any I/O streams, we can avoid use of locks entirely.</p>
<p>Internally, our <code>get</code>, <code>put</code>, and <code>delete</code> functions will all rely on this task to accomplish what they need to.</p>
<p>We can model an event loop task like this as a regular async Rust function, which will get spawned during startup of our node. This is the <a href="https://github.com/s2-streamstore/s2-kv-demo/blob/7f32cbaca849110ca385c3094d0ba3b08fbce4cd/src/main.rs#L222"><code>orchestrate</code> function</a> in the demo.</p>
<p>This function mostly consists of one large <code>loop</code>, the body of which will await a few different async functions. We can use the <a href="https://tokio.rs/tokio/tutorial/select"><code>tokio::select!</code> macro</a>, which is a handy way to await multiple futures simultaneously. That way, we can handle whichever future <a href="https://doc.rust-lang.org/std/task/enum.Poll.html#variant.Ready">returns <code>Ready</code></a> first, and only run the code in the branch that was associated with it. The futures which we don't end up handling will get dropped, but we can re-await futures from the same underlying streams and channels on the next iteration of the <code>orchestrate</code> loop.</p>
<p>Here's what the skeleton of our event loop function will look like:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> fn</span><span style="color:#B392F0"> orchestrate</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#6A737D">    // ...</span></span>
<span data-line=""><span style="color:#E1E4E8">) </span><span style="color:#F97583">-></span><span style="color:#B392F0"> Result</span><span style="color:#E1E4E8">&#x3C;(), </span><span style="color:#B392F0">KVError</span><span style="color:#E1E4E8">> {</span></span>
<span data-line=""><span style="color:#6A737D">    // ...</span></span>
<span data-line=""><span style="color:#F97583">    loop</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#B392F0">        tokio</span><span style="color:#F97583">::</span><span style="color:#B392F0">select!</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#B392F0">            Some</span><span style="color:#E1E4E8">(cmd) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> command_rx</span><span style="color:#F97583">.</span><span style="color:#B392F0">recv</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#6A737D">                // ... commands about new `put`/`delete`/`get` requests</span></span>
<span data-line=""><span style="color:#E1E4E8">            }</span></span>
<span data-line=""><span style="color:#B392F0">            Some</span><span style="color:#E1E4E8">(ack) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> append_acknowledgments</span><span style="color:#F97583">.</span><span style="color:#B392F0">next</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#6A737D">                // ... S2 record batch acknowledgments</span></span>
<span data-line=""><span style="color:#E1E4E8">            }</span></span>
<span data-line=""><span style="color:#B392F0">            Some</span><span style="color:#E1E4E8">(record) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> tailing_reader</span><span style="color:#F97583">.</span><span style="color:#B392F0">next</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#6A737D">                // ... S2 log entry records</span></span>
<span data-line=""><span style="color:#E1E4E8">            }</span></span>
<span data-line=""><span style="color:#F97583">            else</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> { </span><span style="color:#F97583">break</span><span style="color:#E1E4E8">; }</span></span>
<span data-line=""><span style="color:#E1E4E8">        }</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#6A737D">    // ...</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="async fn orchestrate(
    // ...
) -> Result<(), KVError> {
    // ...
    loop {
        tokio::select! {
            Some(cmd) = command_rx.recv() => {
                // ... commands about new &#x60;put&#x60;/&#x60;delete&#x60;/&#x60;get&#x60; requests
            }
            Some(ack) = append_acknowledgments.next() => {
                // ... S2 record batch acknowledgments
            }
            Some(record) = tailing_reader.next() => {
                // ... S2 log entry records
            }
            else => { break; }
        }
    }
    // ...
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>The only catch with using an event loop is that, since we only have a single task responsible for all I/O, we have to be careful to make sure that we correctly offload work. We want the loop to be spending most of its time in the <code>select!</code> await, where the task is able to respond to whichever type of message appears next. We don't want the actual event loop task to be stuck awaiting a single function call within one of the branches — it will need to stay open to quickly deal with whatever occurs next, dispatch it, and move on.</p>
<p>For instance, a <code>put</code> request will take some time for our KV store to service; each <code>put</code> will require a write to the S2 log, and will need to then wait to see if that write was acknowledged by S2. We can't hold up our event loop while we're waiting for that acknowledgment, or it would block other concurrent callers to our system. A typical way to deal with this is by using some sort of async "callback" mechanism (<em>more on that in a bit</em>).</p>
<h3 id="defining-command-messages"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#defining-command-messages">Defining command messages</a></h3>
<p>What are these actual flows of information that <code>orchestrate</code> will be responsible for, and what actions it will take in response to each?</p>
<p>The first branch in our <code>select!</code> block handles commands. Since <code>orchestrate</code> is a task, not a struct, we can't have it perform actions by calling a function directly on it. Instead, we'll communicate with it via message passing. That way, commands are simply modelled as another I/O stream to react to in the main event loop.</p>
<p>The <code>command_rx</code> in the code snippet above is simply the receiving end of an <a href="https://docs.rs/tokio/latest/tokio/sync/mpsc/fn.unbounded_channel.html">unbounded channel</a>. This is a "multi-producer / single-consumer", or MPSC, channel — so the receiver held by <code>orchestrate</code> is guaranteed to be the only one. We can have an unlimited amount of sender handles, on the other hand, which comes in handy. In other words, callers can easily <code>clone</code> and send new commands into our channel, and all of the messages will flow to the main loop.</p>
<p>What actually needs to be communicated to our task? Really, any action we want it to perform. In practice, this will be servicing requests that come from our KV store users directly — so <code>put</code>,<code>delete</code>, and <code>get</code>. The commands themselves can be an enum, allowing us to pattern match on the different variants within the <code>select!</code> branch.</p>
<p>We won't quite just have different commands for <code>put</code>/<code>delete</code>/<code>get</code> respectively. Recall that both <code>put</code> and <code>delete</code> can be handled almost identically. We can consolidate both of them as a single <code>WriteLog</code> command, where the sender constructs a log representing either a <code>Put</code> or <code>Delete</code> action.</p>
<p>On the other hand, <code>get</code> commands actually end up being processed in two different ways. If the caller wants strong, linearizable consistency, we need to make sure that when we execute the <code>get</code> that the local materialized state has caught up with the tail of the S2 log. If the caller is fine with eventual consistency, we can execute that <code>get</code> immediately, regardless of what the tail is.</p>
<p>Our commands end up looking like this:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#F97583">type</span><span style="color:#B392F0"> SequenceNumber</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> u64</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#F97583">type</span><span style="color:#B392F0"> SequencedValue</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> (</span><span style="color:#B392F0">RangeTo</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">SequenceNumber</span><span style="color:#E1E4E8">>, </span><span style="color:#B392F0">Option</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">Value</span><span style="color:#E1E4E8">>);</span></span>
<span data-line=""><span style="color:#F97583">type</span><span style="color:#B392F0"> WriteSender</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> oneshot</span><span style="color:#F97583">::</span><span style="color:#B392F0">Sender</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">Result</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">RangeTo</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">SequenceNumber</span><span style="color:#E1E4E8">>, </span><span style="color:#B392F0">KVError</span><span style="color:#E1E4E8">>>;</span></span>
<span data-line=""><span style="color:#F97583">type</span><span style="color:#B392F0"> ReadSender</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> oneshot</span><span style="color:#F97583">::</span><span style="color:#B392F0">Sender</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">Result</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">SequencedValue</span><span style="color:#E1E4E8">, </span><span style="color:#B392F0">KVError</span><span style="color:#E1E4E8">>>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">enum</span><span style="color:#B392F0"> OrchestratorCommand</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#B392F0">    WriteLog</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">        log</span><span style="color:#F97583">:</span><span style="color:#B392F0"> LogEntry</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">        response_tx</span><span style="color:#F97583">:</span><span style="color:#B392F0"> WriteSender</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    },</span></span>
<span data-line=""><span style="color:#B392F0">    ReadStrongConsistency</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">        key</span><span style="color:#F97583">:</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">        reflect_applied_state</span><span style="color:#F97583">:</span><span style="color:#B392F0"> RangeTo</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">SequenceNumber</span><span style="color:#E1E4E8">>,</span></span>
<span data-line=""><span style="color:#E1E4E8">        response_tx</span><span style="color:#F97583">:</span><span style="color:#B392F0"> ReadSender</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    },</span></span>
<span data-line=""><span style="color:#B392F0">    ReadEventualConsistency</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">        key</span><span style="color:#F97583">:</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">        response_tx</span><span style="color:#F97583">:</span><span style="color:#B392F0"> ReadSender</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    },</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="type SequenceNumber = u64;
type SequencedValue = (RangeTo<SequenceNumber>, Option<Value>);
type WriteSender = oneshot::Sender<Result<RangeTo<SequenceNumber>, KVError>>;
type ReadSender = oneshot::Sender<Result<SequencedValue, KVError>>;

enum OrchestratorCommand {
    WriteLog {
        log: LogEntry,
        response_tx: WriteSender,
    },
    ReadStrongConsistency {
        key: String,
        reflect_applied_state: RangeTo<SequenceNumber>,
        response_tx: ReadSender,
    },
    ReadEventualConsistency {
        key: String,
        response_tx: ReadSender,
    },
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>You may be wondering what these <code>response_tx</code> fields are. Those are how we can call back to the original sender of the command (i.e., the actual <code>put</code>, <code>get</code>, and <code>delete</code> methods on the HTTP server).</p>
<p>Since commands are being communicated to the <code>orchestrate</code> task via an MPSC channel, and not a function call, there is no obvious way for the senders of those commands to be notified when the requested asynchronous work is completed.</p>
<p>A common pattern in these situations is to send a <a href="https://docs.rs/tokio/latest/tokio/sync/oneshot/fn.channel.html">oneshot channel</a>. These are very similar to MPSC channels, but used for receiving a single value — so particularly useful as an async notification mechanism. In our case, the commands contain a sender for the oneshot, which <code>orchestrate</code> will either use immediately or hold on to, and the receiver end is then retained by the original issuer of the command.</p>
<p>For instance, the <code>put</code> command (what actually ends up getting called by the HTTP server's PUT route) looks like this:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#6A737D">/// Put a new key/value to the store.</span></span>
<span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> fn</span><span style="color:#B392F0"> put</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#F97583">    &#x26;</span><span style="color:#79B8FF">self</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    key</span><span style="color:#F97583">:</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    value</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Value</span></span>
<span data-line=""><span style="color:#E1E4E8">) </span><span style="color:#F97583">-></span><span style="color:#B392F0"> Result</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">RangeTo</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">SequenceNumber</span><span style="color:#E1E4E8">>, </span><span style="color:#B392F0">KVError</span><span style="color:#E1E4E8">> {</span></span>
<span data-line=""><span style="color:#6A737D">    // Create the oneshot for receiving a response.</span></span>
<span data-line=""><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> (response_tx, response_rx) </span><span style="color:#F97583">=</span><span style="color:#B392F0"> oneshot</span><span style="color:#F97583">::</span><span style="color:#B392F0">channel</span><span style="color:#E1E4E8">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#6A737D">    // Send a `WriteLog` command corresponding to</span></span>
<span data-line=""><span style="color:#6A737D">    // the `Put` key and value, and providing the</span></span>
<span data-line=""><span style="color:#6A737D">    // sender end of the oneshot.</span></span>
<span data-line=""><span style="color:#79B8FF">    self</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">orchestrator_cmd_tx</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">OrchestratorCommand</span><span style="color:#F97583">::</span><span style="color:#B392F0">WriteLog</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">            log</span><span style="color:#F97583">:</span><span style="color:#B392F0"> LogEntry</span><span style="color:#F97583">::</span><span style="color:#B392F0">Put</span><span style="color:#E1E4E8"> { key, value },</span></span>
<span data-line=""><span style="color:#E1E4E8">            response_tx,</span></span>
<span data-line=""><span style="color:#E1E4E8">        })</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">OrchestratorTaskFailure</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#6A737D">    // Await a response on the receiver.</span></span>
<span data-line=""><span style="color:#E1E4E8">    response_rx</span></span>
<span data-line=""><span style="color:#F97583">        .await</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">OrchestratorTaskFailure</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">?</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="/// Put a new key/value to the store.
async fn put(
    &#x26;self,
    key: String,
    value: Value
) -> Result<RangeTo<SequenceNumber>, KVError> {
    // Create the oneshot for receiving a response.
    let (response_tx, response_rx) = oneshot::channel();

    // Send a &#x60;WriteLog&#x60; command corresponding to
    // the &#x60;Put&#x60; key and value, and providing the
    // sender end of the oneshot.
    self.orchestrator_cmd_tx
        .send(OrchestratorCommand::WriteLog {
            log: LogEntry::Put { key, value },
            response_tx,
        })
        .map_err(|_| KVError::OrchestratorTaskFailure)?;

    // Await a response on the receiver.
    response_rx
        .await
        .map_err(|_| KVError::OrchestratorTaskFailure)?
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h3 id="executing-commands"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#executing-commands">Executing commands</a></h3>
<p>We've seen how commands flow into our <code>orchestrate</code> task, and how responses can be communicated back using <code>oneshot</code>s.</p>
<p>What does <code>orchestrate</code> actually do in response to the different commands?</p>
<h4 id="write-commands"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#write-commands">Write commands</a></h4>
<p>For <code>WriteLog</code> commands (from <code>put</code> or <code>delete</code> requests), it will simply package the log into a record batch, and emit it to the active <code>AppendSession</code> via a channel that supplies (using tokio's <a href="https://docs.rs/tokio-stream/latest/tokio_stream/wrappers/struct.ReceiverStream.html">ReceiverStream</a>) an async stream of <a href="https://docs.rs/streamstore/latest/s2/types/struct.AppendInput.html">AppendInput</a> values.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">Some</span><span style="color:#E1E4E8">(cmd) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> command_rx</span><span style="color:#F97583">.</span><span style="color:#B392F0">recv</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">    match</span><span style="color:#E1E4E8"> cmd {</span></span>
<span data-line=""><span style="color:#B392F0">        OrchestratorCommand</span><span style="color:#F97583">::</span><span style="color:#B392F0">WriteLog</span><span style="color:#E1E4E8"> { log, response_tx } </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">            write_queue</span><span style="color:#F97583">.</span><span style="color:#B392F0">push_back</span><span style="color:#E1E4E8">(response_tx);</span></span>
<span data-line=""><span style="color:#E1E4E8">            append_tx</span><span style="color:#F97583">.</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">AppendInput</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">                records</span><span style="color:#F97583">:</span><span style="color:#B392F0"> AppendRecordBatch</span><span style="color:#F97583">::</span><span style="color:#B392F0">try_from_iter</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#E1E4E8">                    [</span><span style="color:#B392F0">AppendRecord</span><span style="color:#F97583">::</span><span style="color:#B392F0">new</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">bytes</span><span style="color:#F97583">::</span><span style="color:#B392F0">Bytes</span><span style="color:#F97583">::</span><span style="color:#B392F0">from</span><span style="color:#E1E4E8">(log))</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">]</span></span>
<span data-line=""><span style="color:#E1E4E8">                )</span><span style="color:#F97583">.</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">Weird</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"unable to construct batch"</span><span style="color:#E1E4E8">))</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#F97583">                ..</span><span style="color:#B392F0">Default</span><span style="color:#F97583">::</span><span style="color:#B392F0">default</span><span style="color:#E1E4E8">()</span></span>
<span data-line=""><span style="color:#E1E4E8">            })</span><span style="color:#F97583">.</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">Weird</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"s2 append_session rx dropped"</span><span style="color:#E1E4E8">))</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""><span style="color:#E1E4E8">        }</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#6A737D">    // ...</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="Some(cmd) = command_rx.recv() => {
    match cmd {
        OrchestratorCommand::WriteLog { log, response_tx } => {
            write_queue.push_back(response_tx);
            append_tx.send(AppendInput {
                records: AppendRecordBatch::try_from_iter(
                    [AppendRecord::new(bytes::Bytes::from(log))?]
                ).map_err(|_| KVError::Weird(&#x22;unable to construct batch&#x22;))?,
                ..Default::default()
            }).map_err(|_| KVError::Weird(&#x22;s2 append_session rx dropped&#x22;))?;
        }
    }
    // ...
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>Notice that we don't just send the record — we also push the <code>oneshot</code> sender received as part of the command into a <code>write_queue</code>. We can't resolve the originating request until we've received an acknowledgment from S2 that the corresponding log is durable, so we need to hold on to the <code>oneshot</code> sender for now.</p>
<h4 id="eventually-consistent-read-commands"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#eventually-consistent-read-commands">Eventually consistent read commands</a></h4>
<p>Commands for performing eventually consistent reads are pretty easy! Since the caller has elected for non-linearizable reads — in other words, they can tolerate staleness, and don't care if the read doesn't necessarily reflect all prior writes on the shared log — we can simply check the materialized state and return the result immediately:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">Some</span><span style="color:#E1E4E8">(cmd) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> command_rx</span><span style="color:#F97583">.</span><span style="color:#B392F0">recv</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">    match</span><span style="color:#E1E4E8"> cmd {</span></span>
<span data-line=""><span style="color:#6A737D">        // ...</span></span>
<span data-line=""><span style="color:#B392F0">        OrchestratorCommand</span><span style="color:#F97583">::</span><span style="color:#B392F0">ReadEventualConsistency</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">            key,</span></span>
<span data-line=""><span style="color:#E1E4E8">            response_tx</span></span>
<span data-line=""><span style="color:#E1E4E8">        } </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#6A737D">            // Use the oneshot to communicate the read result immediately.</span></span>
<span data-line=""><span style="color:#E1E4E8">            _ </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> response_tx</span></span>
<span data-line=""><span style="color:#F97583">                .</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">Ok</span><span style="color:#E1E4E8">((local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">applied_state, local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">storage</span><span style="color:#F97583">.</span><span style="color:#B392F0">get</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">&#x26;</span><span style="color:#E1E4E8">key)</span><span style="color:#F97583">.</span><span style="color:#B392F0">cloned</span><span style="color:#E1E4E8">())))</span></span>
<span data-line=""><span style="color:#F97583">                .</span><span style="color:#B392F0">inspect_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> debug!</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"read rx dropped"</span><span style="color:#E1E4E8">));</span></span>
<span data-line=""><span style="color:#E1E4E8">        },</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="Some(cmd) = command_rx.recv() => {
    match cmd {
        // ...
        OrchestratorCommand::ReadEventualConsistency {
            key,
            response_tx
        } => {
            // Use the oneshot to communicate the read result immediately.
            _ = response_tx
                .send(Ok((local_state.applied_state, local_state.storage.get(&#x26;key).cloned())))
                .inspect_err(|_| debug!(&#x22;read rx dropped&#x22;));
        },
    }
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h4 id="strongly-consistent-read-commands"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#strongly-consistent-read-commands">Strongly consistent read commands</a></h4>
<p>Strong reads are only slightly more involved. These commands will specify a log prefix that must have been applied to the materialized state before we can return a value. (If you're wondering where that prefix is obtained, we'll get to that later, when we discuss where <code>check_tail</code> gets called.)</p>
<p>If the materialized state already happens to have caught up to that point, we can immediately return the value. If not, we need to use some sort of queue — similar to what we do with writes — and hold on to the <code>oneshot</code> (as well as the <code>key</code>), until we catch up to the desired state.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">Some</span><span style="color:#E1E4E8">(cmd) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> command_rx</span><span style="color:#F97583">.</span><span style="color:#B392F0">recv</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">    match</span><span style="color:#E1E4E8"> cmd {</span></span>
<span data-line=""><span style="color:#6A737D">        // ...</span></span>
<span data-line=""><span style="color:#B392F0">        OrchestratorCommand</span><span style="color:#F97583">::</span><span style="color:#B392F0">ReadStrongConsistency</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">            key,</span></span>
<span data-line=""><span style="color:#E1E4E8">            reflect_applied_state,</span></span>
<span data-line=""><span style="color:#E1E4E8">            response_tx</span></span>
<span data-line=""><span style="color:#E1E4E8">        } </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">            if</span><span style="color:#E1E4E8"> reflect_applied_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">end </span><span style="color:#F97583">&#x3C;=</span><span style="color:#E1E4E8"> local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">applied_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">end {</span></span>
<span data-line=""><span style="color:#6A737D">                // Applied state is already caught up with the tail.</span></span>
<span data-line=""><span style="color:#E1E4E8">                _ </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> response_tx</span></span>
<span data-line=""><span style="color:#F97583">                    .</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">Ok</span><span style="color:#E1E4E8">((local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">applied_state, local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">storage</span><span style="color:#F97583">.</span><span style="color:#B392F0">get</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">&#x26;</span><span style="color:#E1E4E8">key)</span><span style="color:#F97583">.</span><span style="color:#B392F0">cloned</span><span style="color:#E1E4E8">())))</span></span>
<span data-line=""><span style="color:#F97583">                    .</span><span style="color:#B392F0">inspect_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> debug!</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"read rx dropped"</span><span style="color:#E1E4E8">));</span></span>
<span data-line=""><span style="color:#E1E4E8">            } </span><span style="color:#F97583">else</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#6A737D">                // Not yet caught up. Defer until we do.</span></span>
<span data-line=""><span style="color:#E1E4E8">                pending_responses</span><span style="color:#F97583">.</span><span style="color:#B392F0">submit</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">PendingResponse</span><span style="color:#F97583">::</span><span style="color:#B392F0">new</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#E1E4E8">                    reflect_applied_state, </span><span style="color:#B392F0">ResponseContext</span><span style="color:#F97583">::</span><span style="color:#B392F0">Read</span><span style="color:#E1E4E8">{ key, response_tx }</span></span>
<span data-line=""><span style="color:#E1E4E8">                ))</span></span>
<span data-line=""><span style="color:#E1E4E8">            }</span></span>
<span data-line=""><span style="color:#E1E4E8">        },</span></span>
<span data-line=""><span style="color:#6A737D">        // ...</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span>
<span data-line=""> </span><button type="button" title="Copy code" aria-label="Copy code" data="Some(cmd) = command_rx.recv() => {
    match cmd {
        // ...
        OrchestratorCommand::ReadStrongConsistency {
            key,
            reflect_applied_state,
            response_tx
        } => {
            if reflect_applied_state.end <= local_state.applied_state.end {
                // Applied state is already caught up with the tail.
                _ = response_tx
                    .send(Ok((local_state.applied_state, local_state.storage.get(&#x26;key).cloned())))
                    .inspect_err(|_| debug!(&#x22;read rx dropped&#x22;));
            } else {
                // Not yet caught up. Defer until we do.
                pending_responses.submit(PendingResponse::new(
                    reflect_applied_state, ResponseContext::Read{ key, response_tx }
                ))
            }
        },
        // ...
    }
}
" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h3 id="handling-s2-io"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#handling-s2-io">Handling S2 I/O</a></h3>
<p>At this point, we've seen how all of our different <code>OrchestratorCommand</code>s are reacted to. Some reads get handled immediately, others are pending on some future log entries being applied, and are stashed until then. Writes, similarly, are all stashed into a queue where they will await acknowledgments from S2.</p>
<p>In both cases, we are awaiting some additional information from our durability layer, S2. Let's see how that works.</p>
<h4 id="s2-append-acknowledgements"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#s2-append-acknowledgements">S2 append acknowledgements</a></h4>
<p>We send batches of records (individual log entries) to S2 via an MPSC channel (the <code>append_tx</code> sender, as discussed earlier), but also <a href="https://docs.rs/streamstore/latest/s2/client/struct.StreamClient.html#method.append_session">receive acknowledgments back</a> from S2 as an async stream of <a href="https://docs.rs/streamstore/latest/s2/types/struct.AppendOutput.html">AppendOutput</a> messages.</p>
<p>Within an <code>AppendSession</code>, acknowledgments are guaranteed to be received in the same order that we sent appends, which is why a FIFO queue is a good way to keep track of our pending, or "inflight", log writes.</p>
<p>Whenever a new acknowledgement arrives, we simply <code>pop_front</code> from our queue and obtain the <code>oneshot</code> for communicating back to the original <code>put</code> or <code>delete</code> call, and send a message indicating that the write has succeeded.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">Some</span><span style="color:#E1E4E8">(ack) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> append_acknowledgments</span><span style="color:#F97583">.</span><span style="color:#B392F0">next</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> response_tx </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> write_queue</span><span style="color:#F97583">.</span><span style="color:#B392F0">pop_front</span><span style="color:#E1E4E8">()</span><span style="color:#F97583">.</span><span style="color:#B392F0">expect</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"queue entry"</span><span style="color:#E1E4E8">);</span></span>
<span data-line=""><span style="color:#E1E4E8">    _ </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> response_tx</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">Ok</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">..</span><span style="color:#E1E4E8">ack</span><span style="color:#F97583">?.</span><span style="color:#E1E4E8">end_seq_num))</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">inspect_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> debug!</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"write ack rx dropped"</span><span style="color:#E1E4E8">));</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="Some(ack) = append_acknowledgments.next() => {
    let response_tx = write_queue.pop_front().expect(&#x22;queue entry&#x22;);
    _ = response_tx
        .send(Ok(..ack?.end_seq_num))
        .inspect_err(|_| debug!(&#x22;write ack rx dropped&#x22;));
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h4 id="s2-log-entries"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#s2-log-entries">S2 log entries</a></h4>
<p>Our <code>orchestrate</code> task is also always listening for log entries <a href="https://docs.rs/streamstore/latest/s2/client/struct.StreamClient.html#method.read_session">via a <code>ReadSession</code></a>. These could correspond to writes that were proposed earlier by the same node — or they might have been written by one of the other nodes; it's a shared log after all!</p>
<p>Whenever a log entry is received, <code>orchestrate</code> simply has to apply it to the materialized state (the in-memory version of the map), and update the tracker of the currently applied contiguous prefix of log entries. Whenever it updates its state, it can also look for any pending strong <code>get</code> requests, and see if they can now be resolved (and the requested value can be returned from the now up-to-date state).</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">Some</span><span style="color:#E1E4E8">(record) </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> tailing_reader</span><span style="color:#F97583">.</span><span style="color:#B392F0">next</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> record </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> record</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#6A737D">    // Deserialize the json log.</span></span>
<span data-line=""><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> log_entry </span><span style="color:#F97583">=</span><span style="color:#B392F0"> serde_json</span><span style="color:#F97583">::</span><span style="color:#B392F0">from_slice</span><span style="color:#E1E4E8">(record</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">body</span><span style="color:#F97583">.</span><span style="color:#B392F0">as_ref</span><span style="color:#E1E4E8">())</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">e</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">JsonError</span><span style="color:#E1E4E8">(e</span><span style="color:#F97583">.</span><span style="color:#B392F0">to_string</span><span style="color:#E1E4E8">()))</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#6A737D">    // Insert or delete the key from the local storage map.</span></span>
<span data-line=""><span style="color:#F97583">    match</span><span style="color:#E1E4E8"> log_entry {</span></span>
<span data-line=""><span style="color:#B392F0">        LogEntry</span><span style="color:#F97583">::</span><span style="color:#B392F0">Put</span><span style="color:#E1E4E8"> { key, value } </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">storage</span><span style="color:#F97583">.</span><span style="color:#B392F0">insert</span><span style="color:#E1E4E8">(key, value),</span></span>
<span data-line=""><span style="color:#B392F0">        LogEntry</span><span style="color:#F97583">::</span><span style="color:#B392F0">Delete</span><span style="color:#E1E4E8"> { key } </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">storage</span><span style="color:#F97583">.</span><span style="color:#B392F0">remove</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">&#x26;</span><span style="color:#E1E4E8">key),</span></span>
<span data-line=""><span style="color:#E1E4E8">    };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#6A737D">    // Update the applied state prefix. Our materialized state reflects</span></span>
<span data-line=""><span style="color:#6A737D">    // this contiguous range of the log.</span></span>
<span data-line=""><span style="color:#E1E4E8">    local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">applied_state </span><span style="color:#F97583">=</span><span style="color:#F97583"> ..</span><span style="color:#E1E4E8">record</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">seq_num </span><span style="color:#F97583">+</span><span style="color:#79B8FF"> 1</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#6A737D">    // Find any pending `get` requests that may have been waiting for</span></span>
<span data-line=""><span style="color:#6A737D">    // the `applied_state` to catch up.</span></span>
<span data-line=""><span style="color:#F97583">    for</span><span style="color:#B392F0"> PendingResponse</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">        end_seq_num</span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> _,</span></span>
<span data-line=""><span style="color:#E1E4E8">        response_context</span></span>
<span data-line=""><span style="color:#E1E4E8">    } </span><span style="color:#F97583">in</span><span style="color:#E1E4E8"> pending_responses</span><span style="color:#F97583">.</span><span style="color:#B392F0">drain_applied_responses</span><span style="color:#E1E4E8">(local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">applied_state) {</span></span>
<span data-line=""><span style="color:#F97583">        match</span><span style="color:#E1E4E8"> response_context {</span></span>
<span data-line=""><span style="color:#B392F0">            ResponseContext</span><span style="color:#F97583">::</span><span style="color:#B392F0">Read</span><span style="color:#E1E4E8">{ key, response_tx } </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">                _ </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> response_tx</span><span style="color:#F97583">.</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">Ok</span><span style="color:#E1E4E8">((local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">applied_state, local_state</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">storage</span><span style="color:#F97583">.</span><span style="color:#B392F0">get</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">&#x26;</span><span style="color:#E1E4E8">key)</span><span style="color:#F97583">.</span><span style="color:#B392F0">cloned</span><span style="color:#E1E4E8">())))</span></span>
<span data-line=""><span style="color:#F97583">                    .</span><span style="color:#B392F0">inspect_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> debug!</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">"read rx dropped"</span><span style="color:#E1E4E8">));</span></span>
<span data-line=""><span style="color:#E1E4E8">            }</span></span>
<span data-line=""><span style="color:#E1E4E8">        }</span></span>
<span data-line=""><span style="color:#E1E4E8">    }</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="Some(record) = tailing_reader.next() => {
    let record = record?;

    // Deserialize the json log.
    let log_entry = serde_json::from_slice(record.body.as_ref())
        .map_err(|e| KVError::JsonError(e.to_string()))?;

    // Insert or delete the key from the local storage map.
    match log_entry {
        LogEntry::Put { key, value } => local_state.storage.insert(key, value),
        LogEntry::Delete { key } => local_state.storage.remove(&#x26;key),
    };

    // Update the applied state prefix. Our materialized state reflects
    // this contiguous range of the log.
    local_state.applied_state = ..record.seq_num + 1;

    // Find any pending &#x60;get&#x60; requests that may have been waiting for
    // the &#x60;applied_state&#x60; to catch up.
    for PendingResponse {
        end_seq_num: _,
        response_context
    } in pending_responses.drain_applied_responses(local_state.applied_state) {
        match response_context {
            ResponseContext::Read{ key, response_tx } => {
                _ = response_tx.send(Ok((local_state.applied_state, local_state.storage.get(&#x26;key).cloned())))
                    .inspect_err(|_| debug!(&#x22;read rx dropped&#x22;));
            }
        }
    }
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>That's about it for <code>orchestrate</code>!</p>
<h3 id="checktail-operations-for-strong-reads"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#checktail-operations-for-strong-reads"><code>CheckTail</code> operations for strong reads</a></h3>
<p>Earlier, when we were looking at <a href="https://s2.dev/blog/kv-store#strongly-consistent-read-commands">how <code>OrchestratorCommand::ReadStrongConsistency</code> commands get processed</a>, we saw that all strong read requests need to specify a <code>reflect_applied_state</code> parameter. This value indicates the value of the S2 log's tail at the time at which the <code>get</code> was processed, and we <a href="https://s2.dev/blog/kv-store#readsession-and-checktail-for-log-reads">can only achieve linearizability</a> if we ensure that the materialized state has reflected all logs up to that position when we retrieve and return a value.</p>
<p>It is expected, therefore, that the <code>get</code> handler in our KV store call <code>check_tail</code> itself, and provide the value it received in the command to <code>orchestrate</code>. In theory, <code>orchestrate</code> could also be responsible for performing the <code>check_tail</code> op — it's just one additional I/O task after all.</p>
<h4 id="bus-stand-optimization"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#bus-stand-optimization">Bus-stand optimization</a></h4>
<p>In practice, it's a bit neater to have a separate, dedicated event loop task for <code>check_tail</code> purposes. This is mainly to take advantage of what Mahesh Balakrishnan refers to as the "bus-stand" optimization (from §3.2 in <a href="https://maheshba.bitbucket.io/papers/osr2024.pdf">this paper</a> mentioned earlier).</p>
<p>Since every single strong read serviced by our KV store needs to obtain the current tail, by default that means one <a href="https://docs.rs/streamstore/latest/s2/client/struct.StreamClient.html#method.check_tail"><code>check_tail</code> operation</a> per read request. If we're willing to slightly delay reads, we can instead group these tail requests into batches, where — similar to catching a bus at a bus stand — passengers wait until the next "departure" to the underlying <code>check_tail</code> call, and a single response can satisfy that input for several read requests at once. This allows us to trade off additional latency for strong reads with a ceiling on the maximum number of <code>check_tail</code> operations performed per second.</p>
<p>Similar to <code>orchestrate</code>, we can communicate with a dedicated <code>check_tail</code> task over an MPSC command channel, and receive responses via a <code>oneshot</code>. The bus stand optimized version of <code>check_tail</code> looks like this:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#6A737D">/// Get the current stream's tail.</span></span>
<span data-line=""><span style="color:#6A737D">///</span></span>
<span data-line=""><span style="color:#6A737D">/// Wait up to `max_wait` before the actual `check_tail` invocation occurs, allowing</span></span>
<span data-line=""><span style="color:#6A737D">/// other callers to also be served by the same underlying S2 tail op.</span></span>
<span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> fn</span><span style="color:#B392F0"> bus_stand_check_tail</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#F97583">    &#x26;</span><span style="color:#79B8FF">self</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    max_wait</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Duration</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">) </span><span style="color:#F97583">-></span><span style="color:#B392F0"> Result</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">RangeTo</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">SequenceNumber</span><span style="color:#E1E4E8">>, </span><span style="color:#B392F0">KVError</span><span style="color:#E1E4E8">> {</span></span>
<span data-line=""><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> (response_tx, response_rx) </span><span style="color:#F97583">=</span><span style="color:#B392F0"> oneshot</span><span style="color:#F97583">::</span><span style="color:#B392F0">channel</span><span style="color:#E1E4E8">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#79B8FF">    self</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">bus_tx</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(</span><span style="color:#B392F0">BusRider</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">            max_wait,</span></span>
<span data-line=""><span style="color:#E1E4E8">            response_tx,</span></span>
<span data-line=""><span style="color:#E1E4E8">        })</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">BusStandTaskFailure</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E1E4E8">    response_rx</span></span>
<span data-line=""><span style="color:#F97583">        .await</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">BusStandTaskFailure</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">?</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="/// Get the current stream&#x27;s tail.
///
/// Wait up to &#x60;max_wait&#x60; before the actual &#x60;check_tail&#x60; invocation occurs, allowing
/// other callers to also be served by the same underlying S2 tail op.
async fn bus_stand_check_tail(
    &#x26;self,
    max_wait: Duration,
) -> Result<RangeTo<SequenceNumber>, KVError> {
    let (response_tx, response_rx) = oneshot::channel();

    self.bus_tx
        .send(BusRider {
            max_wait,
            response_tx,
        })
        .map_err(|_| KVError::BusStandTaskFailure)?;

    response_rx
        .await
        .map_err(|_| KVError::BusStandTaskFailure)?
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p><em>(The actual implementation of the bus stand task, which performs the batching, can be seen <a href="https://github.com/s2-streamstore/s2-kv-demo/blob/7f32cbaca849110ca385c3094d0ba3b08fbce4cd/src/main.rs#L181">here</a> in the demo).</em></p>
<p>Our new <code>bus_stand_check_tail</code> function can be called by <code>get</code> and used to obtain the tail before sending a read command to the <code>orchestrator</code>:</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="rust" data-theme="github-dark"><code data-language="rust" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#6A737D">/// Get the value of a key.</span></span>
<span data-line=""><span style="color:#F97583">async</span><span style="color:#F97583"> fn</span><span style="color:#B392F0"> get</span><span style="color:#E1E4E8">(</span></span>
<span data-line=""><span style="color:#F97583">    &#x26;</span><span style="color:#79B8FF">self</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    read_consistency</span><span style="color:#F97583">:</span><span style="color:#B392F0"> ReadConsistency</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">    key</span><span style="color:#F97583">:</span><span style="color:#B392F0"> String</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">) </span><span style="color:#F97583">-></span><span style="color:#B392F0"> Result</span><span style="color:#E1E4E8">&#x3C;(</span><span style="color:#B392F0">RangeTo</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">SequenceNumber</span><span style="color:#E1E4E8">>, </span><span style="color:#B392F0">Option</span><span style="color:#E1E4E8">&#x3C;</span><span style="color:#B392F0">Value</span><span style="color:#E1E4E8">>), </span><span style="color:#B392F0">KVError</span><span style="color:#E1E4E8">> {</span></span>
<span data-line=""><span style="color:#6A737D">    // Oneshot for receiving the value.</span></span>
<span data-line=""><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> (response_tx, response_rx) </span><span style="color:#F97583">=</span><span style="color:#B392F0"> oneshot</span><span style="color:#F97583">::</span><span style="color:#B392F0">channel</span><span style="color:#E1E4E8">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#F97583">    let</span><span style="color:#E1E4E8"> cmd </span><span style="color:#F97583">=</span><span style="color:#F97583"> match</span><span style="color:#E1E4E8"> read_consistency {</span></span>
<span data-line=""><span style="color:#B392F0">        ReadConsistency</span><span style="color:#F97583">::</span><span style="color:#B392F0">Eventual</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#B392F0">            OrchestratorCommand</span><span style="color:#F97583">::</span><span style="color:#B392F0">ReadEventualConsistency</span><span style="color:#E1E4E8"> { key, response_tx }</span></span>
<span data-line=""><span style="color:#E1E4E8">        }</span></span>
<span data-line=""><span style="color:#B392F0">        ReadConsistency</span><span style="color:#F97583">::</span><span style="color:#B392F0">Strong</span><span style="color:#F97583"> =></span><span style="color:#B392F0"> OrchestratorCommand</span><span style="color:#F97583">::</span><span style="color:#B392F0">ReadStrongConsistency</span><span style="color:#E1E4E8"> {</span></span>
<span data-line=""><span style="color:#E1E4E8">            key,</span></span>
<span data-line=""><span style="color:#E1E4E8">            reflect_applied_state</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> self</span><span style="color:#F97583">.</span><span style="color:#B392F0">bus_stand_check_tail</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">BUS_RIDER_MAX_WAIT</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">.await?</span><span style="color:#E1E4E8">,</span></span>
<span data-line=""><span style="color:#E1E4E8">            response_tx,</span></span>
<span data-line=""><span style="color:#E1E4E8">        },</span></span>
<span data-line=""><span style="color:#E1E4E8">    };</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#79B8FF">    self</span><span style="color:#F97583">.</span><span style="color:#E1E4E8">orchestrator_cmd_tx</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">send</span><span style="color:#E1E4E8">(cmd)</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">OrchestratorTaskFailure</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">?</span><span style="color:#E1E4E8">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="color:#E1E4E8">    response_rx</span></span>
<span data-line=""><span style="color:#F97583">        .await</span></span>
<span data-line=""><span style="color:#F97583">        .</span><span style="color:#B392F0">map_err</span><span style="color:#E1E4E8">(</span><span style="color:#F97583">|</span><span style="color:#E1E4E8">_</span><span style="color:#F97583">|</span><span style="color:#B392F0"> KVError</span><span style="color:#F97583">::</span><span style="color:#B392F0">OrchestratorTaskFailure</span><span style="color:#E1E4E8">)</span><span style="color:#F97583">?</span></span>
<span data-line=""><span style="color:#E1E4E8">}</span></span><button type="button" title="Copy code" aria-label="Copy code" data="/// Get the value of a key.
async fn get(
    &#x26;self,
    read_consistency: ReadConsistency,
    key: String,
) -> Result<(RangeTo<SequenceNumber>, Option<Value>), KVError> {
    // Oneshot for receiving the value.
    let (response_tx, response_rx) = oneshot::channel();

    let cmd = match read_consistency {
        ReadConsistency::Eventual => {
            OrchestratorCommand::ReadEventualConsistency { key, response_tx }
        }
        ReadConsistency::Strong => OrchestratorCommand::ReadStrongConsistency {
            key,
            reflect_applied_state: self.bus_stand_check_tail(BUS_RIDER_MAX_WAIT).await?,
            response_tx,
        },
    };

    self.orchestrator_cmd_tx
        .send(cmd)
        .map_err(|_| KVError::OrchestratorTaskFailure)?;

    response_rx
        .await
        .map_err(|_| KVError::OrchestratorTaskFailure)?
}" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<h2 id="conclusion"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#conclusion">Conclusion</a></h2>
<p>We covered a lot in this post, and managed to build a simple multi-primary, strongly consistent database! I hope it gave you a glimpse into some of the ways in which S2 can be used at the foundation of real distributed data systems. We can't wait to see what people end up building.</p>
<p>The actual <a href="https://github.com/s2-streamstore/s2-kv-demo/blob/main/src/main.rs">implementation</a> of the system discussed here is only ~600 lines, and worth checking out if you made it this far!</p>
<section data-footnotes="" class="footnotes"><h2 class="sr-only" id="footnote-label"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/kv-store#footnote-label">Footnotes</a></h2>
<ol>
<li id="user-content-fn-1">
<p>Marc Brooker's <a href="https://brooker.co.za/blog/2024/04/25/memorydb.html">blog post about MemoryDB</a> discusses how fencing support by a shared log service can safely support leader leases. MemoryDB itself requires only conditional append support, and uses the same log used for data to coordinate leader election and leases (see §4.1 in the <a href="https://www.amazon.science/publications/amazon-memorydb-a-fast-and-durable-memory-first-cloud-database">paper</a>). You are able to take <a href="https://s2.dev/docs/api/records/append#concurrency-control">either approach</a> with S2! <a href="https://s2.dev/blog/kv-store#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-2">
<p>Currently, S2 is only hosted from AWS's <code>us-east-1</code> region, but we will expand access to other regions and public cloud providers. <a href="https://s2.dev/blog/kv-store#user-content-fnref-2" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-3">
<p>Actually guaranteeing that there is truly only one node capable of writing — either because we didn't want a replicated setup, or in some sort of leader/follower replication scheme — needs careful design. See note above.<sup><a href="https://s2.dev/blog/kv-store#user-content-fn-1" id="user-content-fnref-1-2" data-footnote-ref="" aria-describedby="footnote-label">1</a></sup> <a href="https://s2.dev/blog/kv-store#user-content-fnref-3" data-footnote-backref="" aria-label="Back to reference 3" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content:encoded>
            <author>stephen@s2.dev (Stephen Balogh)</author>
            <category>use-case</category>
            <category>distsys</category>
            <category>tutorial</category>
            <category>rust</category>
        </item>
        <item>
            <title><![CDATA[Introducing S2]]></title>
            <link>https://s2.dev/blog/intro</link>
            <guid isPermaLink="false">https://s2.dev/blog/intro</guid>
            <pubDate>Fri, 20 Dec 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[Introducing S2, a serverless stream store that makes durable real-time streams feel as simple and scalable as object storage.]]></description>
            <content:encoded><![CDATA[<blockquote><p>Elevate the beating heart of data systems.</p></blockquote>
<p>Our team has worked a lot on reliable real-time ingest, where <a href="https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying">The Log</a> is foundational. We loved the serverless experience of object storage, but it <a href="https://blog.schmizz.net/designing-serverless-stream-storage#heading-vision">simply did not exist</a> for streaming data.</p>
<p>We believe the humble log – the <strong><em>stream</em></strong> – deserves to be a cloud storage primitive.</p>
<p>With S2, we are previewing just that: S2 is the Stream Store, our interpretation of streaming for the cloud era.</p>
<h2 id="what-if-streams-had-the-primacy-of-objects"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/intro#what-if-streams-had-the-primacy-of-objects">What if streams had the primacy of objects?</a></h2>
<p>Object storage has been nothing short of revolutionary. S3 broke ground in 2006 with simple storage operations on named objects – and 18 years later, S3 Express One Zone even allows appends. But ultimately, object storage is all about blobs and byte ranges. It is best for data at rest. Our vision of stream storage is predicated on the idea that the demands of <em>data in motion</em> need a fresh perspective.</p>
<p>With S2, you are elevated to the natural granularity of <em>records</em>. Writes to an S2 stream are appended at the tail, and even if multiple writers are acting at a time, S2 will durably sequence all records. S2 takes care of serving your reads efficiently, whether you need to start streaming from seconds ago or years. Streams can also be tailed in real-time, which is not possible with a blob in S3.</p>
<div style="padding: 20px 0">





















<table><thead><tr><th>Object Storage</th><th>Stream Storage</th></tr></thead><tbody><tr><td>Blobs and byte ranges</td><td>Records and sequence numbers</td></tr><tr><td><code>PUT</code> / <code>GET</code> / <code>DELETE</code> value of a named <code>Object</code> in a <code>Bucket</code></td><td><code>APPEND</code> / <code>READ</code> / <code>TRIM</code> records on a named <code>Stream</code> in a <code>Basin</code></td></tr><tr><td>Cumbersome and expensive for granular appends</td><td>Easy and cheap to append records</td></tr></tbody></table>
</div>
<p>Just like buckets are a namespace for objects, <em>basins</em> play that role for streams in S2. Basins and streams lean into the scale of the cloud – there is no limit on how many you can have, or how long data can be retained.</p>
<p>Want to model streams per user? Do it, this isn't Kafka. There are no cluster limitations to wrangle, and no infrastructure to tune.</p>
<figure data-rehype-pretty-code-figure=""><pre style="background-color:#24292e;color:#e1e4e8" tabindex="0" data-language="bash" data-theme="github-dark"><code data-language="bash" data-theme="github-dark" style="display: grid;"><span data-line=""><span style="color:#B392F0">$</span><span style="color:#9ECBFF"> s2</span><span style="color:#9ECBFF"> ls</span><span style="color:#9ECBFF"> s2://copilot-rag-ingest</span></span>
<span data-line=""><span style="color:#B392F0">user-foo/cool-project</span></span>
<span data-line=""><span style="color:#B392F0">user-foo/another-project</span></span>
<span data-line=""><span style="color:#B392F0">user-bar/fork-of-cool-project</span></span>
<span data-line=""><span style="color:#6A737D"># ... ∞</span></span><button type="button" title="Copy code" aria-label="Copy code" data="$ s2 ls s2://copilot-rag-ingest
user-foo/cool-project
user-foo/another-project
user-bar/fork-of-cool-project
# ... ∞" class="rehype-pretty-copy" onclick="navigator.clipboard.writeText(this.attributes.data.value);this.classList.add(&#x27;rehype-pretty-copied&#x27;);window.setTimeout(() => this.classList.remove(&#x27;rehype-pretty-copied&#x27;), 3000);"><span class="ready"></span><span class="success"></span></button><style>:root {--copy-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23adadad' d='M16.187 9.5H12.25a1.75 1.75 0 0 0-1.75 1.75v28.5c0 .967.784 1.75 1.75 1.75h23.5a1.75 1.75 0 0 0 1.75-1.75v-28.5a1.75 1.75 0 0 0-1.75-1.75h-3.937a4.25 4.25 0 0 1-4.063 3h-7.5a4.25 4.25 0 0 1-4.063-3M31.813 7h3.937A4.25 4.25 0 0 1 40 11.25v28.5A4.25 4.25 0 0 1 35.75 44h-23.5A4.25 4.25 0 0 1 8 39.75v-28.5A4.25 4.25 0 0 1 12.25 7h3.937a4.25 4.25 0 0 1 4.063-3h7.5a4.25 4.25 0 0 1 4.063 3M18.5 8.25c0 .966.784 1.75 1.75 1.75h7.5a1.75 1.75 0 1 0 0-3.5h-7.5a1.75 1.75 0 0 0-1.75 1.75'/%3E%3C/svg%3E");--success-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2366ff85' d='M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z'/%3E%3C/svg%3E");}pre:has(code) {position: relative;}pre button.rehype-pretty-copy {right: 1px;padding: 0;width: 24px;height: 24px;display: flex;margin-top: 2px;margin-right: 8px;position: absolute;border-radius: 25%;backdrop-filter: blur(3px);& span {width: 100%;aspect-ratio: 1 / 1;}& .ready {background-image: var(--copy-icon);}& .success {display: none; background-image: var(--success-icon);}}&.rehype-pretty-copied {& .success {display: block;} & .ready {display: none;}}pre button.rehype-pretty-copy.rehype-pretty-copied {opacity: 1;& .ready { display: none; }& .success { display: block; }}</style></code></pre></figure>
<p>This stream interface brought us closer to our vision, but we also wanted to liberate the superpower of <a href="https://blog.schmizz.net/disaggregated-wal">offloading durability</a> which databases like MemoryDB and Neon leverage. Decoupling compute and storage is safest when the storage service cooperates.</p>
<p>So we added a verb to <a href="https://maheshba.bitbucket.io/papers/osr2024.pdf">check the tail</a> of the stream with strong consistency, and support for concurrency control when writing. You can be a pessimist wielding a <a href="https://brooker.co.za/blog/2024/04/25/memorydb.html">fencing token</a> or optimistically supply the sequence number you expect assigned – no judgment which side of the fence you find yourself on.</p>
<h2 id="serverless--at-what-cost"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/intro#serverless--at-what-cost">Serverless – at what cost?</a></h2>
<p>S2 is architected around the infinite scale and unrelenting durability of object storage. That does not necessitate a slow or expensive offering – quite the opposite! We bridge the abstraction gap with a multi-tenant service so that you can have a truly serverless API for streaming data.</p>
<p>Durability is not negotiable for us in the undeniable <a href="https://materializedview.io/p/cloud-storage-triad-latency-cost-durability">cloud storage triad</a>. We allow users to navigate their latency vs cost tradeoff on a per stream basis, with <em>storage classes</em>. We are starting out with two:</p>
<ol>
<li>
<p><strong><code>Standard</code></strong>, backed by S3 Standard in AWS. S3 Standard has a counterpart in all public cloud providers, so we will be able to ship it in all cloud regions as we grow.</p>
</li>
<li>
<p><strong><code>Express</code></strong>, backed by a quorum of three S3 Express One Zone buckets in AWS. Azure has had a regional <a href="https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-block-blob-premium">counterpart</a> for years, and it is <a href="https://www.linkedin.com/feed/update/urn:li:activity:7170847492522061825">in the cards</a> at GCP, so we are optimistic about wider availability.</p>
</li>
</ol>
<p>Our <code>Standard</code> storage class provides end-to-end p99 latencies of <strong>under 500 milliseconds</strong>. With <code>Express</code> you can expect <strong>under 50 milliseconds</strong> – in the realm of disk-based cloud streaming systems! S2 on the other hand is completely diskless, and all writes will be safe in S3 with regional durability before being acknowledged.</p>
<p>These latencies are supported at throughputs of <strong>hundreds of megabytes per second, per stream</strong>. The overhead of reading recently written data is negligible in S2 because of in-memory caching. Lagging readers can be particularly thirsty for throughput, and S2 serves them directly from object storage without a cap. We are initially throttling writes at 125 MiBps and reads against recent writes at 500 MiBps, per stream.</p>
<p><img src="https://s2.dev/blog/20241219-latencies.svg" alt="Consistent low latency for S2 storage classes" title="Latencies at over 1 GiBps aggregate throughput"></p>
<p>The service is free during our preview period to optimize for feedback. We want to be transparent about our <a href="https://s2.dev/pricing">intended pricing</a>, and you will find that S2 comes in meaningfully cheaper than the norms of cloud streaming systems. This will be particularly stark in comparison to "serverless" offerings, which attract tiny ceilings on the number of streams and the throughputs you can push through them – at a very high premium.</p>
<p>There are no fixed costs in S2 like instances or cluster units. When we say serverless, we mean it!</p>
<h2 id="whats-next-for-s2"><a class="subheading-anchor" aria-label="Link to section" href="https://s2.dev/blog/intro#whats-next-for-s2">What's next for S2</a></h2>
<p>S2 stands on a foundation of battle-tested cloud infrastructure, and our own Rust codebase gets put through the wringer with deterministic simulation testing. That said, the system is young, and there will be kinks. We are working hard to mature towards general availability and an SLA you can count on in production.</p>
<p>We are now shipping a gRPC API, <a href="https://github.com/s2-streamstore/s2-sdk-rust">Rust SDK</a>, and a <a href="https://s2.dev/docs/quickstart#get-started-with-the-cli">shiny CLI</a> – and we are going to get cracking on a <a href="https://s2.dev/docs/api">REST API</a>. Do <a href="https://discord.gg/vTCs7kMkAf">tell us</a> which language SDKs you would be most interested in.</p>
<p>To give you a bigger picture sense of our direction:</p>
<ul>
<li>
<p><strong>Kafka protocol compatibility</strong>. This will be an open source layer, and we will integrate certain features like key-based compaction directly in S2.</p>
</li>
<li>
<p><strong>Multi-region basins</strong>. Once we expand into more cloud regions, we see a path towards basins that can span regions and even clouds, for the highest standard of availability.</p>
</li>
<li>
<p><strong>Under 5 millisecond latencies</strong>. We are just getting started with the architectural flexibility of storage classes, and another 10x improvement over <code>Express</code> is achievable.</p>
</li>
</ul>
<p>Can you replace Kafka or Kinesis with S2 today? If you find yourself reaching for their “low-level” APIs, S2 is likely to be a fit, and even address your requirements more directly.</p>
<p>If you expect a lot more from the cloud than current norms for streaming data – like not being limited on how many streams you can have, 10-100x higher ordered throughput, and concurrency control – S2 is the missing piece.</p>
<p>We are beyond excited for all the innovative data systems S2 enables, and invite you to <a href="https://s2.dev/docs">build with us</a>!</p>]]></content:encoded>
            <author>shikhar@s2.dev (Shikhar Bhushan)</author>
            <category>announce</category>
            <category>distsys</category>
        </item>
    </channel>
</rss>