<?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>Building an open-source Resend alternative on AWS: making accepted emails recoverable</title>
      <dc:creator>Yusuke Hayashi</dc:creator>
      <pubDate>Fri, 07 Aug 2026 03:40:24 +0000</pubDate>
      <link>https://dev.to/yhay81/implementing-awss-transactional-outbox-pattern-in-an-open-source-resend-alternative-3cne</link>
      <guid>https://dev.to/yhay81/implementing-awss-transactional-outbox-pattern-in-an-open-source-resend-alternative-3cne</guid>
      <description>&lt;p&gt;When an application asks an email API to send a password-reset link, receipt, or alert, the user sees a simple result: the request was accepted or rejected.&lt;/p&gt;

&lt;p&gt;Behind that response, however, several systems still have work to do. The API must store the request, place work on a queue, let a worker call an email provider, and record what happened. A failure between any two of those steps can leave the application believing that an email is on its way when no worker knows about it.&lt;/p&gt;

&lt;p&gt;I am building &lt;a href="https://github.com/haya-inc/hayasend" rel="noopener noreferrer"&gt;HayaSend&lt;/a&gt;, an Apache-2.0, Resend-compatible transactional email platform. An application can use the official Resend Node SDK and point its &lt;code&gt;baseUrl&lt;/code&gt; at HayaSend. The main difference is operational: the delivery provider and data plane remain in the user's cloud account.&lt;/p&gt;

&lt;p&gt;The purpose of the work in this article was specific:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After HayaSend accepts an email request, preserve enough durable information to recover the work without asking the application to send the request again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article follows one email request through that recovery path. I will introduce each moving part before discussing the AWS and DynamoDB details.&lt;/p&gt;

&lt;p&gt;HayaSend is still early beta. The evidence here covers one tested AWS recovery path, not the production readiness of the whole platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system in one diagram
&lt;/h2&gt;

&lt;p&gt;The normal path looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    |  Resend-compatible API request
    v
HayaSend API
    |  stores the accepted message and recovery intent
    v
DynamoDB  &amp;lt;--- dispatcher looks for unpublished work
    |                    |
    |                    v
    |                  Queue
    |                    |
    |                    v
    +---------------&amp;gt;  Worker  ---&amp;gt;  Email provider  ---&amp;gt;  Recipient
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terms used below mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API&lt;/strong&gt;: the endpoint the application calls to request an email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: the durable record of the message, recipients, and delivery state. HayaSend's tested AWS path uses DynamoDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue&lt;/strong&gt;: a buffer of jobs waiting for a worker. It lets the API return without waiting for the provider to finish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker&lt;/strong&gt;: code that takes a queued job and continues delivery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider&lt;/strong&gt;: the external service that ultimately accepts the email for delivery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dispatcher&lt;/strong&gt;: HayaSend code that finds durable work in DynamoDB and publishes it to the queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The database answers “what did we accept?” The queue answers “what should a worker do next?” The reliability problem appears when those answers get out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure I wanted to prevent
&lt;/h2&gt;

&lt;p&gt;A straightforward implementation performs two separate operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Save the email request in DynamoDB
2. Publish a send job to the queue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now imagine a password-reset request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;DynamoDB successfully stores the message.&lt;/li&gt;
&lt;li&gt;The API process stops before queue publication finishes.&lt;/li&gt;
&lt;li&gt;The application has already received an accepted response, so it does not retry.&lt;/li&gt;
&lt;li&gt;The database contains the request, but the queue contains no job.&lt;/li&gt;
&lt;li&gt;No worker sends the reset email.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is a &lt;strong&gt;dual-write problem&lt;/strong&gt;: one logical operation depends on successful writes to two different systems.&lt;/p&gt;

&lt;p&gt;A client idempotency key solves a related but different problem. It can stop repeated API requests from creating several logical messages. It cannot repair work when the application never repeats the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern is established; the implementation choices are not
&lt;/h2&gt;

&lt;p&gt;I did not invent the solution pattern. &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html" rel="noopener noreferrer"&gt;AWS Prescriptive Guidance&lt;/a&gt; already documents the &lt;strong&gt;transactional outbox&lt;/strong&gt; pattern, including the dual-write problem, possible duplicate messages, ordering, and the need for idempotent consumers.&lt;/p&gt;

&lt;p&gt;An outbox is a durable “work still needs to be published” record stored with the business data. Instead of trying to update DynamoDB and the queue as one impossible cross-service transaction, the API commits the message and its outbox item together in DynamoDB. A dispatcher can publish the queue job later.&lt;/p&gt;

&lt;p&gt;The pattern gave me the starting point. It did not decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which HayaSend records must be committed together;&lt;/li&gt;
&lt;li&gt;whether the public recipient limit fits in one DynamoDB transaction;&lt;/li&gt;
&lt;li&gt;how repeat publications keep the same logical identity;&lt;/li&gt;
&lt;li&gt;how multiple dispatchers compete safely for work;&lt;/li&gt;
&lt;li&gt;what to record when an email provider may have accepted a request;&lt;/li&gt;
&lt;li&gt;how to observe recovery without exposing email data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are the implementation decisions I made and tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: make “accepted” mean “recoverable”
&lt;/h2&gt;

&lt;p&gt;When HayaSend accepts a delivery, one DynamoDB &lt;code&gt;TransactWriteItems&lt;/code&gt; operation writes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one message metadata record;&lt;/li&gt;
&lt;li&gt;one provider-neutral delivery record;&lt;/li&gt;
&lt;li&gt;one record per recipient;&lt;/li&gt;
&lt;li&gt;one optional idempotency claim;&lt;/li&gt;
&lt;li&gt;one initial outbox item;&lt;/li&gt;
&lt;li&gt;one durable backlog counter update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These records either commit together or do not become visible at all.&lt;/p&gt;

&lt;p&gt;For a request with HayaSend's public maximum of 50 recipients, a focused v0.3.11 test observed 55 transaction actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 message + 1 delivery + 50 recipients + 1 idempotency claim
+ 1 outbox item + 1 backlog counter = 55 actions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, an “action” is one item-level operation inside the DynamoDB transaction. AWS currently allows up to 100 actions in &lt;code&gt;TransactWriteItems&lt;/code&gt;, as documented in &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html" rel="noopener noreferrer"&gt;DynamoDB constraints&lt;/a&gt; and the &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html" rel="noopener noreferrer"&gt;&lt;code&gt;TransactWriteItems&lt;/code&gt; API reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The limit itself is ordinary AWS knowledge. The useful project result was checking the real HayaSend record model at the real API limit, including the five non-recipient actions that are easy to forget.&lt;/p&gt;

&lt;p&gt;This changes the meaning of API acceptance. It still does &lt;strong&gt;not&lt;/strong&gt; mean “the recipient received the email.” It means “the message and the intent to continue delivery were durably committed together.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: recover without replaying the API request
&lt;/h2&gt;

&lt;p&gt;After the transaction commits, the dispatcher searches for due outbox items and publishes queue jobs.&lt;/p&gt;

&lt;p&gt;If the API process stops before publication, the outbox item remains in DynamoDB. A later dispatcher can find it. The application does not need to repeat the original send request, and the API does not need to recreate the message from logs.&lt;/p&gt;

&lt;p&gt;A simplified recovery sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API commits message + outbox item
             |
             X  process stops before queue publication

Later dispatcher finds the outbox item
             |
             v
        publishes queue job
             |
             v
     marks outbox item dispatched
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The durable source of truth is the outbox item. A scheduled trigger can wake the dispatcher, but the schedule itself is not proof that the work still exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: give repeated publications one logical identity
&lt;/h2&gt;

&lt;p&gt;The dispatcher can also fail after the queue has accepted a job but before DynamoDB records the successful publication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Dispatcher acquires the outbox item
2. Queue accepts the job
3. Dispatcher stops before acknowledging the outbox item
4. The lease expires
5. Another dispatcher publishes the item again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The queue may therefore contain a duplicate. The transactional outbox does not provide exactly-once delivery.&lt;/p&gt;

&lt;p&gt;HayaSend derives the job ID from the message, job type, and generation instead of creating a new random ID for each publication attempt. Both queue messages then describe the same logical job. Downstream code can use that identity in deduplication and conditional state changes.&lt;/p&gt;

&lt;p&gt;This is a deliberately limited guarantee: duplicates may exist, but retries keep the same name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: use an index to find work, not to decide ownership
&lt;/h2&gt;

&lt;p&gt;Several dispatchers may run at the same time. They need to avoid treating the same outbox item as exclusively theirs.&lt;/p&gt;

&lt;p&gt;HayaSend uses a DynamoDB global secondary index (GSI) to find items whose due time or lease expiry has passed. A GSI is an alternate lookup view of the table, but its contents can briefly lag behind the base table. AWS explains in its &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html" rel="noopener noreferrer"&gt;read consistency guide&lt;/a&gt; that GSI reads are eventually consistent.&lt;/p&gt;

&lt;p&gt;For that reason, an index result is only a candidate. It does not grant ownership.&lt;/p&gt;

&lt;p&gt;The dispatcher performs a conditional update on the base-table item to acquire a short-lived &lt;strong&gt;lease&lt;/strong&gt;—a claim that says, in effect, “this dispatcher may work on the item until this time.” If another dispatcher has already changed the item, the conditional update fails.&lt;/p&gt;

&lt;p&gt;On publication failure, HayaSend releases the lease and makes the item due again. On success, it acknowledges publication and adjusts the backlog together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: admit when the provider result is unknowable
&lt;/h2&gt;

&lt;p&gt;Repairing the database-to-queue boundary reveals another failure window later in the path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Email provider accepts the request
            |
            X  network response is lost
            |
HayaSend cannot confirm the result locally
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Automatically retrying may produce a duplicate email. Reporting success may hide a lost request. Reporting an ordinary failure incorrectly claims that the provider rejected it.&lt;/p&gt;

&lt;p&gt;HayaSend records this result as &lt;code&gt;ambiguous&lt;/code&gt;: the provider may have accepted the request, but the local system could not confirm and commit the outcome.&lt;/p&gt;

&lt;p&gt;The outbox cannot remove this uncertainty. Recovery depends on the provider's own idempotency or correlation features. The important design choice was to preserve the uncertainty instead of converting it into a more convenient but unsupported answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: observe recovery without exposing email content
&lt;/h2&gt;

&lt;p&gt;Operators need to know whether delivery work is stuck. They should not need recipient addresses, subject lines, or message bodies to answer that question.&lt;/p&gt;

&lt;p&gt;HayaSend's default outbox diagnostics expose aggregate operational facts such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;counts of due, leased, expired, and undispatched items;&lt;/li&gt;
&lt;li&gt;the age of the oldest due item;&lt;/li&gt;
&lt;li&gt;cumulative publication failures;&lt;/li&gt;
&lt;li&gt;whether a bounded diagnostic query was truncated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They exclude addresses, subjects, bodies, raw provider responses, queue endpoints, credentials, and signed URLs.&lt;/p&gt;

&lt;p&gt;Recipient and attempt IDs are random opaque values rather than values derived from an email address. Provider events use an opaque event ID or a digest of normalized allowlisted fields instead of copying the raw provider payload.&lt;/p&gt;

&lt;p&gt;The goal is not zero observability. It is enough observability to operate the recovery loop without making logs and metrics another store of customer email data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually tested on AWS
&lt;/h2&gt;

&lt;p&gt;I checked the v0.3.11 implementation at three levels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Observed result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Does the record model fit the transaction limit?&lt;/td&gt;
&lt;td&gt;Maximum-recipient focused test&lt;/td&gt;
&lt;td&gt;50 recipients produced 55 actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Do the model, outbox, DynamoDB adapter, and workflow agree?&lt;/td&gt;
&lt;td&gt;Six focused test files&lt;/td&gt;
&lt;td&gt;43 tests passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can a committed item be recovered in a deployed AWS stack?&lt;/td&gt;
&lt;td&gt;Public integration workflow&lt;/td&gt;
&lt;td&gt;Outbox recovery and acknowledgement succeeded&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The deployed evidence is &lt;a href="https://github.com/haya-inc/hayasend/actions/runs/30498672002" rel="noopener noreferrer"&gt;GitHub Actions run 30498672002&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In its recovery step, the workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;used a delivery already created by the API probe;&lt;/li&gt;
&lt;li&gt;made that delivery's durable outbox item due;&lt;/li&gt;
&lt;li&gt;invoked the deployed dispatcher;&lt;/li&gt;
&lt;li&gt;required the item to gain &lt;code&gt;dispatched_at&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;required its lease and pending-index fields to be removed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The workflow did not replay the client's API request and was designed not to send an actual email through Amazon SES. It deployed an ephemeral stack in a dedicated account, used GitHub OIDC instead of a long-lived AWS access key, and removed the test resources afterward.&lt;/p&gt;

&lt;p&gt;The run used commit &lt;code&gt;071c2a3&lt;/code&gt;, an ancestor of v0.3.11. I compared the run commit with the release tag: the relevant outbox implementation, focused tests, and recovery step were unchanged; the workflow changes were tool-version pins.&lt;/p&gt;

&lt;p&gt;This evidence supports one narrow claim: after an API-created delivery was committed, the deployed AWS dispatcher could recover and acknowledge its outbox work without another client request.&lt;/p&gt;

&lt;p&gt;It does not prove that every email is delivered, that every provider behaves identically, that every deployment pack has the same evidence, or that an early-beta platform is production-ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical takeaway
&lt;/h2&gt;

&lt;p&gt;AWS's transactional outbox guidance answered the architectural question: how can database state and later queue publication survive a crash between systems?&lt;/p&gt;

&lt;p&gt;Implementing it for an email API required more concrete answers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define exactly what “accepted” guarantees.&lt;/li&gt;
&lt;li&gt;Count every transaction action at the public API limit.&lt;/li&gt;
&lt;li&gt;Preserve one logical job identity across publication retries.&lt;/li&gt;
&lt;li&gt;Let a conditional base-table write—not an eventually consistent index—grant a lease.&lt;/li&gt;
&lt;li&gt;Represent possible provider acceptance as an explicit uncertain state.&lt;/li&gt;
&lt;li&gt;Test the failure path on deployed infrastructure without sending customer email.&lt;/li&gt;
&lt;li&gt;Make recovery observable without copying sensitive payloads.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you operate a database-to-queue workflow, draw the normal path first, then mark every place where one system may have accepted work while the next system has not recorded it. Those gaps are where recovery state and honest guarantees matter most.&lt;/p&gt;

&lt;p&gt;Which boundary is least explicit in your system today: API acceptance, queue publication, or provider acknowledgement?&lt;/p&gt;

&lt;p&gt;HayaSend's provider-neutral state model is documented in &lt;a href="https://github.com/haya-inc/hayasend/blob/v0.3.11/docs/delivery-model.md" rel="noopener noreferrer"&gt;delivery-model.md&lt;/a&gt;, and the deployed test is described in &lt;a href="https://github.com/haya-inc/hayasend/blob/v0.3.11/docs/aws-integration-testing.md" rel="noopener noreferrer"&gt;aws-integration-testing.md&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Disclosure: I used an AI assistant to organize public source material and edit this article. I reviewed the AWS documentation, HayaSend v0.3.11 implementation and focused tests, generated-contract check, source comparison, and public AWS run described above on August 7, 2026.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>aws</category>
      <category>architecture</category>
      <category>database</category>
    </item>
    <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>agents</category>
    </item>
    <item>
      <title>Can a Python gettext app adopt t-strings safely? A tested Python 3.14 migration path</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;If your Python application already uses gettext, its translations probably live in &lt;code&gt;.po&lt;/code&gt; files. Source code supplies a message ID, gettext looks up that ID in the catalog, and the application renders the translation with the values for this request.&lt;/p&gt;

&lt;p&gt;That workflow is mature, but interpolation is often held together by convention:&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="c1"&gt;# A translator must preserve the %(name)s placeholder.
&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)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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.14 introduced &lt;a href="https://peps.python.org/pep-0750/" rel="noopener noreferrer"&gt;template strings&lt;/a&gt; (t-strings). Unlike an f-string, a t-string retains its literal text, expressions, and evaluated values as structured data:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is attractive for translation: the message and its values are no longer fused into one ordinary string before the i18n layer sees them. But a t-string does not automatically become a gettext catalog entry, and an existing application cannot replace thousands of calls in one release.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/yhay81/gettext-tstrings" rel="noopener noreferrer"&gt;gettext-tstrings&lt;/a&gt;, an alpha library that bridges that gap. Its goal is deliberately narrow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Let an existing Python gettext application adopt t-strings one call site at a time while keeping its normal &lt;code&gt;.po&lt;/code&gt;/&lt;code&gt;.pot&lt;/code&gt; catalog workflow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article explains the problem, separates standard Python/gettext behavior from what the library adds, and reports a Windows migration check against &lt;code&gt;v0.1.0a8&lt;/code&gt;. It is not a claim that t-strings replace gettext, or that this alpha package is ready for every production system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What needs to fit together
&lt;/h2&gt;

&lt;p&gt;There are three independent pieces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python source code  -&amp;gt;  extraction  -&amp;gt;  .pot/.po catalog  -&amp;gt;  runtime lookup
       |                  |                  |                    |
  t"Hello {name}"   stable message ID   translator edits      render values
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;gettext&lt;/strong&gt; is the established translation system. A &lt;code&gt;.po&lt;/code&gt; file maps a message ID (&lt;code&gt;msgid&lt;/code&gt;) to each language's text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Babel&lt;/strong&gt; is commonly used to extract translatable messages from Python source into a &lt;code&gt;.pot&lt;/code&gt; template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;t-strings&lt;/strong&gt; are Python 3.14 structured templates. They are not a replacement for a translation catalog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;gettext-tstrings&lt;/code&gt; adds a small bridge: it turns an accepted t-string shape into a stable &lt;code&gt;msgid&lt;/code&gt;, keeps translations as catalog data rather than executable expressions, and validates placeholders before a damaged translation reaches a user.&lt;/p&gt;

&lt;p&gt;The standard pieces are not new. The project-specific question was whether old gettext calls and this new bridge could coexist in one real catalog without a flag-day migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration target: old and new calls in one file
&lt;/h2&gt;

&lt;p&gt;The intended change is local. Existing calls keep working while a maintainer converts a selected call site:&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 important promise is not that the syntax is shorter. It is that extraction still produces one catalog containing all three forms.&lt;/p&gt;

&lt;p&gt;For the t-string extractor, the Babel mapping is 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 used the normal Babel command rather than a special migration tool:&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 resulting POT preserved the ordinary gettext entry:&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;It added the t-string as a standard brace-format message, with a marker that identifies the stronger contract:&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;And it kept the existing plural in that 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;That is the result I needed before considering a migration: no all-at-once replacement, no second catalog, and no change to how translators edit &lt;code&gt;.po&lt;/code&gt; files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the library changes—and what it does not
&lt;/h2&gt;

&lt;p&gt;The bridge intentionally accepts only simple named placeholders such as &lt;code&gt;{name}&lt;/code&gt;. It does not try to serialize arbitrary Python expressions into translations.&lt;/p&gt;

&lt;p&gt;That restriction makes the catalog entry readable and reviewable. It also allows the runtime to check whether a translator removed, added, or misspelled a placeholder. A caller can use a fallback behavior for a damaged catalog or request a strict failure, depending on the application's policy.&lt;/p&gt;

&lt;p&gt;The library does &lt;strong&gt;not&lt;/strong&gt; make a translation correct by itself. It does not replace Babel, GNU gettext tooling, translator review, locale testing, or a deployment team's own concurrency checks. Its role is smaller: preserve a structured template long enough to create and validate a conventional gettext message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tested
&lt;/h2&gt;

&lt;p&gt;I checked the exact released source below rather than describing an unpinned development checkout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Version or environment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gettext-tstrings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0.1.0a8&lt;/code&gt;, commit &lt;code&gt;3b65baebfc710a750b943073a3c11b6596e396e3&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;CPython 3.14.6, 64-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extraction&lt;/td&gt;
&lt;td&gt;Babel 2.18.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Windows 11 &lt;code&gt;10.0.26200&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;AMD Ryzen 5 3600XT, 6 cores / 12 logical processors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environment tool&lt;/td&gt;
&lt;td&gt;uv 0.11.28 with the committed lockfile&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I created that environment without allowing uv to download another Python interpreter:&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;p&gt;I then ran the suite with coverage:&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 on that machine 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 skips are part of the result, not a footnote. All three required GNU gettext tools, which were not installed on this Windows machine. Runtime, extraction, checker, conformance, retention, and locale-binding tests passed there; GNU &lt;code&gt;msgfmt&lt;/code&gt; integration was not demonstrated.&lt;/p&gt;

&lt;p&gt;If your release process uses GNU gettext, keep that compiler check in CI. A green Python suite is not proof that every external tool in a translation pipeline is configured correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Windows benchmark did—and did not—show
&lt;/h2&gt;

&lt;p&gt;I ran &lt;code&gt;benchmarks/runtime.py&lt;/code&gt; in five separate processes. These medians help set expectations on this machine:&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; with one 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; with two 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; with one 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; with one 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 the common one-field &lt;code&gt;tr&lt;/code&gt; path, the five runs ranged from 896.0 to 960.9 ns/op. On this particular Windows/AMD system, validation and catalog rendering stayed below one microsecond for that path.&lt;/p&gt;

&lt;p&gt;That is not a universal performance claim. An earlier Apple Silicon measurement was roughly 0.4 microseconds for a broadly similar operation; CPU, OS, Python patch release, and package version make that comparison unsuitable for capacity planning. Run the benchmark on the interpreter and hardware that will actually serve your users.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical adoption checklist
&lt;/h2&gt;

&lt;p&gt;I would consider a gradual migration only when all of these are true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application can run on Python 3.14 or later.&lt;/li&gt;
&lt;li&gt;The team can pin a released version or commit while the package is alpha.&lt;/li&gt;
&lt;li&gt;Old calls and new t-string calls extract into one POT, and the catalog diff is reviewed.&lt;/li&gt;
&lt;li&gt;Placeholder validation runs in both the Babel and GNU gettext stages that the release pipeline uses.&lt;/li&gt;
&lt;li&gt;Broken translations are tested in fallback and strict modes.&lt;/li&gt;
&lt;li&gt;Locale binding is tested under the application's actual thread, task, or request-concurrency model.&lt;/li&gt;
&lt;li&gt;Runtime cost is measured in the deployment environment rather than copied from this article.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Wait if you need arbitrary expressions in translations, cannot move to Python 3.14, or have not validated the same catalog compiler and locale behavior that your release process depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful conclusion
&lt;/h2&gt;

&lt;p&gt;The valuable result was not a synthetic benchmark or a new syntax. It was a smaller operational claim that the evidence supports:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In the tested &lt;code&gt;v0.1.0a8&lt;/code&gt; Windows environment, a gettext codebase could extract ordinary gettext calls, plural calls, and supported t-string calls into one catalog while retaining explicit placeholder validation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That gives a maintainer a reversible migration path: choose one call site, inspect the POT diff, test the affected locale behavior, and continue only if the existing workflow remains intact.&lt;/p&gt;

&lt;p&gt;The exact contract is 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 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, what would you verify before moving the first call site: extraction, placeholder compatibility, catalog compilation, or locale behavior under concurrency?&lt;/p&gt;




&lt;p&gt;Disclosure: I used an AI assistant to organize the public source material, structure the migration test report, and edit the prose. I ran and 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>
  </channel>
</rss>
