<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://paolino.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://paolino.me/" rel="alternate" type="text/html" /><updated>2026-08-14T08:12:56+00:00</updated><id>https://paolino.me/feed.xml</id><title type="html">Carmine Paolino</title><subtitle>I build AI tools at Chat with Work and RubyLLM. Co-founded Freshflow. Outside tech, I make music, run Floppy Disco, and take photos.</subtitle><author><name>Carmine Paolino</name></author><entry><title type="html">RubyLLM::Schema Is Now Schematist: A JSON Schema DSL for Ruby with Full Draft 2020-12 Coverage</title><link href="https://paolino.me/schematist/" rel="alternate" type="text/html" title="RubyLLM::Schema Is Now Schematist: A JSON Schema DSL for Ruby with Full Draft 2020-12 Coverage" /><published>2026-08-11T00:00:00+00:00</published><updated>2026-08-11T00:00:00+00:00</updated><id>https://paolino.me/schematist</id><content type="html" xml:base="https://paolino.me/schematist/"><![CDATA[<p>I want to make Ruby the best language to work with LLMs. Part of that is a great JSON Schema DSL.</p>

<p><a href="https://github.com/crmne/schematist">Schematist</a> is a general purpose JSON Schema DSL that emits Draft 2020-12 schemas. Describe an API payload, a config file, a contract between two services, or the structured output you want back from a model. Trapping that inside another gem’s namespace was a disservice to anyone looking for a great JSON Schema DSL, so it got its own name.</p>

<pre><code class="language-ruby">gem 'schematist'
</code></pre>

<h2 id="it-emits-actual-json-schema">It Emits Actual JSON Schema</h2>

<p>This is the breaking change.</p>

<p><code>to_json_schema</code> used to return this:</p>

<pre><code class="language-ruby">{ name: "PersonSchema", description: nil, schema: { type: "object", ... }, strict: true }
</code></pre>

<p>That’s not a JSON Schema. It’s OpenAI’s <code>response_format</code> envelope, with the actual schema buried one level down under a symbol key. Every consumer that wasn’t OpenAI had to dig it out, and anyone who wanted to hand the result to a validator had to know which part was real.</p>

<p>Now you get the document:</p>

<pre><code class="language-ruby">class Invoice &lt; Schematist::Schema
  title "Invoice"
  description "A billing document"

  string :id, pattern: "^inv_", title: "Invoice ID"
  number :total, greater_than: 0, description: "Amount due"
  string :currency, const: "EUR"
  string :status, enum: %w[draft sent paid], default: "draft"
end

Invoice.new.to_json_schema
# =&gt; {
#   "$schema" =&gt; "https://json-schema.org/draft/2020-12/schema",
#   "title" =&gt; "Invoice",
#   "description" =&gt; "A billing document",
#   "type" =&gt; "object",
#   "properties" =&gt; {
#     "id" =&gt; { "type" =&gt; "string", "pattern" =&gt; "^inv_", "title" =&gt; "Invoice ID" },
#     "total" =&gt; { "type" =&gt; "number", "description" =&gt; "Amount due", "exclusiveMinimum" =&gt; 0 },
#     ...
#   },
#   "required" =&gt; ["id", "total", "currency", "status"],
#   "additionalProperties" =&gt; false
# }
</code></pre>

<p>String keys, <code>$schema</code> declared, no provider keys. Use it with <code>JSON.generate</code> unchanged and any Draft 2020-12 validator will take it.</p>

<p><code>strict</code> went with it. It’s an OpenAI request flag, not a JSON Schema keyword, and a schema library has no business knowing OpenAI exists. Set it where you build the request.</p>

<h2 id="full-draft-2020-12-coverage">Full Draft 2020-12 Coverage</h2>

<p>The old gem covered the basics: types, <code>enum</code>, <code>required</code>, string and numeric bounds, nested objects and arrays, <code>$defs</code> and <code>$ref</code>, <code>if</code>/<code>then</code>/<code>else</code>. <a href="https://github.com/crmne/schematist">Schematist</a> covers the whole vocabulary.</p>

<p><strong>Composition.</strong> <code>allOf</code>, <code>oneOf</code>, and <code>not</code> join <code>anyOf</code>:</p>

<pre><code class="language-ruby">one_of :method do
  object { string :card_number }
  object { string :iban }
end

all_of :account, unevaluated_properties: false do
  object { string :id }
  object { string :status }
end

none_of :state do
  string enum: ["deleted"]
end
</code></pre>

<p><code>unevaluated_properties</code> is the one that makes <code>allOf</code> usable in practice. <code>additionalProperties</code> can’t see across composition branches; <code>unevaluatedProperties</code> can.</p>

<p><strong>Object keys.</strong> Constrain how many properties an object has, what its keys look like, and what the values behind a key pattern must be:</p>

<pre><code class="language-ruby">object :metadata, min_properties: 1, max_properties: 10 do
  keys { string pattern: "^[a-z_]+$" }     # propertyNames
  keys_matching(/^x-/) { string }          # patternProperties
end
</code></pre>

<p><strong>Arrays.</strong> <code>uniqueItems</code>, fixed-length tuples via <code>prefixItems</code>, and <code>contains</code> with its bounds:</p>

<pre><code class="language-ruby">array :tags, of: :string, unique: true

tuple :period do
  string format: "date"
  string format: "date"
end

array :scores do
  integer
  contains(min: 1) { integer minimum: 10 }   # at least one score of 10 or more
end
</code></pre>

<p><strong>Annotations.</strong> <code>title</code>, <code>description</code>, <code>default</code>, <code>examples</code>, <code>deprecated</code>, <code>read_only</code>, <code>write_only</code>. Short ones read well as keyword arguments; longer ones read better in the block, where they annotate the enclosing schema:</p>

<pre><code class="language-ruby">object :account do
  title "Account"
  description "Billing account metadata used for invoices."
  examples [{ id: "acct_123", status: "active" }]

  string :id
  string :status
end
</code></pre>

<p><strong>Encoded content.</strong> For strings that carry something else inside them:</p>

<pre><code class="language-ruby">string :payload, content_encoding: "base64", content_media_type: "application/json" do
  content_schema do
    object { string :name }
  end
end
</code></pre>

<p><strong>Core keywords.</strong> <code>$id</code>, <code>$anchor</code>, <code>$comment</code>, <code>$dynamicAnchor</code>, <code>$dynamicRef</code>, <code>$vocabulary</code>, at the root or on any subschema. They’re passed straight through. Resolving a dynamic reference is the validator’s job, not ours.</p>

<p>Also new: <code>const</code> on every primitive, and <code>greater_than</code> / <code>less_than</code> for <code>exclusiveMinimum</code> / <code>exclusiveMaximum</code>. I picked the Ruby-sounding names over the JSON Schema ones on purpose. You’re writing Ruby.</p>

<h2 id="values-that-arent-known-until-render-time">Values That Aren’t Known Until Render Time</h2>

<p>You define a schema class once, at boot. The allowed values often aren’t known until a request comes in.</p>

<p>Any value can be a proc now, resolved when the document is rendered:</p>

<pre><code class="language-ruby">class RoleSchema &lt; Schematist::Schema
  string :role, enum: -&gt; { @account.roles.pluck(:name) }

  def initialize(account:)
    super()
    @account = account
  end
end

RoleSchema.new(account: account).to_json_schema
</code></pre>

<p>A zero-argument proc is evaluated in the instance’s context, so it can read instance variables. A proc that takes one argument gets the schema instance instead. One class, a different document per instance.</p>

<h2 id="escape-hatches">Escape Hatches</h2>

<p>Covering the spec isn’t the same as guessing everything you’ll want to put in a document, so there are two ways out.</p>

<p>JSON Schema allows <code>true</code> and <code>false</code> in place of a schema object. <code>true</code> accepts anything, <code>false</code> accepts nothing:</p>

<pre><code class="language-ruby">any_of :value do
  any_schema
  string
end
</code></pre>

<p>And <code>raw</code> drops a fragment in as-is, for a vendor extension or anything else the DSL has no opinion about:</p>

<pre><code class="language-ruby">raw :vendor, { "type" =&gt; "object", "x-vendor" =&gt; true }
</code></pre>

<h2 id="a-schema-doesnt-have-to-be-an-object">A Schema Doesn’t Have To Be an Object</h2>

<p>Most schemas describe an object, so that’s the default. But JSON Schema doesn’t care. A schema can be an array, a union, a string, or a pointer somewhere else, and the root of a document is just a schema like any other.</p>

<p>So: a type with a name declares a property. Without a name, it declares what the schema itself is.</p>

<pre><code class="language-ruby">class Tags &lt; Schematist::Schema
  array of: :string, unique: true       # the whole schema is an array
end

class Id &lt; Schematist::Schema
  one_of do                             # the whole schema is a choice
    string
    integer
  end
end

class Person &lt; Schematist::Schema
  raw({ "$ref" =&gt; "https://example.com/person.json" })
end
</code></pre>

<p>It works inside <code>define</code> too, so a reusable definition can be a string with a pattern or a shared enum, not just an object:</p>

<pre><code class="language-ruby">define :status do
  string enum: %w[draft sent paid]
end
</code></pre>

<p>A conditional branch is a schema too, so it can ask for a nested object instead of a flat list of fields:</p>

<pre><code class="language-ruby">given kind: "business" do
  requires :vat_id

  object :tax_details do
    string :vat_number
  end
end
</code></pre>

<h2 id="no-runtime-dependencies">No Runtime Dependencies</h2>

<p><a href="https://github.com/crmne/schematist">Schematist</a> depends on nothing.</p>

<h2 id="migrating">Migrating</h2>

<pre><code class="language-ruby">gem 'schematist'                         # was: gem 'ruby_llm-schema'

class Person &lt; Schematist::Schema        # was: RubyLLM::Schema
end
</code></pre>

<p>Errors moved up a level: <code>Schematist::ValidationError</code>, not <code>RubyLLM::Schema::ValidationError</code>. <code>Schematist::Helpers</code> replaces <code>RubyLLM::Helpers</code>.</p>

<p>If you were reaching into <code>[:schema]</code> to get at the document, stop. <code>to_json_schema</code> returns it directly now, with string keys. If you need the provider wrapper, build it where you send the request:</p>

<pre><code class="language-ruby">{ name: "Invoice", schema: Invoice.new.to_json_schema, strict: true }
</code></pre>

<p>There’s a final <code>ruby_llm-schema</code> 1.0.0 that depends on <a href="https://github.com/crmne/schematist">Schematist</a> and aliases the old constants, so <code>RubyLLM::Schema</code> keeps resolving while you move. It warns on load and it’s the last release of that name.</p>

<p>RubyLLM 2.0 will depend on Schematist, so structured output will get a lot more powerful.</p>

<h2 id="use-it">Use It</h2>

<pre><code class="language-bash">bundle add schematist
</code></pre>

<p><a href="https://github.com/crmne/schematist">Schematist</a> was always a JSON Schema DSL. Now it has the name to match.</p>]]></content><author><name>Carmine Paolino</name></author><category term="Ruby" /><category term="JSON Schema" /><category term="Open Source" /><category term="Schematist" /><category term="RubyLLM" /><category term="AI" /><summary type="html"><![CDATA[RubyLLM::Schema is now Schematist: a general purpose JSON Schema DSL with full Draft 2020-12 coverage, values resolved at render time, and no runtime dependencies.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/images/schematist.png" /><media:content medium="image" url="https://paolino.me/images/schematist.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Founding a Company in Germany: €9,600, 152 Days, and I Still Can’t Send an Invoice</title><link href="https://paolino.me/founding-a-company-in-germany/" rel="alternate" type="text/html" title="Founding a Company in Germany: €9,600, 152 Days, and I Still Can’t Send an Invoice" /><published>2026-06-24T00:00:00+00:00</published><updated>2026-06-24T00:00:00+00:00</updated><id>https://paolino.me/founding-a-company-in-germany</id><content type="html" xml:base="https://paolino.me/founding-a-company-in-germany/"><![CDATA[<p>I started founding my second company in Germany in late January. It is now late June.</p>

<p>In that time, the state, two courts, a notary, a law firm, a tax firm, and software vendors have all found a way to bill me. Every single one of them, on time.</p>

<p>I have spent more than 9,600 euros to start a company: a little over 7,600 in fees and bills, plus 2,000 in share capital frozen in an account I am not allowed to touch. And after five months, here is what I have to show for it:</p>

<p>I have not been able to send a single invoice of my own.</p>

<p>Not one.</p>

<p>The work is happening. The clients are real. The one thing the state exists to let me do, bill them cleanly, is the one thing I still can’t.</p>

<h2 id="the-timeline">The timeline</h2>



<div class="ftl-tl"><ol><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Jan</span><span class="ftl-cal-d">23</span></span><div class="ftl-ev"><span class="ftl-date">23 Jan</span><p>First call with a law firm to set up the company. The clock and the hourly billing start.</p></div><div class="ftl-money"></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Feb</span><span class="ftl-cal-d">5</span></span><div class="ftl-ev"><span class="ftl-date">5 Feb</span><p>I sign the mandate and send my ID. Drafting begins.</p></div><div class="ftl-money"></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Feb</span><span class="ftl-cal-d">18</span></span><div class="ftl-ev"><span class="ftl-date">18 Feb</span><p>The structure is set: PlentyLabs UG &amp; Co. KG, <a href="#postscript-why-a-ug-and-co-kg-two-companies">technically two companies</a>. <a href="#bonus-round-my-company-name-was-too-generic">The name is a saga of its own.</a></p></div><div class="ftl-money"></div></li><li class="ftl-gap"><span>about 1 month of drafting</span></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Mar</span><span class="ftl-cal-d">6</span></span><div class="ftl-ev"><span class="ftl-date">6 Mar</span><p>Incorporation documents ready.</p></div><div class="ftl-money"></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Mar</span><span class="ftl-cal-d">17</span></span><div class="ftl-ev"><span class="ftl-date">17 Mar</span><p>Documents approved. The hunt for a notary begins.</p></div><div class="ftl-money"></div></li><li class="ftl-gap"><span>7 days for the appointment</span></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Mar</span><span class="ftl-cal-d">24</span></span><div class="ftl-ev"><span class="ftl-date">24 Mar</span><p>Notary in Berlin reads the deeds aloud and certifies that I am who I say I am.</p></div><div class="ftl-money"><code>€1,575.24</code><span class="ftl-tag">Notary fees</span></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Mar</span><span class="ftl-cal-d">25</span></span><div class="ftl-ev"><span class="ftl-date">25 Mar</span><p>I pay in <code>€2,000.00</code> of share capital. Money I cannot touch; it has to stay there.</p></div><div class="ftl-money ftl-locked"><code>€2,000.00</code><span class="ftl-tag">Locked, not a fee</span></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Mar</span><span class="ftl-cal-d">26</span></span><div class="ftl-ev"><span class="ftl-date">26 Mar</span><p>The register court demands a fee advance.</p></div><div class="ftl-money"><code>€300.00</code><span class="ftl-tag">Court advance</span></div></li><li class="ftl-gap"><span>17 days after the notary</span></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Apr</span><span class="ftl-cal-d">10</span></span><div class="ftl-ev"><span class="ftl-date">10 Apr</span><p>First company entered in the commercial register.</p></div><div class="ftl-money"></div></li><li class="ftl-gap"><span>1 week more</span></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Apr</span><span class="ftl-cal-d">17</span></span><div class="ftl-ev"><span class="ftl-date">17 Apr</span><p>Second company entered.</p></div><div class="ftl-money"><code>€260.00</code><span class="ftl-tag">Register, 200 + 60</span></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Apr</span><span class="ftl-cal-d">20</span></span><div class="ftl-ev"><span class="ftl-date">20 Apr</span><p>I ask the firm I already pay to handle the tax registration too.</p></div><div class="ftl-money"></div></li><li class="ftl-gap"><span>2.5 weeks just to start</span></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">May</span><span class="ftl-cal-d">6</span></span><div class="ftl-ev"><span class="ftl-date">6 May</span><p>Before the tax work can begin, a fresh engagement is required: proposal, power of attorney, ID checks, per company.</p></div><div class="ftl-money"><code>€630.00</code><span class="ftl-tag">Tax registration quote</span></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">May</span><span class="ftl-cal-d">28</span></span><div class="ftl-ev"><span class="ftl-date">28 May</span><p>The incorporation legal bill lands.</p></div><div class="ftl-money"><code>€4,462.50</code><span class="ftl-tag">Legal fees</span></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">May</span><span class="ftl-cal-d">29</span></span><div class="ftl-ev"><span class="ftl-date">29 May</span><p>Tax questionnaires submitted. I request standard VAT and a VAT ID, urgently.</p></div><div class="ftl-money"></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Jun</span><span class="ftl-cal-d">3</span></span><div class="ftl-ev"><span class="ftl-date">3 Jun</span><p>First bill from the accounting software.</p></div><div class="ftl-money"><code>€426.97</code><span class="ftl-tag">Accounting software</span></div></li><li class="ftl-row"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Jun</span><span class="ftl-cal-d">9</span></span><div class="ftl-ev"><span class="ftl-date">9 Jun</span><p>I am told the VAT ID will arrive by post. A letter.</p></div><div class="ftl-money"></div></li><li class="ftl-row ftl-today"><span class="ftl-cal" aria-hidden="true"><span class="ftl-cal-m">Jun</span><span class="ftl-cal-d">24<small>today</small></span></span><div class="ftl-ev"><span class="ftl-date">24 Jun, today</span><p>Seven weeks since the tax firm, almost four weeks since the questionnaires. No VAT ID. No invoice sent.</p></div><div class="ftl-money"></div></li></ol><div class="ftl-total"><div class="ftl-total-row"><span>Billed by everyone else</span><code>€7,654.71</code></div><div class="ftl-total-row"><span>Share capital I cannot touch</span><code>€2,000.00</code></div><div class="ftl-total-row ftl-grand"><span>Total gone</span><code>€9,654.71</code></div><div class="ftl-total-row ftl-zero"><span>Invoices I have managed to send</span><code>0</code></div></div></div>

<p>Everyone in this story could invoice me. I am the only one who can’t invoice anyone.</p>

<h2 id="but-you-can-invoice-your-german-clients">“But you can invoice your German clients”</h2>

<p>The clients abroad need a VAT ID for reverse charge, and that is exactly the one I am still waiting for. My German clients I could bill today. But a domestic invoice now would have to be reissued the moment the VAT ID arrives. Bill now, bill again later, for no reason. So those wait too.</p>

<h2 id="this-should-have-been-a-web-form">This should have been a web form</h2>

<p>Fill it in, pay a fee, get your company and your VAT ID in a week. Estonia does it. The UK registers a company in a day, online, for the price of a dinner. There is no law of nature that says incorporation has to take five months and arrive by post.</p>

<p>Germany has built a process that chains one dependency to the next, puts a fee on each, and lets a founder run up legal bills, notary bills, court fees, tax retainers, and software subscriptions on zero revenue, all before granting the one permission a company exists for: the right to send an invoice.</p>

<p>If you ask the government, the reason is trust: the notary, the capital, the registers, the endless checks, all there to keep bad actors out. This is the same machine that did not catch Wirecard, a two-billion-euro scam. It does, somehow, generate enough friction to scare new founders out of the country.</p>

<p>And no, I could not just leave instead. My first company, Freshflow, is valuable enough that walking out of Germany would trigger a massive six-figure exit tax, on gains I have not even realised, purely for the privilege of leaving. But that is a story for another post.</p>

<p>This is a country taxing ambition through the roof before you’ve earned a cent, then wondering why the ambitious leave.</p>

<h2 id="bonus-round-my-company-name-was-too-generic">Bonus round: my company name was “too generic”</h2>

<p>Have you heard of Apple? A piece of fruit, and one of the most valuable brands ever built. That name would never have been approved in Germany.</p>

<p>Naming a company is hard. It is the word everyone who touches your work will remember. After months of turning it over, I found one I could stand behind, a name that says what I believe software should be. (That belief will be its own post, soon.) Distinctive, I thought. The kind of name you do not forget.</p>

<p>Plenty.</p>

<p>“No,” said the lawyer. German company names have to be distinctive, and “Plenty” is a plain English word. Berlin would reject it.</p>

<p>“Plenty Group?” Two plain words. “Plenty Labs?” “Labs” is a plain word too. “Plenty.is?” A generic word with a domain on the end is still a generic word, and there was case law to prove it.</p>

<p>The suggestions were worse: stick my surname on the front, Paolino Plenty Labs. Or a prefix, PG Plenty Germany. Or make up a fantasy word.</p>

<p>Is Plenty. Its Plenty. IsPlenty. ItsPlenty. Rejected, all of it.</p>

<p>Fine. They wanted a meaningless word; I gave them one. Plenty Labs, minus the space: PlentyLabs.</p>

<p>Approved.</p>

<p>A name that started out of spite. Weeks of correspondence, resolved by removing a space. A rule that does not reward clarity. It rewards nonsense.</p>

<h2 id="postscript-why-a-ug-and-co-kg-two-companies">Postscript: why a UG and Co. KG, two companies?</h2>

<p>Why does a one-person business need two companies? Because the simple version is worse, and because I am building it into something bigger.</p>

<p>The simplest setup is a sole proprietorship. Thirty euros, no capital, done in an afternoon. It also makes me personally liable for everything. A client sues? They are not suing a company. They are suing me. My savings, my apartment, my name.</p>

<p>So I wanted real limited liability, which means a company. And for one person, the cleanest company turns out not to be one company. It is a KG, a partnership that does the work, with a tiny UG standing in as the partner that carries the liability. Strange, but standard. You probably have seen “GmbH &amp; Co. KG” on German companies a hundred times without wondering why. This is why.</p>

<p>It is taxed the sane way, too. The partnership’s profit is taxed once, as my income, since I am the one who ends up with it. A plain UG would tax the company first, then tax me again when I paid myself.</p>

<p>Why a UG and not the famous GmbH? A GmbH wants 25,000 euros sitting in a bank account before it is allowed to exist. The UG lets you start with almost nothing, on one condition: lock away a quarter of every year’s profit until the reserve reaches 25,000, then convert to a GmbH. The 25,000 does not go away. Germany just takes it in instalments.</p>

<p>Which leaves the only real question. Why 25,000 at all? It is my company and my risk. If I want to start with nothing, that is my call, not a toll the state collects before it will let me try. And the cheap door has a price of its own: to some clients, “UG” reads as “not serious,” and they would rather deal with a GmbH. The structure built to let me in quietly marks me for using it.</p>

<div style="margin:44px 0 0;padding:26px 28px;background:rgba(255,230,106,.18);border-radius:8px;">
<p style="margin:0;">This is also why <a href="https://chatwithwork.com">Chat with Work</a>, my fully private Work AI, is still free: I cannot invoice you yet! Try it before that changes.</p>
</div>]]></content><author><name>Carmine Paolino</name></author><category term="Germany" /><category term="Startups" /><category term="Bureaucracy" /><category term="Plenty" /><summary type="html"><![CDATA[I started a company in Germany in late January. By late June I had spent 9,600 euros, registered two companies, and still cannot issue a single invoice of my own. Here is the timeline, with the bill next to every step.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/assets/images/og/posts/founding-a-company-in-germany.png" /><media:content medium="image" url="https://paolino.me/assets/images/og/posts/founding-a-company-in-germany.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RubyLLM 1.16: Concurrent Tool Execution, Rails-Style Instrumentation, and api_base for Every Provider</title><link href="https://paolino.me/rubyllm-1-16/" rel="alternate" type="text/html" title="RubyLLM 1.16: Concurrent Tool Execution, Rails-Style Instrumentation, and api_base for Every Provider" /><published>2026-06-09T00:00:00+00:00</published><updated>2026-06-09T00:00:00+00:00</updated><id>https://paolino.me/rubyllm-1-16</id><content type="html" xml:base="https://paolino.me/rubyllm-1-16/"><![CDATA[<p>When you first reach for an LLM library, the only question is whether it works. Can it call the model, parse the response, run a tool. Once your app is actually in production, the questions change. Is it fast? Can I see what it’s doing when something goes wrong? Can I send its traffic through my own infrastructure instead of straight out to the provider?</p>

<p>I released <a href="https://rubyllm.com">RubyLLM</a> 1.16 today. It answers these production questions.</p>

<p>The three headline features are about speed, visibility, and control: tools that run concurrently, structured events for everything RubyLLM does, and a configurable base URL for every native provider. None of them change how you write your app. All of them matter the moment real traffic shows up.</p>

<h2 id="tools-that-run-concurrently">Tools That Run Concurrently</h2>

<p>When a model returns several tool calls in one response, it’s telling you those calls are independent. Get the weather, look up the stock price, fetch the exchange rate. The model didn’t ask for them in order. It asked for all of them.</p>

<p>RubyLLM has always run them one at a time. For tools that are CPU-bound that’s fine, but most tools aren’t. Most tools are an HTTP call, a database query, another LLM request. They spend their time waiting. Running three waits back to back, when you could have waited for all three at once, it’s time your users can’t get back.</p>

<p>1.16 runs them together. Turn it on for every chat from one place:</p>

<pre><code class="language-ruby">RubyLLM.configure do |config|
  config.tool_concurrency = true # :threads, :fibers, true, or false
end
</code></pre>

<p><code>true</code> uses <code>:threads</code> and needs no dependencies. If you’d rather not pay for a thread per tool, <code>:fibers</code> mode uses the <code>async</code> gem and gets my recommendation for I/O bound operations. Check out my previous posts on <a href="/async-ruby-is-the-future/">why I think async is the future of Ruby</a> and <a href="/ruby-concurrency-what-actually-happens/">what Ruby concurrency actually does</a>.</p>

<p>When one conversation needs different behaviour than the rest, override it per chat:</p>

<pre><code class="language-ruby">chat.with_tools(Weather, StockPrice, Currency, concurrency: :fibers)
chat.with_tools(Weather, StockPrice, concurrency: false)
</code></pre>

<p>Inside Rails, each concurrent tool call runs wrapped in the Rails executor, so connection pools, <code>CurrentAttributes</code>, and reloading behave the way the rest of your app does. You don’t think about it. It just works.</p>

<p>And concurrency doesn’t make your UI wait for the slowest tool. Each result is added back to the conversation the moment that tool finishes, in completion order, so your streaming callbacks see results land as they happen. RubyLLM still gathers every result before going back to the model, but your users watch progress instead of a spinner.</p>

<h2 id="instrumentation-without-monkey-patching">Instrumentation Without Monkey Patching</h2>

<p>You can’t operate what you can’t see. Some libraries popped up to add instrumentation to RubyLLM, but they monkey patch us. That’s unnecessary maintenance burden.</p>

<p>RubyLLM 1.16 emits structured events for the work it does, the same way Rails does. In a Rails app they flow through <code>ActiveSupport::Notifications</code> automatically, and you subscribe the way you’d subscribe to any framework event:</p>

<pre><code class="language-ruby"># config/initializers/ruby_llm_instrumentation.rb
ActiveSupport::Notifications.subscribe('chat.ruby_llm') do |_name, _start, _finish, _id, payload|
  Rails.logger.info(
    provider: payload[:provider],
    model: payload[:model],
    input_tokens: payload[:input_tokens],
    output_tokens: payload[:output_tokens]
  )
end
</code></pre>

<p>Outside Rails, point <code>config.instrumenter</code> at anything that responds to <code>instrument(name, payload) { ... }</code> and wire it into OpenTelemetry, StatsD, or your own logger. The events cover the whole surface: HTTP requests, chat completions, tool calls, embeddings, and model registry refreshes, each carrying the provider, model, token usage, and the Ruby objects an observability adapter needs.</p>

<p>Those payloads can hold message content, tool arguments, and full provider responses, which is exactly the sensitive data you don’t want sprayed into logs by accident. So log or export those fields only when your policy allows it. The <a href="https://rubyllm.com/instrumentation">Instrumentation guide</a> has the full payload reference.</p>

<h2 id="a-base-url-for-every-native-provider">A Base URL for Every Native Provider</h2>

<p>In production, your AI traffic rarely goes straight to the provider. It goes through a gateway that handles auth, a proxy that enforces rate limits, a private endpoint inside your network. RubyLLM let you point most providers at a custom base URL already. 1.16 fills the last gaps, so now every native provider has one:</p>

<pre><code class="language-ruby">RubyLLM.configure do |config|
  config.bedrock_api_base     = ENV['BEDROCK_API_BASE']
  config.mistral_api_base     = ENV['MISTRAL_API_BASE']
  config.perplexity_api_base  = ENV['PERPLEXITY_API_BASE']
  config.vertexai_api_base    = ENV['VERTEXAI_API_BASE']
  config.xai_api_base         = ENV['XAI_API_BASE']
end
</code></pre>

<p>Together with the bases already there for OpenAI, Anthropic, Gemini, DeepSeek, OpenRouter, Azure, Ollama, and GPUStack, you can front any provider with your own infrastructure. Each override falls back to the provider’s default when unset, so nothing you already have changes.</p>

<p>While I was in the HTTP layer, I made the Faraday adapter configurable too:</p>

<pre><code class="language-ruby">RubyLLM.configure do |config|
  config.faraday_adapter = :async_http # or :typhoeus, :net_http, :httpx, etc.
end
</code></pre>

<p>It defaults to <code>Net::HTTP</code>, so nothing changes unless you ask. Reach for it when you want connection pooling, HTTP/2, or whatever adapter your app already standardizes on.</p>

<h2 id="transcription-words">Transcription Words</h2>

<p><code>Transcription</code> now exposes word-level timing when the provider returns it, so you can build word-by-word highlighting on top of OpenAI’s verbose transcriptions:</p>

<pre><code class="language-ruby">transcription = RubyLLM.transcribe("interview.mp3", model: "whisper-1")
transcription.words # =&gt; [{ word:, start:, end: }, ...]
</code></pre>

<h2 id="getting-ready-for-20">Getting Ready for 2.0</h2>

<p>Deprecation warnings are now yours to control:</p>

<pre><code class="language-ruby">RubyLLM.configure do |config|
  config.deprecation_behavior = :warn # :warn (default), :silence, or :raise
end
</code></pre>

<p>Set <code>:raise</code> in your test environment and a deprecated path fails the build the moment something hits it. That’s the cheapest possible way to be ready before those paths disappear in 2.0, instead of finding out on upgrade day.</p>

<h2 id="fixes-and-the-model-registry">Fixes and the Model Registry</h2>

<p>A release this size carries a long tail of fixes. The ones worth calling out: Anthropic’s “prompt is too long” now raises <code>ContextLengthExceededError</code> so you can rescue it like any other context-length error, streaming parallel tool calls accumulate correctly, Bedrock reasoning streams properly, and Gemini function calls and inline images follow the spec. Active Storage handling in Rails got more careful about pending uploads, load order, and text attachments. And when configuration or a model lookup goes wrong, the error now tells you what happened and how to fix it.</p>

<p>The model registry is refreshed with the latest models, capabilities, and pricing. One fix there is worth a sentence: models.dev started shipping partial release dates like <code>2025-09</code> and <code>2025</code>, RubyLLM was turning those into invalid timestamps, and model loading broke. 1.16 normalizes them to real dates so the registry keeps loading.</p>

<p>The <a href="https://github.com/crmne/ruby_llm/releases/tag/1.16.0">full release notes</a> have the complete list.</p>

<h2 id="use-it">Use It</h2>

<pre><code class="language-ruby">gem 'ruby_llm', '~&gt; 1.16'
</code></pre>

<pre><code class="language-bash">bundle update ruby_llm
</code></pre>

<p>It’s backwards compatible. Concurrency is opt-in, instrumentation stays inert until you subscribe, and every new <code>*_api_base</code> falls back to the provider default. Nothing you’ve built changes until you decide to reach for it. The boring infrastructure is just there now, waiting for the day your app stops being a demo and starts being production.</p>]]></content><author><name>Carmine Paolino</name></author><category term="Ruby" /><category term="AI" /><category term="LLM" /><category term="Rails" /><category term="Open Source" /><category term="RubyLLM" /><category term="Concurrency" /><summary type="html"><![CDATA[RubyLLM 1.16 runs your tools concurrently in threads or fibers, makes RubyLLM observable without monkey patching, and lets every native provider sit behind a proxy.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/images/rubyllm-1.16.png" /><media:content medium="image" url="https://paolino.me/images/rubyllm-1.16.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Engineering Is Not Dead, Because Accountability Isn’t</title><link href="https://paolino.me/engineering-is-not-dead/" rel="alternate" type="text/html" title="Engineering Is Not Dead, Because Accountability Isn’t" /><published>2026-05-22T00:00:00+00:00</published><updated>2026-05-22T00:00:00+00:00</updated><id>https://paolino.me/engineering-is-not-dead</id><content type="html" xml:base="https://paolino.me/engineering-is-not-dead/"><![CDATA[<p>A lot of people have developed a gag reflex against anything touched by AI.</p>

<p>I understand where that comes from. There is a lot of slop, maintainers are tired of reviewing code from people who do not understand it, and people are tired of <a href="https://x.com/jorgemanru/status/2053183727514091820">predictable cadence</a>.</p>

<p>We’re also heading toward a version of the future where all code will be generated. The models are good enough that for a lot of work, especially the boring repetitive kind, typing everything by hand makes very little sense. You can describe what you want, steer the model, ask for changes, review the output, and get to a working implementation much faster than before.</p>

<p>That caused some people to jump from “models can generate code” to “engineering is dead”.</p>

<p>That is wrong.</p>

<h2 id="code-generation-is-not-engineering">Code Generation Is Not Engineering</h2>

<p>Engineering is not the act of producing text that happens to run or compile.</p>

<p>Engineering is deciding what should exist. Understanding the constraints. Knowing what can go wrong. Making trade-offs. Reviewing the result. Being responsible for what happens after you ship it.</p>

<p>The model can write the code. Most of it. Maybe all of it. But the model is not accountable.</p>

<p>You are.</p>

<p>If a generated library has a security issue, people will not open an issue against the model. They will open it against you. If a generated feature behaves badly in production, your reputation will suffer. If the code is impossible to maintain six months later, the model is not at fault.</p>

<p>You are.</p>

<p>This is why your engineering skills matter more than ever. Since you are not spending most of your time typing, you can focus on what really matters.</p>

<h2 id="so-how-do-you-tell">So How Do You Tell?</h2>

<p>The discussion around AI-generated code is confused because people focus too much on the origin.</p>

<p>Lots of good code will be touched by LLMs. So will code from your favorite programmers. So will lots of bad code.</p>

<p>The involvement of AI tells you very little by itself.</p>

<p>The real distinction is whether the result is owned or not. It is the care, attention, review, testing, product design, and engineering the author put into it.</p>

<p>You signal it by producing high-quality output and being accountable for it. By showing up. By fixing bugs. By knowing your own code inside and out. By making it clear that there is a person behind the work who understands the result and accepts responsibility for it.</p>

<p>That takes care, taste, engineering skill, and genuine human effort.</p>

<p>This goes both ways. The same skills are needed by people evaluating code and products. It is not enough to ask whether AI was involved. You have to look at the result, the behavior, the tests, the edge cases, the maintenance story, and the author’s ability to own the thing.</p>

<p>Engineering is not dead, because accountability isn’t.</p>]]></content><author><name>Carmine Paolino</name></author><category term="AI" /><category term="LLM" /><category term="Software Development" /><category term="Taste" /><summary type="html"><![CDATA[Models can generate the code. They cannot be accountable for it. The real distinction is whether the result is owned.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/assets/images/og/posts/engineering-is-not-dead.png" /><media:content medium="image" url="https://paolino.me/assets/images/og/posts/engineering-is-not-dead.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Production Experience Cannot Be Hallucinated</title><link href="https://paolino.me/production-experience-cannot-be-hallucinated/" rel="alternate" type="text/html" title="Production Experience Cannot Be Hallucinated" /><published>2026-05-13T00:00:00+00:00</published><updated>2026-05-13T00:00:00+00:00</updated><id>https://paolino.me/production-experience-cannot-be-hallucinated</id><content type="html" xml:base="https://paolino.me/production-experience-cannot-be-hallucinated/"><![CDATA[<p>I paid five dollars to read a <a href="https://mrrazahussain.medium.com/the-rails-llm-stack-is-finally-ready-for-production-here-is-what-i-learned-shipping-it-ff9d20298c5c">Medium article</a> about <a href="https://rubyllm.com">my own free, open source library</a>. It was sold as hard-won production experience.</p>

<p>It was fabricated.</p>

<p>The first code sample used <code>RubyLLM.client</code>, which does not exist. It called <code>client.chat(messages: ...)</code>, which does not exist. Then it invented <code>RubyLLM::StreamInterrupted</code>, <code>RubyLLM::APIError</code>, and a <code>stream: proc</code> API that RubyLLM has never had.</p>

<p>The problem was not merely wrong information. Wrong information can be corrected. This was sold as experience with RubyLLM in production, which is a much more valuable claim.</p>

<p>AI slop is not just filling the web with <a href="https://x.com/jorgemanru/status/2053183727514091820">predictable cadence</a>. It is fabricating experience. It is letting people skip the work, skip the scar tissue, and still write in the voice of someone who has been there.</p>

<p>In open source, that turns into a tax. Maintainers build the thing, write the docs, publish the source, keep the examples working, answer the issues, and then have to police hallucinated articles about their own projects before users start debugging ghosts.</p>

<h2 id="the-four-magic-words-in-tech">The Four Magic Words in Tech</h2>

<p>Production. Scale. Security. Reliability.</p>

<p>In the tech world, attach one of these words to a claim and it immediately becomes true. “This does not scale” can kill a project before anyone measures it. “This is not production ready” can sabotage it without a single deploy.</p>

<p>So when an article says “what broke in production”, it is not just offering advice. It is claiming experience, and experience cannot be hallucinated.</p>

<p><a href="/assets/receipts/2026-05-13-production-experience-medium-original-article-2026-05-12.md">The first version</a> opened by saying the author had spent three weeks on the wrong side of the problem before getting something stable in production. That is a powerful claim. It tells the reader to relax and inherit the author’s scars.</p>

<p>There were no scars. The author had not even run the first example.</p>

<p>This is why fake experience is so dangerous. Bad code fails fast. Fake experience lingers. It gets quoted. It gets summarized. It gets used in meetings by people who do not know enough yet to see the hollow center.</p>

<p>The recipe is familiar. Streaming failures. Token budgets. Provider fallback. Turbo Streams. Redis circuit breakers. nginx buffering. Load testing. They sit near “LLM production” in the LLM training data. Arrange them with enough confidence and the result smells real.</p>

<p>Production experience is not a smell. It is a thing that happened, and none of these things happened.</p>

<h2 id="what-actually-happened">What Actually Happened</h2>

<p>Here is the short version.</p>

<p>Most articles about RubyLLM are good. Since it became popular, I have seen a few confident guides from people who clearly had not run the code. Usually they disappear into LinkedIn or search results. This one made the pattern impossible to ignore.</p>

<p><a href="/assets/receipts/2026-05-13-production-experience-maintainer-first-correction.png">I called it out</a>:</p>

<blockquote>
  <p>Author of RubyLLM here.</p>

  <p>The very first example does not work.</p>

  <p>The article is not merely wrong in a few places. It is fabricated.</p>

  <p>…</p>
</blockquote>

<p><a href="/assets/receipts/2026-05-13-production-experience-author-admission.png">The author replied</a>:</p>

<blockquote>
  <p>You were right.</p>

  <p>The code in the original article was not verified against the actual gem. <code>RubyLLM.client</code>, <code>RubyLLM::StreamInterrupted</code>, <code>RubyLLM::APIError</code>, <code>stream: proc</code> – none of it exists. You caught every fabrication accurately.</p>

  <p>I’ve replaced the article entirely. The new version has been verified against your documentation and source. The fake “production experience” framing is gone. It’s now an honest documentation-based guide with a correction notice at the top explaining what happened.</p>
</blockquote>

<p>“I’ve replaced the article entirely.”</p>

<p>It was a long article. The completely rewritten replacement appeared in a few minutes. The fake method names were replaced with real ones, but the posture stayed the same: “RubyLLM in production”, “what tutorials skip”, “streaming failures”, “provider fallback”, “token budgets.”</p>

<p>The method names got real. The experience didn’t.</p>

<p>The new version claimed Puma restarts produce neat RubyLLM streaming errors. They do not. If the worker dies, the Ruby process running the call is gone. It suggested deleting old persisted chat messages as context management. That is destroying conversation history. It described fallback by throwing away the chat and asking another provider the last prompt as a fresh question. That is not conversation fallback. It confused HTTP/SSE buffering with Turbo Streams over ActionCable.</p>

<p>Not battle scars. Guesses presented as authority.</p>

<p><a href="/assets/receipts/2026-05-13-production-experience-maintainer-second-correction.png">I called the second version what it was: phony</a>. <a href="/assets/receipts/2026-05-13-production-experience-responses-hidden.png">The author then hid responses</a> while keeping the article up.</p>

<p>I reported the article to Medium and contacted the publication that promoted it with the fabricated APIs, the author’s admission, and the hidden corrections. To their credit, the editor replied quickly, apologized, and removed it from the publication. But only the author can take down the original Medium article, so the piece remained available without the maintainer corrections visible next to it.</p>

<h2 id="do-not-counterfeit-experience">Do Not Counterfeit Experience</h2>

<p>Please do write about your favourite software. Critique it too. Tell us maintainers where the API is wrong, the docs are bad, the abstraction leaks. Preferably in an issue so we can actually see it. That feedback is gold.</p>

<p>But do not counterfeit experience. If you’re using The Four Magic Words in Tech, the bar is even higher.</p>

<p>And if you run a technical publication, please at least check the first example.</p>]]></content><author><name>Carmine Paolino</name></author><category term="AI" /><category term="Ruby" /><category term="RubyLLM" /><category term="Open Source" /><category term="Technical Writing" /><summary type="html"><![CDATA[A paid Medium article claimed hard-won production lessons about RubyLLM. The code had not even run, and the regenerated version only made the fake experience harder to spot.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/assets/images/og/posts/production-experience-cannot-be-hallucinated.png" /><media:content medium="image" url="https://paolino.me/assets/images/og/posts/production-experience-cannot-be-hallucinated.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RubyLLM 1.15: Image Editing, Cost Tracking and Less Tool Boilerplate</title><link href="https://paolino.me/rubyllm-1-15/" rel="alternate" type="text/html" title="RubyLLM 1.15: Image Editing, Cost Tracking and Less Tool Boilerplate" /><published>2026-05-07T00:00:00+00:00</published><updated>2026-05-07T00:00:00+00:00</updated><id>https://paolino.me/rubyllm-1-15</id><content type="html" xml:base="https://paolino.me/rubyllm-1-15/"><![CDATA[<p>I released <a href="https://rubyllm.com">RubyLLM</a> 1.15 today.</p>

<p>It ships image editing, cost tracking, cleaner token accounting, inferred tool parameters, additive callbacks, and Rails fixes.</p>

<p>The theme is simple: stop making me write glue code. If the computer can infer it, RubyLLM should infer it. If a provider reports usage, RubyLLM should turn it into cost. If Rails already has a blob, RubyLLM should not download it and upload it again.</p>

<h2 id="image-editing">Image Editing</h2>

<p><code>RubyLLM.paint</code> could already generate images:</p>

<pre><code class="language-ruby">image = RubyLLM.paint("A watercolor robot holding a Ruby gem")
</code></pre>

<p>Now <code>with:</code> turns it into an image edit:</p>

<pre><code class="language-ruby">image = RubyLLM.paint(
  "Turn the logo green and keep the background transparent",
  model: "gpt-image-1",
  with: "logo.png"
)
</code></pre>

<p>Same method, same attachment shape.</p>

<p>The source can be a path, a URL, an IO-like object, or an Active Storage attachment. Multiple source images work too:</p>

<pre><code class="language-ruby">image = RubyLLM.paint(
  "Combine these references into a postcard illustration",
  model: "gpt-image-1",
  with: ["person.png", "style-reference.png"]
)
</code></pre>

<p>And if you need to constrain the edit, pass a mask:</p>

<pre><code class="language-ruby">image = RubyLLM.paint(
  "Replace only the background with a sunset sky",
  model: "gpt-image-1",
  with: "portrait.png",
  mask: "portrait-mask.png"
)
</code></pre>

<p>That’s it. <code>paint</code> paints. Sometimes from scratch, sometimes from an existing image.</p>

<h2 id="cost-tracking">Cost Tracking</h2>

<p>RubyLLM has tracked tokens since 1.0. But “this used 18,432 tokens” is only half the answer. The next question is always: how much did that cost?</p>

<p>Calculating that was never hard. Take the input tokens, output tokens, cached tokens, maybe reasoning tokens. The pricing is already in RubyLLM’s model registry. Multiply by the per-million rate.</p>

<p>But why should every app have to write that code?</p>

<p>RubyLLM already has the usage. RubyLLM already knows the model. RubyLLM already ships the model registry. So now it does the boring math for you.</p>

<p>Now you can ask:</p>

<pre><code class="language-ruby">response = chat.ask("Summarize Ruby's object model.")

response.cost.total
chat.cost.total
agent.cost.total
</code></pre>

<p>Same for images:</p>

<pre><code class="language-ruby">image = RubyLLM.paint("A small watercolor robot", model: "gpt-image-1")

image.tokens.input
image.tokens.output

image.cost.input
image.cost.output
image.cost.total
</code></pre>

<p>If RubyLLM does not have pricing for part of the usage, the cost is <code>nil</code>. Better no answer than a fake one.</p>

<p>A chat with ten messages can tell you the total. An agent can tell you the total. A generated image can tell you the total. No more handrolled sums.</p>

<h2 id="token-counts-that-mean-what-they-say">Token Counts That Mean What They Say</h2>

<p>Prompt caching made token counts messy.</p>

<p>Some providers include cache reads in prompt tokens. Some report cache creation separately. Some don’t. If you multiply the wrong number by the wrong price, your cost tracking is wrong before it starts.</p>

<p>So 1.15 separates the different kinds of tokens before exposing them:</p>

<pre><code class="language-ruby">response.tokens.input       # standard input tokens
response.tokens.output      # billable output tokens
response.tokens.cache_read  # prompt cache reads
response.tokens.cache_write # prompt cache writes
</code></pre>

<p><code>tokens.input</code> now means normal input tokens. Cache reads and cache writes are separate. <code>tokens.output</code> always mean billable output tokens.</p>

<p>The old top-level helpers still work. New code should use <code>response.tokens.*</code>.</p>

<p>No new Rails migration is required if you already ran the 1.9 token migration. If you display token counts directly, read the <a href="https://rubyllm.com/upgrading/#upgrade-to-115">1.15 upgrade notes</a>.</p>

<h2 id="less-tool-boilerplate">Less Tool Boilerplate</h2>

<p>Tools in RubyLLM are Ruby classes. But for very simple tools, RubyLLM still made you repeat yourself:</p>

<pre><code class="language-ruby">class Weather &lt; RubyLLM::Tool
  description "Gets current weather for a location"
  param :latitude  # why?
  param :longitude # DRY!

  def execute(latitude:, longitude:)
    # ...
  end
end
</code></pre>

<p>That is silly. The method signature already says there is a <code>latitude</code> and a <code>longitude</code>.</p>

<p>Now this works:</p>

<pre><code class="language-ruby">class Weather &lt; RubyLLM::Tool
  desc "Gets current weather for a location"

  def execute(latitude:, longitude:, units: "metric")
    # ...
  end
end
</code></pre>

<p>Required keywords become required string parameters. Optional keywords become optional string parameters.</p>

<p>Ruby method signatures don’t tell us JSON Schema types or descriptions, so if those matter, keep using <code>param</code>:</p>

<pre><code class="language-ruby">param :units, type: :string, desc: "metric or imperial", required: false
</code></pre>

<p>And when you need nested objects, arrays, enums, or full schema control, use <code>params</code>. Nothing changed there.</p>

<p>Also:</p>

<ul>
  <li><code>desc</code> is now an alias for <code>description</code></li>
  <li><code>param</code> accepts <code>description:</code> as an alias for <code>desc:</code></li>
  <li>the tool generator now emits <code>desc</code></li>
  <li>we retain full backwards compatibility!</li>
</ul>

<h2 id="callbacks-that-stack">Callbacks That Stack</h2>

<p>The old <code>on_*</code> callbacks were replace-style callbacks. Register another one and you replaced the previous one.</p>

<p>That caused an obvious problem: Rails persistence wants callbacks, and your app also wants callbacks. Logging wants callbacks. Analytics wants callbacks. Replacing the previous callback is the wrong default.</p>

<p>So 1.15 adds additive callbacks:</p>

<pre><code class="language-ruby">chat.before_message { ... }
chat.after_message { |message| ... }
chat.before_tool_call { |tool_call| ... }
chat.after_tool_result { |result| ... }
</code></pre>

<p>Register five callbacks, all five run.</p>

<p>Rails persistence uses these internally now. Your app can layer its own callbacks on top without breaking persistence.</p>

<p>The old <code>on_*</code> callbacks are deprecated. They’ll go away in RubyLLM 2.0.</p>

<h2 id="rails-fixes">Rails Fixes</h2>

<p>Rails got a lot of boring, important fixes:</p>

<ul>
  <li>Action Text-backed message content is converted to plain text before being sent to the model.</li>
  <li>ActiveRecord support no longer sits in the core gem eager-load path, fixing standalone <code>require "ruby_llm"</code> with Zeitwerk eager loading.</li>
  <li>The <code>acts_as</code> API follows Rails association inference more closely.</li>
  <li>Existing Active Storage blobs and attachments passed through <code>with:</code> are reused instead of downloaded and re-uploaded.</li>
</ul>

<h2 id="providers-and-models">Providers and Models</h2>

<p>Empty tool results are now handled consistently across Anthropic, Bedrock, and Gemini. When a tool returns nothing, RubyLLM sends a small placeholder instead of provider-invalid empty content.</p>

<p>Streaming and non-streaming token usage is normalized across OpenAI, OpenRouter, Bedrock, and Gemini before cost calculation.</p>

<p>The model registry has been refreshed too: cache read/write pricing, reasoning output pricing, GPT Image pricing, and new aliases including Claude Opus 4.7, DeepSeek V4, Gemini Embedding 2, Gemma 4, and GPT-5.5.</p>

<h2 id="use-it">Use It</h2>

<pre><code class="language-ruby">gem 'ruby_llm', '~&gt; 1.15'
</code></pre>

<p>Then:</p>

<pre><code class="language-bash">bundle update ruby_llm
</code></pre>

<p>Full release notes on <a href="https://github.com/crmne/ruby_llm/releases/tag/1.15.0">GitHub</a>.</p>]]></content><author><name>Carmine Paolino</name></author><category term="Ruby" /><category term="AI" /><category term="LLM" /><category term="Rails" /><category term="Open Source" /><category term="RubyLLM" /><category term="Image Generation" /><summary type="html"><![CDATA[RubyLLM 1.15 adds image editing, cost tracking, inferred tool parameters, additive callbacks, and Rails fixes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/images/rubyllm-1.15.png" /><media:content medium="image" url="https://paolino.me/images/rubyllm-1.15.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">kamal-backup: Scheduled Rails Backups for Kamal Apps</title><link href="https://paolino.me/kamal-backup/" rel="alternate" type="text/html" title="kamal-backup: Scheduled Rails Backups for Kamal Apps" /><published>2026-05-05T00:00:00+00:00</published><updated>2026-05-05T00:00:00+00:00</updated><id>https://paolino.me/kamal-backup</id><content type="html" xml:base="https://paolino.me/kamal-backup/"><![CDATA[<p>I released <a href="https://kamal-backup.dev">kamal-backup</a> today.</p>

<p>I run <a href="https://chatwithwork.com">Chat with Work</a> on Kamal, and I needed backups. There are already Kamal accessories for database backups. None of them also back up Active Storage. None use restic, so encryption, deduplication, and repository checks are on you. None ship a CLI with restores and drills. None produce evidence you can hand a security reviewer.</p>

<p>So I built one.</p>

<h2 id="a-gem-and-a-docker-image">A gem and a Docker image</h2>

<p><code>kamal-backup</code> is two pieces: a Ruby gem you add to your Rails app, and a Docker image you boot as a Kamal accessory. They point at a restic repository you bring yourself.</p>

<p>The gem is your CLI. Local commands run directly on your machine using restic. Production-side commands shell out through Kamal into the accessory. The same <code>kamal-backup</code> binary covers setup (<code>init</code>, <code>validate</code>), on-demand operations (<code>backup</code>, <code>list</code>, <code>check</code>), data movement (<code>restore local</code>, <code>restore production</code>), verification (<code>drill local</code>, <code>drill production</code>), and audit (<code>evidence</code>).</p>

<p>The Docker image (<code>ghcr.io/crmne/kamal-backup</code>) ships with <code>restic</code>, <code>pg_dump</code>, <code>mariadb-dump</code>/<code>mysqldump</code>, and <code>sqlite3</code> baked in. The default container command is <code>kamal-backup schedule</code>, a loop that fires every <code>backup_schedule_seconds</code> and writes one database snapshot and one Active Storage file snapshot per run.</p>

<p>The restic repository is where the encrypted snapshots end up: S3-compatible object storage, a restic REST server, or a filesystem path. <code>kamal-backup</code> points at it. It doesn’t run it for you.</p>

<h2 id="why-restic">Why restic</h2>

<p>I didn’t want to invent a backup format, and I didn’t want to bolt encryption and deduplication onto shell scripts. Restic does what I needed:</p>

<ul>
  <li>encrypted repositories by default;</li>
  <li>a tag system, so the database dump and the Active Storage tree from the same run share a <code>run:&lt;timestamp&gt;</code> and pair up at restore time;</li>
  <li>deduplication across runs, so a year of daily backups doesn’t grow linearly;</li>
  <li><code>restic forget --prune</code> for retention;</li>
  <li><code>restic check</code> for repository health;</li>
  <li>S3-compatible storage, a restic REST server, or a local filesystem path, so you host the repository wherever fits.</li>
</ul>

<p>It’s a single binary that drops cleanly into a Docker image, alongside the database client tools. Nothing extra to install on the Rails host. <code>kamal-backup</code> is the Rails- and Kamal-shaped layer on top, and restic does the cryptography, the storage, and the integrity checks.</p>

<h2 id="setting-it-up">Setting it up</h2>

<p>Add the gem in development:</p>

<pre><code class="language-ruby"># Gemfile
group :development do
  gem "kamal-backup"
end
</code></pre>

<p>Run <code>init</code>. It creates <code>config/kamal-backup.yml</code> and prints an accessory block you paste into your Kamal deploy config:</p>

<pre><code class="language-sh">bundle install
bundle exec kamal-backup init
</code></pre>

<p><code>config/kamal-backup.yml</code> holds the backup settings:</p>

<pre><code class="language-yaml">accessory: backup
app_name: chatwithwork
database_adapter: postgres
database_url: postgres://chatwithwork@chatwithwork-db:5432/chatwithwork_production
backup_paths:
  - /data/storage
restic_repository: s3:https://s3.example.com/chatwithwork-backups
restic_init_if_missing: true
backup_schedule_seconds: 86400
</code></pre>

<p>Kamal mounts that file read-only into the accessory, so the accessory block in <code>config/deploy.yml</code> stays small. Only secrets live in <code>env</code>:</p>

<pre><code class="language-yaml">accessories:
  backup:
    image: ghcr.io/crmne/kamal-backup:latest
    host: chatwithwork.com
    files:
      - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
    env:
      secret:
        - PGPASSWORD
        - RESTIC_PASSWORD
        - AWS_ACCESS_KEY_ID
        - AWS_SECRET_ACCESS_KEY
    volumes:
      - "chatwithwork_storage:/data/storage:ro"
      - "chatwithwork_backup_state:/var/lib/kamal-backup"
</code></pre>

<p>Validate, boot, and watch the logs:</p>

<pre><code class="language-sh">bundle exec kamal-backup validate
bin/kamal accessory boot backup
bin/kamal accessory logs backup
</code></pre>

<p><code>validate</code> catches missing required settings before the accessory has to be running. Once it’s up, the container loops on <code>kamal-backup schedule</code>.</p>

<p>Then run the first backup and print evidence:</p>

<pre><code class="language-sh">bundle exec kamal-backup backup
bundle exec kamal-backup list
bundle exec kamal-backup evidence
</code></pre>

<p>No cron glue. No separate backup host. No “remember to install restic on production.” The accessory image already has it.</p>

<h2 id="rails-data-not-just-a-database-dump">Rails data, not just a database dump</h2>

<p>A Rails app has two things worth backing up: the database, and file-backed Active Storage. <code>kamal-backup</code> handles both.</p>

<p>Postgres uses <code>pg_dump</code>. MySQL and MariaDB use <code>mariadb-dump</code> or <code>mysqldump</code>. SQLite uses <code>sqlite3 .backup</code>. File-backed Active Storage uses <code>restic backup</code> from mounted volumes.</p>

<p>Each run writes one database snapshot and one file snapshot, both tagged with <code>app:&lt;name&gt;</code>, <code>type:database</code> or <code>type:files</code>, and the same <code>run:&lt;timestamp&gt;</code>. You pair them at restore time using that timestamp.</p>

<p>If your app stores Active Storage blobs directly in S3, there’s no mounted path for <code>backup_paths</code> to capture. <code>kamal-backup</code> still covers the database. The S3 side is on your bucket lifecycle and replication settings.</p>

<h2 id="restores-are-part-of-the-product">Restores are part of the product</h2>

<p>The backup script is the easy part. The restore path is where most setups fail.</p>

<p>So <code>kamal-backup</code> ships with restore commands:</p>

<pre><code class="language-sh">bundle exec kamal-backup restore local
bundle exec kamal-backup restore production
</code></pre>

<p><code>restore local</code> pulls a production backup down to your laptop. Useful when you want to inspect real data, reproduce a production bug, or prove the backup actually comes back.</p>

<p><code>restore production</code> prompts before it overwrites anything.</p>

<h2 id="restore-drills">Restore drills</h2>

<p>The command I care about most is <code>drill</code>.</p>

<pre><code class="language-sh">bundle exec kamal-backup drill local \
  --check "bin/rails runner 'puts User.count'"
</code></pre>

<p>A drill means: restore, check, record the result.</p>

<p>Two modes:</p>

<ul>
  <li><code>drill local</code> restores onto your machine and runs an optional check.</li>
  <li><code>drill production</code> restores into scratch production-side targets, never the live database.</li>
</ul>

<p>That second one matters. For Postgres and MySQL, you give it a scratch database. For SQLite, a scratch file path. For Active Storage, a scratch restore directory. The drill uses production infrastructure, without pointing at live production.</p>

<p>That’s the difference between “the backup ran” and “we restored the latest production snapshot into a scratch target on April 30, ran this check, and it passed.”</p>

<h2 id="evidence-for-reviews">Evidence for reviews</h2>

<p>I went through a security review for <a href="https://chatwithwork.com">Chat with Work</a> this year. The questions were fair:</p>

<ul>
  <li>What’s being backed up?</li>
  <li>Where does it go?</li>
  <li>Is it encrypted?</li>
  <li>When did the last backup run?</li>
  <li>When did the last repository check run?</li>
  <li>When was the last restore drill?</li>
  <li>Can you prove all of that without leaking secrets?</li>
</ul>

<p><code>kamal-backup evidence</code> prints redacted JSON: current backup settings, latest snapshots, latest restic check, latest restore drill, retention settings, tool versions.</p>

<pre><code class="language-sh">bundle exec kamal-backup evidence
</code></pre>

<p>Secrets are redacted. The output is meant to land in an internal ops record or a CASA packet. Not a screenshot of a green cron job. Actual evidence.</p>

<h2 id="try-it">Try it</h2>

<pre><code class="language-ruby"># Gemfile
gem "kamal-backup"
</code></pre>

<p>Docs at <a href="https://kamal-backup.dev">kamal-backup.dev</a>, source on <a href="https://github.com/crmne/kamal-backup">GitHub</a>.</p>]]></content><author><name>Carmine Paolino</name></author><category term="Ruby" /><category term="Rails" /><category term="Kamal" /><category term="Backups" /><category term="Open Source" /><summary type="html"><![CDATA[One Kamal accessory for encrypted Rails database and Active Storage backups, restore drills, and redacted evidence for security reviews.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/images/kamal-backup.png" /><media:content medium="image" url="https://paolino.me/images/kamal-backup.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ruby Concurrency: What Actually Happens</title><link href="https://paolino.me/ruby-concurrency-what-actually-happens/" rel="alternate" type="text/html" title="Ruby Concurrency: What Actually Happens" /><published>2026-04-28T00:00:00+00:00</published><updated>2026-04-28T00:00:00+00:00</updated><id>https://paolino.me/ruby-concurrency-what-actually-happens</id><content type="html" xml:base="https://paolino.me/ruby-concurrency-what-actually-happens/"><![CDATA[<p>Since I wrote about <a href="/async-ruby-is-the-future/">async Ruby</a> and <a href="/solid-queue-doesnt-need-a-thread-per-job/">patched Solid Queue to support fibers</a>, people keep asking the same questions. What happens when a fiber blocks? Don’t you still need threads? What about database transactions? What about Ractors?</p>

<p>This post answers all of it. From the ground up.</p>

<h2 id="the-four-primitives">The four primitives</h2>

<p>Ruby gives you four concurrency primitives: processes, threads, fibers, and Ractors. They nest. Every process has an implicit “main Ractor” where your code runs by default, so you never have to think about Ractors unless you explicitly create one. Without Ractors, the hierarchy is simply process – threads – fibers. With Ractors, it becomes:</p>

<div class="mermaid">
graph TD
    P[Process] --&gt; R1["Ractor 1 (GVL 1)"]
    P --&gt; R2["Ractor 2 (GVL 2)"]
    R1 --&gt; T1[Thread 1]
    R1 --&gt; T2[Thread 2]
    R2 --&gt; T3[Thread 3]
    T1 --&gt; F1[Fiber A]
    T1 --&gt; F2[Fiber B]
    T2 --&gt; F3[Fiber C]
    T3 --&gt; F4[Fiber D]
    T3 --&gt; F5[Fiber E]
    style P fill:#4a90a4,color:#fff
    style R1 fill:#c084fc,color:#fff
    style R2 fill:#c084fc,color:#fff
    style T1 fill:#7fb069,color:#fff
    style T2 fill:#7fb069,color:#fff
    style T3 fill:#7fb069,color:#fff
    style F1 fill:#e8a87c,color:#fff
    style F2 fill:#e8a87c,color:#fff
    style F3 fill:#e8a87c,color:#fff
    style F4 fill:#e8a87c,color:#fff
    style F5 fill:#e8a87c,color:#fff
</div>

<p>Think of your computer as an office building.</p>

<p><strong>Processes</strong> are fully isolated: separate offices, each with its own locked door, furniture, and files. Each process has its own memory, its own Ruby VM, and its own GVL. When you run Puma with 3 workers, you get 3 processes. They can’t corrupt each other’s state because they don’t share memory. The OS schedules them independently. The cost: each one loads your entire application into memory.</p>

<p><strong>Ractors</strong> sit between processes and threads: offices that share a mailroom but not their filing cabinets. Each Ractor has its own GVL, so threads in different Ractors can execute Ruby code truly in parallel, but they can only pass notes to each other – no shared mutable objects. You communicate via message passing, copying or moving data between them. Every Ruby process has a “main Ractor” where all your code runs by default. Creating additional Ractors is opt-in.</p>

<p><strong>Threads</strong> live inside a process and share its memory: workers sharing the same office, accessing the same filing cabinets, coordinating to avoid collisions. In CRuby, they are native threads, with the GVL deciding which one can execute Ruby code at a time. You don’t control when Ruby switches between them. The GVL releases during I/O, so two threads can wait on two different network calls simultaneously, but they can’t crunch numbers at the same time.</p>

<p><strong>Fibers</strong> live inside a thread and are cooperatively scheduled: multiple tasks juggled by one worker at their desk. When they’re waiting for something – a phone call, a fax, a response – they set it aside and pick up the next task. A fiber runs until it explicitly yields. When it hits I/O – a network call, a database query, reading a file – it yields to the reactor, and another fiber picks up. No OS thread context switch for the fiber itself, no preemption. One thread can run thousands of fibers.</p>

<p>Here’s what that means for cost:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Process</th>
      <th>Ractor</th>
      <th>Thread</th>
      <th>Fiber</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Memory</td>
      <td>full app copy</td>
      <td>~thread + Ractor state</td>
      <td>~8MB virtual stack reservation</td>
      <td>~4KB initial virtual stack, grows as needed</td>
    </tr>
    <tr>
      <td>Creation time</td>
      <td>~ms</td>
      <td>~80μs</td>
      <td>~80μs</td>
      <td>~3μs</td>
    </tr>
    <tr>
      <td>Context switch</td>
      <td>kernel</td>
      <td>kernel (threads within)</td>
      <td>~1.3μs (kernel)</td>
      <td>~0.1μs (userspace)</td>
    </tr>
    <tr>
      <td>Isolation</td>
      <td>Full (own memory)</td>
      <td>Share-nothing (messages)</td>
      <td>Shared memory</td>
      <td>Shared thread</td>
    </tr>
    <tr>
      <td>Parallelism</td>
      <td>Yes</td>
      <td>Yes (own GVL)</td>
      <td>No (shared GVL)</td>
      <td>No</td>
    </tr>
    <tr>
      <td>I/O concurrency</td>
      <td>Yes</td>
      <td>Yes</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Rails compatible</td>
      <td>Yes</td>
      <td>No</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>Creation and switching benchmarks are from <a href="https://github.com/socketry/performance/tree/adfd780c6b4842b9534edfa15e383e5dfd4b4137/fiber-vs-thread">Samuel Williams’ fiber-vs-thread performance comparison</a>. Fibers create 20x faster and switch 10x faster than threads. The memory row is about virtual address space reserved by the platform/runtime, not resident memory. The benchmark reports actual RSS, where the gap is much smaller than the virtual stack numbers suggest. But the shape is still real: each thread is a kernel object with scheduler state and a stack reservation, while each fiber is scheduled in userspace. Ractors give you parallelism too, but can’t run Rails. Everything is a tradeoff.</p>

<h2 id="how-scheduling-works">How scheduling works</h2>

<p>This is where most of the confusion lives. Let me show you what actually happens.</p>

<h3 id="thread-scheduling">Thread scheduling</h3>

<p>CRuby threads are native threads, but the GVL decides which one can run Ruby code. Your code has no say. A thread can be paused mid-calculation, mid-assignment, mid-anything.</p>

<div class="mermaid">
sequenceDiagram
    participant VM as CRuby / OS
    participant T1 as Thread 1
    participant T2 as Thread 2
    participant LLM as LLM API

    VM-&gt;&gt;T1: Run
    T1-&gt;&gt;LLM: Send request
    Note over T1: Blocks in I/O (parked)
    VM-&gt;&gt;T2: Run
    T2-&gt;&gt;LLM: Send request
    Note over T2: Blocks in I/O (parked)
    Note over VM: Both threads parked
    LLM--&gt;&gt;T1: Response ready
    LLM--&gt;&gt;T2: Response ready
    VM-&gt;&gt;T1: Wake and run
    Note over T1: Processing response
    VM-&gt;&gt;VM: Time slice expired
    VM-&gt;&gt;T2: Preempt T1, run T2
    Note over T2: Processing response
    VM-&gt;&gt;VM: Time slice expired
    VM-&gt;&gt;T1: Resume T1
    Note over T1: Finish response
    VM-&gt;&gt;T2: Resume T2
    Note over T2: Finish response
</div>

<p>CRuby can switch runnable threads on a time slice, but a thread blocked in I/O is parked until the socket is ready. That part matters: threads do not spin uselessly while waiting for tokens. The switch happens when a thread is runnable – including in the middle of response processing, object allocation, assignment, or any other Ruby code.</p>

<p>For two threads doing I/O, this works fine. The overhead is noise. For 200 threads mostly waiting for LLM tokens, the problem is the one-operation-per-thread shape: 200 kernel threads, 200 stack reservations, 200 scheduler entries, and usually 200 copies of whatever per-thread application resources the worker holds.</p>

<p>This is also why a worker limit means different things in Solid Queue’s current thread mode and in the fiber mode from my patch. <code>threads: 25</code> is both “run 25 jobs at once” and “create 25 kernel threads.” If all 25 jobs are streaming tokens, job 26 waits. <code>fibers: 250</code> is mostly an admission limit for the reactor: run up to 250 jobs as fibers on the same thread, park the ones waiting on I/O, and resume them when ready. You still need limits because APIs, sockets, memory, and databases have limits. But the cap is no longer tied to one kernel thread per job.</p>

<h3 id="cooperative-scheduling-fibers">Cooperative scheduling (fibers)</h3>

<p>Fibers switch only when they choose to. In practice, the <a href="https://github.com/socketry/async">async</a> gem makes this automatic: your code yields at I/O boundaries without you writing anything special.</p>

<div class="mermaid">
sequenceDiagram
    participant R as Reactor
    participant F1 as Fiber 1
    participant F2 as Fiber 2
    participant LLM as LLM API

    R-&gt;&gt;F1: Run
    F1-&gt;&gt;LLM: Send request
    Note over F1: Yields (I/O wait)
    R-&gt;&gt;F2: Run
    F2-&gt;&gt;LLM: Send request
    Note over F2: Yields (I/O wait)
    Note over R: Both waiting, reactor sleeps
    LLM--&gt;&gt;F1: Response ready
    R-&gt;&gt;F1: Resume immediately
    Note over F1: Processes response
    F1-&gt;&gt;R: Done
    LLM--&gt;&gt;F2: Response ready
    R-&gt;&gt;F2: Resume immediately
    Note over F2: Processes response
    F2-&gt;&gt;R: Done
</div>

<p>No OS thread context switch per fiber. No timer-based preemption between fibers. When a fiber yields, the reactor checks which fibers have I/O ready and resumes them. When nothing is ready, the reactor sleeps in the OS until something is. The kernel still does the I/O readiness work; Ruby just avoids one kernel thread per wait.</p>

<h2 id="the-gvl-why-threads-and-fibers-are-more-similar-than-you-think">The GVL: why threads and fibers are more similar than you think</h2>

<p>This is the part that makes thread-based Ruby less different from fiber-based Ruby than it first looks.</p>

<p>The GVL means only one thread can execute Ruby code at a time. Threads run in parallel only during I/O, when the GVL is released. So if your workload is I/O-bound – HTTP calls, database queries, LLM streaming – threads give you I/O concurrency, not parallelism.</p>

<p>Fibers give you the same I/O concurrency. One fiber yields at I/O, another picks up. The difference: fibers do it without kernel thread overhead, without the memory cost of a thread stack, and without making job concurrency itself imply one worker thread or one database slot per job.</p>

<p>If threads only help with I/O anyway, why pay their overhead?</p>

<p>There is one case where threads win: CPU-bound work that releases the GVL. Some C extensions (image processing, cryptographic operations) release the GVL while doing heavy computation. Multiple threads can then run those C extensions in parallel. Fibers can’t do that. They share a thread.</p>

<p>For actual Ruby-level CPU parallelism, you need processes or <a href="#why-not-ractors">Ractors</a>. Processes are production-ready and Rails-compatible. Ractors are lighter than processes, but still experimental.</p>

<h2 id="what-happens-when-a-fiber-hits-io">What happens when a fiber hits I/O</h2>

<p>This is the happy path and the most common question.</p>

<pre><code class="language-ruby"># Inside a fiber
response = Net::HTTP.get(URI("https://api.example.com/v1/completions"))
</code></pre>

<p>Here’s the full chain:</p>

<ol>
  <li><code>Net::HTTP</code> opens a socket and sends the request</li>
  <li>The socket isn’t readable yet (the server hasn’t responded)</li>
  <li>Ruby calls <code>rb_io_wait</code> on the socket</li>
  <li>The async gem’s <code>Fiber.scheduler</code> intercepts this call</li>
  <li>The scheduler suspends the current fiber and registers the socket with the event loop</li>
  <li>The reactor runs other fibers while this one sleeps</li>
  <li>When the socket becomes readable, the reactor resumes this fiber</li>
  <li><code>Net::HTTP</code> reads the response as if nothing happened</li>
</ol>

<p>Your code doesn’t change. No <code>await</code>, no callbacks, no promises. The same <code>Net::HTTP.get</code> call that works in a thread works in a fiber. The yield is invisible.</p>

<p>Bob Nystrom called this <a href="https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/">the function color problem</a> in 2015. In languages with async/await, every function is either sync or async. An async function can only be called with <code>await</code>, and <code>await</code> can only live inside another async function. The color spreads upward through your entire call stack.</p>

<p><strong>Python:</strong></p>

<pre><code class="language-python"># Python: the color spreads, and you need different libraries
async def get_user(id):
    async with aiohttp.ClientSession() as session:  # can't use requests
        response = await session.get(f"/users/{id}")  # must await
        return await response.json()                   # must await

async def handle_request():  # must be async because it calls get_user
    user = await get_user(1)  # must await
</code></pre>

<p>You can’t use <code>requests</code> in async Python without blocking the event loop. You need <code>aiohttp</code>, <code>httpx</code> in async mode, or a thread wrapper. You can’t use the blocking <code>psycopg2</code> API as async I/O; you need <code>asyncpg</code> or Psycopg’s async API. The ecosystem splits: sync libraries and async libraries, doing the same thing differently.</p>

<p><strong>JavaScript:</strong></p>

<pre><code class="language-javascript">// JavaScript: same problem, less severe (Node has fewer library splits)
async function getUser(id) {
  const response = await fetch(`/users/${id}`);  // must await
  return await response.json();                   // must await
}

async function handleRequest() {  // must be async
  const user = await getUser(1);  // must await
}
</code></pre>

<p><strong>Ruby:</strong></p>

<pre><code class="language-ruby"># Ruby: no color
def get_user(id)
  response = Net::HTTP.get(URI("https://api.example.com/users/#{id}"))  # just a normal call
  JSON.parse(response)                            # just a normal call
end

def handle_request
  user = get_user(1)  # just a normal call
end
</code></pre>

<p>Same <code>Net::HTTP</code>. Same <code>pg</code>. Same call stack, as long as the library uses scheduler-aware Ruby I/O. The fiber scheduler intercepts I/O at the Ruby runtime level, below your code. Your methods don’t know and don’t care whether they’re running in a thread or a fiber.</p>

<h2 id="what-happens-when-a-fiber-does-cpu-bound-work">What happens when a fiber does CPU-bound work</h2>

<pre><code class="language-ruby"># Inside a fiber
100_000.times { Digest::SHA256.hexdigest("work") }
</code></pre>

<p>This blocks the reactor. No other fiber runs until it finishes. There’s no I/O boundary to yield at, so the fiber holds the thread.</p>

<div class="mermaid">
sequenceDiagram
    participant R as Reactor
    participant F1 as Fiber 1 (CPU)
    participant F2 as Fiber 2 (I/O)

    R-&gt;&gt;F1: Run
    Note over F1,F2: F1 doing CPU work...
    Note over F2: Waiting to run
    Note over F1,F2: F1 still computing...
    Note over F2: Still waiting
    F1-&gt;&gt;R: Done
    R-&gt;&gt;F2: Finally runs
</div>

<p>This is not a bug. It’s the current tradeoff of cooperative scheduling. Fibers are designed for I/O-bound work; CPU-bound work belongs on a thread, where CRuby can preempt it.</p>

<p>With <a href="/solid-queue-doesnt-need-a-thread-per-job/">my fiber-mode patch for Solid Queue</a>, this is a configuration choice:</p>

<pre><code class="language-yaml">workers:
  - queues: [ chat, turbo, notifications ]
    fibers: 50       # I/O-bound: use fibers
  - queues: [ cpu ]
    threads: 2        # CPU-bound: use threads
</code></pre>

<p>One backend, two modes, matching the concurrency model to the workload.</p>

<h2 id="what-happens-when-a-fiber-queries-the-database">What happens when a fiber queries the database</h2>

<p>The <a href="https://github.com/ged/ruby-pg">pg gem</a> has supported <code>Fiber.scheduler</code> since v1.3.0. When a fiber executes a query, the pg gem sends it non-blockingly via <code>PQsendQuery</code>, then calls <code>rb_io_wait</code> on the PostgreSQL socket. The scheduler intercepts this, suspends the fiber, and lets others run while PostgreSQL processes the query.</p>

<pre><code class="language-ruby"># Inside a fiber
user = User.find(42)  # yields while waiting for PostgreSQL
</code></pre>

<p>The fiber yields. Other fibers run. When PostgreSQL responds, the reactor resumes the fiber. Your code doesn’t know the difference.</p>

<h3 id="pool-size-follows-database-work">Pool size follows database work</h3>

<p>A database connection is busy until its query finishes. While PostgreSQL works, Ruby can run something else – another thread, or another fiber on the reactor – but that connection stays checked out.</p>

<p>For an LLM job, most of the wall time is not database time. Read a row, call an API, stream tokens, write a status update. The database touches are short. The long waits are external HTTP. So 100 jobs in flight does not mean 100 jobs hitting PostgreSQL at the same instant.</p>

<p>The reactor never preempts a fiber – it only switches when a fiber yields at an I/O boundary:</p>

<div class="mermaid">
sequenceDiagram
    participant R as Reactor
    participant F1 as Fiber A
    participant F2 as Fiber B
    participant Pool as DB Pool (1 conn)
    participant PG as PostgreSQL
    participant HTTP as HTTP API

    R-&gt;&gt;F1: Run
    F1-&gt;&gt;Pool: Check out
    F1-&gt;&gt;PG: SELECT * FROM users
    Note over F1: Yields (waiting for PG)
    R-&gt;&gt;F2: Run
    F2-&gt;&gt;HTTP: GET /api/data
    Note over F2: Yields (waiting for HTTP)
    PG--&gt;&gt;R: F1's result ready
    R-&gt;&gt;F1: Resume
    F1-&gt;&gt;Pool: Return
    F1-&gt;&gt;R: Done
    HTTP--&gt;&gt;R: F2's result ready
    R-&gt;&gt;F2: Resume
    F2-&gt;&gt;Pool: Check out
    F2-&gt;&gt;PG: UPDATE messages SET ...
    Note over F2: Yields (waiting for PG)
    PG--&gt;&gt;R: F2's result ready
    R-&gt;&gt;F2: Resume
    F2-&gt;&gt;Pool: Return
    F2-&gt;&gt;R: Done
</div>

<p>Read this as a timeline. Fiber A uses the only connection for its query. While PostgreSQL works, Fiber B waits on HTTP. After Fiber A returns the connection, Fiber B can use it for its update. If both fibers tried to query at the same time, one would wait unless the pool had another connection.</p>

<p>Active Record follows the same checkout rules in both cases. The current Solid Queue difference is a guardrail: thread mode expects <code>threads + 2</code> connections per process, so you don’t run 50 execution threads against a 5-connection pool. Fiber mode can use a smaller baseline because <code>fibers: 100</code> means “allow 100 jobs to wait,” not “create 100 execution threads.” In my patch, I/O-heavy workers often start at 3 connections per process (1 execution + 2 worker overhead). If the jobs are DB-heavy, raise it.</p>

<h2 id="what-happens-when-a-fiber-starts-a-transaction">What happens when a fiber starts a transaction</h2>

<p>A transaction changes the timeline. The connection cannot be returned after each statement, because the transaction state lives on that connection.</p>

<p>When a fiber starts a transaction, it keeps its checked-out connection for the entire duration – from <code>BEGIN</code> to <code>COMMIT</code> or <code>ROLLBACK</code>. The connection is not released mid-transaction. Other fibers that need the database wait for the connection to be returned.</p>

<div class="mermaid">
sequenceDiagram
    participant R as Reactor
    participant F1 as Fiber A
    participant F2 as Fiber B
    participant Pool as DB Pool (1 conn)
    participant PG as PostgreSQL

    R-&gt;&gt;F1: Run
    F1-&gt;&gt;Pool: Check out
    F1-&gt;&gt;PG: BEGIN
    F1-&gt;&gt;PG: UPDATE accounts SET ...
    Note over F1: Yields (waiting for PG)
    R-&gt;&gt;F2: Run
    F2-&gt;&gt;Pool: Check out
    Note over F2: Waits (connection held by F1)
    PG--&gt;&gt;F1: Result
    R-&gt;&gt;F1: Resume
    F1-&gt;&gt;PG: COMMIT
    F1-&gt;&gt;Pool: Return
    F1-&gt;&gt;R: Done
    Pool-&gt;&gt;F2: Connection available
    F2-&gt;&gt;PG: SELECT * FROM accounts
    Note over F2: Yields (waiting for PG)
    PG--&gt;&gt;F2: Result
    R-&gt;&gt;F2: Resume
    F2-&gt;&gt;Pool: Return
    F2-&gt;&gt;R: Done
</div>

<p>Under fiber isolation (<code>config.active_support.isolation_level = :fiber</code>), Active Support’s execution state is fiber-scoped, so Active Record’s lease is associated with the current fiber instead of the surrounding thread. The connection still gets a real <code>Monitor</code> lock. No other fiber can touch it during a transaction.</p>

<p>Safe. No interleaving. Fiber B just waits.</p>

<p>For the target workload – LLM streaming, HTTP calls – database touches are short reads and status updates. Transactions are brief. The wait is negligible. If your jobs run long transactions, those jobs belong on a thread-based worker.</p>

<h2 id="what-happens-when-you-have-too-many-fibers">What happens when you have too many fibers</h2>

<p>Fibers aren’t free. Each one uses memory (~4KB), and each one might hold open connections to external services. If you spawn 10,000 fibers that all hit the same API, you’re opening 10,000 connections to that API. The API will not be happy.</p>

<p>Async doesn’t eliminate resource limits; it changes where they show up. With threads, the limit is explicit: 25 threads, 25 concurrent jobs. With fibers, the limit is implicit: you keep going until something else breaks.</p>

<p>The fix is a semaphore. The <code>FiberPool</code> in my Solid Queue patch uses one:</p>

<pre><code class="language-ruby">semaphore = Async::Semaphore.new(size)

# Only `size` fibers run concurrently
semaphore.async do
  perform_job
end
</code></pre>

<p>When you configure <code>fibers: 100</code> with the patch, that’s not “unlimited fibers.” It’s a semaphore capping concurrency at 100. You control the ceiling.</p>

<h2 id="why-not-just-configure-more-solid-queue-threads">“Why not just configure more Solid Queue threads?”</h2>

<p>In plain Ruby, more threads can be reasonable. In Solid Queue thread mode, <code>threads: 200</code> means more than “allow 200 jobs to wait on I/O.”</p>

<p><strong>Kernel threads are the expensive unit.</strong> Fibers don’t make I/O complete faster; they let you wait on far more of it at once for a fraction of the cost. <a href="https://github.com/socketry/performance/tree/adfd780c6b4842b9534edfa15e383e5dfd4b4137/fiber-vs-thread">Samuel Williams’ benchmarks</a> show fibers allocate 20x faster (~3μs vs ~80μs) and switch 10x faster (~0.1μs vs ~1.3μs) than threads. The OS can manage thousands of threads, but scheduler state, stack reservations, wakeups, and GVL coordination make that a poor default concurrency knob.</p>

<p><strong>Solid Queue currently enforces a database-pool guard.</strong> Today it expects <code>threads + 2</code> database connections per process, so 200 threads across 2 processes won’t boot unless the pool is at least 404. That guard may be conservative for I/O-heavy jobs; <a href="https://github.com/rails/solid_queue/issues/736">there’s an open issue</a> about making it advisory or bypassable. But it is still a guard you hit today.</p>

<p><strong>A blocked job still occupies its worker thread.</strong> The OS can park an LLM streaming thread until the socket is ready, but in Solid Queue thread mode it still consumes one of the configured thread workers. If all 25 are streaming tokens, job 26 waits.</p>

<p>Fibers make the Solid Queue limit mean “how many jobs may wait at once” instead of “how many kernel threads should exist.” They still need limits, but the limit is no longer one kernel thread per waiting job.</p>

<h2 id="why-not-ractors">“Why not Ractors?”</h2>

<p>Ractors solve a different problem. Fibers give you I/O concurrency – many things waiting at once. Ractors give you CPU parallelism – many things computing at once.</p>

<p>Here’s what they look like:</p>

<pre><code class="language-ruby"># Two Ractors computing fibonacci in parallel
r1 = Ractor.new { fibonacci(38) }
r2 = Ractor.new { fibonacci(38) }

r1.value  # Ruby 4.0+
r2.value  # Both ran in parallel, each with their own GVL
</code></pre>

<p>Each Ractor has its own GVL, so they can execute Ruby code truly in parallel across CPU cores. The tradeoff: strict isolation. You can only share immutable (frozen) objects. Everything else gets copied or moved between Ractors via message passing. Access a mutable variable from an outer scope? <code>Ractor::IsolationError</code>.</p>

<p>When Ractors win, they win big. Fibonacci(38) five times: 0.68s with Ractors vs 2.26s sequential. 3.3x speedup. Real parallelism.</p>

<p>But they are not a practical answer for Rails jobs yet:</p>

<ul>
  <li><strong>Still experimental in Ruby 4.0.</strong> Creating a Ractor still emits the experimental API warning.</li>
  <li><strong>Many gems don’t work without changes.</strong> Gems that rely on mutable constants, global variables, class variables, or shared process state can hit <code>Ractor::IsolationError</code>.</li>
  <li><strong>No Rails integration.</strong> ActiveRecord, ActionCable, the router, the logger – Rails is built on shared mutable state. None of it runs inside a Ractor.</li>
  <li><strong>No Ractor-based job queue exists.</strong></li>
  <li><strong>Still active bug surface.</strong> The Ruby bug tracker still has Ractor-related issues, including recent crash reports.</li>
</ul>

<p>For I/O concurrency, Ractors don’t help at all. Each Ractor still has threads constrained by its own GVL. Fibers within those threads still do the actual I/O multiplexing. Ractors add CPU parallelism, which is not what LLM streaming needs.</p>

<p>For Rails jobs that need CPU parallelism today, processes are still the boring answer. Puma already uses that model for web workers. Ractors may become useful for isolated CPU-heavy Ruby work, but they are not the answer to this Solid Queue I/O problem.</p>

<h2 id="isnt-this-just-what-javascript-does">“Isn’t this just what JavaScript does?”</h2>

<p>No. I showed the <a href="#what-happens-when-a-fiber-hits-io">code comparison above</a>. JavaScript’s async/await is a colored concurrency model: the <code>async</code> keyword spreads upward through every caller. Ruby’s fibers are colorless: your existing code works unchanged, and the scheduler handles yields below your code.</p>

<p>There’s a deeper difference too. JavaScript async/await runs on an event loop. Ruby fibers run on top of a multi-threaded runtime. You can have multiple Ruby threads, each running its own reactor with its own fibers, and mix fibers and threads in the same application. Node can run JavaScript in parallel with <code>worker_threads</code>, but that’s a worker/isolate model, not the same thing as putting multiple reactors inside ordinary application threads.</p>

<h2 id="isnt-this-just-what-go-does">“Isn’t this just what Go does?”</h2>

<p>Closer. Goroutines are lightweight, runtime-scheduled, and multiplexed across OS threads. Conceptually similar to Ruby fibers, but Go’s scheduler can also preempt goroutines.</p>

<p>Two differences:</p>

<ol>
  <li>
    <p><strong>Go has true parallelism.</strong> Goroutines run across multiple OS threads with no GVL equivalent. CPU-bound goroutines run in parallel. Ruby fibers don’t.</p>
  </li>
  <li>
    <p><strong>Ruby has existing code.</strong> If you have a Rails application with hundreds of thousands of lines of Ruby, you can add fiber-based concurrency without rewriting anything. Your models, your controllers, your views, your gems – they all work. With Go, you’re rewriting.</p>
  </li>
</ol>

<p>If you’re starting from scratch and need both I/O concurrency and CPU parallelism, Go is a strong choice. If you have a Ruby application and need I/O concurrency, fibers give you that without a rewrite.</p>

<h2 id="fibers-need-async-do-blocks-thats-still-new-syntax">“Fibers need <code>Async do</code> blocks. That’s still new syntax.”</h2>

<p>Someone on <a href="https://news.ycombinator.com/item?id=44516555">Hacker News</a> called this out: I said “no async/await” but the examples show <code>Async do</code> and <code>.wait</code>.</p>

<p>Here’s the actual change:</p>

<pre><code class="language-ruby"># Before
chat = RubyLLM.chat
response = chat.ask("Hello")

# After
Async do
  chat = RubyLLM.chat
  response = chat.ask("Hello")
end
</code></pre>

<p>Two lines of wrapping. Your application code inside doesn’t change. Your models don’t change. Your gems don’t change. Nothing gets a new keyword.</p>

<p>In Python, adopting async means rewriting every function signature in the call chain to <code>async def</code>, adding <code>await</code> to every call, and replacing or wrapping blocking libraries. <code>requests</code> becomes <code>aiohttp</code> or async <code>httpx</code>. Blocking database APIs become async database APIs. Your test framework changes. Your middleware changes. It’s a rewrite.</p>

<p>Two lines of wrapping vs. rewriting your stack. That’s not even the same conversation.</p>

<h2 id="when-to-use-what">When to use what</h2>

<div class="mermaid">
flowchart TD
    A[What kind of work?] --&gt; B{CPU-bound?}
    B --&gt;|Yes| C{Need parallelism?}
    C --&gt;|Yes| D{Rails?}
    D --&gt;|Yes| E[Processes]
    D --&gt;|No| H[Ractors]
    C --&gt;|No| F[Threads]
    B --&gt;|No| I[Fibers]

    style E fill:#4a90a4,color:#fff
    style H fill:#c084fc,color:#fff
    style F fill:#7fb069,color:#fff
    style I fill:#e8a87c,color:#fff
</div>

<ul>
  <li><strong>I/O-bound work</strong> (LLM streaming, HTTP calls, webhooks, email delivery): <strong>fibers.</strong> Low overhead, high concurrency, database connections sized to database work rather than waiting jobs.</li>
  <li><strong>CPU-bound work</strong> (image processing, data crunching, PDF generation): <strong>threads.</strong> CRuby can preempt them, and C extensions can release the GVL for parallelism.</li>
  <li><strong>CPU parallelism with Rails</strong>: <strong>processes.</strong> Each one gets its own GVL, its own memory, its own everything. Puma already does this.</li>
  <li><strong>CPU parallelism without Rails</strong>: <strong>Ractors</strong> (when they graduate from experimental). Lighter than processes, true parallelism, but strict isolation means most gems don’t work.</li>
  <li><strong>All of them at once</strong>: that’s what a well-configured Rails app does. Puma forks processes. Each process runs threads. Fibers run inside those threads for I/O-heavy jobs. They coexist.</li>
</ul>

<pre><code class="language-yaml"># Solid Queue with the fiber-mode patch: all three working together
workers:
  - queues: [ chat, turbo ]
    fibers: 50        # I/O-bound: fibers
    processes: 2       # parallelism: processes
  - queues: [ pdf, images ]
    threads: 4         # CPU-bound: threads
    processes: 1
</code></pre>

<p>No single model is universally better. The right answer is matching the model to the workload.</p>

<hr />

<p>This covers every “what happens when” question I’ve gotten so far. If I missed yours, <a href="https://twitter.com/paolino">find me on Twitter</a>; I’ll either update this post or write a follow-up.</p>]]></content><author><name>Carmine Paolino</name></author><category term="Ruby" /><category term="Concurrency" /><category term="Async" /><category term="Fibers" /><category term="Performance" /><summary type="html"><![CDATA[Every 'what happens when' question about Ruby concurrency, answered with diagrams.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/images/ruby-concurrency.png" /><media:content medium="image" url="https://paolino.me/images/ruby-concurrency.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Making the Rails Default Job Queue Fiber-Based</title><link href="https://paolino.me/solid-queue-doesnt-need-a-thread-per-job/" rel="alternate" type="text/html" title="Making the Rails Default Job Queue Fiber-Based" /><published>2026-04-21T00:00:00+00:00</published><updated>2026-07-31T00:00:00+00:00</updated><id>https://paolino.me/solid-queue-doesnt-need-a-thread-per-job</id><content type="html" xml:base="https://paolino.me/solid-queue-doesnt-need-a-thread-per-job/"><![CDATA[<blockquote>
  <p><strong>Update, July 31, 2026:</strong> <a href="https://github.com/rails/solid_queue/releases/tag/v1.6.0">Solid Queue 1.6.0</a> has shipped with fiber worker execution. The default Rails job queue can now run many long-running, cooperative I/O-bound jobs like LLM streaming far more efficiently, without making the queue database pool grow with the number of jobs waiting on I/O. The setup below now uses the official release.</p>
</blockquote>

<p>Last year I moved the LLM streaming jobs in <a href="https://chatwithwork.com">Chat with Work</a> to <a href="https://github.com/socketry/async-job">Async::Job</a>. It was fast. Genuinely fast. Fiber-based execution with Redis, thousands of concurrent jobs on a single thread. I was so convinced that I <a href="/async-ruby-is-the-future/">wrote a whole post</a> about why async Ruby is the future for AI apps and recommended it to everyone.</p>

<p>Then I started hitting walls.</p>

<p>Async::Job doesn’t persist jobs. They go into Redis and they’re gone. <a href="https://github.com/rails/mission_control-jobs">Mission Control</a> shows nothing. Background jobs in Rails are already quieter than the rest of your application – they fail without anyone noticing unless you go looking. Even with Honeybadger catching exceptions, I still want to see the full picture: which jobs are queued, which are running, which failed, what the system looks like right now. Without job persistence, you don’t get that.</p>

<p>Solid Queue is the default in Rails 8. Every new Rails app ships with it. When someone picks up Rails to build an LLM application and their 25-thread worker pool can only handle 25 concurrent streaming conversations, the answer shouldn’t be “swap your entire job backend.” It should be “change one line of config.”</p>

<p>So I <a href="https://github.com/rails/solid_queue/pull/728">opened a PR</a>. On July 31, it shipped in Solid Queue 1.6.0.</p>

<h2 id="threads-vs-fibers-quickly">Threads vs fibers, quickly</h2>

<p>If you already know this, <a href="#the-switch">skip ahead to the config</a>.</p>

<p>By default, Solid Queue runs each job on its own thread. Those threads can all query the database concurrently, so conservative pool sizing assumes one connection per execution thread. There is also stack memory and kernel thread overhead. For a job that crunches data for 30 seconds, that’s fine – the thread is busy. For a job that streams an LLM response for 30 seconds but spends 99% of that time waiting for tokens, the thread is just sitting there holding resources.</p>

<p>Fibers sidestep much of this. They are cooperatively scheduled in userspace on a single thread. When a fiber hits scheduler-aware I/O – an <a href="https://github.com/socketry/async-http">Async::HTTP</a> request or waiting for the next token through a compatible client – it steps aside and another fiber picks up. One thread, hundreds of concurrent jobs. No kernel thread overhead per job, and database pools can be sized for simultaneous database work rather than every job waiting on network I/O.</p>

<p>The <a href="https://github.com/socketry/async">async</a> gem installs the fiber scheduler. Ruby operations such as <code>Kernel.sleep</code>, scheduler-aware <code>IO</code>, and fiber-aware libraries yield without changing the job itself. This is not magic around every blocking call: a library or C extension that does not cooperate with the scheduler can still block the reactor thread.</p>

<p>For the full deep dive – processes, threads, fibers, the GVL, I/O multiplexing – see <a href="/async-ruby-is-the-future/">Async Ruby is the Future</a>.</p>

<h2 id="the-switch">The switch</h2>

<p>Fiber mode ships in Solid Queue 1.6.0. Upgrade Solid Queue and add <a href="https://github.com/socketry/async">async</a> as an application dependency:</p>

<pre><code class="language-ruby"># Gemfile
gem "solid_queue", "~&gt; 1.6"
gem "async" # required for fiber workers
</code></pre>

<p>Then switch that worker’s execution setting:</p>

<pre><code class="language-yaml"># config/queue.yml
production:
  workers:
    - queues: ["*"]
      # threads: 10
      fibers: 100  # &lt;- that's it
      processes: 2
</code></pre>

<p>Your jobs don’t change. Your queue doesn’t change. The worker runs them as fibers instead of threads.</p>

<p><code>threads</code> or <code>fibers</code>. Pick one per worker.</p>

<p><strong>Fiber-scoped Rails isolation is required.</strong> Add this to your Rails application configuration:</p>

<pre><code class="language-ruby"># config/application.rb
config.active_support.isolation_level = :fiber  # required for fibers
</code></pre>

<p>Fibers share a thread, so they need fiber-scoped state instead of the default thread-scoped state. Solid Queue validates this at boot and refuses to start fiber workers if the application still uses thread-scoped isolation.</p>

<p>That isolation setting is global to the Rails application, not local to Solid Queue. Also, fiber worker execution is separate from Solid Queue’s supervisor <code>async</code> mode. The configuration above uses the default <code>fork</code> supervisor, so <code>processes: 2</code> creates two worker processes and each gets its own fiber reactor. If you start <code>bin/jobs --mode async</code>, the workers share the supervisor process and the <code>processes</code> setting is ignored.</p>

<h2 id="under-the-hood">Under the hood</h2>

<p>The core of the implementation is <code>FiberPool</code>. It starts its reactor lazily when the first execution is posted, so the pool can be constructed safely before the default supervisor forks. A single thread runs one <a href="https://github.com/socketry/async">async</a> reactor, with a semaphore capping concurrency at whatever number you set:</p>

<pre><code class="language-ruby">def start_reactor
  create_thread do
    Async do |task|
      semaphore = Async::Semaphore.new(size, parent: task)
      boot_queue &lt;&lt; :ready

      wait_for_executions(semaphore)
    end
  rescue Exception =&gt; error
    register_fatal_error(error)
    raise
  end
end
</code></pre>

<p>When the worker picks up jobs, it hands them to the pool. Each one becomes a fiber:</p>

<pre><code class="language-ruby">def wait_for_executions(semaphore)
  while execution = pending_executions.pop
    semaphore.async(execution) do |_execution_task, scheduled_execution|
      perform_execution(scheduled_execution)
    end
  end
end
</code></pre>

<p>The worker poller claims only as many jobs as the pool has capacity for and pushes them into a <code>Thread::Queue</code>. Its <code>pop</code> is fiber-scheduler-aware, so the reactor can run execution fibers while it waits for more work. Each compatible I/O wait yields back to the reactor instead of occupying a dedicated execution thread.</p>

<p>CPU-bound work gets nothing from fibers. They don’t parallelize computation. A CPU-heavy job or blocking call stalls every execution fiber in that worker until it returns. In the default <code>fork</code> supervisor mode, the supervisor and other worker processes keep running, but that worker’s reactor does not. Put CPU-bound or blocking jobs on a thread worker instead.</p>

<h2 id="the-database-connection-math">The database connection math</h2>

<p>I <a href="/async-ruby-is-the-future/">wrote about this last year</a>:</p>

<blockquote>
  <p>For 1000 concurrent conversations using traditional job queues like SolidQueue or Sidekiq, you’d need 1000 worker slots. That means 1000 kernel threads across your worker fleet, plus enough database pool capacity for whatever fraction of those jobs can hit the database at the same time. Even when the jobs are 99% idle waiting for streaming tokens, the thread resources are still reserved.</p>
</blockquote>

<p>That framing is about worker resources, not a special Active Record rule. The released code’s pool-size check is specifically about the <strong>Solid Queue database pool</strong> (<code>SolidQueue::Record.connection_pool</code>), not every database your job might use. It estimates connections for polling, heartbeats, and job execution. Size any application database pools touched by the job separately.</p>

<p>Solid Queue 1.6 does not give thread workers the same small pool estimate. It still estimates one execution connection per configured thread, plus one for polling and one for heartbeats. That’s <code>threads + 2</code>.</p>

<p>There is a separate change that makes this easy to confuse: since Solid Queue 1.5, that estimate is advisory. A thread worker can boot with a smaller pool and wait for a connection when the pool is busy, although it can still hit a checkout timeout under sustained contention. But 1.6 did not make the thread and fiber estimates equal.</p>

<p>Here is the relevant version history:</p>

<table>
  <thead>
    <tr>
      <th>Solid Queue version and worker</th>
      <th>Queue pool estimate</th>
      <th>What happens below it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1.4.0 thread worker</td>
      <td><code>threads + 2</code></td>
      <td>Configuration is invalid; the supervisor aborts</td>
    </tr>
    <tr>
      <td>1.5.x thread worker</td>
      <td><code>threads + 2</code></td>
      <td>Warning; the worker still boots</td>
    </tr>
    <tr>
      <td>1.6.0 thread worker</td>
      <td><code>threads + 2</code></td>
      <td>Warning; the worker still boots</td>
    </tr>
    <tr>
      <td>1.6.0 fiber worker, Active Record 7.1</td>
      <td><code>fibers + 2</code></td>
      <td>Warning; the worker still boots</td>
    </tr>
    <tr>
      <td>1.6.0 fiber worker, Active Record 7.2+</td>
      <td><code>3</code></td>
      <td>Warning; the worker still boots</td>
    </tr>
  </tbody>
</table>

<p>So the connection-sizing distinction is <strong>Solid Queue 1.6 fiber workers on Active Record 7.2+ versus every thread worker</strong>. The warning-versus-boot-error distinction is older: it changed between Solid Queue 1.4 and 1.5.</p>

<p>For fiber workers, the estimate depends on the Active Record version. On Active Record 7.2+, Solid Queue assumes ordinary query paths release connections between queries, so it estimates one execution connection plus two worker connections regardless of the fiber count: <code>1 + 2 = 3</code>. On Active Record 7.1, it conservatively estimates one execution connection per fiber, so the estimate is <code>fibers + 2</code>.</p>

<p>The three-connection estimate is a starting point, not a guarantee. Long transactions, <code>ActiveRecord::Base.connection</code>, <code>lease_connection</code>, direct pool checkouts, and long-lived <code>with_connection</code> blocks can pin connections across waits. If your jobs do that or generate simultaneous database work, increase the relevant pool.</p>

<p>Here is the exact warning threshold calculated by Solid Queue 1.6 for a worker process at different concurrency levels:</p>

<table>
  <thead>
    <tr>
      <th>Concurrent jobs</th>
      <th>Thread worker</th>
      <th>Fiber worker, Active Record 7.2+</th>
      <th>Fiber worker, Active Record 7.1</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>10</td>
      <td>12</td>
      <td>3</td>
      <td>12</td>
    </tr>
    <tr>
      <td>25</td>
      <td>27</td>
      <td>3</td>
      <td>27</td>
    </tr>
    <tr>
      <td>50</td>
      <td>52</td>
      <td>3</td>
      <td>52</td>
    </tr>
    <tr>
      <td>100</td>
      <td>102</td>
      <td>3</td>
      <td>102</td>
    </tr>
    <tr>
      <td>200</td>
      <td>202</td>
      <td>3</td>
      <td>202</td>
    </tr>
  </tbody>
</table>

<p>On Active Record 7.2+, the thread estimate scales linearly while the fiber estimate stays flat. In the default <code>fork</code> mode, multiply the per-process pool by the number of worker processes: 6 processes with 50 execution slots means 312 configured queue connections for thread workers versus 18 for fiber workers. PostgreSQL’s default <code>max_connections</code> is 100.</p>

<p>Again, Solid Queue only calculates this estimate and warns. It does not configure the pool automatically. In supervisor <code>async</code> mode, workers share a process, so their connection needs must be added together rather than applying the per-process table independently.</p>

<p>The benchmarks below use two pool policies. The primary Solid Queue comparison deliberately gives both modes the same pool, <code>DB_POOL = concurrency + 5</code> per worker process, so it measures the executor instead of measuring pool starvation. The stress suite uses mode-specific pools to show the operational failure envelope under higher connection demand.</p>

<h2 id="the-benchmarks">The benchmarks</h2>

<p>I reran the benchmark suite on April 28, 2026. These results were produced from the PR branch before the final 1.6.0 implementation was reorganized during review, so treat them as benchmarks of that implementation, not fresh Solid Queue 1.6.0 numbers. The architecture is the same, but a new run is required before attributing the exact deltas to the release tag. I will update this post over the next few weeks with fresh benchmarks against Solid Queue 1.6.0.</p>

<p>The headline Solid Queue comparison covers four workloads across per-process concurrency 5, 10, 25, 50, and 100; process counts 1, 2, and 6; and both execution modes. Three runs per cell, median real run reported, with total concurrency capped at 60 so the main comparison stays about executor behavior.</p>

<p>The workloads:</p>

<ul>
  <li><strong>Sleep</strong>: 50ms <code>Kernel.sleep</code>. Pure cooperative wait. The I/O upper bound.</li>
  <li><strong>Async HTTP</strong>: HTTP request to a local server with 50ms delay via <a href="https://github.com/socketry/async-http">Async::HTTP</a>. Real fiber-friendly I/O.</li>
  <li><strong>CPU</strong>: 50,000 SHA256 iterations. Pure computation. The control.</li>
  <li><strong>RubyLLM Stream</strong>: Actual <a href="https://rubyllm.com">RubyLLM</a> chat completion through a fake OpenAI SSE endpoint, with token-by-token Turbo Stream broadcasts. 40 tokens at 20ms each. The closest thing to a production AI workload you can benchmark repeatably.</li>
</ul>

<h3 id="results">Results</h3>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th>Best throughput</th>
      <th>Avg paired delta</th>
      <th>Best paired delta</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RubyLLM Stream</td>
      <td>fiber, 7.01 j/s</td>
      <td><strong>+11.9%</strong></td>
      <td><strong>+21.8%</strong></td>
    </tr>
    <tr>
      <td>Async HTTP</td>
      <td>fiber, 492.82 j/s</td>
      <td><strong>+9.5%</strong></td>
      <td><strong>+25.5%</strong></td>
    </tr>
    <tr>
      <td>Sleep</td>
      <td>fiber, 500.50 j/s</td>
      <td><strong>+7.4%</strong></td>
      <td><strong>+15.9%</strong></td>
    </tr>
    <tr>
      <td>CPU</td>
      <td>fiber, 110.02 j/s</td>
      <td>+0.6%</td>
      <td>+2.4%</td>
    </tr>
  </tbody>
</table>

<p>RubyLLM Stream is the workload that matters. It runs an actual <a href="https://rubyllm.com">RubyLLM</a> chat completion with streaming, database writes, and Turbo broadcasts per token – the same thing <a href="https://chatwithwork.com">Chat with Work</a> does in production. Fiber wins every single paired experiment there: 9 out of 9.</p>

<p>The CPU row is the control. Fibers don’t help computation, and the average confirms it: essentially flat. That’s how you know the I/O gains are real and not measurement noise.</p>

<p>That table shows the best observed point and the paired-cell deltas. Here’s the full spread. Some configurations favor threads for synthetic workloads, but the paired averages are the steadier signal: fiber wins the I/O workloads, and RubyLLM Stream always favors fiber.</p>

<p><img src="/images/solid-queue-headline-fiber-vs-thread.svg" alt="Solid Queue fiber over thread throughput ranges across all workloads." /></p>

<p>The newer suite also adds database-shaped workloads. With matched pools, short DB bursts still favor fiber: <code>db_queries</code> averages +12.6%, and a read/API/write mix averages +6.9%. The transaction case is the useful caveat: when each job pins a connection for the whole transaction, fiber still averages +3.5%, but the win is less consistent. That’s exactly the workload where you should be more careful with pool sizing.</p>

<h2 id="thread-mode-hit-the-wall">Thread mode hit the wall</h2>

<p>Those benchmarks cap total concurrency at 60. I wanted to see what breaks when you push past that, so I ran a stress suite: per-process concurrency 25, 50, 100, 150, and 200; process counts 2 and 6; three runs per cell. Read this as the April PR implementation’s failure-envelope test, not a Solid Queue 1.6.0 result or a universal law about threads and fibers.</p>

<p>The result is stark. Thread mode only completed the smallest cell for each workload. Fiber mode completed every planned cell.</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th>Thread cells completed</th>
      <th>Fiber cells completed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Sleep</td>
      <td>1/10</td>
      <td>10/10</td>
    </tr>
    <tr>
      <td>Async HTTP</td>
      <td>1/10</td>
      <td>10/10</td>
    </tr>
    <tr>
      <td>RubyLLM Stream</td>
      <td>1/10</td>
      <td>10/10</td>
    </tr>
  </tbody>
</table>

<p><img src="/images/solid-queue-stress-cell-status.svg" alt="Solid Queue stress cell status." /></p>

<p>PostgreSQL’s default <code>max_connections</code> is 100. In this stress run, thread mode at concurrency 50 with 2 processes asked for 110 worker-pool connections. With 6 processes, even concurrency 25 asked for 180. The one surviving thread cell was the smallest: concurrency 25, 2 processes.</p>

<p>Fiber mode in the stress suite used a smaller mode-specific pool: 6 connections per process for 2-process runs, 10 per process for 6-process runs. That is 60 worker-pool connections at concurrency 200 across 6 processes, while the benchmark’s thread policy would configure 1,230. The exact constants are benchmark policy, but the shape is the point for this worker design: Solid Queue’s thread estimate scales with thread concurrency; the Active Record 7.2+ fiber baseline scales with worker process overhead plus actual database concurrency.</p>

<h2 id="one-backend-two-modes">One backend, two modes</h2>

<p>Fiber mode isn’t universally better. CPU-bound jobs get nothing from it, and blocking libraries or C extensions that do not cooperate with Ruby’s fiber scheduler stall the reactor. And that’s fine – you don’t have to pick one.</p>

<p>As Trevor Turk pointed out in the PR discussion, that’s the whole point: separately configured worker pools. Here’s what <a href="https://chatwithwork.com">Chat with Work</a> actually runs in production:</p>

<pre><code class="language-yaml">workers:
  - queues: [ chat ]
    fibers: 10
    processes: 2
    polling_interval: 0.1
  - queues: [ turbo ]
    fibers: 10
    processes: 1
    polling_interval: 0.05
  - queues: [ notifications, default, maintenance ]
    fibers: 5
    processes: 1
    polling_interval: 0.2
  - queues: [ cpu ]
    threads: 1
    processes: 1
</code></pre>

<p>Almost everything uses fibers. LLM streaming, Turbo broadcasts, notifications, maintenance jobs – all fiber-based. Only the <code>cpu</code> queue uses threads, and right now it’s just one thread for the occasional heavy extraction. One backend. One deployment. <a href="https://github.com/rails/mission_control-jobs">Mission Control</a> shows all of it.</p>

<p>Instead of running Solid Queue and Async::Job side by side – two processors, two configurations, two sets of things to monitor – you run one. I moved <a href="https://chatwithwork.com">Chat with Work</a> to this setup, and Brad Gessler has been running it in production too.</p>

<p>Async::Job is actually faster if you compare raw throughput against Redis. It is a backend comparison, not a Solid Queue executor comparison, but the ceiling is useful:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th>Solid Queue fiber best</th>
      <th>Async::Job best</th>
      <th>Delta</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RubyLLM Stream</td>
      <td>7.01 j/s</td>
      <td>16.94 j/s</td>
      <td>+141.7%</td>
    </tr>
    <tr>
      <td>Async HTTP</td>
      <td>492.82 j/s</td>
      <td>652.96 j/s</td>
      <td>+32.5%</td>
    </tr>
    <tr>
      <td>Sleep</td>
      <td>500.50 j/s</td>
      <td>644.98 j/s</td>
      <td>+28.9%</td>
    </tr>
    <tr>
      <td>CPU</td>
      <td>110.02 j/s</td>
      <td>125.75 j/s</td>
      <td>+14.3%</td>
    </tr>
  </tbody>
</table>

<p><img src="/images/solid-queue-headline-asyncjob-vs-fiber.svg" alt="Async::Job over Solid Queue fiber throughput ranges." /></p>

<p>If you want raw speed and don’t need persistence, Async::Job is the right call. But if you want job visibility, failure tracking, retries, Mission Control, everything Rails gives you out of the box, fiber mode gets you there. Same concurrency. You can size database connections to database work instead of the number of jobs waiting on network I/O. You set <code>fibers: N</code> and keep building.</p>

<hr />

<p>Fiber mode is now available in <a href="https://github.com/rails/solid_queue/releases/tag/v1.6.0">Solid Queue 1.6.0</a>. The <a href="https://github.com/rails/solid_queue/pull/728">PR</a> has the implementation history, and the <a href="https://github.com/crmne/solid_queue_bench">benchmark suite</a> is open source. Run your own numbers, or challenge mine.</p>]]></content><author><name>Carmine Paolino</name></author><category term="Ruby" /><category term="Async" /><category term="Rails" /><category term="Solid Queue" /><category term="Performance" /><category term="Concurrency" /><category term="Open Source" /><summary type="html"><![CDATA[I patched Solid Queue to run jobs as fibers. Now it ships in 1.6.0, making the Rails default ready for highly concurrent, cooperative I/O-bound jobs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/images/solid-queue-async.webp" /><media:content medium="image" url="https://paolino.me/images/solid-queue-async.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Your Agent’s Context Window Is Not a Junk Drawer</title><link href="https://paolino.me/your-agents-context-window-is-not-a-junk-drawer/" rel="alternate" type="text/html" title="Your Agent’s Context Window Is Not a Junk Drawer" /><published>2026-04-07T00:00:00+00:00</published><updated>2026-04-07T00:00:00+00:00</updated><id>https://paolino.me/your-agents-context-window-is-not-a-junk-drawer</id><content type="html" xml:base="https://paolino.me/your-agents-context-window-is-not-a-junk-drawer/"><![CDATA[<p>Your agent’s context window is the most precious resource it has. The more you stuff into it, the worse your agent performs.</p>

<p>Researchers call it <a href="https://research.trychroma.com/context-rot">context rot</a>: the more tokens in the window, the harder it becomes for the model to follow instructions, retrieve information, and stay on task. Chroma tested 18 frontier models and found that accuracy drops up to 30% when you go from a focused 300-token input to 113k tokens of conversation history, with the task held constant. The model essentially became <em>dumber</em>.</p>

<p>This holds true regardless of how big the window is, yet most agent setups treat the context window like a junk drawer.</p>

<p>“Just toss it in there, the LLM will figure it out!”</p>

<h2 id="mcp-the-biggest-offender">MCP: the biggest offender</h2>

<p>Don’t get me wrong. MCP is a fine idea. You need to talk to a service? Grab an MCP server, plug it in, and you’re running in ten minutes. For prototyping, for exploration, for answering “is this even worth building?”, it’s great.</p>

<p>The problem is what happens next. Which is: nothing.</p>

<p>People leave the MCP servers plugged in. They add more. Every MCP server you connect dumps tool descriptions, schemas, and instructions into your context. You didn’t write those. You didn’t optimize them. You probably haven’t even read them. You’re handing over a chunk of your context window to whatever some third party decided to shove in there.</p>

<p>Say you need a tool that checks the weather. You could plug in an MCP server and get dozens of tool descriptions, parameter schemas, and whatever instructions its author decided to write. Or you could write this:</p>

<pre><code class="language-ruby">class Weather &lt; RubyLLM::Tool
  description "Gets current weather for a location"

  param :latitude, desc: "Latitude (e.g., 52.5200)"
  param :longitude, desc: "Longitude (e.g., 13.4050)"

  def execute(latitude:, longitude:)
    url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&amp;longitude=#{longitude}&amp;current=temperature_2m,wind_speed_10m"
    Faraday.get(url).body
  rescue =&gt; e
    { error: e.message }
  end
end
</code></pre>

<p>Twelve lines of <a href="https://rubyllm.com">RubyLLM</a>. You wrote the description, so you know exactly what tokens are going into your context. You wrote the parameters, so the model gets precisely the interface it needs, no more. You own it, you can tune it, and nobody can inject anything into your agent’s brain through it.</p>

<p>Use MCP to prototype. Then replace it with crafted tools you actually control.</p>

<h2 id="tool-responses-are-context-too">Tool responses are context too</h2>

<p>Your RAG retrieves ten full documents when the model needs a paragraph. Your API call returns a massive JSON blob when the model needs two fields. You’re paying for every one of those tokens with your agent’s IQ.</p>

<p>The fix is progressive disclosure. At <a href="https://chatwithwork.com">Chat with Work</a>, when the agent searches your Google Drive, we don’t dump entire files into context. The search tool returns only some metadata and a single line from the file, the line that matched the search keywords. Fifty results, fifty lines. The AI reads those, decides which files actually matter, and only then reads them. If a file is too large, it reads it in chunks. At every step, the model is only looking at what it needs.</p>

<p>The same principle applies to any tool. Don’t return everything. Return enough for the model to decide what to look at next.</p>

<h2 id="your-instructions-are-context-too">Your instructions are context too</h2>

<p>Then there’s the stuff you wrote yourself. Your system prompt is context. Your tool descriptions are context. Your parameter schemas are context. Every edge case, every guardrail, every overly detailed description competes for attention. You think you’re being thorough. You’re actually drowning the instructions that matter in a sea of instructions that don’t. A focused system prompt will outperform an exhaustive one every time.</p>

<h2 id="tool-count-is-context-too">Tool count is context too</h2>

<p>You hand-crafted 40 beautiful tools. Your agent needs 5 for this task. The other 35 sit in context doing nothing except making the model slower at picking the right one.</p>

<p>Don’t register every tool your agent might ever need. Load the tools the current task actually requires. If you’re building a support agent that handles billing and technical issues, don’t give it all of both. Route billing questions to a billing agent and technical questions to a technical agent. Two focused agents will outperform one bloated one.</p>

<h2 id="every-token-should-earn-its-place">Every token should earn its place</h2>

<p>The context window is not a junk drawer. It’s a workbench. Everything on it should be there for a reason, and you should be able to say what that reason is.</p>

<p>So before you plug in another MCP server, add another RAG source, or write another paragraph in your system prompt, ask yourself one question: is this worth making my agent dumber?</p>]]></content><author><name>Carmine Paolino</name></author><category term="AI" /><category term="LLM" /><category term="MCP" /><category term="Agents" /><category term="Developer Experience" /><summary type="html"><![CDATA[Strategies to combat context rot.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://paolino.me/images/context-rot.png" /><media:content medium="image" url="https://paolino.me/images/context-rot.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>