<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yusuke Hayashi</title>
    <description>The latest articles on DEV Community by Yusuke Hayashi (@yhay81).</description>
    <link>https://dev.to/yhay81</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F622018%2F99e3e659-d221-4cdb-8f95-2ee881f2de5b.jpeg</url>
      <title>DEV Community: Yusuke Hayashi</title>
      <link>https://dev.to/yhay81</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yhay81"/>
    <language>en</language>
    <item>
      <title>How Much Can the OpenTelemetry Collector's Persistent Queue Really Protect? Measuring SIGKILL and Lost ACKs</title>
      <dc:creator>Yusuke Hayashi</dc:creator>
      <pubDate>Thu, 06 Aug 2026 19:10:35 +0000</pubDate>
      <link>https://dev.to/yhay81/how-much-can-the-opentelemetry-collectors-persistent-queue-really-protect-measuring-sigkill-and-dmn</link>
      <guid>https://dev.to/yhay81/how-much-can-the-opentelemetry-collectors-persistent-queue-really-protect-measuring-sigkill-and-dmn</guid>
      <description>&lt;p&gt;The traces produced while your observability backend was down are exactly the ones you need&lt;br&gt;
for the postmortem. But what happens if the OpenTelemetry Collector relaying that data&lt;br&gt;
restarts at the same time as the backend?&lt;/p&gt;

&lt;p&gt;The OpenTelemetry Collector (hereafter "Collector") is open source software that receives&lt;br&gt;
traces, metrics, and logs, applies whatever processing you need, and forwards them to an&lt;br&gt;
observability backend. It ships as a&lt;br&gt;
&lt;a href="https://github.com/open-telemetry/opentelemetry-collector" rel="noopener noreferrer"&gt;binary implemented in Go&lt;/a&gt;&lt;br&gt;
(&lt;a href="https://opentelemetry.io/docs/collector/architecture/" rel="noopener noreferrer"&gt;architecture docs&lt;/a&gt;) and as a&lt;br&gt;
container image. You wire three kinds of components together in YAML:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Receiver: takes data in from applications and other sources&lt;/li&gt;
&lt;li&gt;Processor: batches, adds attributes, filters, and otherwise transforms data&lt;/li&gt;
&lt;li&gt;Exporter: sends data to destinations such as an observability backend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this article all data moves over OTLP (OpenTelemetry Protocol), the standard protocol for&lt;br&gt;
sending OpenTelemetry traces, metrics, and logs. The experiments use OTLP/gRPC.&lt;/p&gt;

&lt;p&gt;Exporters have a &lt;code&gt;sending_queue&lt;/code&gt; that temporarily holds data that cannot be sent yet. You can&lt;br&gt;
keep queue contents in memory only (a "memory queue") or write them to disk with&lt;br&gt;
&lt;code&gt;file_storage&lt;/code&gt; (a "persistent queue").&lt;/p&gt;

&lt;p&gt;For this article I stopped the destination, let data pile up in the queue, and then either&lt;br&gt;
gracefully or forcibly terminated the Collector. I also built a failure in which the&lt;br&gt;
destination loses its success response (ACK) immediately after durably storing the data, and&lt;br&gt;
counted loss and duplication by Span ID.&lt;/p&gt;
&lt;h2&gt;
  
  
  Conclusion first: what a persistent queue actually protects
&lt;/h2&gt;

&lt;p&gt;In OpenTelemetry, the whole flow of one request through multiple operations is recorded as a&lt;br&gt;
"trace", and each individual operation as a "span". The input for these experiments was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What is counted&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;What it means here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Traces&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500 application request executions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spans per trace&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;one parent span and one child span&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spans&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;the data actually checked for loss/duplicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spans per OTLP request&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;the unit sent to the Collector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OTLP requests&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;how the queue holds them: 10 requests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So "500 traces", "1,000 spans", and "10 requests" throughout this article are the same input&lt;br&gt;
counted in different units. Each span carries a Span ID, and those IDs are what I use to count&lt;br&gt;
loss and duplication.&lt;/p&gt;

&lt;p&gt;There are two ways I stopped the Collector:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SIGTERM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;requests a graceful shutdown; the Collector can run shutdown logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SIGKILL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no chance to shut down; the OS terminates the process immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I ran six ordinary conditions plus one ACK-loss condition, three times each. Counted at the&lt;br&gt;
destination:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Queue&lt;/th&gt;
&lt;th&gt;Failure condition&lt;/th&gt;
&lt;th&gt;Span records stored&lt;/th&gt;
&lt;th&gt;Unique Span IDs&lt;/th&gt;
&lt;th&gt;Duplicates&lt;/th&gt;
&lt;th&gt;Lost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;destination stopped, then recovered&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SIGTERM&lt;/code&gt; while destination is down&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SIGKILL&lt;/code&gt; while destination is down&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SIGTERM&lt;/code&gt; while destination is down&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SIGKILL&lt;/code&gt; while destination is down&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;queue files lost after &lt;code&gt;SIGKILL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;stored, then died before ACKing the Collector&lt;/td&gt;
&lt;td&gt;1,100&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Duplicates" is the number of extra records stored relative to unique Span IDs. In the last&lt;br&gt;
condition, 100 Span IDs were each recorded twice, so the total record count came to 1,100.&lt;/p&gt;

&lt;p&gt;The most surprising result was the memory queue under &lt;code&gt;SIGTERM&lt;/code&gt;. The Collector logged&lt;br&gt;
&lt;code&gt;Shutdown complete.&lt;/code&gt; and exited with code 0 — a clean shutdown — but because the destination&lt;br&gt;
was still down it could not drain the queue, and nothing arrived after the restart.&lt;/p&gt;

&lt;p&gt;Three takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A memory queue survives a temporary destination outage, but nothing carries over when the
Collector itself restarts.&lt;/li&gt;
&lt;li&gt;A persistent queue can resend after a Collector restart, but only when it can reattach to
the same stored data.&lt;/li&gt;
&lt;li&gt;Losing the destination's success response triggers a resend, so you can get duplicates, not
just loss.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A persistent queue widens the range of Collector restarts you can resend through. It is not an&lt;br&gt;
end-to-end guarantee that everything the Collector accepted reaches the backend exactly once.&lt;/p&gt;

&lt;p&gt;To see why these results came out this way, let's look at the setup, then pin down what&lt;br&gt;
"successfully sent" actually refers to, and then walk through each failure.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is under test: app → Gateway → backend
&lt;/h2&gt;

&lt;p&gt;The same Collector binary can be deployed in different roles. The official docs call running it&lt;br&gt;
as a central OTLP endpoint the&lt;br&gt;
&lt;a href="https://opentelemetry.io/docs/collector/deploy/gateway/" rel="noopener noreferrer"&gt;Gateway deployment pattern&lt;/a&gt;. The&lt;br&gt;
&lt;code&gt;gateway&lt;/code&gt; container in this article is just the official Collector image configured in YAML.&lt;/p&gt;

&lt;p&gt;Persistent-queue resilience matters most in exactly that shape: applications send OTLP to a&lt;br&gt;
central Gateway Collector, and the Gateway relays to the observability backend.&lt;/p&gt;

&lt;p&gt;When backend maintenance or a network failure coincides with a Gateway rolling update, pod&lt;br&gt;
eviction, or process crash, the question becomes whether traces accepted during the incident&lt;br&gt;
survive into the next process.&lt;/p&gt;

&lt;p&gt;Gateways are typically deployed as a Kubernetes Deployment or a service on a VM, and are used&lt;br&gt;
for centralized concerns: credentials, egress consolidation, filtering, sampling. They are not&lt;br&gt;
mandatory — small environments, or setups where each language SDK (the library that emits&lt;br&gt;
OpenTelemetry data) sends straight to the backend, can skip them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The subject under test is a single Collector with a queue and storage area shared with no&lt;br&gt;
other replica.&lt;/strong&gt; Load balancers, queue sharing across replicas, and SDK-side retries are out of&lt;br&gt;
scope.&lt;/p&gt;

&lt;p&gt;Here is how real components map to the test environment. &lt;code&gt;telemetrygen&lt;/code&gt; is OpenTelemetry's&lt;br&gt;
official tool for generating test traces and other signals.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Real system / subject&lt;/th&gt;
&lt;th&gt;Test-environment stand-in&lt;/th&gt;
&lt;th&gt;Not measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application / SDK&lt;/td&gt;
&lt;td&gt;OTLP/gRPC from the official &lt;code&gt;telemetrygen&lt;/code&gt; image&lt;/td&gt;
&lt;td&gt;SDK batching, retries, language variation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway Collector&lt;/td&gt;
&lt;td&gt;official Collector image configured in YAML&lt;/td&gt;
&lt;td&gt;multiple replicas, load balancing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability backend&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;file&lt;/code&gt; exporter, or a purpose-built Go sink&lt;/td&gt;
&lt;td&gt;indexing, search, backend durability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fault injection and tally&lt;/td&gt;
&lt;td&gt;Docker Compose, POSIX shell, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Kubernetes, host-level failures&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the measurement starts once the Gateway has accepted OTLP/gRPC, the source language is&lt;br&gt;
not fixed. Whether you send from Java, Go, or Python, the fault injection and counting done&lt;br&gt;
here are unchanged. A setup that sends from the SDK directly to the backend has no Collector&lt;br&gt;
queue at all, so you have to check that SDK's own queueing and retry behavior separately.&lt;/p&gt;

&lt;p&gt;From here on, the Collector that receives from the application and forwards is the "Gateway",&lt;br&gt;
and the Collector or Go server that stores what it receives is the "Sink". The Sink stands in&lt;br&gt;
for the observability backend in this experiment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  telemetrygen (app stand-in, 500 traces)
        |  OTLP/gRPC
        v
  Gateway Collector (single instance)
        |
        v
  Exporter sending_queue  ---- file_storage (persistent queue only)
        |
        x  (cannot send while the Sink is down)
        v
  Sink (backend stand-in)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the components. Next, let's split up how far along the path data has to get before&lt;br&gt;
you can call it "sent". That distinction is the foundation for understanding the duplicates&lt;br&gt;
caused by a lost ACK later on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Background: splitting "it was sent" into four boundaries
&lt;/h2&gt;

&lt;p&gt;An application can succeed at sending without the data being stored in, or searchable from, the&lt;br&gt;
backend. To keep those look-alike states apart, here are four boundaries along the delivery&lt;br&gt;
path.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;th&gt;Evidence measured&lt;/th&gt;
&lt;th&gt;What it lets you claim&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gateway accepted&lt;/td&gt;
&lt;td&gt;&lt;code&gt;otelcol_receiver_accepted_spans=1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the pipeline accepted 1,000 spans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enqueued / in flight&lt;/td&gt;
&lt;td&gt;&lt;code&gt;otelcol_exporter_queue_size=10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10 requests occupied queue capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Send to Sink succeeded&lt;/td&gt;
&lt;td&gt;&lt;code&gt;otelcol_exporter_sent_spans=1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,000 logical spans obtained a downstream ACK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sink wrote to file&lt;/td&gt;
&lt;td&gt;500 unique Trace IDs / 1,000 unique Span IDs / 0 dupes&lt;/td&gt;
&lt;td&gt;the spans are in the &lt;code&gt;file&lt;/code&gt; exporter's JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Two different success responses (ACKs)
&lt;/h3&gt;

&lt;p&gt;There are two OTLP success responses in this article that are easy to conflate. ACK is short for&lt;br&gt;
acknowledgement — here, the success response returned by whoever received a request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  telemetrygen --(1) upstream OTLP request--&amp;gt; Gateway
  Gateway      --(2) enqueue---------------&amp;gt;  sending_queue
  Gateway      &amp;lt;-(3) upstream ACK----------   (returned to telemetrygen)
  sending_queue--(4) downstream OTLP request-&amp;gt; Sink
  Sink         --(5) downstream ACK---------&amp;gt;  Gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;What success means in this experiment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upstream ACK&lt;/td&gt;
&lt;td&gt;Gateway → telemetrygen&lt;/td&gt;
&lt;td&gt;the Gateway pipeline accepted the data and enqueued it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downstream ACK&lt;/td&gt;
&lt;td&gt;Sink → Gateway&lt;/td&gt;
&lt;td&gt;the OTLP request from Gateway to Sink succeeded&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I explicitly set the exporter helper's &lt;code&gt;wait_for_result&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;. With that setting, the&lt;br&gt;
upstream request does not wait for the exporter to finish sending to the Sink. So an upstream&lt;br&gt;
ACK does not mean the Sink stored, indexed, or can search the data.&lt;/p&gt;

&lt;p&gt;What the later ACK-loss experiment destroys is &lt;strong&gt;the downstream ACK from Sink to Gateway&lt;/strong&gt;. Even&lt;br&gt;
when the Sink has already stored the data, a Gateway that never receives the downstream ACK will&lt;br&gt;
resend the same request, and duplicates can result.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;otelcol_exporter_sent_spans&lt;/code&gt; counts logical spans that eventually got a downstream ACK. It is&lt;br&gt;
neither the total number of send attempts nor the total records the Sink stored. In the ACK-loss&lt;br&gt;
experiment, &lt;code&gt;sent_spans&lt;/code&gt; stayed at 1000 while the Sink's stored records reached 1,100 spans.&lt;/p&gt;

&lt;p&gt;It matters not to conflate the&lt;br&gt;
&lt;a href="https://opentelemetry.io/docs/specs/otlp/#full-success" rel="noopener noreferrer"&gt;"accepted by the server"&lt;/a&gt; boundary in&lt;br&gt;
the OTLP spec with everything downstream of it: storage, indexing, and searchability.&lt;/p&gt;


What the Sink actually measured

The Sink's `file` exporter was used only for counting records. Its
[stability for traces is alpha as of 0.157.0](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.157.0/exporter/fileexporter).
The tally scripts are written to fail if the JSON structure changes. Nothing here measures a real
backend's index build, searchable-at time, or durability.



&lt;p&gt;With four delivery boundaries and two ACK directions established, let's pin down when each&lt;br&gt;
failure was injected and which evidence decided success or failure.&lt;/p&gt;
&lt;h2&gt;
  
  
  Method: seven failure conditions and how they were judged
&lt;/h2&gt;

&lt;p&gt;Testing ran 2026-07-25 to 2026-07-27. I used the official Contrib distribution, which bundles&lt;br&gt;
many extra components including &lt;code&gt;file_storage&lt;/code&gt;, pinned to&lt;br&gt;
&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/tag/v0.157.0" rel="noopener noreferrer"&gt;OpenTelemetry Collector 0.157.0&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The ordinary conditions inject failure in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do not start the Sink, so the Gateway cannot send.&lt;/li&gt;
&lt;li&gt;Send 500 traces and wait until the Gateway's accepted count reaches 1,000 spans.&lt;/li&gt;
&lt;li&gt;Wait until queue usage reaches 10 requests.&lt;/li&gt;
&lt;li&gt;Depending on the condition, leave the Gateway running or stop it with &lt;code&gt;SIGTERM&lt;/code&gt; / &lt;code&gt;SIGKILL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Recover the Sink and the Gateway, then count the stored Trace IDs and Span IDs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For conditions judged as total loss, I waited 10 seconds after Sink recovery — longer than the&lt;br&gt;
5-second maximum retry interval — and confirmed the destination was still empty. Rather than&lt;br&gt;
waiting a fixed time before injecting a failure, each step proceeds only after the accepted count&lt;br&gt;
and queue usage are confirmed.&lt;/p&gt;

&lt;p&gt;Only the ACK-loss condition starts the Sink from the beginning. It stops right after storing the&lt;br&gt;
first OTLP request, so no downstream ACK is returned.&lt;/p&gt;

&lt;p&gt;This is a low-volume functional test on a single machine, not a performance test. 500 traces is a&lt;br&gt;
number chosen to make loss and duplication easy to count, not an assumed production rate. No&lt;br&gt;
external telemetry backend or credentials are required.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Measured results and hypothetical requirements are kept separate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Collector configuration, fault injection code, counts, logs, and CSVs all come from running&lt;br&gt;
the published code locally. The production requirements used as examples later — "tolerate a&lt;br&gt;
10-minute outage" and so on — are hypothetical, for illustration.&lt;/p&gt;
&lt;/blockquote&gt;


Test environment and what was not tested

The [captured environment details](https://github.com/yhay81/otel-collector-resilience-lab/blob/main/results/2026-07-28-environment.txt)
are saved as well. Prerequisites and the measured environment:

| Category           | Prerequisite / measured environment                                            |
| ------------------ | ------------------------------------------------------------------------------ |
| Host               | macOS 26.5.1 / Apple M2 Pro / 12 CPU / 32 GiB                                  |
| Docker VM          | arm64 / 12 CPU / approx. 15.6 GiB                                              |
| Container platform | Docker Desktop 4.81.0 / Engine 29.6.1 / Compose v5.2.0                         |
| Host-side CLI      | POSIX shell, `curl`, `jq`                                                      |
| ACK-loss Sink      | Go 1.25.12 / OTLP proto 1.11.0                                                 |
| Local resources    | TCP 4317/8888 free, and the test code's `data/` writable                       |
| Network (first run)| image pulls from GHCR and Docker Hub, Go module fetch for the ACK-loss Sink    |

Non-arm64 CPUs, Linux, and minimum CPU/memory/disk requirements were not tested.



&lt;p&gt;The input is 500 traces, 1,000 spans, and 10 requests, with a queue limit of 100 requests.&lt;br&gt;
&lt;code&gt;telemetrygen&lt;/code&gt;'s &lt;code&gt;--child-spans 1&lt;/code&gt; makes each trace two spans, and &lt;code&gt;--batch-size 100&lt;/code&gt; sends 100&lt;br&gt;
spans at a time. Additional batching inside &lt;code&gt;sending_queue&lt;/code&gt; is disabled; retries are unlimited&lt;br&gt;
with a 5-second maximum interval.&lt;/p&gt;

&lt;p&gt;The full reproduction set lives in the&lt;br&gt;
&lt;a href="https://github.com/yhay81/otel-collector-resilience-lab" rel="noopener noreferrer"&gt;public repository with the test code and measured results&lt;/a&gt;.&lt;br&gt;
The scripts contain not only the experimental conditions but the tallying logic and failure&lt;br&gt;
conditions too.&lt;/p&gt;

&lt;p&gt;The six non-ACK-loss conditions and the ACK-loss condition run with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/yhay81/otel-collector-resilience-lab.git
&lt;span class="nb"&gt;cd &lt;/span&gt;otel-collector-resilience-lab
./run.sh
./run-ack-loss.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three trials each are recorded in the&lt;br&gt;
&lt;a href="https://github.com/yhay81/otel-collector-resilience-lab/blob/main/results/2026-07-25.csv" rel="noopener noreferrer"&gt;ordinary-conditions CSV&lt;/a&gt;,&lt;br&gt;
the &lt;a href="https://github.com/yhay81/otel-collector-resilience-lab/blob/main/results/2026-07-27-ack-loss.csv" rel="noopener noreferrer"&gt;ACK-loss CSV&lt;/a&gt;,&lt;br&gt;
and the &lt;a href="https://github.com/yhay81/otel-collector-resilience-lab/tree/main/results/" rel="noopener noreferrer"&gt;metrics and logs&lt;/a&gt;.&lt;br&gt;
From here, the results follow this order: destination-only outage, Gateway also terminated, ACK&lt;br&gt;
alone lost, and storage itself lost.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 1: a memory queue resent fine when only the destination stopped
&lt;/h2&gt;

&lt;p&gt;First, the Gateway kept running and only the Sink was stopped. After sending 500 traces and&lt;br&gt;
waiting for the internal metric &lt;code&gt;otelcol_exporter_queue_size&lt;/code&gt; to reach 10, I started the Sink.&lt;br&gt;
All 500 traces / 1,000 spans arrived, with zero duplicate Span IDs.&lt;/p&gt;


The memory queue configuration used



```yaml
exporters:
  otlp_grpc/sink:
    endpoint: sink:4317
    tls:
      insecure: true
    sending_queue:
      enabled: true
      sizer: requests
      queue_size: 100
      num_consumers: 1
      wait_for_result: false
      block_on_overflow: false
      batch:
        enabled: false
    retry_on_failure:
      enabled: true
      initial_interval: 1s
      max_interval: 5s
      max_elapsed_time: 0s
```





&lt;p&gt;&lt;code&gt;sending_queue&lt;/code&gt; acted as a temporary holding area while the destination was down, and&lt;br&gt;
&lt;code&gt;retry_on_failure&lt;/code&gt; resent with a gradually widening interval. Because the Gateway process kept&lt;br&gt;
running, the 10 in-memory requests were still there.&lt;/p&gt;

&lt;p&gt;So a memory queue prepares you for a backend restart or a brief network interruption. Stopping&lt;br&gt;
the Collector itself is a different story.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 2: nothing carried over from the memory queue after the Collector stopped
&lt;/h2&gt;

&lt;p&gt;With the same 10 requests in the memory queue, I sent &lt;code&gt;SIGTERM&lt;/code&gt; to the Gateway — in practice&lt;br&gt;
&lt;code&gt;docker compose stop -t 10 gateway&lt;/code&gt;. The Collector received &lt;code&gt;terminated&lt;/code&gt;, logged&lt;br&gt;
&lt;code&gt;Shutdown complete.&lt;/code&gt; within the same second, and exited with code 0. It did not fall through to&lt;br&gt;
the forced kill after 10 seconds.&lt;/p&gt;

&lt;p&gt;Even so, not a single trace arrived at the Sink. Shutdown stopped retrying, attempted to drain&lt;br&gt;
the queue, and dropped the 100-span requests it could not deliver to the stopped Sink.&lt;/p&gt;


Gateway log on SIGTERM



```text
Received signal from OS  signal: terminated
Starting shutdown...
Exporting failed. Dropping data.  dropped_items: 100
Shutdown complete.
```





&lt;p&gt;Under &lt;code&gt;SIGKILL&lt;/code&gt; in the same condition, no trace arrived either — the data awaiting retry vanished&lt;br&gt;
along with the Collector process.&lt;/p&gt;

&lt;p&gt;The conclusion here is not "&lt;code&gt;SIGTERM&lt;/code&gt; always loses data". If the destination is up and the data&lt;br&gt;
can be sent during shutdown, the outcome can differ. What this measures is that &lt;strong&gt;when you&lt;br&gt;
terminate the Collector while the destination is still down, even a graceful shutdown cannot&lt;br&gt;
carry the memory queue over to the next process.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason for the loss is that the queue existed only in the Collector process's memory. So&lt;br&gt;
next, the same data is written to a file outside the process before the same shutdowns are&lt;br&gt;
repeated.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 3: a persistent queue resent after both graceful and forced termination
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;file_storage&lt;/code&gt; is an extension that persists queue data to local files. Point an exporter's&lt;br&gt;
&lt;code&gt;sending_queue.storage&lt;/code&gt; at that extension and it uses a persistent queue instead of a memory one.&lt;/p&gt;


Configuration diff for switching to a persistent queue



```yaml
extensions:
  file_storage/queue:
    directory: /var/lib/otelcol
    create_directory: true
    fsync: true

exporters:
  otlp_grpc/sink:
    sending_queue:
      storage: file_storage/queue

service:
  extensions: [file_storage/queue]
```





&lt;p&gt;The endpoint, queue limit, send concurrency, batching, and retry settings are identical to the&lt;br&gt;
memory queue. The only substantive change is where the queue lives.&lt;/p&gt;

&lt;p&gt;The Gateway's &lt;code&gt;/var/lib/otelcol&lt;/code&gt; was bind-mounted to a host directory, so recreating the Gateway&lt;br&gt;
container still reads the same queue files. Under &lt;code&gt;SIGTERM&lt;/code&gt; I confirmed exit code 0 and&lt;br&gt;
&lt;code&gt;Shutdown complete.&lt;/code&gt;; under &lt;code&gt;SIGKILL&lt;/code&gt;, exit code 137. Both delivered all 500 traces / 1,000 spans&lt;br&gt;
after restart, with zero duplicate Span IDs.&lt;/p&gt;


Queue files and logs at restart

`file_storage` uses bbolt, an embedded database, internally. Just before `SIGKILL` there was a
bbolt file holding the 10 requests. The restart logs show the persistent queue metadata being
loaded and the one in-flight request being moved back to the queue. `readIndex: 1` does not mean
only one request was stored: `itemsSize: 1000` is the number of retained spans, and
`numberOfItems: 1` is the number of requests that had been handed to a consumer at crash time.



```text
Loaded queue metadata
readIndex: 1, writeIndex: 10, itemsSize: 1000

Moved items for dispatching back to queue
numberOfItems: 1
```





&lt;p&gt;This resume behavior and the ID tallies were consistent across three trials, and match the&lt;br&gt;
behavior documented in the&lt;br&gt;
&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.157.0/extension/storage/filestorage" rel="noopener noreferrer"&gt;official &lt;code&gt;file_storage&lt;/code&gt; README&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fsync: true&lt;/code&gt; asks the OS to sync to disk on every write, improving database consistency across&lt;br&gt;
an interruption — at the cost of write performance. The default is &lt;code&gt;false&lt;/code&gt;, so decide based on&lt;br&gt;
the durability you need and measured performance under real traffic. What I compared here is&lt;br&gt;
only &lt;code&gt;SIGKILL&lt;/code&gt; against the process: no performance or loss comparison against &lt;code&gt;fsync: false&lt;/code&gt;, and&lt;br&gt;
no host power loss, Docker Desktop VM, filesystem, or storage-controller durability. &lt;code&gt;fsync: true&lt;/code&gt;&lt;br&gt;
alone is not grounds for claiming power-loss resilience. The &lt;code&gt;file_storage&lt;/code&gt; extension itself is&lt;br&gt;
also beta as of 0.157.0. Pin the version, and re-run the same fault injection tests against the&lt;br&gt;
release notes when you upgrade.&lt;/p&gt;

&lt;p&gt;So far, when the stored data survives, the Collector can resend after a restart. But no loss does&lt;br&gt;
not imply no duplication. Next, the downstream ACK boundary gets broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 4: losing the downstream ACK duplicated 100 spans
&lt;/h2&gt;

&lt;p&gt;The Gateway cannot know whether the Sink finished storing unless it receives the downstream ACK.&lt;br&gt;
So this experiment destroys &lt;strong&gt;only the downstream ACK from Sink to Gateway&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The purpose-built ACK-loss Sink writes the 100 spans in the first OTLP request to a JSONL file&lt;br&gt;
(one record per line) and &lt;code&gt;fsync&lt;/code&gt;s it. Then, before returning a success response, it exits with&lt;br&gt;
code 23. Docker Compose restarts just the Sink, and the Gateway — having never received a&lt;br&gt;
downstream ACK — resends the same request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Gateway --(1) OTLP request, 100 spans--&amp;gt; crash-before-ACK Sink
                                            |
                                            +--(2) append + fsync--&amp;gt; Sink storage file
  Gateway &amp;lt;-X(3) process exits before responding
  Gateway    (4) Unavailable / EOF: back to the queue, retry
  Gateway --(5) resend the same request---&amp;gt; Sink
                                            |
                                            +--(6) append the same 100 spans again
  Gateway &amp;lt;--(7) OTLP success---------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three trials agreed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Value (3 trials)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Logical spans the Gateway accepted from upstream&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logical spans for which the Gateway got a downstream ACK&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue usage after recovery&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Span records stored at the Sink&lt;/td&gt;
&lt;td&gt;1,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique Span IDs at the Sink&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Span IDs recorded twice&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Successful resends of the request holding the first 100 spans&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 1,000 unique spans arrived, so nothing was lost. But the first 100 spans were recorded twice&lt;br&gt;
— before the crash and after the resend — bringing stored records to 1,100.&lt;/p&gt;

&lt;p&gt;The Sink's logs show the same request hash appearing both in the pre-crash store and in the&lt;br&gt;
post-restart ACK.&lt;/p&gt;


ACK-loss Sink log



```text
persisted request sha256=0642...d96d2e spans=100; exiting before OTLP response
ack-loss-sink ready on :4317
acknowledged request sha256=0642...d96d2e spans=100
```





&lt;p&gt;On the Gateway side, &lt;code&gt;rpc error: code = Unavailable ... EOF&lt;/code&gt; was logged, followed by a retry about&lt;br&gt;
1.1 seconds later. The exporter counted 1,000 spans as successful after the resend, but the Sink&lt;br&gt;
processed 1,100 spans in total.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://opentelemetry.io/docs/specs/otlp/#duplicate-data" rel="noopener noreferrer"&gt;OTLP spec is explicit that a disconnect before the ACK can produce duplicates via&lt;br&gt;
retry&lt;/a&gt;. This experiment reproduces that&lt;br&gt;
ambiguous window using an identical request hash and identical Span IDs. It is evidence of&lt;br&gt;
duplication across a process crash in a purpose-built Sink — not proof of host power-loss&lt;br&gt;
durability or of any real backend's deduplication.&lt;/p&gt;

&lt;p&gt;Up to this point the queue data needed for the resend still existed. Next: losing that storage&lt;br&gt;
itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 5: losing the stored data meant even a persistent queue could not resend
&lt;/h2&gt;

&lt;p&gt;Despite the name "persistent", once the queue files themselves are gone there is nothing to&lt;br&gt;
resend. In this condition I confirmed 10 requests in the persistent queue, sent &lt;code&gt;SIGKILL&lt;/code&gt; to the&lt;br&gt;
Gateway, then deleted the queue directory &lt;code&gt;data/queue&lt;/code&gt; before restarting.&lt;/p&gt;

&lt;p&gt;This models &lt;strong&gt;complete loss of the storage contents&lt;/strong&gt;. Kubernetes is not involved, so nothing here&lt;br&gt;
tests pod rescheduling to another node or PersistentVolume reattachment. It is not an experiment&lt;br&gt;
showing "a node failure always means total loss". Whatever the reason for the failure, this shows&lt;br&gt;
what happens when the original queue data cannot be read after a restart.&lt;/p&gt;

&lt;p&gt;In all three trials, not a single trace arrived at the Sink. &lt;code&gt;file_storage&lt;/code&gt; can resume delivery&lt;br&gt;
because it re-reads the remaining queue files. Having persistence configured and being able to&lt;br&gt;
reach that same persisted data after a failure are two different requirements.&lt;/p&gt;

&lt;p&gt;On Kubernetes, post-failure behavior differs by storage type. A PersistentVolume (PV) is storage&lt;br&gt;
with a lifetime independent of the pod.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Caveat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emptyDir&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;survives container restarts, but disappears with the pod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local PV&lt;/td&gt;
&lt;td&gt;bound to a specific node's disk, so unusable as-is on another node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network PV&lt;/td&gt;
&lt;td&gt;verify reattachment on another node, plus I/O latency and AZ failures for real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External queue such as Kafka&lt;/td&gt;
&lt;td&gt;decouples storage from the Gateway, but adds a new operational surface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The range of failure types you intend to protect against is what I call the "failure boundary"&lt;br&gt;
here. If the boundary you accept is process crashes, &lt;code&gt;file_storage&lt;/code&gt; plus a reattachable volume is&lt;br&gt;
a candidate. If you need to keep accepting data through node or AZ (availability zone) loss, a&lt;br&gt;
Collector's own persistent queue is not enough.&lt;/p&gt;

&lt;p&gt;Storage loss is one limit of persistent queues. There are others, where data is lost before&lt;br&gt;
storage or during resend. Here is where the measured and unmeasured ranges separate.&lt;/p&gt;
&lt;h2&gt;
  
  
  Limits of this test: conditions a persistent queue does not cover
&lt;/h2&gt;

&lt;p&gt;Reading these results as "&lt;code&gt;file_storage&lt;/code&gt; means you never lose anything" is dangerous. What a&lt;br&gt;
persistent queue protects is resuming, after a Collector process restart, a queue that was&lt;br&gt;
written to durable disk. At least these four conditions are outside that protection.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Unprotected condition&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Main countermeasure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Queue limit reached&lt;/td&gt;
&lt;td&gt;with &lt;code&gt;block_on_overflow: false&lt;/code&gt;, enqueue fails&lt;/td&gt;
&lt;td&gt;size capacity; verify upstream failure and retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry deadline exceeded&lt;/td&gt;
&lt;td&gt;requests past &lt;code&gt;max_elapsed_time&lt;/code&gt; are dropped&lt;/td&gt;
&lt;td&gt;set the deadline against your recovery objective&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk failure/exhaustion&lt;/td&gt;
&lt;td&gt;the persistent queue cannot be written&lt;/td&gt;
&lt;td&gt;monitor capacity, I/O, and errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored data lost&lt;/td&gt;
&lt;td&gt;the original queue files cannot be read after restart&lt;/td&gt;
&lt;td&gt;give each pod a reattachable persistent volume&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of those four, only stored-data loss was measured. With this pipeline configuration, enqueue&lt;br&gt;
failures are returned as errors on the upstream OTLP request. If upstream can resend, that is not&lt;br&gt;
immediately data loss — but ignoring the error is. &lt;code&gt;max_elapsed_time: 0s&lt;/code&gt; is likewise a setting&lt;br&gt;
chosen to isolate restarts for comparison. Unlimited retention keeps old data around and can fill&lt;br&gt;
the queue or disk first. The official default is 300 seconds.&lt;/p&gt;

&lt;p&gt;Also untested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Performance and loss compared against &lt;code&gt;fsync: false&lt;/code&gt;, host power loss, filesystem corruption&lt;/li&gt;
&lt;li&gt;Kubernetes pod deletion, node shutdown, PVC reattachment, CSI failover&lt;/li&gt;
&lt;li&gt;Real backend acceptance, index build, searchable-at time, deduplication&lt;/li&gt;
&lt;li&gt;Production-equivalent throughput, 10-minute outages, queue drain within 15 minutes, resource usage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So far this has mostly covered everything after the Gateway accepted the data. But the real&lt;br&gt;
delivery path starts at the application. Let's look upstream.&lt;/p&gt;
&lt;h2&gt;
  
  
  The application side: from SDK to Gateway
&lt;/h2&gt;

&lt;p&gt;Collector and PersistentVolume configuration may belong to a platform team. But you cannot think&lt;br&gt;
about loss between the application and the backend without the SDK's behavior.&lt;/p&gt;

&lt;p&gt;This experiment measures only what happens after the Gateway accepted the data. In a real system,&lt;br&gt;
also verify the following between the application and the Gateway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How the SDK batches spans, and how much it can hold in memory&lt;/li&gt;
&lt;li&gt;Whether the SDK retries or drops when it cannot connect to the Gateway or gets an error back&lt;/li&gt;
&lt;li&gt;Whether application shutdown waits for unsent data, and for how long&lt;/li&gt;
&lt;li&gt;After a success response from the Gateway, when the data becomes searchable in the backend&lt;/li&gt;
&lt;li&gt;How the backend displays and aggregates the same Span ID arriving more than once&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each language's OpenTelemetry SDK configures batching, queueing, retry, and shutdown differently.&lt;br&gt;
Do not treat "the application's send API returned success" as the delivery-complete condition —&lt;br&gt;
test the SDK and backend you actually use, together.&lt;/p&gt;

&lt;p&gt;Once the application-side boundary is settled, the Gateway side turns "how much downtime do we&lt;br&gt;
absorb" into concrete capacity. Logical capacity and disk capacity are separate.&lt;/p&gt;
&lt;h2&gt;
  
  
  Capacity planning: separate &lt;code&gt;queue_size&lt;/code&gt; from disk capacity
&lt;/h2&gt;

&lt;p&gt;Sizing a persistent queue happens in two stages. First derive the Collector setting &lt;code&gt;queue_size&lt;/code&gt;,&lt;br&gt;
then translate that into disk capacity for a PersistentVolume or similar.&lt;/p&gt;
&lt;h3&gt;
  
  
  Logical capacity: derive &lt;code&gt;queue_size&lt;/code&gt; from the outage you must survive
&lt;/h3&gt;

&lt;p&gt;Rather than copying a sample value like &lt;code&gt;queue_size: 1000&lt;/code&gt;, decide first how long a destination&lt;br&gt;
outage you want to survive. A rough formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;required queue_size = peak enqueue units/sec x tolerated outage seconds x safety factor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What an "enqueue unit" is depends on the &lt;code&gt;sizer&lt;/code&gt; setting.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;sizer&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;What &lt;code&gt;queue_size&lt;/code&gt; counts&lt;/th&gt;
&lt;th&gt;Characteristics&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;number of requests entering the queue&lt;/td&gt;
&lt;td&gt;lowest computational cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;items&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;spans, data points, log records, etc.&lt;/td&gt;
&lt;td&gt;easiest to match against record counts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bytes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;serialized data size&lt;/td&gt;
&lt;td&gt;direct size control, highest computation cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://github.com/open-telemetry/opentelemetry-collector/tree/v0.157.0/exporter/exporterhelper" rel="noopener noreferrer"&gt;exporter helper 0.157.0&lt;/a&gt;&lt;br&gt;
documentation likewise describes &lt;code&gt;requests&lt;/code&gt; as the lightest and &lt;code&gt;bytes&lt;/code&gt; as the most&lt;br&gt;
computationally expensive option. Base the calculation not on the application's send count but on&lt;br&gt;
the units that actually enter the queue after processors and exporters have done their work. If&lt;br&gt;
you pick &lt;code&gt;requests&lt;/code&gt;, measure spans-per-request as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disk capacity: derive it from actual file growth
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;queue_size&lt;/code&gt; from the formula above is a logical ceiling, not the byte count a&lt;br&gt;
PersistentVolume needs. Even with &lt;code&gt;sizer: bytes&lt;/code&gt;, bbolt's bookkeeping and space that remains&lt;br&gt;
allocated after release mean the queue file size will not match exactly.&lt;/p&gt;

&lt;p&gt;Accumulate production-like traces, metrics, and logs for the required duration and measure the&lt;br&gt;
following to set disk capacity and free-space alert thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;File growth before and after enqueueing&lt;/li&gt;
&lt;li&gt;How much file size remains after the queue drains&lt;/li&gt;
&lt;li&gt;Changes from &lt;code&gt;file_storage&lt;/code&gt; compaction settings&lt;/li&gt;
&lt;li&gt;I/O latency in steady state and during recovery&lt;/li&gt;
&lt;li&gt;Safety margin including filesystem reserved space&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the queue is in place, monitor at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;otelcol_exporter_queue_size&lt;/code&gt; and &lt;code&gt;otelcol_exporter_queue_capacity&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Enqueue failures such as &lt;code&gt;otelcol_exporter_enqueue_failed_spans&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Send-attempt failures such as &lt;code&gt;otelcol_exporter_send_failed_spans&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Persistent volume usage, free space, I/O latency, and &lt;code&gt;file_storage&lt;/code&gt; error logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These Collector internal metrics are alpha as of 0.157.0; recheck names and attributes when&lt;br&gt;
upgrading. Detect rising utilization together with send failures, before the queue fills. And do&lt;br&gt;
not put the Collector's own metrics solely on the same delivery path — keep an independent health&lt;br&gt;
check.&lt;/p&gt;

&lt;p&gt;That covers failure boundaries, capacity, and monitoring — the inputs to an adoption decision.&lt;br&gt;
Finally, let's turn these individual results into a production checklist you can pass or fail item&lt;br&gt;
by item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adoption decision: turning measurements into a production checklist
&lt;/h2&gt;

&lt;p&gt;As an example, take a hypothetical requirement: "survive a 10-minute destination outage and one&lt;br&gt;
forced Gateway termination, and drain the queue within 15 minutes of recovery." Do not treat this&lt;br&gt;
scaled-down experiment as production certification; verify the incomplete items below under&lt;br&gt;
production-like conditions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check item&lt;/th&gt;
&lt;th&gt;Pass condition&lt;/th&gt;
&lt;th&gt;Evidence from this test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Temporary outage&lt;/td&gt;
&lt;td&gt;no loss of accepted traces&lt;/td&gt;
&lt;td&gt;memory and persistent both pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Graceful shutdown&lt;/td&gt;
&lt;td&gt;no loss when terminating/restarting while destination is down&lt;/td&gt;
&lt;td&gt;persistent only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process kill&lt;/td&gt;
&lt;td&gt;no loss after &lt;code&gt;SIGKILL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;persistent only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage loss&lt;/td&gt;
&lt;td&gt;declare out of scope, or decide an upstream recovery path&lt;/td&gt;
&lt;td&gt;deleting stored data: total loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume reattachment&lt;/td&gt;
&lt;td&gt;the same queue is re-read after pod/node rescheduling&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capacity&lt;/td&gt;
&lt;td&gt;holds 10 minutes at peak throughput plus a safety factor&lt;/td&gt;
&lt;td&gt;needs production-rate testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery time&lt;/td&gt;
&lt;td&gt;queue returns to normal within 15 minutes of recovery&lt;/td&gt;
&lt;td&gt;needs production-rate testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;permissions, encryption, and deletion of stored data verified&lt;/td&gt;
&lt;td&gt;review per environment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The decision is not a binary "adopt or skip the persistent queue". Adoption judgment means&lt;br&gt;
separating the process boundary measured here from the node and storage boundaries not yet&lt;br&gt;
verified, and deciding who accepts the remaining risk.&lt;/p&gt;

&lt;p&gt;Building a checklist is not proof that things work in a real environment. The last step is to&lt;br&gt;
inject the failures you expect into a pre-production environment and confirm recovery matches&lt;br&gt;
expectations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: what to confirm before adopting a persistent queue
&lt;/h2&gt;

&lt;p&gt;Enabling a persistent queue does not by itself mean "we now have a configuration that never loses&lt;br&gt;
data". What matters is confirming, in your actual setup, which failures keep the queue intact and&lt;br&gt;
allow delivery to resume afterwards.&lt;/p&gt;

&lt;p&gt;From these tests, four things to nail down before production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A success response at the application or the Gateway does not mean the data is searchable in
the backend at that moment. Verify acceptance and storage/search separately.&lt;/li&gt;
&lt;li&gt;Restarting the Collector while the destination is down does not carry memory queue contents to
the next process, even on a graceful shutdown.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;file_storage&lt;/code&gt;, do more than enable the setting: build a storage arrangement that can
re-read the same queue data after a restart.&lt;/li&gt;
&lt;li&gt;Resends can produce duplicates. Beyond loss, confirm how the backend handles duplicates and
how you would detect them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once the configuration is settled, try each failure one at a time: destination down, &lt;code&gt;SIGTERM&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;SIGKILL&lt;/code&gt;, queue limit reached, disk exhaustion. Do not let testing end at "the application sent&lt;br&gt;
successfully". Make the completion criterion for a delivery test the ability to actually search&lt;br&gt;
traces generated during the incident after recovery, and to explain both whether anything was lost&lt;br&gt;
and how duplicates appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://opentelemetry.io/docs/collector/resiliency/" rel="noopener noreferrer"&gt;OpenTelemetry Collector: Resiliency&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opentelemetry.io/docs/specs/otlp/#request-acknowledgements" rel="noopener noreferrer"&gt;OTLP specification: Request acknowledgements and duplicates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.157.0/cmd/telemetrygen/pkg/traces" rel="noopener noreferrer"&gt;telemetrygen traces v0.157.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/storage/volumes/" rel="noopener noreferrer"&gt;Kubernetes: Volumes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tested 2026-07-25 to 2026-07-27.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was originally published in Japanese on &lt;a href="https://zenn.dev/yhay81/articles/202607-otel-collector-persistent-queue" rel="noopener noreferrer"&gt;Zenn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>architecture</category>
      <category>observability</category>
    </item>
    <item>
      <title>A SELECT-only prompt is not a sandbox: bounding agent-generated SQL</title>
      <dc:creator>Yusuke Hayashi</dc:creator>
      <pubDate>Thu, 06 Aug 2026 18:16:52 +0000</pubDate>
      <link>https://dev.to/yhay81/a-select-only-prompt-is-not-a-sandbox-bounding-agent-generated-sql-3mga</link>
      <guid>https://dev.to/yhay81/a-select-only-prompt-is-not-a-sandbox-bounding-agent-generated-sql-3mga</guid>
      <description>&lt;p&gt;Suppose an AI agent has one job: read &lt;code&gt;package.json&lt;/code&gt; and return the package name and version.&lt;/p&gt;

&lt;p&gt;You can put “use only &lt;code&gt;SELECT&lt;/code&gt;” in the prompt. But if the SQL engine can still open another file, load an extension, run forever, or overwrite an output path, the prompt has not created a security boundary. It has only described one.&lt;/p&gt;

&lt;p&gt;I ran into this distinction while building &lt;a href="https://github.com/yhay81/sqrail" rel="noopener noreferrer"&gt;sqrail&lt;/a&gt;, a small DuckDB-based executor for analytical SQL over explicitly named files. The useful lesson was not about generating better SQL. It was about turning the agent's request into a process contract that remains true when the request is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five promises a prompt cannot enforce
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prompt-level intention&lt;/th&gt;
&lt;th&gt;Executor-level boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;“Read only this dataset”&lt;/td&gt;
&lt;td&gt;Canonical file allowlist plus disabled external access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Only run a query”&lt;/td&gt;
&lt;td&gt;One parser-validated &lt;code&gt;SELECT&lt;/code&gt; statement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Do not use too much”&lt;/td&gt;
&lt;td&gt;Deadlines and row, byte, file, thread, memory, and spill limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Do not overwrite anything”&lt;/td&gt;
&lt;td&gt;No-replace output commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Tell me what failed”&lt;/td&gt;
&lt;td&gt;Stable JSON diagnostics and exit classes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second column is where enforcement starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind data instead of giving SQL a filesystem
&lt;/h2&gt;

&lt;p&gt;The agent refers to a logical table name, while the host supplies the path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqrail run &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;pkg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;package.json &lt;span class="nt"&gt;--timeout&lt;/span&gt; 5s &lt;span class="s2"&gt;"SELECT name, version FROM pkg"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On sqrail 0.3.4, that produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"sqrail-site"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0.3.4"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The executor canonicalizes every bound path. Globs and partitioned Parquet directories are expanded, sorted, deduplicated, and required to be non-empty. After binding, only those exact files are allowlisted and DuckDB external access is disabled.&lt;/p&gt;

&lt;p&gt;I then tried to bypass the binding and read the same file directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;read_json_auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'package.json'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The v0.3.4 Windows release rejected it with exit 4 and a &lt;code&gt;QUERY_FAILED&lt;/code&gt; diagnostic whose message began &lt;code&gt;Permission Error: Cannot access file&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That generic error code is worth noting: the important guarantee is the denied access, not a specially branded “sandbox” message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the statement; do not pattern-match text
&lt;/h2&gt;

&lt;p&gt;Checking whether a string begins with &lt;code&gt;SELECT&lt;/code&gt; is not sufficient. Comments, multiple statements, and other syntax make text-prefix rules brittle.&lt;/p&gt;

&lt;p&gt;The v0.3 contract accepts exactly one parsed statement whose type is &lt;code&gt;SELECT&lt;/code&gt;. That includes &lt;code&gt;VALUES&lt;/code&gt; and queries beginning with &lt;code&gt;WITH&lt;/code&gt;. It rejects DDL, DML, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;ATTACH&lt;/code&gt;, &lt;code&gt;INSTALL&lt;/code&gt;, &lt;code&gt;LOAD&lt;/code&gt;, PRAGMA statements, and multiple statements. Extension autoloading, automatic installation, and community extensions are disabled before configuration is locked.&lt;/p&gt;

&lt;p&gt;This probe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returned exit 4 with &lt;code&gt;MULTIPLE_STATEMENTS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is still not a general sandbox for a hostile SQL engine. It is a deliberately narrower contract: one read-only analytical statement over files the caller explicitly bound.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the agent a preflight lane
&lt;/h2&gt;

&lt;p&gt;An agent often does not know the schema before writing its query. Executing a guess just to discover column names wastes budget and mixes planning with side effects.&lt;/p&gt;

&lt;p&gt;sqrail separates the flow into three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;schema  -&amp;gt; inspect names and types
check   -&amp;gt; bind and plan without running the query
run     -&amp;gt; execute once
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the package query, &lt;code&gt;check&lt;/code&gt; reported two &lt;code&gt;VARCHAR&lt;/code&gt; result columns, one resolved input file, and a &lt;code&gt;READ_JSON_AUTO&lt;/code&gt; physical plan. An orchestrator can inspect that JSON before spending the execution budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bound the whole task, not only the query
&lt;/h2&gt;

&lt;p&gt;A memory flag alone does not bound an agent task. Discovery, schema inference, planning, execution, spilling, result materialization, and output finalization all consume resources.&lt;/p&gt;

&lt;p&gt;The v0.3 executor can limit threads, deadline, result rows, output bytes, input-file count, SQL bytes, memory, and temporary spill. Its timeout begins when command handling starts rather than when DuckDB finally begins executing.&lt;/p&gt;

&lt;p&gt;I reran two failure probes against the Windows x86-64 release:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Exit&lt;/th&gt;
&lt;th&gt;Diagnostic code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Three rows with &lt;code&gt;--max-rows 2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RESULT_LIMIT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large aggregation with &lt;code&gt;--timeout 1ms&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;QUERY_TIMEOUT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The DuckDB memory setting is not a hard operating-system RSS limit. If the threat model requires hard containment, the host still needs a process, container, Windows job object, or equivalent OS boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat file output as a commit
&lt;/h2&gt;

&lt;p&gt;Streaming JSONL to stdout is useful, but it cannot be rolled back. A failure after several rows may leave the consumer with a valid partial prefix.&lt;/p&gt;

&lt;p&gt;File output needs different semantics. With &lt;code&gt;-o&lt;/code&gt;, sqrail writes a private same-directory temporary file and exposes it only after the result completes. POSIX uses a no-replace hard-link commit; Windows uses a no-replace, write-through move.&lt;/p&gt;

&lt;p&gt;When I pointed v0.3.4 at an existing &lt;code&gt;.jsonl&lt;/code&gt; destination, it returned exit 5 with &lt;code&gt;OUTPUT_EXISTS&lt;/code&gt;; the destination was not replaced.&lt;/p&gt;

&lt;p&gt;That distinction belongs in the tool contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use stdout when partial streaming is acceptable;&lt;/li&gt;
&lt;li&gt;use a file destination when the consumer needs all-or-nothing output.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would require from any agent data tool
&lt;/h2&gt;

&lt;p&gt;Before giving an agent a local SQL executor, I would ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Are all readable files explicitly bound and canonicalized?&lt;/li&gt;
&lt;li&gt;Is the statement type checked by the parser?&lt;/li&gt;
&lt;li&gt;Are extension loading and other escape hatches disabled?&lt;/li&gt;
&lt;li&gt;Do discovery, planning, execution, spill, and output share bounded resources?&lt;/li&gt;
&lt;li&gt;Can failed output become visible or overwrite an existing file?&lt;/li&gt;
&lt;li&gt;Are failures stable enough for software to handle without scraping prose?&lt;/li&gt;
&lt;li&gt;Which guarantees still require OS-level isolation?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A prompt can tell a model to be careful. An execution contract determines what happens when it is not.&lt;/p&gt;

&lt;p&gt;If you expose a data tool to an agent, which boundary has failed first in your experience: input access, resource use, or output commit?&lt;/p&gt;

&lt;p&gt;The complete v0.3 process contract is public in &lt;a href="https://github.com/yhay81/sqrail/blob/v0.3.4/docs/CONTRACT.md" rel="noopener noreferrer"&gt;CONTRACT.md&lt;/a&gt;, with the measurement rules in &lt;a href="https://github.com/yhay81/sqrail/blob/v0.3.4/docs/BENCHMARKS.md" rel="noopener noreferrer"&gt;BENCHMARKS.md&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Disclosure: I used an AI assistant to organize the public specification and edit this article. The commands and failure cases above were rerun on August 7, 2026 against the published sqrail 0.3.4 Windows x86-64 archive after its SHA-256 digest was matched to the release checksum.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sql</category>
      <category>security</category>
      <category>cpp</category>
    </item>
    <item>
      <title>Migrating gettext one t-string at a time: what extraction, 449 tests, and Windows benchmarks showed</title>
      <dc:creator>Yusuke Hayashi</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:27:41 +0000</pubDate>
      <link>https://dev.to/yhay81/migrating-gettext-one-t-string-at-a-time-what-extraction-449-tests-and-windows-benchmarks-showed-4d46</link>
      <guid>https://dev.to/yhay81/migrating-gettext-one-t-string-at-a-time-what-extraction-449-tests-and-windows-benchmarks-showed-4d46</guid>
      <description>&lt;p&gt;My first article about &lt;a href="https://github.com/yhay81/gettext-tstrings" rel="noopener noreferrer"&gt;gettext-tstrings&lt;/a&gt; explained the design: turn a Python 3.14 t-string into a stable gettext message ID, keep translations as data rather than code, and validate placeholders before a damaged catalog reaches users.&lt;/p&gt;

&lt;p&gt;That article left a more practical question unanswered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can an existing gettext codebase adopt t-strings one call site at a time without replacing its catalog workflow?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I tested the current &lt;code&gt;v0.1.0a8&lt;/code&gt; release on Windows with a mixed old/new example, the full test suite, and the repository's microbenchmark. This is not another t-string introduction. It is a migration check with commands and limitations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gettext-tstrings 0.1.0a8&lt;/code&gt; at commit &lt;code&gt;3b65baebfc710a750b943073a3c11b6596e396e3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CPython 3.14.6, 64-bit&lt;/li&gt;
&lt;li&gt;Babel 2.18.0&lt;/li&gt;
&lt;li&gt;Windows 11 (&lt;code&gt;10.0.26200&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;AMD Ryzen 5 3600XT, 6 cores / 12 logical processors&lt;/li&gt;
&lt;li&gt;uv 0.11.28 with the committed lockfile&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I created the environment without allowing uv to download another Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;uv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--python&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Users\yusuk\AppData\Local\Python\pythoncore-3.14-64\python.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--no-python-downloads&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--frozen&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A deliberately mixed source file
&lt;/h2&gt;

&lt;p&gt;The migration sample contains ordinary gettext calls and one t-string call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gettext_tstrings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt;

&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

&lt;span class="n"&gt;old_style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Legacy message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;new_style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello {name}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;plural&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ngettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One legacy file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{count} legacy files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Babel mapping remains small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[gettext_tstrings: **.py]&lt;/span&gt;
&lt;span class="py"&gt;encoding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;utf-8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then ran the normal extraction command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;uv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--no-sync&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pybabel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;extract&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;babel.cfg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verification&lt;/span&gt;&lt;span class="nx"&gt;/messages.pot&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;examples&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same POT contained all three forms. The ordinary message remained ordinary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#: examples/mixed_migration.py:6
msgid "Legacy message"
msgstr ""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The t-string received the library marker and Babel's standard brace-format flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#. gettext-tstrings
#: examples/app.py:31 examples/app.py:37 examples/mixed_migration.py:7
#, python-brace-format
msgid "Hello {name}"
msgstr ""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The legacy plural was extracted into the same catalog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#: examples/mixed_migration.py:8
#, python-brace-format
msgid "One legacy file"
msgid_plural "{count} legacy files"
msgstr[0] ""
msgstr[1] ""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the migration property I wanted to verify: the extractor does not require a flag day. Existing string calls and new t-string calls can share one POT while individual call sites move gradually.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the full suite covered on this machine
&lt;/h2&gt;

&lt;p&gt;I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;uv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--no-sync&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pytest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--cov&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;gettext_tstrings&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--cov-report&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;term-missing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;452 tests collected
449 passed, 3 skipped in 8.52s
905 statements, 310 branches
100% coverage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three skips matter. All were toolchain tests requiring GNU gettext tools, which were not installed on this Windows machine. I am not treating a skip as proof. Runtime, extraction, checker, conformance, retention, and binding tests passed here; the GNU &lt;code&gt;msgfmt&lt;/code&gt; integration still needs a machine with that tool installed.&lt;/p&gt;

&lt;p&gt;That limitation is useful migration information by itself. A green Python suite does not demonstrate every external catalog compiler in your delivery pipeline. Keep the GNU check in CI if your translators or release process depend on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measured runtime cost
&lt;/h2&gt;

&lt;p&gt;I ran the repository's &lt;code&gt;benchmarks/runtime.py&lt;/code&gt; in five separate processes. These are the medians:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Median ns/op&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;f-string&lt;/td&gt;
&lt;td&gt;55.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gettext(str).format&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;316.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compiled.render&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;304.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compile_template&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;517.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tr&lt;/code&gt; (1 field)&lt;/td&gt;
&lt;td&gt;901.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tr&lt;/code&gt; (2 fields)&lt;/td&gt;
&lt;td&gt;1,271.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Translator&lt;/code&gt; (1 field)&lt;/td&gt;
&lt;td&gt;1,046.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ngettext&lt;/code&gt; (1 field)&lt;/td&gt;
&lt;td&gt;1,758.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For &lt;code&gt;tr&lt;/code&gt; with one field, the five runs ranged from 896.0 to 960.9 ns/op.&lt;/p&gt;

&lt;p&gt;These numbers are not universal. My earlier article included an Apple Silicon measurement around 0.4 microseconds; this Windows/AMD result is about 0.90 microseconds for the same broad operation. Different CPU, OS, Python patch release, and package version make a direct comparison misleading.&lt;/p&gt;

&lt;p&gt;The useful conclusion is narrower: on this machine, validation and catalog rendering remained below one microsecond for the common one-field &lt;code&gt;tr&lt;/code&gt; path. Measure it again on your own interpreter and hardware before using the number in a capacity decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would require before migrating production code
&lt;/h2&gt;

&lt;p&gt;This experiment changed my migration checklist into something concrete:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pin a released version or commit while the package is alpha.&lt;/li&gt;
&lt;li&gt;Extract old and new calls into one POT and review the diff.&lt;/li&gt;
&lt;li&gt;Keep placeholder validation in both Babel and GNU gettext stages used by your pipeline.&lt;/li&gt;
&lt;li&gt;Test damaged translations in fallback and strict modes.&lt;/li&gt;
&lt;li&gt;Test locale binding under the concurrency model your application actually uses.&lt;/li&gt;
&lt;li&gt;Benchmark on the deployment interpreter instead of copying a number from a README or article.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The most valuable result was not the benchmark. It was seeing the old and new calls coexist in an ordinary catalog with an explicit marker on messages that require the stricter t-string contract.&lt;/p&gt;

&lt;p&gt;The reproducible contract is documented in &lt;a href="https://github.com/yhay81/gettext-tstrings/blob/v0.1.0a8/SPEC.md" rel="noopener noreferrer"&gt;SPEC.md&lt;/a&gt;, and the exact release tested here is &lt;a href="https://github.com/yhay81/gettext-tstrings/tree/v0.1.0a8" rel="noopener noreferrer"&gt;v0.1.0a8&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you maintain a gettext-based Python project, I would be interested in the migration failure you would test first. Catalog extraction, placeholder compatibility, thread/context behavior, or something else?&lt;/p&gt;




&lt;p&gt;Disclosure: I used an AI assistant to organize the test plan, run the documented checks on my development machine, and edit this draft. I reviewed the commands, source references, and results included above before publication.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>testing</category>
      <category>i18n</category>
    </item>
    <item>
      <title>Python 3.14 t-strings finally fix gettext i18n — stdlib said no, so I built the bridge</title>
      <dc:creator>Yusuke Hayashi</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:32:10 +0000</pubDate>
      <link>https://dev.to/yhay81/python-314-t-strings-finally-fix-gettext-i18n-stdlib-said-no-so-i-built-the-bridge-2i7m</link>
      <guid>https://dev.to/yhay81/python-314-t-strings-finally-fix-gettext-i18n-stdlib-said-no-so-i-built-the-bridge-2i7m</guid>
      <description>&lt;p&gt;For twenty years, internationalizing Python code has meant writing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello %(name)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;# % style
&lt;/span&gt;&lt;span class="nf"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello {name}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# or .format style
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for twenty years, the same bugs have shipped: &lt;code&gt;.format()&lt;/code&gt; applied &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;_()&lt;/code&gt; so the catalog never matches; a translator's typo in &lt;code&gt;%(name)s&lt;/code&gt; crashing production in exactly one locale; a placeholder that can't move to where Japanese or German grammar needs it. The string being translated and the values being interpolated are glued together by convention and hope.&lt;/p&gt;

&lt;p&gt;Python 3.14's template strings (&lt;a href="https://peps.python.org/pep-0750/" rel="noopener noreferrer"&gt;PEP 750&lt;/a&gt;) change the raw material. A t-string is not a string — it's a structured object that carries the static text, the expressions, and the evaluated values &lt;em&gt;separately&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello {name}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# Template(strings=('Hello ', ''), interpolations=...)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That structure is exactly what an i18n layer always wanted. So the obvious question: can &lt;code&gt;_(t"Hello {name}")&lt;/code&gt; just... work?&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap nobody owned
&lt;/h2&gt;

&lt;p&gt;It turns out there was no answer for what happens between a t-string and a &lt;code&gt;.po&lt;/code&gt; file. CPython &lt;a href="https://github.com/python/cpython/issues/137353" rel="noopener noreferrer"&gt;considered and declined&lt;/a&gt; t-string support in stdlib gettext — with a core developer noting that a convention with this many design choices belongs on PyPI, where it can iterate. Babel has &lt;a href="https://github.com/python-babel/babel/issues/1206" rel="noopener noreferrer"&gt;an open issue&lt;/a&gt; for native support that's been stalled since 2025. Meanwhile Python 3.14 is climbing past 8% of PyPI downloads.&lt;/p&gt;

&lt;p&gt;The missing piece is small but load-bearing: a &lt;strong&gt;convention&lt;/strong&gt; for turning a t-string into a gettext msgid, plus enforcement that translations stay compatible with it. That's what &lt;a href="https://github.com/yhay81/gettext-tstrings" rel="noopener noreferrer"&gt;gettext-tstrings&lt;/a&gt; is — not a new i18n framework, just the bridge.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gettext&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gettext_tstrings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Translator&lt;/span&gt;

&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Translator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;localedir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locales&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;languages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ja&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello {name}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                     &lt;span class="c1"&gt;# -&amp;gt; "Adaさん、こんにちは"
&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ngettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{n} files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# plural rules per language
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalog receives the complete sentence &lt;code&gt;Hello {name}&lt;/code&gt;. The Japanese translation &lt;code&gt;{name}さん、こんにちは&lt;/code&gt; reorders the placeholder — the thing &lt;code&gt;%&lt;/code&gt;-style positional formatting never allowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The convention: boring on purpose
&lt;/h2&gt;

&lt;p&gt;The whole msgid derivation fits in a sentence: literal braces are escaped, each interpolation becomes &lt;code&gt;{name}&lt;/code&gt;, and &lt;strong&gt;only simple variable names are allowed&lt;/strong&gt;. This is valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Total: {amount:,.2f}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# msgid: "Total: {amount}" — format spec stays in code
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are rejected, at extraction time and at runtime, with the same rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello {user.name}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# attribute access
&lt;/span&gt;&lt;span class="nf"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello {get_name()}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# function call
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compute the value first: &lt;code&gt;name = user.display_name()&lt;/code&gt;. Why so strict? Because any scheme that derives names from expressions (&lt;code&gt;user.name&lt;/code&gt; to &lt;code&gt;user_name&lt;/code&gt;?) becomes permanent — change it later and every existing catalog breaks. And because a translation is &lt;em&gt;data&lt;/em&gt;, never code: a &lt;code&gt;.po&lt;/code&gt; file can reorder and repeat &lt;code&gt;{name}&lt;/code&gt;, but &lt;code&gt;{name.__class__}&lt;/code&gt;, &lt;code&gt;{name!r}&lt;/code&gt;, and &lt;code&gt;{name:&amp;gt;9999}&lt;/code&gt; in a translation are all rejected. The catalog can't execute anything, can't reformat anything, can't be an expression language. The full convention is written down as a versioned contract in &lt;a href="https://github.com/yhay81/gettext-tstrings/blob/main/SPEC.md" rel="noopener noreferrer"&gt;SPEC.md&lt;/a&gt;, so other tools can implement it and produce identical catalogs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your existing toolchain already validates this
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't expect. Babel automatically marks these msgids with the standard &lt;code&gt;python-brace-format&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#, python-brace-format
msgid "Hello {name}"
msgstr "Bonjour {wrong}"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one flag lights up the entire existing toolchain. GNU &lt;code&gt;msgfmt&lt;/code&gt; refuses to compile the broken translation above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;msgfmt &lt;span class="nt"&gt;--check-format&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null ja.po
&lt;span class="go"&gt;ja.po:24: a format specification for argument 'name' doesn't exist in 'msgstr'
msgfmt: found 1 fatal error
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Weblate runs its Python-brace-format check as translators type. Crowdin, Transifex, and POEditor flag placeholder mismatches in their QA. &lt;strong&gt;Zero configuration.&lt;/strong&gt; Your translation vendor's pipeline from 2015 validates your t-string catalogs today. Extraction is the normal Babel workflow (&lt;code&gt;pybabel extract&lt;/code&gt; with an entry-point extractor); PO and MO files are completely standard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that make it production-shaped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A broken catalog never crashes a render.&lt;/strong&gt; If a translation's placeholders don't match, you get the source text and a logged warning — mirroring gettext's own contract. &lt;code&gt;strict=True&lt;/code&gt; restores fail-loud for CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-request language&lt;/strong&gt; via a contextvar: &lt;code&gt;with use_translations(ja): ...&lt;/code&gt; — safe under concurrency, built for web frameworks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy translation&lt;/strong&gt; for module-level strings: &lt;code&gt;SAVE = lazy_gettext(t"Save changes")&lt;/code&gt; renders in whatever language is active when it's used, like Django's &lt;code&gt;gettext_lazy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-microsecond overhead&lt;/strong&gt;: ~0.4us per rendered message on CPython 3.14 (Apple Silicon). The cached render path is actually faster than a naive &lt;code&gt;gettext(...).format(...)&lt;/code&gt;. The difference from a bare f-string buys you validation and safety — this optimizes safety per nanosecond, not benchmarks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;Python 3.14+ only, by nature. Extraction requires Babel (xgettext and pygettext can't parse t-strings — the SPEC exists partly so they someday can). Only simple &lt;code&gt;{name}&lt;/code&gt; placeholders, by design. And it's an alpha: the core contract is small and tested (94 tests, 3 OSes), but the API deserves real-world bruises before 1.0.&lt;/p&gt;

&lt;p&gt;If you maintain a Python project with existing &lt;code&gt;.po&lt;/code&gt; catalogs and you're eyeing 3.14 — this migrates one call site at a time, and the extractor handles mixed &lt;code&gt;_("old style")&lt;/code&gt; and &lt;code&gt;_(t"new style")&lt;/code&gt; codebases in one pass. I'd love issues, especially disagreements with &lt;a href="https://github.com/yhay81/gettext-tstrings/blob/main/SPEC.md" rel="noopener noreferrer"&gt;the SPEC&lt;/a&gt; while it's still cheap to change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;pip install gettext-tstrings
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;: &lt;a href="https://github.com/yhay81/gettext-tstrings" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://pypi.org/project/gettext-tstrings/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt; · &lt;a href="https://github.com/yhay81/gettext-tstrings/blob/main/SPEC.md" rel="noopener noreferrer"&gt;SPEC&lt;/a&gt; · related: &lt;a href="https://discuss.python.org/t/support-t-strings-in-gettext/101109" rel="noopener noreferrer"&gt;CPython discussion&lt;/a&gt;, &lt;a href="https://github.com/python-babel/babel/issues/1206" rel="noopener noreferrer"&gt;Babel #1206&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>i18n</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
